Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Keyboard move and resize improvements #295

Merged
merged 1 commit into from
Nov 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 105 additions & 65 deletions packages/client/src/features/accessibility/move-zoom/move-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,24 +14,34 @@
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
********************************************************************************/

import { inject, injectable } from 'inversify';
import { throttle } from 'lodash';
import {
Action,
ChangeBoundsOperation,
DisposableCollection,
ElementAndBounds,
ElementMove,
GModelRoot,
IActionDispatcher,
IActionHandler,
ICommand,
ISnapper,
MoveAction,
Point,
GModelRoot,
SetViewportAction,
TYPES,
Viewport,
findParentByFeature,
isBoundsAware,
isViewport
} from '@eclipse-glsp/sprotty';
import { inject, injectable, optional } from 'inversify';
import { DebouncedFunc, debounce } from 'lodash';
import { EditorContextService } from '../../../base/editor-context-service';
import { IFeedbackActionDispatcher } from '../../../base/feedback/feedback-action-dispatcher';
import { SelectableBoundsAware, getElements, isSelectableAndBoundsAware } from '../../../utils/gmodel-util';
import { isValidMove } from '../../../utils/layout-utils';
import { outsideOfViewport } from '../../../utils/viewpoint-util';
import { IMovementRestrictor } from '../../change-bounds/movement-restrictor';

/**
* Action for triggering moving of the viewport.
Expand Down Expand Up @@ -77,6 +87,10 @@ export interface MoveElementAction extends Action {
* used to specify the amount to be moved in the y-axis
*/
moveY: number;
/**
* used to specify whether we should snap to the grid
*/
snap: boolean;
}

export namespace MoveElementAction {
Expand All @@ -86,24 +100,24 @@ export namespace MoveElementAction {
return Action.hasKind(object, KIND);
}

export function create(elementIds: string[], moveX: number, moveY: number): MoveElementAction {
return { kind: KIND, elementIds, moveX, moveY };
export function create(elementIds: string[], moveX: number, moveY: number, snap: boolean = true): MoveElementAction {
return { kind: KIND, elementIds, moveX, moveY, snap };
}
}

/* The MoveViewportHandler class is an implementation of the IActionHandler interface that handles
moving of the viewport. */
/**
* Action handler for moving of the viewport.
*/
@injectable()
export class MoveViewportHandler implements IActionHandler {
@inject(EditorContextService)
protected editorContextService: EditorContextService;

@inject(TYPES.IActionDispatcher) protected dispatcher: IActionDispatcher;
protected readonly throttledHandleViewportMove = throttle((action: MoveViewportAction) => this.handleMoveViewport(action), 150);
@inject(TYPES.IActionDispatcher)
protected dispatcher: IActionDispatcher;

handle(action: Action): void | Action | ICommand {
if (MoveViewportAction.is(action)) {
this.throttledHandleViewportMove(action);
this.handleMoveViewport(action);
}
}

Expand All @@ -123,87 +137,113 @@ export class MoveViewportHandler implements IActionHandler {
},
zoom: viewport.zoom
};

return SetViewportAction.create(viewport.id, newViewport, { animate: true });
return SetViewportAction.create(viewport.id, newViewport, { animate: false });
}
}

