Skip to content

Commit

Permalink
Keyboard move and resize improvements (eclipse-glsp#295)
Browse files Browse the repository at this point in the history
* Show client-side feedback without animation
* Trigger server operation with a debounce delay
* Respect snap modifier (ALT) for move
* Respect movement restrictor for move

Fixes eclipse-glsp/glsp#1156
  • Loading branch information
planger authored and holkerveen committed Dec 21, 2024
1 parent e380b15 commit 9599c0a
Show file tree
Hide file tree
Showing 6 changed files with 185 additions and 100 deletions.
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

0 comments on commit 9599c0a

Please sign in to comment.