diff --git a/packages/client/src/features/accessibility/move-zoom/move-handler.ts b/packages/client/src/features/accessibility/move-zoom/move-handler.ts index e2bd3538..56b6ba45 100644 --- a/packages/client/src/features/accessibility/move-zoom/move-handler.ts +++ b/packages/client/src/features/accessibility/move-zoom/move-handler.ts @@ -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. @@ -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 { @@ -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); } } @@ -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; } } diff --git a/packages/client/src/features/accessibility/resize-key-tool/resize-key-handler.ts b/packages/client/src/features/accessibility/resize-key-tool/resize-key-handler.ts index 668eabc5..6e3ce70d 100644 --- a/packages/client/src/features/accessibility/resize-key-tool/resize-key-handler.ts +++ b/packages/client/src/features/accessibility/resize-key-tool/resize-key-handler.ts @@ -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'; @@ -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; @@ -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; @@ -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 { diff --git a/packages/client/src/features/accessibility/search/search-palette.ts b/packages/client/src/features/accessibility/search/search-palette.ts index 17f84e38..330c0b74 100644 --- a/packages/client/src/features/accessibility/search/search-palette.ts +++ b/packages/client/src/features/accessibility/search/search-palette.ts @@ -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'; diff --git a/packages/client/src/features/accessibility/view-key-tools/movement-key-tool.ts b/packages/client/src/features/accessibility/view-key-tools/movement-key-tool.ts index b1dc22e4..770caaab 100644 --- a/packages/client/src/features/accessibility/view-key-tools/movement-key-tool.ts +++ b/packages/client/src/features/accessibility/view-key-tools/movement-key-tool.ts @@ -14,9 +14,9 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +import { Action, GModelElement, ISnapper, KeyListener, KeyTool, Point, TYPES } from '@eclipse-glsp/sprotty'; import { inject, injectable, optional } from 'inversify'; import { matchesKeystroke } from 'sprotty/lib/utils/keyboard'; -import { Action, ISnapper, KeyListener, KeyTool, GModelElement, TYPES } from '@eclipse-glsp/sprotty'; import { GLSPActionDispatcher } from '../../../base/action-dispatcher'; import { SelectionService } from '../../../base/selection-service'; import { Tool } from '../../../base/tool-manager/tool'; @@ -63,14 +63,18 @@ export class MoveKeyListener extends KeyListener implements AccessibleKeyShortcu protected readonly token = MoveKeyListener.name; - protected grid = { x: MoveKeyListener.defaultMoveX, y: MoveKeyListener.defaultMoveY }; - constructor(protected readonly tool: MovementKeyTool) { super(); + } + protected get grid(): Point { if (this.tool.snapper instanceof GridSnapper) { - this.grid = this.tool.snapper.grid; + return this.tool.snapper.grid; } + return { + x: MoveKeyListener.defaultMoveX, + y: MoveKeyListener.defaultMoveY + }; } registerShortcutKey(): void { @@ -84,22 +88,25 @@ export class MoveKeyListener extends KeyListener implements AccessibleKeyShortcu override keyDown(element: GModelElement, event: KeyboardEvent): Action[] { const selectedElementIds = this.tool.selectionService.getSelectedElementIDs(); + const snap = !event.altKey; + const offsetX = snap ? this.grid.x : 1; + const offsetY = snap ? this.grid.y : 1; if (selectedElementIds.length > 0) { if (this.matchesMoveUpKeystroke(event)) { - return [MoveElementAction.create(selectedElementIds, 0, -this.grid.x)]; + return [MoveElementAction.create(selectedElementIds, 0, -offsetY, snap)]; } else if (this.matchesMoveDownKeystroke(event)) { - return [MoveElementAction.create(selectedElementIds, 0, this.grid.x)]; + return [MoveElementAction.create(selectedElementIds, 0, offsetY, snap)]; } else if (this.matchesMoveRightKeystroke(event)) { - return [MoveElementAction.create(selectedElementIds, this.grid.x, 0)]; + return [MoveElementAction.create(selectedElementIds, offsetX, 0, snap)]; } else if (this.matchesMoveLeftKeystroke(event)) { - return [MoveElementAction.create(selectedElementIds, -this.grid.x, 0)]; + return [MoveElementAction.create(selectedElementIds, -offsetX, 0, snap)]; } } else { if (this.matchesMoveUpKeystroke(event)) { - return [MoveViewportAction.create(0, -this.grid.x)]; + return [MoveViewportAction.create(0, -this.grid.y)]; } else if (this.matchesMoveDownKeystroke(event)) { - return [MoveViewportAction.create(0, this.grid.x)]; + return [MoveViewportAction.create(0, this.grid.y)]; } else if (this.matchesMoveRightKeystroke(event)) { return [MoveViewportAction.create(this.grid.x, 0)]; } else if (this.matchesMoveLeftKeystroke(event)) { @@ -110,18 +117,18 @@ export class MoveKeyListener extends KeyListener implements AccessibleKeyShortcu } protected matchesMoveUpKeystroke(event: KeyboardEvent): boolean { - return matchesKeystroke(event, 'ArrowUp'); + return matchesKeystroke(event, 'ArrowUp') || matchesKeystroke(event, 'ArrowUp', 'alt'); } protected matchesMoveDownKeystroke(event: KeyboardEvent): boolean { - return matchesKeystroke(event, 'ArrowDown'); + return matchesKeystroke(event, 'ArrowDown') || matchesKeystroke(event, 'ArrowDown', 'alt'); } protected matchesMoveRightKeystroke(event: KeyboardEvent): boolean { - return matchesKeystroke(event, 'ArrowRight'); + return matchesKeystroke(event, 'ArrowRight') || matchesKeystroke(event, 'ArrowRight', 'alt'); } protected matchesMoveLeftKeystroke(event: KeyboardEvent): boolean { - return matchesKeystroke(event, 'ArrowLeft'); + return matchesKeystroke(event, 'ArrowLeft') || matchesKeystroke(event, 'ArrowLeft', 'alt'); } } diff --git a/packages/client/src/features/tools/change-bounds/change-bounds-tool-feedback.ts b/packages/client/src/features/tools/change-bounds/change-bounds-tool-feedback.ts index 32708219..cb711d4c 100644 --- a/packages/client/src/features/tools/change-bounds/change-bounds-tool-feedback.ts +++ b/packages/client/src/features/tools/change-bounds/change-bounds-tool-feedback.ts @@ -13,8 +13,6 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { inject, injectable } from 'inversify'; -import { VNode } from 'snabbdom'; import { Action, CommandExecutionContext, @@ -22,11 +20,11 @@ import { Disposable, ElementMove, GChildElement, + GModelElement, + GModelRoot, MouseListener, MoveAction, Point, - GModelElement, - GModelRoot, TYPES, findParentByFeature, hasStringProp, @@ -34,6 +32,8 @@ import { isSelectable, isViewport } from '@eclipse-glsp/sprotty'; +import { inject, injectable } from 'inversify'; +import { VNode } from 'snabbdom'; import { CursorCSS, cursorFeedbackAction } from '../../../base/feedback/css-feedback'; import { FeedbackCommand } from '../../../base/feedback/feedback-command'; @@ -197,7 +197,7 @@ export class FeedbackMoveMouseListener extends MouseListener implements Disposab y: (event.pageY - this.startDragPosition.y) / zoom }; - const elementMoves: ElementMove[] = this.getElementMovesForDelta(target, delta, !event.shiftKey, finished); + const elementMoves: ElementMove[] = this.getElementMovesForDelta(target, delta, !event.altKey, finished); if (elementMoves.length > 0) { return MoveAction.create(elementMoves, { animate: false, finished }); } else { diff --git a/packages/client/src/utils/viewpoint-util.ts b/packages/client/src/utils/viewpoint-util.ts index a6d11168..9df75461 100644 --- a/packages/client/src/utils/viewpoint-util.ts +++ b/packages/client/src/utils/viewpoint-util.ts @@ -19,8 +19,9 @@ import { BoundsAware, Dimension, GChildElement, - Point, GModelElement, + GModelRoot, + Point, Viewport, findParentByFeature, isAlignable, @@ -133,3 +134,18 @@ export function absoluteToLocal(element: BoundsAwareModelElement, absolutePoint: const absoluteElementBounds = toAbsoluteBounds(element); return { x: absolutePoint.x - absoluteElementBounds.x, y: absolutePoint.y - absoluteElementBounds.y }; } + +/** + * Returns `true` if `point` is outside of the `viewport`. + * @param point The point to check. + * @param viewport The viewport. + * @returns `true` if `point` is outside, `false` otherwise. + */ +export function outsideOfViewport(point: Point, viewport: GModelRoot & Viewport): boolean { + return ( + point.x < viewport.scroll.x || + point.x > viewport.scroll.x + viewport.canvasBounds.width || + point.y < viewport.scroll.y || + point.y > viewport.scroll.y + viewport.canvasBounds.height + ); +}