/* The MoveElementHandler class is an implementation of the IActionHandler interface that handles
moving elements. */
/**
* Action handler for moving elements.
*/
@injectable()
export class MoveElementHandler implements IActionHandler {
@inject(EditorContextService)
protected editorContextService: EditorContextService;
@inject(TYPES.IActionDispatcher) protected dispatcher: IActionDispatcher;
protected readonly throttledHandleElementMove = throttle((action: MoveElementAction) => this.handleMoveElement(action), 150);

@inject(TYPES.IActionDispatcher)
protected dispatcher: IActionDispatcher;

@inject(TYPES.IFeedbackActionDispatcher)
protected feedbackDispatcher: IFeedbackActionDispatcher;

@inject(TYPES.ISnapper)
@optional()
readonly snapper?: ISnapper;

@inject(TYPES.IMovementRestrictor)
@optional()
readonly movementRestrictor?: IMovementRestrictor;

protected debouncedChangeBounds?: DebouncedFunc<() => void>;
protected disposableFeedback = new DisposableCollection();

handle(action: Action): void | Action | ICommand {
if (MoveElementAction.is(action)) {
this.throttledHandleElementMove(action);
this.handleMoveElement(action);
}
}

handleMoveElement(action: MoveElementAction): void {
const viewport = findParentByFeature(this.editorContextService.modelRoot, isViewport);
const modelRoot = this.editorContextService.modelRoot;
const viewport = findParentByFeature(modelRoot, isViewport);
if (!viewport) {
return;
}

const elements = getElements(this.editorContextService.modelRoot.index, action.elementIds, isSelectableAndBoundsAware);
const viewportActions: Action[] = [];
const elementMoves: ElementMove[] = [];
const elements = getElements(modelRoot.index, action.elementIds, isSelectableAndBoundsAware);
for (const element of elements) {
const newPosition = this.getTargetBounds(element, action);
elementMoves.push({
elementId: element.id,
fromPosition: {
x: element.bounds.x,
y: element.bounds.y
},
toPosition: newPosition
});
if (outsideOfViewport(newPosition, viewport)) {
viewportActions.push(MoveViewportAction.create(action.moveX, action.moveY));
}
}

this.dispatcher.dispatchAll(this.move(viewport, elements, action.moveX, action.moveY));
}
this.dispatcher.dispatchAll(viewportActions);
const moveAction = MoveAction.create(elementMoves, { animate: false });
this.disposableFeedback.push(this.feedbackDispatcher.registerFeedback(this, [moveAction]));

protected getBounds(element: SelectableBoundsAware, offSetX: number, offSetY: number): Point {
return { x: element.bounds.x + offSetX, y: element.bounds.y + offSetY };
this.scheduleChangeBounds(this.toElementAndBounds(elementMoves));
}

protected adaptViewport(
viewport: GModelRoot & Viewport,
newPoint: Point,
moveX: number,
moveY: number
): MoveViewportAction | undefined {
if (
newPoint.x < viewport.scroll.x ||
newPoint.x > viewport.scroll.x + viewport.canvasBounds.width ||
newPoint.y < viewport.scroll.y ||
newPoint.y > viewport.scroll.y + viewport.canvasBounds.height
) {
return MoveViewportAction.create(moveX, moveY);
protected getTargetBounds(element: SelectableBoundsAware, action: MoveElementAction): Point {
let position = { x: element.bounds.x + action.moveX, y: element.bounds.y + action.moveY };
if (this.snapper && action.snap) {
position = this.snapper.snap(position, element);
}
if (!isValidMove(element, position, this.movementRestrictor)) {
// reset to position before the move, if not valid
position = { x: element.bounds.x, y: element.bounds.y };
}
return;
return position;
}

protected moveElement(element: SelectableBoundsAware, offSetX: number, offSetY: number): ChangeBoundsOperation {
return ChangeBoundsOperation.create([
{
elementId: element.id,
newSize: {
width: element.bounds.width,
height: element.bounds.height
},
newPosition: {
x: element.bounds.x + offSetX,
y: element.bounds.y + offSetY
}
}
]);
protected scheduleChangeBounds(elementAndBounds: ElementAndBounds[]): void {
this.debouncedChangeBounds?.cancel();
this.debouncedChangeBounds = debounce(() => {
this.disposableFeedback.dispose();
this.dispatcher.dispatchAll([ChangeBoundsOperation.create(elementAndBounds)]);
this.debouncedChangeBounds = undefined;
}, 300);
this.debouncedChangeBounds();
}

protected move(viewport: GModelRoot & Viewport, selectedElements: SelectableBoundsAware[], deltaX: number, deltaY: number): Action[] {
const results: Action[] = [];

if (selectedElements.length !== 0) {
selectedElements.forEach(currentElement => {
results.push(this.moveElement(currentElement, deltaX, deltaY));
const newPosition = this.getBounds(currentElement, deltaX, deltaY);
const viewportAction = this.adaptViewport(viewport, newPosition, deltaX, deltaY);
if (viewportAction) {
results.push(viewportAction);
}
});
protected toElementAndBounds(elementMoves: ElementMove[]): ElementAndBounds[] {
const elementBounds: ElementAndBounds[] = [];
for (const elementMove of elementMoves) {
const element = this.editorContextService.modelRoot.index.getById(elementMove.elementId);
if (element && isBoundsAware(element)) {
elementBounds.push({
elementId: elementMove.elementId,
newSize: {
height: element.bounds.height,
width: element.bounds.width
},
newPosition: {
x: elementMove.toPosition.x,
y: elementMove.toPosition.y
}
});
}
}
return results;
return elementBounds;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,28 @@
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
********************************************************************************/

import { inject, injectable, optional } from 'inversify';
import {
Action,
ChangeBoundsOperation,
Dimension,
DisposableCollection,
ElementAndBounds,
GModelElement,
GParentElement,
IActionDispatcher,
IActionHandler,
ICommand,
ISnapper,
Point,
GModelElement,
GParentElement,
SetBoundsAction,
TYPES
} from '@eclipse-glsp/sprotty';
import { inject, injectable, optional } from 'inversify';
import { DebouncedFunc, debounce } from 'lodash';
import { EditorContextService } from '../../../base/editor-context-service';
import { isValidMove, isValidSize, minHeight, minWidth } from '../../../utils/layout-utils';
import { IFeedbackActionDispatcher } from '../../../base/feedback/feedback-action-dispatcher';
import { SelectableBoundsAware, getElements, isSelectableAndBoundsAware, toElementAndBounds } from '../../../utils/gmodel-util';
import { isValidMove, isValidSize, minHeight, minWidth } from '../../../utils/layout-utils';
import { Resizable } from '../../change-bounds/model';
import { GridSnapper } from '../../change-bounds/snap';

Expand Down Expand Up @@ -67,7 +72,14 @@ export class ResizeElementHandler implements IActionHandler {
@inject(EditorContextService)
protected editorContextService: EditorContextService;

@inject(TYPES.IActionDispatcher) protected dispatcher: IActionDispatcher;
@inject(TYPES.IActionDispatcher)
protected dispatcher: IActionDispatcher;

@inject(TYPES.IFeedbackActionDispatcher)
protected feedbackDispatcher: IFeedbackActionDispatcher;

protected debouncedChangeBounds?: DebouncedFunc<() => void>;
protected disposableFeedback = new DisposableCollection();

// Default x resize used if GridSnapper is not provided
static readonly defaultResizeX = 20;
Expand All @@ -92,11 +104,21 @@ export class ResizeElementHandler implements IActionHandler {

handleResizeElement(action: ResizeElementAction): void {
const elements = getElements(this.editorContextService.modelRoot.index, action.elementIds, isSelectableAndBoundsAware);
this.dispatcher.dispatchAll(this.resize(elements, action));
const elementAndBounds = this.computeElementAndBounds(elements, action);

this.disposableFeedback.push(this.feedbackDispatcher.registerFeedback(this, [SetBoundsAction.create(elementAndBounds)]));

this.debouncedChangeBounds?.cancel();
this.debouncedChangeBounds = debounce(() => {
this.disposableFeedback.dispose();
this.dispatcher.dispatchAll([ChangeBoundsOperation.create(elementAndBounds)]);
this.debouncedChangeBounds = undefined;
}, 300);
this.debouncedChangeBounds();
}

protected resize(elements: SelectableBoundsAware[], action: ResizeElementAction): Action[] {
const actions: Action[] = [];
protected computeElementAndBounds(elements: SelectableBoundsAware[], action: ResizeElementAction): ElementAndBounds[] {
const elementAndBounds: ElementAndBounds[] = [];

elements.forEach(element => {
const { x, y, width: oldWidth, height: oldHeight } = element.bounds;
Expand All @@ -116,11 +138,11 @@ export class ResizeElementHandler implements IActionHandler {

if (this.isValidBoundChange(element, { x, y }, { width, height })) {
const resizeElement = { id: element.id, bounds: { x, y, width, height } } as GModelElement & GParentElement & Resizable;
actions.push(ChangeBoundsOperation.create([toElementAndBounds(resizeElement)]));
elementAndBounds.push(toElementAndBounds(resizeElement));
}
});

return actions;
return elementAndBounds;
}

protected isValidBoundChange(element: SelectableBoundsAware, newPosition: Point, newSize: Dimension): boolean {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,27 +14,27 @@
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
********************************************************************************/

import { injectable } from 'inversify';
import { isEqual } from 'lodash';
import { toArray } from 'sprotty/lib/utils/iterable';
import {
Action,
CenterAction,
LabeledAction,
GModelElement,
GModelRoot,
GNode,
LabeledAction,
SelectAction,
SelectAllAction,
codiconCSSString,
isNameable,
name
} from '@eclipse-glsp/sprotty';
import { injectable } from 'inversify';
import { isEqual } from 'lodash';
import { toArray } from 'sprotty/lib/utils/iterable';
import { BaseAutocompletePalette } from '../../../base/auto-complete/base-autocomplete-palette';

import { AutocompleteSuggestion, IAutocompleteSuggestionProvider } from '../../../base/auto-complete/autocomplete-suggestion-providers';
import { applyCssClasses, deleteCssClasses } from '../../../base/feedback/css-feedback';
import { RepositionAction } from '../../../features/viewport/reposition';
import { AutocompleteSuggestion, IAutocompleteSuggestionProvider } from '../../../base/auto-complete/autocomplete-suggestion-providers';
import { GEdge } from '../../../model';

const CSS_SEARCH_HIDDEN = 'search-hidden';
Expand Down
Loading
Loading