From 0b1c053a2c795117f7ef1278cdcc3dd57c3cc933 Mon Sep 17 00:00:00 2001 From: Martin Fleck Date: Wed, 8 May 2024 10:13:10 +0200 Subject: [PATCH 1/3] Improve handling of change bounds - Introduce change bounds manager to centralize bounds-related services -- Bounds changes through position snapping and movement restriction -- Validation for size and position of an element -- Customizable methods for when to use move and resize options - Introduce change bounds tracker for moves and resizes -- Tracker calculates move on diagram and calculates move and resizes -- Tracker supports options on which parts of the process are applied - Provide moveable wrappers for resize and routing handles Fixes https://github.com/eclipse-glsp/glsp/issues/1337 - Extend current resize capabilities -- Introduce mode for symmetric resize -- Introduce one-dimensional resize on top, right, bottom and left side Fixes https://github.com/eclipse-glsp/glsp/issues/1338 Fixes https://github.com/eclipse-glsp/glsp/issues/1339 - Fix elements moving during resizing when hitting minimum bounds -- Store calculated minimum size from layouter in element -- Adapt resize so we do not produce invalid sized bounds Fixes https://github.com/eclipse-glsp/glsp/issues/1340 Minor: - Ensure we get proper cursor feedback when hovering over resize handle - Add additional convenience functions - Add origin viewport command for convenience Contributed on behalf of Axon Ivy AG --- packages/client/css/change-bounds.css | 29 +- packages/client/css/glsp-sprotty.css | 20 +- packages/client/src/base/default.module.ts | 5 +- .../client/src/base/feedback/css-feedback.ts | 12 +- packages/client/src/base/index.ts | 1 + .../client/src/base/mouse-position-tracker.ts | 25 + .../view-key-tools/movement-key-tool.ts | 15 +- .../src/features/bounds/freeform-layout.ts | 22 +- .../bounds/glsp-hidden-bounds-updater.ts | 109 +++-- .../client/src/features/bounds/hbox-layout.ts | 27 +- packages/client/src/features/bounds/index.ts | 1 + .../client/src/features/bounds/layout-data.ts | 48 ++ .../src/features/bounds/local-bounds.ts | 24 +- .../client/src/features/bounds/vbox-layout.ts | 28 +- .../src/features/change-bounds/index.ts | 1 + .../src/features/change-bounds/model.ts | 108 +++- .../change-bounds/movement-restrictor.ts | 10 + .../change-bounds/point-position-updater.ts | 3 + .../change-bounds/position-snapper.ts | 4 + .../client/src/features/change-bounds/snap.ts | 6 + .../src/features/change-bounds/tracker.ts | 63 +++ ...ouse-tracking-element-position-listener.ts | 101 ++-- .../helper-line-manager-default.ts | 39 +- .../helper-lines/helper-line-manager.ts | 13 +- .../src/features/routing/edge-router.ts | 42 ++ .../client/src/features/tools/base-tools.ts | 50 +- .../change-bounds/change-bounds-manager.ts | 143 ++++++ .../change-bounds-tool-feedback.ts | 23 +- .../change-bounds-tool-module.ts | 2 + .../change-bounds-tool-move-feedback.ts | 108 ++-- .../tools/change-bounds/change-bounds-tool.ts | 213 +++----- .../change-bounds/change-bounds-tracker.ts | 460 ++++++++++++++++++ .../src/features/tools/change-bounds/index.ts | 2 + .../src/features/tools/change-bounds/view.tsx | 16 +- .../edge-edit/edge-edit-tool-feedback.ts | 86 ++-- .../tools/edge-edit/edge-edit-tool.ts | 7 +- .../marquee-selection/marquee-mouse-tool.ts | 4 + .../tools/node-creation/node-creation-tool.ts | 14 +- packages/client/src/utils/gmodel-util.ts | 73 +++ packages/client/src/utils/layout-utils.ts | 4 + packages/glsp-sprotty/src/types.ts | 3 +- .../src/action-protocol/model-layout.ts | 8 +- .../protocol/src/action-protocol/types.ts | 24 + .../src/sprotty-geometry-bounds.spec.ts | 34 +- .../protocol/src/sprotty-geometry-bounds.ts | 36 ++ packages/protocol/src/utils/type-util.ts | 5 + 46 files changed, 1579 insertions(+), 492 deletions(-) create mode 100644 packages/client/src/base/mouse-position-tracker.ts create mode 100644 packages/client/src/features/bounds/layout-data.ts create mode 100644 packages/client/src/features/change-bounds/tracker.ts create mode 100644 packages/client/src/features/tools/change-bounds/change-bounds-manager.ts create mode 100644 packages/client/src/features/tools/change-bounds/change-bounds-tracker.ts diff --git a/packages/client/css/change-bounds.css b/packages/client/css/change-bounds.css index 6ba3fce73..c001c80cb 100644 --- a/packages/client/css/change-bounds.css +++ b/packages/client/css/change-bounds.css @@ -2,14 +2,39 @@ cursor: nw-resize; } +.sprotty-resize-handle[data-kind='top'] { + cursor: n-resize; +} + .sprotty-resize-handle[data-kind='top-right'] { cursor: ne-resize; } -.sprotty-resize-handle[data-kind='bottom-left'] { - cursor: sw-resize; +.sprotty-resize-handle[data-kind='right'] { + cursor: e-resize; } .sprotty-resize-handle[data-kind='bottom-right'] { cursor: se-resize; } + +.sprotty-resize-handle[data-kind='bottom'] { + cursor: s-resize; +} + +.sprotty-resize-handle[data-kind='bottom-left'] { + cursor: sw-resize; +} + +.sprotty-resize-handle[data-kind='left'] { + cursor: w-resize; +} + +.sprotty-resize-handle.resize-not-allowed { + fill: var(--glsp-error-foreground) !important; +} + +.sprotty g .resize-not-allowed > .sprotty-node { + stroke: var(--glsp-error-foreground) !important; + stroke-width: 1.5px; +} diff --git a/packages/client/css/glsp-sprotty.css b/packages/client/css/glsp-sprotty.css index e2b2eb418..7f678a4e9 100644 --- a/packages/client/css/glsp-sprotty.css +++ b/packages/client/css/glsp-sprotty.css @@ -152,18 +152,34 @@ cursor: nw-resize; } +.sprotty .resize-w-mode { + cursor: n-resize; +} + .sprotty .resize-ne-mode { cursor: ne-resize; } -.sprotty .resize-sw-mode { - cursor: sw-resize; +.sprotty .resize-e-mode { + cursor: e-resize; } .sprotty .resize-se-mode { cursor: se-resize; } +.sprotty .resize-s-mode { + cursor: s-resize; +} + +.sprotty .resize-sw-mode { + cursor: sw-resize; +} + +.sprotty .resize-w-mode { + cursor: w-resize; +} + .sprotty .element-deletion-mode { cursor: pointer; } diff --git a/packages/client/src/base/default.module.ts b/packages/client/src/base/default.module.ts index 802598978..6c4d36cb0 100644 --- a/packages/client/src/base/default.module.ts +++ b/packages/client/src/base/default.module.ts @@ -18,6 +18,7 @@ import { FeatureModule, KeyTool, LocationPostprocessor, + MousePositionTracker, MouseTool, MoveCommand, SetDirtyStateAction, @@ -47,6 +48,7 @@ import { DiagramLoader } from './model/diagram-loader'; import { GLSPModelSource } from './model/glsp-model-source'; import { DefaultModelInitializationConstraint, ModelInitializationConstraint } from './model/model-initialization-constraint'; import { GModelRegistry } from './model/model-registry'; +import { GLSPMousePositionTracker } from './mouse-position-tracker'; import { SelectionClearingMouseListener } from './selection-clearing-mouse-listener'; import { SelectionService } from './selection-service'; import { EnableDefaultToolsAction, EnableToolsAction } from './tool-manager/tool'; @@ -85,7 +87,8 @@ export const defaultModule = new FeatureModule((bind, unbind, isBound, rebind, . bind(GLSPMouseTool).toSelf().inSingletonScope(); bindOrRebind(context, MouseTool).toService(GLSPMouseTool); bind(TYPES.IDiagramStartup).toService(GLSPMouseTool); - + bind(GLSPMousePositionTracker).toSelf().inSingletonScope(); + bindOrRebind(context, MousePositionTracker).toService(GLSPMousePositionTracker); bind(GLSPKeyTool).toSelf().inSingletonScope(); bindOrRebind(context, KeyTool).toService(GLSPKeyTool); bind(TYPES.IDiagramStartup).toService(GLSPKeyTool); diff --git a/packages/client/src/base/feedback/css-feedback.ts b/packages/client/src/base/feedback/css-feedback.ts index a9e268d50..713b44f48 100644 --- a/packages/client/src/base/feedback/css-feedback.ts +++ b/packages/client/src/base/feedback/css-feedback.ts @@ -85,14 +85,18 @@ export enum CursorCSS { RESIZE_NESW = 'resize-nesw-mode', RESIZE_NWSE = 'resize-nwse-mode', RESIZE_NW = 'resize-nw-mode', + RESIZE_N = 'resize-n-mode', RESIZE_NE = 'resize-ne-mode', - RESIZE_SW = 'resize-sw-mode', + RESIZE_E = 'resize-e-mode', RESIZE_SE = 'resize-se-mode', + RESIZE_S = 'resize-s-mode', + RESIZE_SW = 'resize-sw-mode', + RESIZE_W = 'resize-w-mode', MOVE = 'move-mode', MARQUEE = 'marquee-mode' } -export function cursorFeedbackAction(cursorCss?: CursorCSS): ModifyCSSFeedbackAction { +export function cursorFeedbackAction(cursorCss?: string): ModifyCSSFeedbackAction { const add = []; if (cursorCss) { add.push(cursorCss); @@ -107,3 +111,7 @@ export function applyCssClasses(element: GModelElement, ...add: string[]): Modif export function deleteCssClasses(element: GModelElement, ...remove: string[]): ModifyCSSFeedbackAction { return ModifyCSSFeedbackAction.create({ elements: [element], remove }); } + +export function toggleCssClasses(element: GModelElement, add: boolean, ...cssClasses: string[]): ModifyCSSFeedbackAction { + return add ? applyCssClasses(element, ...cssClasses) : deleteCssClasses(element, ...cssClasses); +} diff --git a/packages/client/src/base/index.ts b/packages/client/src/base/index.ts index 70cd9e357..cc790464c 100644 --- a/packages/client/src/base/index.ts +++ b/packages/client/src/base/index.ts @@ -24,6 +24,7 @@ export * from './editor-context-service'; export * from './feedback'; export * from './focus'; export * from './model'; +export * from './mouse-position-tracker'; export * from './ranked'; export * from './selection-clearing-mouse-listener'; export * from './selection-service'; diff --git a/packages/client/src/base/mouse-position-tracker.ts b/packages/client/src/base/mouse-position-tracker.ts new file mode 100644 index 000000000..f66fcf654 --- /dev/null +++ b/packages/client/src/base/mouse-position-tracker.ts @@ -0,0 +1,25 @@ +/******************************************************************************** + * Copyright (c) 2024 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + rank: number; + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ +import { MousePositionTracker } from '@eclipse-glsp/sprotty'; +import { injectable } from 'inversify'; +import { Ranked } from './ranked'; + +@injectable() +export class GLSPMousePositionTracker extends MousePositionTracker implements Ranked { + /* we want to be executed before all default mouse listeners since we are just tracking the position and others may need it */ + rank = Ranked.DEFAULT_RANK - 200; +} 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 16497910c..5d5bfaef0 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 @@ -20,8 +20,8 @@ import { matchesKeystroke } from 'sprotty/lib/utils/keyboard'; import { GLSPActionDispatcher } from '../../../base/action-dispatcher'; import { SelectionService } from '../../../base/selection-service'; import { Tool } from '../../../base/tool-manager/tool'; -import { unsnapModifier, useSnap } from '../../change-bounds/snap'; import { Grid } from '../../grid'; +import { ChangeBoundsManager } from '../../tools'; import { AccessibleKeyShortcutProvider, SetAccessibleKeyShortcutAction } from '../key-shortcut/accessible-key-shortcut'; import { MoveElementAction, MoveViewportAction } from '../move-zoom/move-handler'; @@ -41,6 +41,7 @@ export class MovementKeyTool implements Tool { @inject(TYPES.ISnapper) @optional() readonly snapper?: ISnapper; @inject(TYPES.IActionDispatcher) readonly actionDispatcher: GLSPActionDispatcher; @optional() @inject(TYPES.Grid) protected grid: Grid; + @inject(TYPES.IChangeBoundsManager) readonly changeBoundsManager: ChangeBoundsManager; get id(): string { return MovementKeyTool.ID; @@ -86,7 +87,7 @@ export class MoveKeyListener extends KeyListener implements AccessibleKeyShortcu override keyDown(_element: GModelElement, event: KeyboardEvent): Action[] { const selectedElementIds = this.tool.selectionService.getSelectedElementIDs(); - const snap = useSnap(event); + const snap = this.tool.changeBoundsManager.usePositionSnap(event); const offsetX = snap ? this.grid.x : 1; const offsetY = snap ? this.grid.y : 1; @@ -115,18 +116,20 @@ export class MoveKeyListener extends KeyListener implements AccessibleKeyShortcu } protected matchesMoveUpKeystroke(event: KeyboardEvent): boolean { - return matchesKeystroke(event, 'ArrowUp') || matchesKeystroke(event, 'ArrowUp', unsnapModifier()); + return matchesKeystroke(event, 'ArrowUp') || matchesKeystroke(event, 'ArrowUp', this.tool.changeBoundsManager.unsnapModifier()); } protected matchesMoveDownKeystroke(event: KeyboardEvent): boolean { - return matchesKeystroke(event, 'ArrowDown') || matchesKeystroke(event, 'ArrowDown', unsnapModifier()); + return matchesKeystroke(event, 'ArrowDown') || matchesKeystroke(event, 'ArrowDown', this.tool.changeBoundsManager.unsnapModifier()); } protected matchesMoveRightKeystroke(event: KeyboardEvent): boolean { - return matchesKeystroke(event, 'ArrowRight') || matchesKeystroke(event, 'ArrowRight', unsnapModifier()); + return ( + matchesKeystroke(event, 'ArrowRight') || matchesKeystroke(event, 'ArrowRight', this.tool.changeBoundsManager.unsnapModifier()) + ); } protected matchesMoveLeftKeystroke(event: KeyboardEvent): boolean { - return matchesKeystroke(event, 'ArrowLeft') || matchesKeystroke(event, 'ArrowLeft', unsnapModifier()); + return matchesKeystroke(event, 'ArrowLeft') || matchesKeystroke(event, 'ArrowLeft', this.tool.changeBoundsManager.unsnapModifier()); } } diff --git a/packages/client/src/features/bounds/freeform-layout.ts b/packages/client/src/features/bounds/freeform-layout.ts index 8f6b68436..0cdfe78e3 100644 --- a/packages/client/src/features/bounds/freeform-layout.ts +++ b/packages/client/src/features/bounds/freeform-layout.ts @@ -13,7 +13,6 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { injectable } from 'inversify'; import { AbstractLayout, AbstractLayoutOptions, @@ -21,11 +20,13 @@ import { BoundsData, Dimension, GChildElement, + GParentElement, LayoutContainer, Point, - GParentElement, StatefulLayouter } from '@eclipse-glsp/sprotty'; +import { injectable } from 'inversify'; +import { LayoutAware } from './layout-data'; /** * Layouts children of a container with explicit X/Y positions @@ -44,9 +45,11 @@ export class FreeFormLayouter extends AbstractLayout { const maxWidth = childrenSize.width > 0 ? childrenSize.width + options.paddingLeft + options.paddingRight : 0; const maxHeight = childrenSize.height > 0 ? childrenSize.height + options.paddingTop + options.paddingBottom : 0; - if (maxWidth > 0 && maxHeight > 0) { + if (childrenSize.width > 0 && childrenSize.height > 0) { const offset = this.layoutChildren(container, layouter, options, maxWidth, maxHeight); - boundsData.bounds = this.getFinalContainerBounds(container, offset, options, maxWidth, maxHeight); + const computed = this.getComputedContainerDimensions(options, childrenSize.width, childrenSize.height); + LayoutAware.setComputedDimensions(boundsData, computed); + boundsData.bounds = this.getFinalContainerBounds(container, offset, options, computed.width, computed.height); boundsData.boundsChanged = true; } else { boundsData.bounds = { x: boundsData.bounds!.x, y: boundsData.bounds!.y, width: 0, height: 0 }; @@ -96,6 +99,13 @@ export class FreeFormLayouter extends AbstractLayout { return currentOffset; } + protected getComputedContainerDimensions(options: AbstractLayoutOptions, maxWidth: number, maxHeight: number): Dimension { + return { + width: maxWidth + options.paddingLeft + options.paddingRight, + height: maxHeight + options.paddingTop + options.paddingBottom + }; + } + protected override getFinalContainerBounds( container: GParentElement & LayoutContainer, lastOffset: Point, @@ -106,8 +116,8 @@ export class FreeFormLayouter extends AbstractLayout { const result = { x: container.bounds.x, y: container.bounds.y, - width: Math.max(options.minWidth, maxWidth + options.paddingLeft + options.paddingRight), - height: Math.max(options.minHeight, maxHeight + options.paddingTop + options.paddingBottom) + width: Math.max(options.minWidth, maxWidth), + height: Math.max(options.minHeight, maxHeight) }; return result; diff --git a/packages/client/src/features/bounds/glsp-hidden-bounds-updater.ts b/packages/client/src/features/bounds/glsp-hidden-bounds-updater.ts index 36453de3d..44fad426f 100644 --- a/packages/client/src/features/bounds/glsp-hidden-bounds-updater.ts +++ b/packages/client/src/features/bounds/glsp-hidden-bounds-updater.ts @@ -18,21 +18,29 @@ import { Action, BoundsData, ComputedBoundsAction, - Deferred, EdgeRouterRegistry, + ElementAndAlignment, + ElementAndBounds, + ElementAndLayoutData, ElementAndRoutingPoints, + GChildElement, GModelElement, HiddenBoundsUpdater, - IActionDispatcher, + LayoutData, ModelIndexImpl, - RequestAction, - ResponseAction + RequestBoundsAction, + isLayoutContainer } from '@eclipse-glsp/sprotty'; import { inject, injectable, optional } from 'inversify'; import { VNode } from 'snabbdom'; import { BoundsAwareModelElement, calcElementAndRoute, getDescendantIds, isRoutable } from '../../utils/gmodel-util'; +import { LayoutAware } from './layout-data'; import { LocalComputedBoundsAction, LocalRequestBoundsAction } from './local-bounds'; +export class BoundsDataExt extends BoundsData { + layoutData?: LayoutData; +} + /** * Grabs the bounds from hidden SVG DOM elements, applies layouts, collects routes and fires {@link ComputedBoundsAction}s. * @@ -44,7 +52,7 @@ export class GLSPHiddenBoundsUpdater extends HiddenBoundsUpdater { protected element2route: ElementAndRoutingPoints[] = []; - protected getElement2BoundsData(): Map { + protected getElement2BoundsData(): Map { return this['element2boundsData']; } @@ -60,10 +68,56 @@ export class GLSPHiddenBoundsUpdater extends HiddenBoundsUpdater { if (LocalRequestBoundsAction.is(cause) && cause.elementIDs) { this.focusOnElements(cause.elementIDs); } - const actions = this.captureActions(() => super.postUpdate(cause)); - actions - .filter(action => ComputedBoundsAction.is(action)) - .forEach(action => this.actionDispatcher.dispatch(this.enhanceAction(action as ComputedBoundsAction, cause))); + + // collect bounds and layout data in element2BoundsData + this.getBoundsFromDOM(); + this.layouter.layout(this.getElement2BoundsData()); + + // prepare data for action + const resizes: ElementAndBounds[] = []; + const alignments: ElementAndAlignment[] = []; + const layoutData: ElementAndLayoutData[] = []; + this.getElement2BoundsData().forEach((boundsData, element) => { + if (boundsData.boundsChanged && boundsData.bounds !== undefined) { + const resize: ElementAndBounds = { + elementId: element.id, + newSize: { + width: boundsData.bounds.width, + height: boundsData.bounds.height + } + }; + // don't copy position if the element is layouted by the server + if (element instanceof GChildElement && isLayoutContainer(element.parent)) { + resize.newPosition = { + x: boundsData.bounds.x, + y: boundsData.bounds.y + }; + } + resizes.push(resize); + } + if (boundsData.alignmentChanged && boundsData.alignment !== undefined) { + alignments.push({ + elementId: element.id, + newAlignment: boundsData.alignment + }); + } + if (LayoutAware.is(boundsData)) { + layoutData.push({ elementId: element.id, layoutData: boundsData.layoutData }); + } + }); + const routes = this.element2route.length === 0 ? undefined : this.element2route; + + // prepare and dispatch action + const responseId = (cause as RequestBoundsAction).requestId; + const revision = this.root !== undefined ? this.root.revision : undefined; + const computedBoundsAction = ComputedBoundsAction.create(resizes, { revision, alignments, layoutData, routes, responseId }); + if (LocalRequestBoundsAction.is(cause)) { + LocalComputedBoundsAction.mark(computedBoundsAction); + } + this.actionDispatcher.dispatch(computedBoundsAction); + + // cleanup + this.getElement2BoundsData().clear(); this.element2route = []; } @@ -82,41 +136,4 @@ export class GLSPHiddenBoundsUpdater extends HiddenBoundsUpdater { protected expandElementId(id: string, index: ModelIndexImpl, elementIDs: string[]): string[] { return getDescendantIds(index.getById(id)); } - - protected captureActions(call: () => void): Action[] { - const capturingActionDispatcher = new CapturingActionDispatcher(); - const actualActionDispatcher = this.actionDispatcher; - this.actionDispatcher = capturingActionDispatcher; - try { - call(); - return capturingActionDispatcher.actions; - } finally { - this.actionDispatcher = actualActionDispatcher; - } - } - - protected enhanceAction(action: ComputedBoundsAction, cause?: Action): ComputedBoundsAction { - if (LocalRequestBoundsAction.is(cause)) { - LocalComputedBoundsAction.mark(action); - } - action.routes = this.element2route.length === 0 ? undefined : this.element2route; - return action; - } -} - -class CapturingActionDispatcher implements IActionDispatcher { - readonly actions: Action[] = []; - - async dispatch(action: Action): Promise { - this.actions.push(action); - } - - async dispatchAll(actions: Action[]): Promise { - this.actions.push(...actions); - } - - async request(action: RequestAction): Promise { - // ignore, not needed for our purposes - return new Deferred().promise; - } } diff --git a/packages/client/src/features/bounds/hbox-layout.ts b/packages/client/src/features/bounds/hbox-layout.ts index 4028037f1..ac0b080ac 100644 --- a/packages/client/src/features/bounds/hbox-layout.ts +++ b/packages/client/src/features/bounds/hbox-layout.ts @@ -13,22 +13,23 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { injectable } from 'inversify'; import { Bounds, BoundsData, Dimension, GChildElement, + GModelElement, + GParentElement, HBoxLayoutOptions, HBoxLayouter, LayoutContainer, Point, - GModelElement, - GParentElement, StatefulLayouter, isBoundsAware, isLayoutableChild } from '@eclipse-glsp/sprotty'; +import { injectable } from 'inversify'; +import { LayoutAware } from './layout-data'; export interface HBoxLayoutOptionsExt extends HBoxLayoutOptions { hGrab: boolean; @@ -79,7 +80,9 @@ export class HBoxLayouterExt extends HBoxLayouter { if (width > 0 && height > 0) { const offset = this.layoutChildren(container, layouter, options, width, height, grabWidth, grabbingChildren); - boundsData.bounds = this.getFinalContainerBounds(container, offset, options, childrenSize.width, childrenSize.height); + const computed = this.getComputedContainerDimensions(options, childrenSize.width, childrenSize.height); + LayoutAware.setComputedDimensions(boundsData, computed); + boundsData.bounds = this.getFinalContainerBounds(container, offset, options, computed.width, computed.height); boundsData.boundsChanged = true; } } @@ -223,22 +226,28 @@ export class HBoxLayouterExt extends HBoxLayouter { return (element as any).layoutOptions; } + protected getComputedContainerDimensions(options: HBoxLayoutOptionsExt, maxWidth: number, maxHeight: number): Dimension { + return { + width: maxWidth + options.paddingLeft + options.paddingRight, + height: maxHeight + options.paddingTop + options.paddingBottom + }; + } + protected override getFinalContainerBounds( container: GParentElement & LayoutContainer, lastOffset: Point, options: HBoxLayoutOptionsExt, - maxWidth: number, - maxHeight: number + computedWidth: number, + computedHeight: number ): Bounds { const elementOptions = this.getElementLayoutOptions(container); const width = elementOptions?.prefWidth ?? options.minWidth; const height = elementOptions?.prefHeight ?? options.minHeight; - const result = { x: container.bounds.x, y: container.bounds.y, - width: Math.max(width, maxWidth + options.paddingLeft + options.paddingRight), - height: Math.max(height, maxHeight + options.paddingTop + options.paddingBottom) + width: Math.max(width, computedWidth), + height: Math.max(height, computedHeight) }; return result; diff --git a/packages/client/src/features/bounds/index.ts b/packages/client/src/features/bounds/index.ts index b35779b02..4bd0493b9 100644 --- a/packages/client/src/features/bounds/index.ts +++ b/packages/client/src/features/bounds/index.ts @@ -17,6 +17,7 @@ export * from './bounds-module'; export * from './freeform-layout'; export * from './glsp-hidden-bounds-updater'; export * from './hbox-layout'; +export * from './layout-data'; export * from './layouter'; export * from './local-bounds'; export * from './set-bounds-feedback-command'; diff --git a/packages/client/src/features/bounds/layout-data.ts b/packages/client/src/features/bounds/layout-data.ts new file mode 100644 index 000000000..af3eec6e6 --- /dev/null +++ b/packages/client/src/features/bounds/layout-data.ts @@ -0,0 +1,48 @@ +/******************************************************************************** + * Copyright (c) 2024 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { Dimension, LayoutData } from '@eclipse-glsp/sprotty'; + +export interface LayoutAware { + layoutData: LayoutData; +} + +export namespace LayoutAware { + export function is(element: T): element is T & LayoutAware { + return 'layoutData' in element; + } + + export function getLayoutData(element: T): LayoutData | undefined { + return is(element) ? element.layoutData : undefined; + } + + export function setLayoutData(element: T, data: LayoutData): void { + (element as LayoutAware).layoutData = data; + } + + export function setComputedDimensions(element: T, computedDimensions: Dimension): void { + ensureLayoutAware(element).layoutData.computedDimensions = computedDimensions; + } + + export function getComputedDimensions(element: T): Dimension | undefined { + return getLayoutData(element)?.computedDimensions; + } + + function ensureLayoutAware(element: T): T & LayoutAware { + (element as LayoutAware).layoutData = (element as LayoutAware).layoutData ?? {}; + return element as T & LayoutAware; + } +} diff --git a/packages/client/src/features/bounds/local-bounds.ts b/packages/client/src/features/bounds/local-bounds.ts index 101968d46..df2169846 100644 --- a/packages/client/src/features/bounds/local-bounds.ts +++ b/packages/client/src/features/bounds/local-bounds.ts @@ -31,6 +31,7 @@ import { } from '@eclipse-glsp/sprotty'; import { inject, injectable } from 'inversify'; import { ServerAction } from '../../base/model/glsp-model-source'; +import { LayoutAware } from './layout-data'; export interface LocalRequestBoundsAction extends RequestBoundsAction { elementIDs?: string[]; @@ -41,9 +42,11 @@ export namespace LocalRequestBoundsAction { return RequestBoundsAction.is(object) && !ServerAction.is(object) && hasArrayProp(object, 'elementIDs', true); } - export function create(newRoot: GModelRootSchema, elementIDs?: string[]): LocalRequestBoundsAction { + export function create(newRoot: GModelRoot, elementIDs?: string[]): LocalRequestBoundsAction; + export function create(newRoot: GModelRootSchema, elementIDs?: string[]): LocalRequestBoundsAction; + export function create(newRoot: GModelRoot | GModelRootSchema, elementIDs?: string[]): LocalRequestBoundsAction { return { - ...RequestBoundsAction.create(newRoot), + ...RequestBoundsAction.create(newRoot as unknown as GModelRootSchema), elementIDs }; } @@ -55,7 +58,7 @@ export namespace LocalRequestBoundsAction { elementIDs?: string[] ): CommandResult { // do not modify the main model (modelChanged = false) but request local bounds calculation on hidden model - actionDispatcher.dispatch(LocalRequestBoundsAction.create(root as unknown as GModelRootSchema, elementIDs)); + actionDispatcher.dispatch(LocalRequestBoundsAction.create(root, elementIDs)); return { model: root, modelChanged: false, @@ -65,7 +68,7 @@ export namespace LocalRequestBoundsAction { } export namespace LocalComputedBoundsAction { - export function is(object: unknown): object is RequestBoundsAction { + export function is(object: unknown): object is ComputedBoundsAction & ServerAction { return ComputedBoundsAction.is(object) && ServerAction.is(object); } @@ -94,9 +97,22 @@ export class LocalComputedBoundsCommand extends Command { } // apply computed bounds from the hidden model and return updated model to render new main model this.computedBoundsApplicator.apply(context.root as unknown as GModelRootSchema, this.action); + this.action.layoutData?.forEach(({ elementId, layoutData }) => { + const element = context.root.index.getById(elementId); + if (element !== undefined) { + LayoutAware.setLayoutData(element, layoutData); + } + }); return context.root; } + this.action.layoutData?.forEach(({ elementId, layoutData }) => { + const element = context.root.index.getById(elementId); + if (element !== undefined) { + LayoutAware.setLayoutData(element, layoutData); + } + }); + // computed bounds action from server -> we do not care and do not trigger any update of the main model return { model: context.root, diff --git a/packages/client/src/features/bounds/vbox-layout.ts b/packages/client/src/features/bounds/vbox-layout.ts index 95f59a624..9f6641ca8 100644 --- a/packages/client/src/features/bounds/vbox-layout.ts +++ b/packages/client/src/features/bounds/vbox-layout.ts @@ -13,22 +13,23 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { injectable } from 'inversify'; import { Bounds, BoundsData, Dimension, GChildElement, - LayoutContainer, - Point, GModelElement, GParentElement, + LayoutContainer, + Point, StatefulLayouter, VBoxLayoutOptions, VBoxLayouter, isBoundsAware, isLayoutableChild } from '@eclipse-glsp/sprotty'; +import { injectable } from 'inversify'; +import { LayoutAware } from './layout-data'; export interface VBoxLayoutOptionsExt extends VBoxLayoutOptions { hGrab: boolean; @@ -79,7 +80,9 @@ export class VBoxLayouterExt extends VBoxLayouter { if (maxWidth > 0 && maxHeight > 0) { const offset = this.layoutChildren(container, layouter, options, width, height, grabHeight, grabbingChildren); - boundsData.bounds = this.getFinalContainerBounds(container, offset, options, childrenSize.width, childrenSize.height); + const computed = this.getComputedContainerDimensions(options, childrenSize.width, childrenSize.height); + LayoutAware.setComputedDimensions(boundsData, computed); + boundsData.bounds = this.getFinalContainerBounds(container, offset, options, computed.width, computed.height); boundsData.boundsChanged = true; } } @@ -223,24 +226,29 @@ export class VBoxLayouterExt extends VBoxLayouter { return (element as any).layoutOptions; } + protected getComputedContainerDimensions(options: VBoxLayoutOptionsExt, maxWidth: number, maxHeight: number): Dimension { + return { + width: maxWidth + options.paddingLeft + options.paddingRight, + height: maxHeight + options.paddingTop + options.paddingBottom + }; + } + protected override getFinalContainerBounds( container: GParentElement & LayoutContainer, lastOffset: Point, options: VBoxLayoutOptionsExt, - maxWidth: number, - maxHeight: number + computedWidth: number, + computedHeight: number ): Bounds { const elementOptions = this.getElementLayoutOptions(container); const width = elementOptions?.prefWidth ?? options.minWidth; const height = elementOptions?.prefHeight ?? options.minHeight; - const result = { x: container.bounds.x, y: container.bounds.y, - width: Math.max(width, maxWidth + options.paddingLeft + options.paddingRight), - height: Math.max(height, maxHeight + options.paddingTop + options.paddingBottom) + width: Math.max(width, computedWidth), + height: Math.max(height, computedHeight) }; - return result; } diff --git a/packages/client/src/features/change-bounds/index.ts b/packages/client/src/features/change-bounds/index.ts index 85d8b38f6..dc4fc9726 100644 --- a/packages/client/src/features/change-bounds/index.ts +++ b/packages/client/src/features/change-bounds/index.ts @@ -18,3 +18,4 @@ export * from './movement-restrictor'; export * from './point-position-updater'; export * from './position-snapper'; export * from './snap'; +export * from './tracker'; diff --git a/packages/client/src/features/change-bounds/model.ts b/packages/client/src/features/change-bounds/model.ts index 796eaebb6..c6d283b25 100644 --- a/packages/client/src/features/change-bounds/model.ts +++ b/packages/client/src/features/change-bounds/model.ts @@ -14,15 +14,18 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ import { + Bounds, GChildElement, GModelElement, GParentElement, Hoverable, + Point, hoverFeedbackFeature, isBoundsAware, isMoveable, isSelectable } from '@eclipse-glsp/sprotty'; +import { CursorCSS } from '../../base'; import { BoundsAwareModelElement, MoveableElement, ResizableModelElement } from '../../utils'; export const resizeFeature = Symbol('resizeFeature'); @@ -34,9 +37,36 @@ export function isResizable(element: GModelElement): element is ResizableModelEl // eslint-disable-next-line no-shadow export enum ResizeHandleLocation { TopLeft = 'top-left', + Top = 'top', TopRight = 'top-right', + Right = 'right', + BottomRight = 'bottom-right', + Bottom = 'bottom', BottomLeft = 'bottom-left', - BottomRight = 'bottom-right' + Left = 'left' +} + +export namespace ResizeHandleLocation { + export function opposite(location: ResizeHandleLocation): ResizeHandleLocation { + switch (location) { + case ResizeHandleLocation.TopLeft: + return ResizeHandleLocation.BottomRight; + case ResizeHandleLocation.Top: + return ResizeHandleLocation.Bottom; + case ResizeHandleLocation.TopRight: + return ResizeHandleLocation.BottomLeft; + case ResizeHandleLocation.Right: + return ResizeHandleLocation.Left; + case ResizeHandleLocation.BottomRight: + return ResizeHandleLocation.TopLeft; + case ResizeHandleLocation.Bottom: + return ResizeHandleLocation.Top; + case ResizeHandleLocation.BottomLeft: + return ResizeHandleLocation.TopRight; + case ResizeHandleLocation.Left: + return ResizeHandleLocation.Right; + } + } } export function isBoundsAwareMoveable(element: GModelElement): element is BoundsAwareModelElement & MoveableElement { @@ -64,18 +94,34 @@ export class SResizeHandle extends GChildElement implements Hoverable { return this.location === ResizeHandleLocation.TopLeft; } - isSeResize(): boolean { - return this.location === ResizeHandleLocation.BottomRight; + isNResize(): boolean { + return this.location === ResizeHandleLocation.Top; } isNeResize(): boolean { return this.location === ResizeHandleLocation.TopRight; } + isEResize(): boolean { + return this.location === ResizeHandleLocation.Right; + } + + isSeResize(): boolean { + return this.location === ResizeHandleLocation.BottomRight; + } + + isSResize(): boolean { + return this.location === ResizeHandleLocation.Bottom; + } + isSwResize(): boolean { return this.location === ResizeHandleLocation.BottomLeft; } + isWResize(): boolean { + return this.location === ResizeHandleLocation.Left; + } + isNwSeResize(): boolean { return this.isNwResize() || this.isSeResize(); } @@ -83,18 +129,72 @@ export class SResizeHandle extends GChildElement implements Hoverable { isNeSwResize(): boolean { return this.isNeResize() || this.isSwResize(); } + + static getHandlePosition(handle: SResizeHandle): Point; + static getHandlePosition(parent: ResizableModelElement, location: ResizeHandleLocation): Point; + static getHandlePosition(bounds: Bounds, location: ResizeHandleLocation): Point; + static getHandlePosition(first: ResizableModelElement | SResizeHandle | Bounds, second?: ResizeHandleLocation): Point { + const bounds = SResizeHandle.is(first) ? first.parent.bounds : first instanceof GModelElement ? first.bounds : first; + const location = SResizeHandle.is(first) ? first.location : second!; + switch (location) { + case ResizeHandleLocation.TopLeft: + return Bounds.topLeft(bounds); + case ResizeHandleLocation.Top: + return Bounds.topCenter(bounds); + case ResizeHandleLocation.TopRight: + return Bounds.topRight(bounds); + case ResizeHandleLocation.Right: + return Bounds.middleRight(bounds); + case ResizeHandleLocation.BottomRight: + return Bounds.bottomRight(bounds); + case ResizeHandleLocation.Bottom: + return Bounds.bottomCenter(bounds); + case ResizeHandleLocation.BottomLeft: + return Bounds.bottomLeft(bounds); + case ResizeHandleLocation.Left: + return Bounds.middleLeft(bounds); + } + } + + static getCursorCss(handle: SResizeHandle): string { + switch (handle.location) { + case ResizeHandleLocation.TopLeft: + return CursorCSS.RESIZE_NW; + case ResizeHandleLocation.Top: + return CursorCSS.RESIZE_N; + case ResizeHandleLocation.TopRight: + return CursorCSS.RESIZE_NE; + case ResizeHandleLocation.Right: + return CursorCSS.RESIZE_E; + case ResizeHandleLocation.BottomRight: + return CursorCSS.RESIZE_SE; + case ResizeHandleLocation.Bottom: + return CursorCSS.RESIZE_S; + case ResizeHandleLocation.BottomLeft: + return CursorCSS.RESIZE_SW; + case ResizeHandleLocation.Left: + return CursorCSS.RESIZE_W; + } + } + + static is(handle: unknown): handle is SResizeHandle { + return typeof handle === 'object' && !!handle && 'type' in handle && handle.type === SResizeHandle.TYPE; + } } export function addResizeHandles( element: ResizableModelElement, locations: ResizeHandleLocation[] = [ ResizeHandleLocation.TopLeft, - ResizeHandleLocation.TopRight, + ResizeHandleLocation.Top, ResizeHandleLocation.BottomLeft, ResizeHandleLocation.BottomRight ] ): void { for (const location of Object.values(ResizeHandleLocation)) { + if (typeof location === 'function') { + continue; + } const existing = element.children.find(child => child instanceof SResizeHandle && child.location === location); if (locations.includes(location) && !existing) { // add missing handle diff --git a/packages/client/src/features/change-bounds/movement-restrictor.ts b/packages/client/src/features/change-bounds/movement-restrictor.ts index da5e73b94..8158d2fc9 100644 --- a/packages/client/src/features/change-bounds/movement-restrictor.ts +++ b/packages/client/src/features/change-bounds/movement-restrictor.ts @@ -108,3 +108,13 @@ export function removeMovementRestrictionFeedback( return ModifyCSSFeedbackAction.create({ elements, remove: movementRestrictor.cssClasses }); } + +export function movementRestrictionFeedback( + element: GModelElement, + movementRestrictor: IMovementRestrictor, + valid: boolean +): ModifyCSSFeedbackAction { + return valid + ? removeMovementRestrictionFeedback(element, movementRestrictor) + : createMovementRestrictionFeedback(element, movementRestrictor); +} diff --git a/packages/client/src/features/change-bounds/point-position-updater.ts b/packages/client/src/features/change-bounds/point-position-updater.ts index 244de68f2..1788bf19a 100644 --- a/packages/client/src/features/change-bounds/point-position-updater.ts +++ b/packages/client/src/features/change-bounds/point-position-updater.ts @@ -29,6 +29,9 @@ import { useSnap } from './snap'; * * You can initialize a this class with a optional {@link ISnapper}. If a * snapper is present, the positions will be snapped to the defined grid. + * + * @deprecated The use of this class is discouraged. Use the {@link ChangeBoundsManager.createTracker} + * instead which centralized a few aspects of the tracking. */ export class PointPositionUpdater { protected positionSnapper: PositionSnapper; diff --git a/packages/client/src/features/change-bounds/position-snapper.ts b/packages/client/src/features/change-bounds/position-snapper.ts index 573c49cb3..eeacae025 100644 --- a/packages/client/src/features/change-bounds/position-snapper.ts +++ b/packages/client/src/features/change-bounds/position-snapper.ts @@ -18,6 +18,10 @@ import { inject, injectable, optional } from 'inversify'; import { IHelperLineManager } from '../helper-lines/helper-line-manager'; import { Direction, HelperLine, isHelperLine } from '../helper-lines/model'; +/** + * @deprecated The use of this class is discouraged. Use the {@link ChangeBoundsManager.createTracker} + * instead which centralized a few aspects of the tracking. + */ @injectable() export class PositionSnapper { constructor( diff --git a/packages/client/src/features/change-bounds/snap.ts b/packages/client/src/features/change-bounds/snap.ts index 246c07c8c..5716c18cc 100644 --- a/packages/client/src/features/change-bounds/snap.ts +++ b/packages/client/src/features/change-bounds/snap.ts @@ -16,10 +16,16 @@ /* eslint-disable @typescript-eslint/no-shadow */ import { KeyboardModifier } from '@eclipse-glsp/sprotty'; +/** + * @deprecated Use {@link ChangeBoundsManager.useSnap} instead which may be customized. + */ export function useSnap(event: MouseEvent | KeyboardEvent): boolean { return !event.shiftKey; } +/** + * @deprecated Use {@link ChangeBoundsManager.unsnapModifier} instead which may be customized. + */ export function unsnapModifier(): KeyboardModifier { return 'shift'; } diff --git a/packages/client/src/features/change-bounds/tracker.ts b/packages/client/src/features/change-bounds/tracker.ts new file mode 100644 index 000000000..470a98d57 --- /dev/null +++ b/packages/client/src/features/change-bounds/tracker.ts @@ -0,0 +1,63 @@ +/******************************************************************************** + * Copyright (c) 2024 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ +import { Disposable, MousePositionTracker, Movement, Point, Vector } from '@eclipse-glsp/sprotty'; + +export class MovementCalculator implements Disposable { + protected position?: Point; + + setPosition(position: Point): void { + this.position = { ...position }; + } + + updatePosition(param: Vector | Movement): void { + const vector = Vector.is(param) ? param : param.vector; + this.setPosition(Point.add(this.position ?? Point.ORIGIN, vector)); + } + + get hasPosition(): boolean { + return this.position !== undefined; + } + + calculateMoveTo(targetPosition: Point): Movement { + return !this.position ? Movement.ZERO : Point.move(this.position, targetPosition); + } + + dispose(): void { + this.position = undefined; + } +} + +export class DiagramMovementCalculator extends MovementCalculator { + constructor(readonly positionTracker: MousePositionTracker) { + super(); + } + + init(): void { + const position = this.positionTracker.lastPositionOnDiagram; + if (position) { + this.setPosition(position); + } + } + + calculateMoveToCurrent(): Movement { + const targetPosition = this.positionTracker.lastPositionOnDiagram; + return targetPosition ? this.calculateMoveTo(targetPosition) : Movement.ZERO; + } + + reset(): void { + this.dispose(); + } +} diff --git a/packages/client/src/features/element-template/mouse-tracking-element-position-listener.ts b/packages/client/src/features/element-template/mouse-tracking-element-position-listener.ts index cca0e2b8a..755c36d66 100644 --- a/packages/client/src/features/element-template/mouse-tracking-element-position-listener.ts +++ b/packages/client/src/features/element-template/mouse-tracking-element-position-listener.ts @@ -14,55 +14,20 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { - Action, - Dimension, - Disposable, - ElementMove, - GModelElement, - MoveAction, - Point, - isBoundsAware, - isMoveable -} from '@eclipse-glsp/sprotty'; -import { injectable } from 'inversify'; +import { Action, Dimension, GModelElement, MoveAction, Point, isBoundsAware, isMoveable } from '@eclipse-glsp/sprotty'; import { DragAwareMouseListener } from '../../base/drag-aware-mouse-listener'; import { CSS_HIDDEN, ModifyCSSFeedbackAction } from '../../base/feedback/css-feedback'; import { FeedbackEmitter } from '../../base/feedback/feeback-emitter'; -import { IFeedbackEmitter } from '../../base/feedback/feedback-action-dispatcher'; -import { Tool } from '../../base/tool-manager/tool'; -import { MoveableElement } from '../../utils'; -import { getAbsolutePosition } from '../../utils/viewpoint-util'; -import { - IMovementRestrictor, - createMovementRestrictionFeedback, - removeMovementRestrictionFeedback -} from '../change-bounds/movement-restrictor'; -import { PointPositionUpdater } from '../change-bounds/point-position-updater'; -import { PositionSnapper } from '../change-bounds/position-snapper'; -import { useSnap } from '../change-bounds/snap'; -import { MoveFinishedEventAction } from '../tools'; +import { getAbsolutePosition } from '../../utils'; +import { ChangeBoundsManager, ChangeBoundsTracker, FeedbackAwareTool, MoveFinishedEventAction, TrackedElementMove } from '../tools'; -export interface PositioningTool extends Tool { - readonly positionSnapper: PositionSnapper; - readonly movementRestrictor?: IMovementRestrictor; - - createFeedbackEmitter(): FeedbackEmitter; - /** - * @deprecated It is recommended to create a {@link createFeedbackEmitter dedicated emitter} per feedback instead of using the tool. - */ - registerFeedback(feedbackActions: Action[], feedbackEmitter?: IFeedbackEmitter, cleanupActions?: Action[]): Disposable; - /** - * @deprecated It is recommended to create a {@link createFeedbackEmitter dedicated emitter} per feedback and dispose it like that. - */ - deregisterFeedback(feedbackEmitter?: IFeedbackEmitter, cleanupActions?: Action[]): void; +export interface PositioningTool extends FeedbackAwareTool { + readonly changeBoundsManager: ChangeBoundsManager; } -@injectable() export class MouseTrackingElementPositionListener extends DragAwareMouseListener { - protected positionUpdater: PointPositionUpdater; protected moveGhostFeedback: FeedbackEmitter; - protected currentPosition?: Point; + protected tracker: ChangeBoundsTracker; constructor( protected elementId: string, @@ -70,7 +35,7 @@ export class MouseTrackingElementPositionListener extends DragAwareMouseListener protected cursorPosition: 'top-left' | 'middle' = 'top-left' ) { super(); - this.positionUpdater = new PointPositionUpdater(this.tool.positionSnapper); + this.tracker = this.tool.changeBoundsManager.createTracker(); this.moveGhostFeedback = this.tool.createFeedbackEmitter(); } @@ -80,44 +45,36 @@ export class MouseTrackingElementPositionListener extends DragAwareMouseListener if (!element || !isMoveable(element)) { return []; } - if (this.positionUpdater.isLastDragPositionUndefined()) { - this.positionUpdater.updateLastDragPosition(element.position); + if (!this.tracker.isTracking()) { + this.tracker.startTracking(); + const mousePosition = getAbsolutePosition(target, event); + element.position = + this.cursorPosition === 'middle' && isBoundsAware(element) + ? Point.subtract(mousePosition, Dimension.center(element.bounds)) + : mousePosition; } - const mousePosition = getAbsolutePosition(target, event); - const delta = this.positionUpdater.updatePosition(element, mousePosition, useSnap(event)); - if (!delta) { + // we only validate if we use the position already as a target, otherwise we need to re-evaluate after moving to the center anyway + const move = this.tracker.moveElements([element], { snap: event, restrict: event, validate: true }); + const elementMove = move.elementMoves[0]; + if (!elementMove) { return []; } - const toPosition = this.getElementTargetPosition(mousePosition, element, event); - const elementMove = { elementId: element.id, toPosition: toPosition }; - this.addMoveFeeback(element, elementMove); - this.currentPosition = toPosition; + this.addMoveFeeback(elementMove); // since we are moving a ghost element that is feedback-only and will be removed anyway, // we just send a MoveFinishedEventAction instead of reseting the position with a MoveAction and the finished flag set to true. - this.moveGhostFeedback.add(MoveAction.create([elementMove], { animate: false }), MoveFinishedEventAction.create()).submit(); + this.moveGhostFeedback + .add( + MoveAction.create([{ elementId: this.elementId, toPosition: elementMove.toPosition }], { animate: false }), + MoveFinishedEventAction.create() + ) + .submit(); + this.tracker.updateTrackingPosition(elementMove.moveVector); return []; } - protected getElementTargetPosition(mousePosition: Point, element: MoveableElement, event: MouseEvent): Point { - const unsnappedPosition = - this.cursorPosition === 'middle' && isBoundsAware(element) - ? Point.subtract(mousePosition, Dimension.center(element.bounds)) - : mousePosition; - return this.tool.positionSnapper.snapPosition(unsnappedPosition, element, useSnap(event)); - } - - protected addMoveFeeback(element: MoveableElement, elementMove: ElementMove): void { - if (this.tool.movementRestrictor) { - if (!this.tool.movementRestrictor.validate(element, elementMove.toPosition)) { - this.moveGhostFeedback.add( - createMovementRestrictionFeedback(element, this.tool.movementRestrictor), - removeMovementRestrictionFeedback(element, this.tool.movementRestrictor) - ); - } else { - this.moveGhostFeedback.add(removeMovementRestrictionFeedback(element, this.tool.movementRestrictor)); - } - } - this.moveGhostFeedback.add(ModifyCSSFeedbackAction.create({ elements: [element.id], remove: [CSS_HIDDEN] })); + protected addMoveFeeback(move: TrackedElementMove): void { + this.tool.changeBoundsManager.addRestrictionFeedback(this.moveGhostFeedback, move); + this.moveGhostFeedback.add(ModifyCSSFeedbackAction.create({ elements: [move.element.id], remove: [CSS_HIDDEN] })); } override dispose(): void { diff --git a/packages/client/src/features/helper-lines/helper-line-manager-default.ts b/packages/client/src/features/helper-lines/helper-line-manager-default.ts index a358c7c7b..55fd201a6 100644 --- a/packages/client/src/features/helper-lines/helper-line-manager-default.ts +++ b/packages/client/src/features/helper-lines/helper-line-manager-default.ts @@ -13,7 +13,18 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { Action, GModelElement, GModelRoot, IActionHandler, MoveAction, Point, SetBoundsAction, TYPES } from '@eclipse-glsp/sprotty'; +import { + Action, + GModelElement, + GModelRoot, + IActionHandler, + MoveAction, + Point, + SetBoundsAction, + TYPES, + Vector, + Writable +} from '@eclipse-glsp/sprotty'; import { inject, injectable, optional, postConstruct } from 'inversify'; import { FeedbackEmitter } from '../../base'; import { IFeedbackActionDispatcher } from '../../base/feedback/feedback-action-dispatcher'; @@ -33,7 +44,7 @@ import { ViewportLineType } from './helper-line-feedback'; import { IHelperLineManager } from './helper-line-manager'; -import { Direction, HelperLineType } from './model'; +import { Direction, HelperLine, HelperLineType, isHelperLine } from './model'; export interface IHelperLineOptions { /** @@ -158,4 +169,28 @@ export class HelperLineManager implements IActionHandler, ISelectionListener, IH ? this.options.minimumMoveDelta.x : this.options.minimumMoveDelta.y; } + + getMinimumMoveVector(element: GModelElement, isSnap: boolean, directions: Direction[]): Vector | undefined { + if (!isSnap) { + return undefined; + } + + const helperLines = element.root.children.filter(child => isHelperLine(child)) as HelperLine[]; + if (helperLines.length === 0) { + return undefined; + } + + const minimum: Writable = { ...Vector.ZERO }; + if (directions.includes(Direction.Left) && helperLines.some(line => line.isLeft || line.isCenter)) { + minimum.x = this.getMinimumMoveDelta(element, isSnap, Direction.Left); + } else if (directions.includes(Direction.Right) && helperLines.some(line => line.isRight || line.isCenter)) { + minimum.x = this.getMinimumMoveDelta(element, isSnap, Direction.Right); + } + if (directions.includes(Direction.Up) && helperLines.some(line => line.isTop || line.isMiddle)) { + minimum.y = this.getMinimumMoveDelta(element, isSnap, Direction.Up); + } else if (directions.includes(Direction.Down) && helperLines.some(line => line.isBottom || line.isMiddle)) { + minimum.y = this.getMinimumMoveDelta(element, isSnap, Direction.Down); + } + return Vector.isZero(minimum) ? undefined : minimum; + } } diff --git a/packages/client/src/features/helper-lines/helper-line-manager.ts b/packages/client/src/features/helper-lines/helper-line-manager.ts index 18f7a57a9..5c00c8850 100644 --- a/packages/client/src/features/helper-lines/helper-line-manager.ts +++ b/packages/client/src/features/helper-lines/helper-line-manager.ts @@ -13,16 +13,25 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { GModelElement } from '@eclipse-glsp/sprotty'; +import { GModelElement, Vector } from '@eclipse-glsp/sprotty'; import { Direction } from './model'; export interface IHelperLineManager { /** - * Calculates the minimum move delta that is necessary to break through a helper line. + * Calculates the minimum move delta on one axis that is necessary to break through a helper line. * * @param element element that is being moved * @param isSnap whether snapping is active or not * @param direction direction in which the target element is moving */ getMinimumMoveDelta(element: GModelElement, isSnap: boolean, direction: Direction): number; + + /** + * Calculates the minimum move vector that is necessary to break through a helper line. + * + * @param element element that is being moved + * @param isSnap whether snapping is active or not + * @param directions directions in which the target element is moving + */ + getMinimumMoveVector(element: GModelElement, isSnap: boolean, directions: Direction[]): Vector | undefined; } diff --git a/packages/client/src/features/routing/edge-router.ts b/packages/client/src/features/routing/edge-router.ts index cbfb71c91..c2ebdc529 100644 --- a/packages/client/src/features/routing/edge-router.ts +++ b/packages/client/src/features/routing/edge-router.ts @@ -41,6 +41,13 @@ export abstract class GLSPAbstractEdgeRouter extends AbstractEdgeRouter { const anchor = super.getTranslatedAnchor(connectable, refPoint, refContainer, edge, anchorCorrection); return Point.isValid(anchor) ? anchor : refPoint; } + + override cleanupRoutingPoints(edge: GRoutableElement, routingPoints: Point[], updateHandles: boolean, addRoutingPoints: boolean): void { + // sometimes it might happen that the source or target has the bounds not properly set when using the feedback edge + if (ensureBounds(edge.source) && ensureBounds(edge.target)) { + super.cleanupRoutingPoints(edge, routingPoints, updateHandles, addRoutingPoints); + } + } } @injectable() @@ -56,6 +63,13 @@ export class GLSPPolylineEdgeRouter extends PolylineEdgeRouter { const anchor = super.getTranslatedAnchor(connectable, refPoint, refContainer, edge, anchorCorrection); return Point.isValid(anchor) ? anchor : refPoint; } + + override cleanupRoutingPoints(edge: GRoutableElement, routingPoints: Point[], updateHandles: boolean, addRoutingPoints: boolean): void { + // sometimes it might happen that the source or target has the bounds not properly set when using the feedback edge + if (ensureBounds(edge.source) && ensureBounds(edge.target)) { + super.cleanupRoutingPoints(edge, routingPoints, updateHandles, addRoutingPoints); + } + } } @injectable() @@ -111,6 +125,13 @@ export class GLSPManhattanEdgeRouter extends ManhattanEdgeRouter { } }); } + + override cleanupRoutingPoints(edge: GRoutableElement, routingPoints: Point[], updateHandles: boolean, addRoutingPoints: boolean): void { + // sometimes it might happen that the source or target has the bounds not properly set when using the feedback edge + if (ensureBounds(edge.source) && ensureBounds(edge.target)) { + super.cleanupRoutingPoints(edge, routingPoints, updateHandles, addRoutingPoints); + } + } } @injectable() @@ -126,4 +147,25 @@ export class GLSPBezierEdgeRouter extends BezierEdgeRouter { const anchor = super.getTranslatedAnchor(connectable, refPoint, refContainer, edge, anchorCorrection); return Point.isValid(anchor) ? anchor : refPoint; } + + override cleanupRoutingPoints(edge: GRoutableElement, routingPoints: Point[], updateHandles: boolean, addRoutingPoints: boolean): void { + // sometimes it might happen that the source or target has the bounds not properly set when using the feedback edge + if (ensureBounds(edge.source) && ensureBounds(edge.target)) { + super.cleanupRoutingPoints(edge, routingPoints, updateHandles, addRoutingPoints); + } + } +} + +function ensureBounds(element?: GConnectableElement): boolean { + if (!element) { + return false; + } + if (element.bounds) { + return true; + } + if (element.position && element.size) { + element.bounds = { ...element.position, ...element.size }; + return true; + } + return false; } diff --git a/packages/client/src/features/tools/base-tools.ts b/packages/client/src/features/tools/base-tools.ts index b232f06a1..25917bf36 100644 --- a/packages/client/src/features/tools/base-tools.ts +++ b/packages/client/src/features/tools/base-tools.ts @@ -23,11 +23,41 @@ import { EnableToolsAction, Tool } from '../../base/tool-manager/tool'; import { GLSPKeyTool } from '../../base/view/key-tool'; import { GLSPMouseTool } from '../../base/view/mouse-tool'; +export interface FeedbackAwareTool extends Tool { + /** + * Creates a new feedback emitter helper object. While anything can serve as a feedback emitter, + * this method ensures that the emitter is stable and does not change between model updates. + */ + createFeedbackEmitter(): FeedbackEmitter; + + /** + * Registers `actions` to be sent out as feedback, i.e., changes that are re-established whenever the `GModelRoot` + * has been set or updated. + * + * @param feedbackActions the actions to be sent out. + * @param feedbackEmitter the emitter sending out feedback actions (this tool by default). + * @param cleanupActions the actions to be sent out when the feedback is de-registered through the returned Disposable. + * @returns A 'Disposable' that de-registers the feedback and cleans up any pending feedback with the given `cleanupActions`. + * @deprecated It is recommended to create a {@link createFeedbackEmitter dedicated emitter} per feedback instead of using the tool. + */ + registerFeedback(feedbackActions: Action[], feedbackEmitter?: IFeedbackEmitter, cleanupActions?: MaybeActions): Disposable; + + /** + * De-registers all feedback from the given `feedbackEmitter` (this tool by default) and cleans up any pending feedback with the + * given `cleanupActions`. + * + * @param feedbackEmitter the emitter to be deregistered (this tool by default). + * @param cleanupActions the actions to be dispatched right after the deregistration to clean up any pending feedback. + * @deprecated It is recommended to create a {@link createFeedbackEmitter dedicated emitter} per feedback and dispose it like that. + */ + deregisterFeedback(feedbackEmitter?: IFeedbackEmitter, cleanupActions?: MaybeActions): void; +} + /** * A reusable base implementation for edit {@link Tool}s. */ @injectable() -export abstract class BaseEditTool implements Tool { +export abstract class BaseEditTool implements FeedbackAwareTool { @inject(TYPES.IFeedbackActionDispatcher) protected feedbackDispatcher: IFeedbackActionDispatcher; @inject(TYPES.IActionDispatcher) protected actionDispatcher: GLSPActionDispatcher; @inject(GLSPMouseTool) protected mouseTool: GLSPMouseTool; @@ -56,28 +86,10 @@ export abstract class BaseEditTool implements Tool { return this.feedbackDispatcher.createEmitter(); } - /** - * Registers `actions` to be sent out as feedback, i.e., changes that are re-established whenever the `GModelRoot` - * has been set or updated. - * - * @param feedbackActions the actions to be sent out. - * @param feedbackEmitter the emitter sending out feedback actions (this tool by default). - * @param cleanupActions the actions to be sent out when the feedback is de-registered through the returned Disposable. - * @returns A 'Disposable' that de-registers the feedback and cleans up any pending feedback with the given `cleanupActions`. - * @deprecated It is recommended to create a {@link createFeedbackEmitter dedicated emitter} per feedback instead of using the tool. - */ registerFeedback(feedbackActions: Action[], feedbackEmitter: IFeedbackEmitter = this, cleanupActions?: MaybeActions): Disposable { return this.feedbackDispatcher.registerFeedback(feedbackEmitter, feedbackActions, cleanupActions); } - /** - * De-registers all feedback from the given `feedbackEmitter` (this tool by default) and cleans up any pending feedback with the - * given `cleanupActions`. - * - * @param feedbackEmitter the emitter to be deregistered (this tool by default). - * @param cleanupActions the actions to be dispatched right after the deregistration to clean up any pending feedback. - * @deprecated It is recommended to create a {@link createFeedbackEmitter dedicated emitter} per feedback and dispose it like that. - */ deregisterFeedback(feedbackEmitter: IFeedbackEmitter = this, cleanupActions?: MaybeActions): void { this.feedbackDispatcher.deregisterFeedback(feedbackEmitter, cleanupActions); } diff --git a/packages/client/src/features/tools/change-bounds/change-bounds-manager.ts b/packages/client/src/features/tools/change-bounds/change-bounds-manager.ts new file mode 100644 index 000000000..705d41f2b --- /dev/null +++ b/packages/client/src/features/tools/change-bounds/change-bounds-manager.ts @@ -0,0 +1,143 @@ +/******************************************************************************** + * Copyright (c) 2024 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { + Dimension, + GModelElement, + ISnapper, + KeyboardModifier, + MousePositionTracker, + Movement, + Point, + TYPES, + Vector, + isBoundsAware, + isLocateable +} from '@eclipse-glsp/sprotty'; +import { inject, injectable, optional } from 'inversify'; +import { FeedbackEmitter } from '../../../base'; +import { isValidMove, minDimensions } from '../../../utils'; +import { LayoutAware } from '../../bounds/layout-data'; +import { + IMovementRestrictor, + ResizeHandleLocation, + movementRestrictionFeedback, + removeMovementRestrictionFeedback +} from '../../change-bounds'; +import { IHelperLineManager } from '../../helper-lines'; +import { ChangeBoundsTracker, TrackedElementMove } from './change-bounds-tracker'; + +@injectable() +export class ChangeBoundsManager { + constructor( + @inject(MousePositionTracker) readonly positionTracker: MousePositionTracker, + @optional() @inject(TYPES.IMovementRestrictor) readonly movementRestrictor?: IMovementRestrictor, + @optional() @inject(TYPES.ISnapper) readonly snapper?: ISnapper, + @optional() @inject(TYPES.IHelperLineManager) readonly helperLineManager?: IHelperLineManager + ) {} + + unsnapModifier(): KeyboardModifier { + return 'shift'; + } + + usePositionSnap(arg: MouseEvent | KeyboardEvent | any): boolean { + return typeof arg === 'boolean' ? arg : !(arg as MouseEvent | KeyboardEvent).shiftKey; + } + + snapPosition(element: GModelElement, position: Point): Point { + return this.snapper?.snap(position, element) ?? position; + } + + isValid(element: GModelElement): boolean { + return this.hasValidPosition(element) && this.hasValidSize(element); + } + + hasValidPosition(element: GModelElement, position?: Point): boolean { + return !isLocateable(element) || isValidMove(element, position ?? element.position, this.movementRestrictor); + } + + hasValidSize(element: GModelElement, size?: Dimension): boolean { + if (!isBoundsAware(element)) { + return true; + } + const dimension: Dimension = size ?? element.bounds; + const minimum = this.getMinimumSize(element); + if (dimension.width < minimum.width || dimension.height < minimum.height) { + return false; + } + return true; + } + + getMinimumSize(element: GModelElement): Dimension { + if (!isBoundsAware(element)) { + return Dimension.EMPTY; + } + const definedMinimum = minDimensions(element); + const computedMinimum = LayoutAware.getComputedDimensions(element); + return computedMinimum + ? { + width: Math.max(definedMinimum.width, computedMinimum.width), + height: Math.max(definedMinimum.height, computedMinimum.height) + } + : definedMinimum; + } + + useMovementRestriction(arg: MouseEvent | KeyboardEvent | any): boolean { + return this.usePositionSnap(arg); + } + + restrictMovement(element: GModelElement, movement: Movement): Movement { + const minimumMovement = this.helperLineManager?.getMinimumMoveVector(element, true, movement.direction); + if (!minimumMovement) { + return movement; + } + // minimum is given in absolute coordinates + // if minimum is not reached, reset to original position for that coordinate + const absVector = Vector.abs(movement.vector); + const targetPosition: Point = { + x: absVector.x < minimumMovement.x ? movement.from.x : movement.to.x, + y: absVector.y < minimumMovement.y ? movement.from.y : movement.to.y + }; + return Point.move(movement.from, targetPosition); + } + + addRestrictionFeedback(feedback: FeedbackEmitter, move: TrackedElementMove): FeedbackEmitter { + if (this.movementRestrictor) { + feedback.add( + movementRestrictionFeedback(move.element, this.movementRestrictor!, move.valid), + removeMovementRestrictionFeedback(move.element, this.movementRestrictor!) + ); + } + return feedback; + } + + defaultResizeLocations(): ResizeHandleLocation[] { + return [ + ResizeHandleLocation.TopLeft, + ResizeHandleLocation.TopRight, + ResizeHandleLocation.BottomRight, + ResizeHandleLocation.BottomLeft + ]; + } + + useSymmetricResize(arg: MouseEvent | KeyboardEvent | any): boolean { + return typeof arg === 'boolean' ? arg : (arg as MouseEvent | KeyboardEvent).ctrlKey; + } + + createTracker(): ChangeBoundsTracker { + return new ChangeBoundsTracker(this); + } +} 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 d79fd46a1..9cce98491 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 @@ -19,12 +19,14 @@ import { inject, injectable } from 'inversify'; import { FeedbackCommand } from '../../../base/feedback/feedback-command'; import { OptionalAction } from '../../../base/model/glsp-model-source'; import { forEachElement } from '../../../utils/gmodel-util'; -import { addResizeHandles, isResizable, removeResizeHandles } from '../../change-bounds/model'; +import { ResizeHandleLocation, addResizeHandles, isResizable, removeResizeHandles } from '../../change-bounds/model'; +import { ChangeBoundsManager } from './change-bounds-manager'; export interface ShowChangeBoundsToolResizeFeedbackAction extends Action { kind: typeof ShowChangeBoundsToolResizeFeedbackAction.KIND; elementId: string; + resizeLocations?: ResizeHandleLocation[]; } export namespace ShowChangeBoundsToolResizeFeedbackAction { @@ -34,10 +36,16 @@ export namespace ShowChangeBoundsToolResizeFeedbackAction { return Action.hasKind(object, KIND) && hasStringProp(object, 'elementId'); } - export function create(elementId: string): ShowChangeBoundsToolResizeFeedbackAction { + /** @deprecated Use the create method with the options object parameter instead and set the 'elementId' parameter. */ + export function create(elementId: string): ShowChangeBoundsToolResizeFeedbackAction; + export function create(options: Omit): ShowChangeBoundsToolResizeFeedbackAction; + export function create( + options: Omit | string + ): ShowChangeBoundsToolResizeFeedbackAction { + const opts = typeof options === 'string' ? { elementId: options } : options; return { kind: KIND, - elementId + ...opts }; } } @@ -63,15 +71,18 @@ export class ShowChangeBoundsToolResizeFeedbackCommand extends FeedbackCommand { static readonly KIND = ShowChangeBoundsToolResizeFeedbackAction.KIND; @inject(TYPES.Action) protected action: ShowChangeBoundsToolResizeFeedbackAction; + @inject(TYPES.IChangeBoundsManager) protected changeBoundsManager: ChangeBoundsManager; execute(context: CommandExecutionContext): CommandReturn { const index = context.root.index; forEachElement(index, isResizable, element => element.id !== this.action.elementId && removeResizeHandles(element)); - const resizeElement = index.getById(this.action.elementId); - if (resizeElement && isResizable(resizeElement)) { - addResizeHandles(resizeElement); + if (this.action.elementId) { + const resizeElement = index.getById(this.action.elementId); + if (resizeElement && isResizable(resizeElement)) { + addResizeHandles(resizeElement, this.action.resizeLocations ?? this.changeBoundsManager.defaultResizeLocations()); + } } return context.root; } diff --git a/packages/client/src/features/tools/change-bounds/change-bounds-tool-module.ts b/packages/client/src/features/tools/change-bounds/change-bounds-tool-module.ts index c12fb2283..7bfb6513f 100644 --- a/packages/client/src/features/tools/change-bounds/change-bounds-tool-module.ts +++ b/packages/client/src/features/tools/change-bounds/change-bounds-tool-module.ts @@ -16,12 +16,14 @@ import { FeatureModule, TYPES, bindAsService, configureCommand, configureView } from '@eclipse-glsp/sprotty'; import '../../../../css/change-bounds.css'; import { SResizeHandle } from '../../change-bounds/model'; +import { ChangeBoundsManager } from './change-bounds-manager'; import { ChangeBoundsTool } from './change-bounds-tool'; import { HideChangeBoundsToolResizeFeedbackCommand, ShowChangeBoundsToolResizeFeedbackCommand } from './change-bounds-tool-feedback'; import { SResizeHandleView } from './view'; export const changeBoundsToolModule = new FeatureModule((bind, unbind, isBound, rebind) => { const context = { bind, unbind, isBound, rebind }; + bindAsService(context, TYPES.IChangeBoundsManager, ChangeBoundsManager); bindAsService(context, TYPES.IDefaultTool, ChangeBoundsTool); configureCommand(context, ShowChangeBoundsToolResizeFeedbackCommand); configureCommand(context, HideChangeBoundsToolResizeFeedbackCommand); diff --git a/packages/client/src/features/tools/change-bounds/change-bounds-tool-move-feedback.ts b/packages/client/src/features/tools/change-bounds/change-bounds-tool-move-feedback.ts index e7ddc0fbd..9d4f5b516 100644 --- a/packages/client/src/features/tools/change-bounds/change-bounds-tool-move-feedback.ts +++ b/packages/client/src/features/tools/change-bounds/change-bounds-tool-move-feedback.ts @@ -16,26 +16,15 @@ import { Action, Disposable, ElementMove, GModelElement, GModelRoot, MoveAction, Point, findParentByFeature } from '@eclipse-glsp/sprotty'; import { DebouncedFunc, debounce } from 'lodash'; +import { ChangeBoundsTracker, TrackedMove } from '.'; import { DragAwareMouseListener } from '../../../base/drag-aware-mouse-listener'; import { CursorCSS, cursorFeedbackAction } from '../../../base/feedback/css-feedback'; import { FeedbackEmitter } from '../../../base/feedback/feeback-emitter'; import { MoveableElement, filter, getElements, isNonRoutableSelectedMovableBoundsAware, removeDescendants } from '../../../utils'; -import { PointPositionUpdater } from '../../change-bounds'; import { SResizeHandle } from '../../change-bounds/model'; -import { createMovementRestrictionFeedback, removeMovementRestrictionFeedback } from '../../change-bounds/movement-restrictor'; import { ChangeBoundsTool } from './change-bounds-tool'; import { MoveFinishedEventAction, MoveInitializedEventAction } from './change-bounds-tool-feedback'; -export interface ValidatedElementMove extends ElementMove { - valid: boolean; -} - -export namespace ValidatedElementMove { - export function isValid(move: ElementMove): boolean { - return (move as ValidatedElementMove).valid ?? true; - } -} - /** * This mouse listener provides visual feedback for moving by sending client-side * `MoveAction`s while elements are selected and dragged. This will also update @@ -45,7 +34,7 @@ export namespace ValidatedElementMove { */ export class FeedbackMoveMouseListener extends DragAwareMouseListener implements Disposable { protected rootElement?: GModelRoot; - protected positionUpdater; + protected tracker: ChangeBoundsTracker; protected elementId2startPos = new Map(); protected pendingMoveInitialized?: DebouncedFunc<() => void>; @@ -55,7 +44,7 @@ export class FeedbackMoveMouseListener extends DragAwareMouseListener implements constructor(protected tool: ChangeBoundsTool) { super(); - this.positionUpdater = new PointPositionUpdater(tool.positionSnapper); + this.tracker = tool.createChangeBoundsTracker(); this.moveInitializedFeedback = tool.createFeedbackEmitter(); this.moveFeedback = tool.createFeedbackEmitter(); } @@ -66,7 +55,7 @@ export class FeedbackMoveMouseListener extends DragAwareMouseListener implements this.initializeMove(target, event); return []; } - this.positionUpdater.resetPosition(); + this.tracker.stopTracking(); return []; } @@ -77,10 +66,10 @@ export class FeedbackMoveMouseListener extends DragAwareMouseListener implements } const moveable = findParentByFeature(target, this.isValidMoveable); if (moveable !== undefined) { - this.positionUpdater.updateLastDragPosition(event); + this.tracker.startTracking(); this.scheduleMoveInitialized(); } else { - this.positionUpdater.resetPosition(); + this.tracker.stopTracking(); } } @@ -113,7 +102,7 @@ export class FeedbackMoveMouseListener extends DragAwareMouseListener implements override nonDraggingMouseUp(element: GModelElement, event: MouseEvent): Action[] { // should reset everything that may have happend on mouse down this.moveInitializedFeedback.dispose(); - this.positionUpdater.resetPosition(); + this.tracker.stopTracking(); return []; } @@ -121,7 +110,7 @@ export class FeedbackMoveMouseListener extends DragAwareMouseListener implements super.mouseMove(target, event); if (event.buttons === 0) { return this.mouseUp(target, event); - } else if (!this.positionUpdater.isLastDragPositionUndefined()) { + } else if (this.tracker.isTracking()) { return this.moveElements(target, event); } return []; @@ -131,35 +120,34 @@ export class FeedbackMoveMouseListener extends DragAwareMouseListener implements if (this.elementId2startPos.size === 0) { this.initializeElementsToMove(target.root); } - const moveAction = this.getElementMoves(target, event, false); - if (!moveAction) { + const elementsToMove = this.getElementsToMove(target); + const move = this.tracker.moveElements(elementsToMove, { snap: event, restrict: event }); + if (move.elementMoves.length === 0) { return []; } // cancel any pending move this.pendingMoveInitialized?.cancel(); - this.moveFeedback.add(moveAction, () => this.resetElementPositions(target)); - this.addMovementFeedback(moveAction, target); + this.moveFeedback.add(this.createMoveAction(move), () => this.resetElementPositions(target)); + this.addMovementFeedback(move, target, event); + this.tracker.updateTrackingPosition(move); this.moveFeedback.submit(); return []; } - protected addMovementFeedback(movement: MoveAction, ctx: GModelElement): void { + protected createMoveAction(trackedMove: TrackedMove): Action { + // we never want to animate the move action as this interferes with the move feedback + return MoveAction.create( + trackedMove.elementMoves.map(move => ({ elementId: move.element.id, toPosition: move.toPosition })), + { animate: false } + ); + } + + protected addMovementFeedback(trackedMove: TrackedMove, ctx: GModelElement, event: MouseEvent): void { // cursor feedback this.moveFeedback.add(cursorFeedbackAction(CursorCSS.MOVE), cursorFeedbackAction(CursorCSS.DEFAULT)); // restriction feedback - movement.moves.forEach(move => { - const element = ctx.root.index.getById(move.elementId); - if (element && this.tool.movementRestrictor) { - if (!ValidatedElementMove.isValid(move)) { - this.moveFeedback.add(createMovementRestrictionFeedback(element, this.tool.movementRestrictor), () => - removeMovementRestrictionFeedback(element, this.tool.movementRestrictor!) - ); - } else { - this.moveFeedback.add(removeMovementRestrictionFeedback(element, this.tool.movementRestrictor)); - } - } - }); + trackedMove.elementMoves.forEach(move => this.tool.changeBoundsManager.addRestrictionFeedback(this.moveFeedback, move)); } protected initializeElementsToMove(root: GModelRoot): void { @@ -177,45 +165,6 @@ export class FeedbackMoveMouseListener extends DragAwareMouseListener implements return getElements(context.root.index, Array.from(this.elementId2startPos.keys()), this.isValidMoveable); } - protected getElementMoves(target: GModelElement, event: MouseEvent, finished: boolean): MoveAction | undefined { - const delta = this.positionUpdater.updatePosition(target, event); - if (!delta) { - return undefined; - } - const elementMoves: ElementMove[] = this.getElementMovesForDelta(target, delta, finished).filter(move => - this.filterElementMove(move) - ); - if (elementMoves.length > 0) { - // we never want to animate the move action as this interferes with the move feedback - return MoveAction.create(elementMoves, { animate: false, finished }); - } else { - return undefined; - } - } - - protected filterElementMove(elementMove: ValidatedElementMove): boolean { - return !!elementMove.fromPosition && !Point.equals(elementMove.fromPosition, elementMove.toPosition); - } - - protected getElementMovesForDelta(target: GModelElement, delta: Point, finished: boolean): ValidatedElementMove[] { - return this.getElementsToMove(target).flatMap(element => { - const startPosition = this.elementId2startPos.get(element.id); - if (!startPosition) { - return []; - } - const targetPosition = Point.add(element.position, delta); - const valid = this.tool.movementRestrictor?.validate(element, targetPosition) ?? true; - return [this.createElementMove(element, targetPosition, valid, finished)]; - }); - } - - protected createElementMove(element: MoveableElement, toPosition: Point, valid: boolean, finished: boolean): ValidatedElementMove { - // if we are finished and have an invalid move, we want to move the element back to its start position - return !valid && finished - ? { elementId: element.id, fromPosition: element.position, toPosition: element.position, valid } - : { elementId: element.id, fromPosition: element.position, toPosition, valid }; - } - protected resetElementPositions(context: GModelElement): MoveAction | undefined { const elementMoves: ElementMove[] = this.revertElementMoves(context); return MoveAction.create(elementMoves, { animate: false, finished: true }); @@ -233,12 +182,13 @@ export class FeedbackMoveMouseListener extends DragAwareMouseListener implements } override draggingMouseUp(target: GModelElement, event: MouseEvent): Action[] { - if (this.positionUpdater.isLastDragPositionUndefined()) { + if (!this.tracker.isTracking()) { return []; } // only reset the move of invalid elements, the others will be handled by the change bounds tool itself - const moves = this.getElementMovesForDelta(target, Point.ORIGIN, false); - moves.filter(move => move.valid).forEach(move => this.elementId2startPos.delete(move.elementId)); + this.getElementsToMove(target) + .filter(element => this.tool.changeBoundsManager.isValid(element)) + .forEach(element => this.elementId2startPos.delete(element.id)); this.dispose(); return []; } @@ -247,7 +197,7 @@ export class FeedbackMoveMouseListener extends DragAwareMouseListener implements this.pendingMoveInitialized?.cancel(); this.moveInitializedFeedback.dispose(); this.moveFeedback.dispose(); - this.positionUpdater.resetPosition(); + this.tracker.dispose(); this.elementId2startPos.clear(); super.dispose(); } diff --git a/packages/client/src/features/tools/change-bounds/change-bounds-tool.ts b/packages/client/src/features/tools/change-bounds/change-bounds-tool.ts index d7b3645ed..842d095d6 100644 --- a/packages/client/src/features/tools/change-bounds/change-bounds-tool.ts +++ b/packages/client/src/features/tools/change-bounds/change-bounds-tool.ts @@ -37,11 +37,11 @@ import { TYPES, findParentByFeature } from '@eclipse-glsp/sprotty'; +import { ChangeBoundsManager, ChangeBoundsTracker, TrackedElementResize, TrackedResize } from '..'; import { DragAwareMouseListener } from '../../../base/drag-aware-mouse-listener'; -import { CursorCSS, applyCssClasses, cursorFeedbackAction, deleteCssClasses } from '../../../base/feedback/css-feedback'; +import { CursorCSS, applyCssClasses, cursorFeedbackAction, deleteCssClasses, toggleCssClasses } from '../../../base/feedback/css-feedback'; import { FeedbackEmitter } from '../../../base/feedback/feeback-emitter'; import { ISelectionListener, SelectionService } from '../../../base/selection-service'; -import { isValidMove, isValidSize } from '../../../utils'; import { BoundsAwareModelElement, ResizableModelElement, @@ -51,16 +51,13 @@ import { isNonRoutableSelectedMovableBoundsAware, toElementAndBounds } from '../../../utils/gmodel-util'; -import { SetBoundsFeedbackAction } from '../../bounds'; -import { PointPositionUpdater } from '../../change-bounds'; -import { ResizeHandleLocation, SResizeHandle, isResizable } from '../../change-bounds/model'; +import { LocalRequestBoundsAction, SetBoundsFeedbackAction } from '../../bounds'; +import { SResizeHandle, isResizable } from '../../change-bounds/model'; import { IMovementRestrictor, - createMovementRestrictionFeedback, + movementRestrictionFeedback, removeMovementRestrictionFeedback } from '../../change-bounds/movement-restrictor'; -import { PositionSnapper } from '../../change-bounds/position-snapper'; -import { getDirectionFrom } from '../../helper-lines'; import { BaseEditTool } from '../base-tools'; import { HideChangeBoundsToolResizeFeedbackAction, @@ -89,7 +86,7 @@ export class ChangeBoundsTool extends BaseEditTool { @inject(SelectionService) protected selectionService: SelectionService; @inject(EdgeRouterRegistry) @optional() readonly edgeRouterRegistry?: EdgeRouterRegistry; @inject(TYPES.IMovementRestrictor) @optional() readonly movementRestrictor?: IMovementRestrictor; - @inject(PositionSnapper) readonly positionSnapper: PositionSnapper; + @inject(TYPES.IChangeBoundsManager) readonly changeBoundsManager: ChangeBoundsManager; get id(): string { return ChangeBoundsTool.ID; @@ -115,6 +112,10 @@ export class ChangeBoundsTool extends BaseEditTool { ); } + createChangeBoundsTracker(): ChangeBoundsTracker { + return this.changeBoundsManager.createTracker(); + } + protected createMoveMouseListener(): MouseListener { return new FeedbackMoveMouseListener(this); } @@ -124,22 +125,12 @@ export class ChangeBoundsTool extends BaseEditTool { } } -export interface ValidatedElementAndBounds extends ElementAndBounds { - valid: boolean; -} - -export namespace ValidatedElementAndBounds { - export function isValid(move: ElementAndBounds): boolean { - return (move as ValidatedElementAndBounds).valid ?? true; - } -} - export class ChangeBoundsListener extends DragAwareMouseListener implements ISelectionListener, Disposable { static readonly CSS_CLASS_ACTIVE = 'active'; // members for calculating the correct position change protected initialBounds: ElementAndBounds | undefined; - protected positionUpdater: PointPositionUpdater; + protected tracker: ChangeBoundsTracker; // members for resize mode protected activeResizeElement?: ResizableModelElement; @@ -149,7 +140,7 @@ export class ChangeBoundsListener extends DragAwareMouseListener implements ISel constructor(protected tool: ChangeBoundsTool) { super(); - this.positionUpdater = new PointPositionUpdater(tool.positionSnapper); + this.tracker = tool.createChangeBoundsTracker(); this.handleFeedback = tool.createFeedbackEmitter(); this.resizeFeedback = tool.createFeedbackEmitter(); } @@ -171,21 +162,28 @@ export class ChangeBoundsListener extends DragAwareMouseListener implements ISel this.activeResizeElement = this.activeResizeHandle?.parent ?? this.findResizeElement(target); if (this.activeResizeElement) { if (event) { - this.positionUpdater.updateLastDragPosition(event); + this.tracker.startTracking(); } this.initialBounds = { newSize: this.activeResizeElement.bounds, newPosition: this.activeResizeElement.bounds, elementId: this.activeResizeElement.id }; + // we trigger the local bounds calculation once to get the correct layout information for reszing + // for any sub-sequent calls the layout information will be updated automatically + this.tool + .createFeedbackEmitter() + .add(LocalRequestBoundsAction.create(target.root, [this.activeResizeElement.id])) + .submit() + .dispose(); this.handleFeedback.add( - ShowChangeBoundsToolResizeFeedbackAction.create(this.activeResizeElement.id), + ShowChangeBoundsToolResizeFeedbackAction.create({ elementId: this.activeResizeElement.id }), HideChangeBoundsToolResizeFeedbackAction.create() ); this.handleFeedback.submit(); return true; } else { - this.positionUpdater.resetPosition(); + this.tracker.stopTracking(); this.handleFeedback.dispose(); return false; } @@ -200,34 +198,42 @@ export class ChangeBoundsListener extends DragAwareMouseListener implements ISel protected override draggingMouseMove(target: GModelElement, event: MouseEvent): Action[] { // rely on the FeedbackMoveMouseListener to update the element bounds of selected elements // consider resize handles ourselves - if (this.activeResizeHandle && !this.positionUpdater.isLastDragPositionUndefined()) { - if (!this.initialBounds) { - const element = this.activeResizeHandle.parent; - this.initialBounds = { elementId: element.id, newSize: element.bounds, newPosition: element.bounds }; - } - const positionUpdate = this.positionUpdater.updatePosition(target, event, getDirectionFrom(this.activeResizeHandle.location)); - if (positionUpdate) { - const resizeActions = this.handleResizeOnClient(positionUpdate).filter(action => action); - if (resizeActions.length > 0) { - this.resizeFeedback.add(SetBoundsFeedbackAction.create(resizeActions), () => this.resetBoundsAction()); - this.addResizeFeedback(resizeActions, this.activeResizeHandle, target, event); - this.resizeFeedback.submit(); + if (this.activeResizeHandle && this.tracker.isTracking()) { + const resize = this.tracker.resizeElements(this.activeResizeHandle, { + snap: event, + symmetric: event, + restrict: event, + skipStatic: false + }); + this.addResizeFeedback(resize, target, event); + if (resize.elementResizes.length > 0) { + const resizeAction = this.resizeBoundsAction(resize); + this.resizeFeedback.add(resizeAction, () => this.resetBounds()); + if (resizeAction.bounds.length > 0) { + this.tracker.updateTrackingPosition(resize.handleMove.moveVector); } } + this.resizeFeedback.submit(); } return super.draggingMouseMove(target, event); } - protected filterResizeOnClient(resize: ValidatedElementAndBounds, positionUpdate: Point): boolean { - return true; + protected resizeBoundsAction(resize: TrackedResize): SetBoundsFeedbackAction { + // we do not want to resize elements beyond their valid size, not even for feedback, as the next layout cycle usually corrects this + const elementResizes = resize.elementResizes.filter(elementResize => elementResize.valid.size); + return SetBoundsFeedbackAction.create(elementResizes.map(elementResize => this.toElementAndBounds(elementResize))); + } + + protected toElementAndBounds(elementResize: TrackedElementResize): ElementAndBounds { + return { + elementId: elementResize.element.id, + newSize: elementResize.toBounds, + newPosition: elementResize.toBounds + }; } - protected addResizeFeedback( - resizeActions: ValidatedElementAndBounds[], - handle: SResizeHandle, - target: GModelElement, - event: MouseEvent - ): void { + protected addResizeFeedback(resize: TrackedResize, target: GModelElement, event: MouseEvent): void { + const handle = resize.handleMove.element; // handle feedback this.resizeFeedback.add( applyCssClasses(handle, ChangeBoundsListener.CSS_CLASS_ACTIVE), @@ -235,39 +241,37 @@ export class ChangeBoundsListener extends DragAwareMouseListener implements ISel ); // cursor feedback - const cursorClass = handle.isNeResize() - ? CursorCSS.RESIZE_NE - : handle.isNwResize() - ? CursorCSS.RESIZE_NW - : handle.isSeResize() - ? CursorCSS.RESIZE_SE - : CursorCSS.RESIZE_SW; + const cursorClass = SResizeHandle.getCursorCss(resize.handleMove.element); this.resizeFeedback.add(cursorFeedbackAction(cursorClass), cursorFeedbackAction(CursorCSS.DEFAULT)); - // restriction feedback - resizeActions.forEach(elementResize => { - const element = handle.root.index.getById(elementResize.elementId); - if (element && this.tool.movementRestrictor) { - if (!elementResize.valid) { - this.resizeFeedback.add(createMovementRestrictionFeedback(element, this.tool.movementRestrictor), () => - removeMovementRestrictionFeedback(element, this.tool.movementRestrictor!) - ); - } else { - this.resizeFeedback.add(removeMovementRestrictionFeedback(element, this.tool.movementRestrictor!)); - } + const movementRestrictor = this.tool.changeBoundsManager.movementRestrictor; + resize.elementResizes.forEach(elementResize => { + if (movementRestrictor) { + this.resizeFeedback.add( + movementRestrictionFeedback(elementResize.element, movementRestrictor, elementResize.valid.move), + removeMovementRestrictionFeedback(elementResize.element, movementRestrictor) + ); } + this.resizeFeedback.add( + toggleCssClasses(elementResize.element, !elementResize.valid.size, 'resize-not-allowed'), + deleteCssClasses(elementResize.element, 'resize-not-allowed') + ); }); + this.resizeFeedback.add( + toggleCssClasses(resize.handleMove.element, !resize.valid.size, 'resize-not-allowed'), + deleteCssClasses(resize.handleMove.element, 'resize-not-allowed') + ); } - protected resetBoundsAction(): Action[] { + protected resetBounds(): Action[] { // reset the bounds to the initial bounds and ensure that we do not show helper line feedback anymore (MoveFinishedEventAction) return this.initialBounds ? [SetBoundsFeedbackAction.create([this.initialBounds]), MoveFinishedEventAction.create()] : [MoveFinishedEventAction.create()]; } - override draggingMouseUp(target: GModelElement, _event: MouseEvent): Action[] { - if (this.positionUpdater.isLastDragPositionUndefined()) { + override draggingMouseUp(target: GModelElement, event: MouseEvent): Action[] { + if (!this.tracker.isTracking()) { return []; } const actions: Action[] = []; @@ -302,11 +306,7 @@ export class ChangeBoundsListener extends DragAwareMouseListener implements ISel } protected isValidElement(element: BoundsAwareModelElement, selectedElements: Set = new Set()): boolean { - return ( - this.isValidMove(element, element.bounds) && - this.isValidSize(element, element.bounds) && - !this.isChildOfSelected(selectedElements, element) - ); + return this.tool.changeBoundsManager.isValid(element) && !this.isChildOfSelected(selectedElements, element); } protected isChildOfSelected(selectedElements: Set, element: GModelElement): boolean { @@ -342,7 +342,7 @@ export class ChangeBoundsListener extends DragAwareMouseListener implements ISel } protected handleResizeOnServer(activeResizeHandle: SResizeHandle): Action[] { - if (this.isValidElement(activeResizeHandle.parent) && this.initialBounds) { + if (this.tool.changeBoundsManager.isValid(activeResizeHandle.parent) && this.initialBounds) { const elementAndBounds = toElementAndBounds(activeResizeHandle.parent); if (!this.initialBounds.newPosition || !elementAndBounds.newPosition) { return []; @@ -382,80 +382,11 @@ export class ChangeBoundsListener extends DragAwareMouseListener implements ISel return element !== undefined && this.activeResizeElement !== undefined && element.id === this.activeResizeElement.id; } - protected handleResizeOnClient(positionUpdate: Point): ValidatedElementAndBounds[] { - if (!this.activeResizeHandle) { - return []; - } - - if (this.isActiveResizeElement(this.activeResizeHandle.parent)) { - switch (this.activeResizeHandle.location) { - case ResizeHandleLocation.TopLeft: - return this.handleTopLeftResize(this.activeResizeHandle.parent, positionUpdate); - case ResizeHandleLocation.TopRight: - return this.handleTopRightResize(this.activeResizeHandle.parent, positionUpdate); - case ResizeHandleLocation.BottomLeft: - return this.handleBottomLeftResize(this.activeResizeHandle.parent, positionUpdate); - case ResizeHandleLocation.BottomRight: - return this.handleBottomRightResize(this.activeResizeHandle.parent, positionUpdate); - } - } - return []; - } - - protected handleTopLeftResize(resizeElement: ResizableModelElement, positionUpdate: Point): ValidatedElementAndBounds[] { - return this.createSetBoundsAction( - resizeElement, - { x: resizeElement.bounds.x + positionUpdate.x, y: resizeElement.bounds.y + positionUpdate.y }, - { width: resizeElement.bounds.width - positionUpdate.x, height: resizeElement.bounds.height - positionUpdate.y } - ); - } - - protected handleTopRightResize(resizeElement: ResizableModelElement, positionUpdate: Point): ValidatedElementAndBounds[] { - return this.createSetBoundsAction( - resizeElement, - { x: resizeElement.bounds.x, y: resizeElement.bounds.y + positionUpdate.y }, - { width: resizeElement.bounds.width + positionUpdate.x, height: resizeElement.bounds.height - positionUpdate.y } - ); - } - - protected handleBottomLeftResize(resizeElement: ResizableModelElement, positionUpdate: Point): ValidatedElementAndBounds[] { - return this.createSetBoundsAction( - resizeElement, - { x: resizeElement.bounds.x + positionUpdate.x, y: resizeElement.bounds.y }, - { width: resizeElement.bounds.width - positionUpdate.x, height: resizeElement.bounds.height + positionUpdate.y } - ); - } - - protected handleBottomRightResize(resizeElement: ResizableModelElement, positionUpdate: Point): ValidatedElementAndBounds[] { - return this.createSetBoundsAction( - resizeElement, - { x: resizeElement.bounds.x, y: resizeElement.bounds.y }, - { width: resizeElement.bounds.width + positionUpdate.x, height: resizeElement.bounds.height + positionUpdate.y } - ); - } - - protected createSetBoundsAction(element: BoundsAwareModelElement, newPosition: Point, newSize: Dimension): ValidatedElementAndBounds[] { - if (!isValidSize(element, newSize)) { - // we are not allowing any invalid sizes (breaking min size), not even during client feedback - return []; - } - const valid = this.isValidMove(element, newPosition); - return [{ elementId: element.id, newPosition, newSize, valid }]; - } - - protected isValidSize(element: BoundsAwareModelElement, size: Dimension): boolean { - return isValidSize(element, size); - } - - protected isValidMove(element: BoundsAwareModelElement, newPosition: Point): boolean { - return isValidMove(element, newPosition, this.tool.movementRestrictor); - } - protected disposeAllButHandles(): void { // We do not dispose the handle feedback as we want to keep showing the handles on selected elements // this.handleFeedback.dispose(); this.resizeFeedback.dispose(); - this.positionUpdater.resetPosition(); + this.tracker.dispose(); this.activeResizeElement = undefined; this.activeResizeHandle = undefined; this.initialBounds = undefined; diff --git a/packages/client/src/features/tools/change-bounds/change-bounds-tracker.ts b/packages/client/src/features/tools/change-bounds/change-bounds-tracker.ts new file mode 100644 index 000000000..4b76a1884 --- /dev/null +++ b/packages/client/src/features/tools/change-bounds/change-bounds-tracker.ts @@ -0,0 +1,460 @@ +/******************************************************************************** + * Copyright (c) 2024 EclipseSource and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { + Bounds, + Dimension, + GModelElement, + GRoutingHandle, + Locateable, + Movement, + Point, + ResolvedElementMove, + TypeGuard, + Vector, + Writable, + hasBooleanProp, + isMoveable +} from '@eclipse-glsp/sprotty'; +import { ChangeBoundsManager } from '..'; +import { BoundsAwareModelElement, MoveableElement, ResizableModelElement, getElements } from '../../../utils'; +import { DiagramMovementCalculator, ResizeHandleLocation, SResizeHandle } from '../../change-bounds'; + +export interface ElementTrackingOptions { + /** Snap position. Default: true. */ + snap: boolean | MouseEvent | KeyboardEvent | any; + /** Restrict position. Default: true */ + restrict: boolean | MouseEvent | KeyboardEvent | any; + /** Validate operation. Default: true */ + validate: boolean; + + /** Skip operations that do not trigger change. Default: true */ + skipStatic: boolean; +} + +export interface MoveOptions extends ElementTrackingOptions { + /** Skip operations are invalid. Default: false */ + skipInvalid: boolean; +} + +export const DEFAULT_MOVE_OPTIONS: MoveOptions = { + snap: true, + restrict: true, + validate: true, + + skipStatic: true, + skipInvalid: false +}; + +export type MoveableElements = + | MoveableElement[] + | { + ctx: GModelElement; + elementIDs: string[]; + guard?: TypeGuard; + }; + +export interface TrackedElementMove extends ResolvedElementMove { + moveVector: Vector; + valid: boolean; +} + +export type TypedElementMove = TrackedElementMove & { element: T }; + +export namespace ElementMovement { + export function empty(element: T, point = Point.ORIGIN): TypedElementMove { + return { + element, + fromPosition: point, + toPosition: point, + moveVector: Vector.ZERO, + valid: true + }; + } +} + +export interface TrackedMove extends Movement { + elementMoves: TrackedElementMove[]; + valid: boolean; + options: MoveOptions; +} + +export namespace TrackedMove { + export function is(obj: any): obj is TrackedMove { + return Movement.is(obj) && hasBooleanProp(obj, 'valid'); + } +} + +export interface ResizeOptions extends ElementTrackingOptions { + /** Skip resizes that do not actually change the dimension of the element. Default: true. */ + skipStatic: boolean; + + /** Perform symmetric resize on the opposite side. Default: false. */ + symmetric: boolean | MouseEvent | KeyboardEvent | any; + + /** + * Avoids resizes smaller than the minimum size which will yield invalid sizes. + * The minimum size takes precedence over the snapped (grid) position. + * + * Default: true. + */ + capAtMinimumSize: boolean; + + /** Skip resizes that produce an invalid size. Default: false. */ + skipInvalidSize: boolean; + + /** Skip resizes that produce an invalid move. Default: false. */ + skipInvalidMove: boolean; +} + +export const DEFAULT_RESIZE_OPTIONS: ResizeOptions = { + snap: true, + restrict: true, + validate: true, + symmetric: true, + + capAtMinimumSize: true, + + skipStatic: true, + skipInvalidSize: false, + skipInvalidMove: false +}; + +export interface TrackedHandleMove extends TypedElementMove {} + +export interface TrackedElementResize { + element: BoundsAwareModelElement; + fromBounds: Bounds; + toBounds: Bounds; + valid: { + size: boolean; + move: boolean; + }; +} + +export interface TrackedResize extends Movement { + handleMove: TrackedHandleMove; + elementResizes: TrackedElementResize[]; + valid: { + size: boolean; + move: boolean; + }; + options: ResizeOptions; +} + +export class ChangeBoundsTracker { + protected diagramMovement: DiagramMovementCalculator; + + constructor(readonly manager: ChangeBoundsManager) { + this.diagramMovement = new DiagramMovementCalculator(manager.positionTracker); + } + + startTracking(): this { + this.diagramMovement.init(); + return this; + } + + updateTrackingPosition(param: Vector | Movement | TrackedMove): void { + const update = TrackedMove.is(param) ? Vector.max(...param.elementMoves.map(move => move.moveVector)) : param; + this.diagramMovement.updatePosition(update); + } + + isTracking(): boolean { + return this.diagramMovement.hasPosition; + } + + stopTracking(): this { + this.diagramMovement.dispose(); + return this; + } + + // + // MOVE + // + + moveElements(elements: MoveableElements, opts?: Partial): TrackedMove { + const options = this.resolveMoveOptions(opts); + const update = this.calculateDiagramMovement(); + const move: TrackedMove = { ...update, elementMoves: [], valid: true, options }; + + if (Vector.isZero(update.vector)) { + // no movement detected so elements won't be moved, exit early + return move; + } + + // calculate move for each element + const elementsToMove = this.getMoveableElements(elements, options); + for (const element of elementsToMove) { + const elementMove = this.calculateElementMove(element, update.vector, options); + if (!this.skipElementMove(elementMove, options)) { + move.elementMoves.push(elementMove); + move.valid &&= elementMove.valid; + } + } + return move; + } + + protected resolveMoveOptions(opts?: Partial): MoveOptions { + return { + ...DEFAULT_MOVE_OPTIONS, + ...opts, + snap: this.manager.usePositionSnap(opts?.snap ?? DEFAULT_MOVE_OPTIONS.snap), + restrict: this.manager.useMovementRestriction(opts?.restrict ?? DEFAULT_MOVE_OPTIONS.restrict) + }; + } + + protected calculateDiagramMovement(): Movement { + return this.diagramMovement.calculateMoveToCurrent(); + } + + protected getMoveableElements(elements: MoveableElements, options: MoveOptions): MoveableElement[] { + return Array.isArray(elements) ? elements : getElements(elements.ctx.index, elements.elementIDs, elements.guard ?? isMoveable); + } + + protected skipElementMove(elementMove: TrackedElementMove, options: MoveOptions): boolean { + return (options.skipInvalid && !elementMove.valid) || (options.skipStatic && Vector.isZero(elementMove.moveVector)); + } + + protected calculateElementMove(element: T, vector: Vector, options: MoveOptions): TypedElementMove { + const fromPosition = element.position; + const toPosition = Point.add(fromPosition, vector); + const move: TypedElementMove = { element, fromPosition, toPosition, valid: true, moveVector: Vector.ZERO }; + + if (options.snap) { + move.toPosition = this.snapPosition(move, options); + } + + if (options.restrict) { + move.toPosition = this.restrictMovement(move, options); + } + + if (options.validate) { + move.valid = this.validateElementMove(move, options); + } + + move.moveVector = Point.vector(move.fromPosition, move.toPosition); + return move; + } + + protected snapPosition(elementMove: TrackedElementMove, opts: MoveOptions): Point { + return this.manager.snapPosition(elementMove.element, elementMove.toPosition); + } + + protected restrictMovement(elementMove: TrackedElementMove, opts: MoveOptions): Point { + const movement = Point.move(elementMove.fromPosition, elementMove.toPosition); + return this.manager.restrictMovement(elementMove.element, movement).to; + } + + protected validateElementMove(elementMove: TrackedElementMove, opts: MoveOptions): boolean { + return this.manager.hasValidPosition(elementMove.element, elementMove.toPosition); + } + + // + // RESIZE + // + + resizeElements(handle: SResizeHandle, opts?: Partial): TrackedResize { + const options = this.resolveResizeOptions(opts); + const update = this.calculateDiagramMovement(); + const handleMove = this.calculateHandleMove(new MoveableResizeHandle(handle), update.vector, options); + const resize: TrackedResize = { ...update, valid: { move: true, size: true }, options, handleMove, elementResizes: [] }; + if (Vector.isZero(handleMove.moveVector)) { + // no movement detected so elements won't be moved, exit early + return resize; + } + + // symmetic handle move applies the diagram movement to the opposite handle + const symmetricHandleMove = options.symmetric + ? this.calculateHandleMove(handleMove.element.opposite(), Vector.reverse(update.vector), options) + : undefined; + + // calculate resize for each element (typically only one element is resized at a time but customizations are possible) + const elementsToResize = this.getResizeableElements(handle, options); + for (const element of elementsToResize) { + const elementResize = this.calculateElementResize(element, handleMove, options, symmetricHandleMove); + if (!this.skipElementResize(elementResize, options)) { + resize.elementResizes.push(elementResize); + resize.valid.move = resize.valid.move && elementResize.valid.move; + resize.valid.size = resize.valid.size && elementResize.valid.size; + // handleMove.toPosition = SResizeHandle.getHandlePosition(elementResize.toBounds, handle.location); + // handleMove.moveVector = Point.vector(handleMove.fromPosition, handleMove.toPosition); + } + } + return resize; + } + + protected resolveResizeOptions(opts?: Partial): ResizeOptions { + return { + ...DEFAULT_RESIZE_OPTIONS, + ...opts, + snap: this.manager.usePositionSnap(opts?.snap ?? DEFAULT_RESIZE_OPTIONS.snap), + restrict: this.manager.useMovementRestriction(opts?.restrict ?? DEFAULT_RESIZE_OPTIONS.restrict), + symmetric: this.manager.useSymmetricResize(opts?.symmetric ?? DEFAULT_RESIZE_OPTIONS.symmetric) + }; + } + + protected calculateHandleMove(handle: MoveableResizeHandle, diagramMovement: Vector, opts?: Partial): TrackedHandleMove { + const moveOptions = this.resolveMoveOptions({ ...opts, validate: false }); + return this.calculateElementMove(handle, diagramMovement, moveOptions); + } + + protected getResizeableElements(handle: SResizeHandle, options: ResizeOptions): ResizableModelElement[] { + return [handle.parent]; + } + + protected skipElementResize(elementResize: TrackedElementResize, options: ResizeOptions): boolean { + return ( + (options.skipInvalidMove && !elementResize.valid.move) || + (options.skipInvalidSize && !elementResize.valid.size) || + (options.skipStatic && Dimension.equals(elementResize.fromBounds, elementResize.toBounds)) + ); + } + + protected calculateElementResize( + element: ResizableModelElement, + handleMove: TrackedHandleMove, + options: ResizeOptions, + symmetricHandleMove?: TrackedHandleMove + ): TrackedElementResize { + const fromBounds = element.bounds; + const toBounds = this.calculateElementBounds(element, handleMove, options, symmetricHandleMove); + const resize: TrackedElementResize = { element, fromBounds, toBounds, valid: { size: true, move: true } }; + + if (options.validate) { + resize.valid.size = this.manager.hasValidSize(resize.element, resize.toBounds); + resize.valid.move = handleMove.valid && this.manager.hasValidPosition(resize.element, resize.toBounds); + } + + return resize; + } + + private calculateElementBounds( + element: ResizableModelElement, + handleMove: TrackedHandleMove, + options: ResizeOptions, + symmetricHandleMove?: TrackedHandleMove + ): Bounds { + if (options.capAtMinimumSize) { + const minimum = this.manager.getMinimumSize(element); + const vector = this.capMoveVector(element.bounds, handleMove, minimum); + let toBounds = this.calculateBounds(element.bounds, handleMove.element.location, vector); + if (symmetricHandleMove) { + const symmetricVector = this.capMoveVector(toBounds, symmetricHandleMove, minimum); + toBounds = this.calculateBounds(toBounds, symmetricHandleMove.element.location, symmetricVector); + } + return toBounds; + } else { + const vector = handleMove.moveVector; + let toBounds = this.calculateBounds(element.bounds, handleMove.element.location, vector); + if (symmetricHandleMove) { + toBounds = this.calculateBounds(toBounds, symmetricHandleMove.element.location, symmetricHandleMove.moveVector); + } + return toBounds; + } + } + + protected capMoveVector(src: Bounds, handleMove: TrackedHandleMove, minimum: Dimension): Vector { + const vector = handleMove.moveVector as Writable; + switch (handleMove.element.location) { + case ResizeHandleLocation.TopLeft: + vector.x = src.width - vector.x < minimum.width ? src.width - minimum.width : vector.x; + vector.y = src.height - vector.y < minimum.height ? src.height - minimum.height : vector.y; + break; + case ResizeHandleLocation.Top: + vector.y = src.height - vector.y < minimum.height ? src.height - minimum.height : vector.y; + break; + case ResizeHandleLocation.TopRight: + vector.x = src.width + vector.x < minimum.width ? minimum.width - src.width : vector.x; + vector.y = src.height - vector.y < minimum.height ? src.height - minimum.height : vector.y; + break; + case ResizeHandleLocation.Right: + vector.x = src.width + vector.x < minimum.width ? minimum.width - src.width : vector.x; + break; + case ResizeHandleLocation.BottomRight: + vector.x = src.width + vector.x < minimum.width ? minimum.width - src.width : vector.x; + vector.y = src.height + vector.y < minimum.height ? minimum.height - src.height : vector.y; + break; + case ResizeHandleLocation.Bottom: + vector.y = src.height + vector.y < minimum.height ? minimum.height - src.height : vector.y; + break; + case ResizeHandleLocation.BottomLeft: + vector.x = src.width - vector.x < minimum.width ? src.width - minimum.width : vector.x; + vector.y = src.height + vector.y < minimum.height ? minimum.height - src.height : vector.y; + break; + case ResizeHandleLocation.Left: + vector.x = src.width - vector.x < minimum.width ? src.width - minimum.width : vector.x; + break; + } + return vector; + } + + protected calculateBounds(src: Bounds, location: ResizeHandleLocation, vector: Vector): Writable { + if (Vector.isZero(vector)) { + return src; + } + switch (location) { + case ResizeHandleLocation.TopLeft: + return { x: src.x + vector.x, y: src.y + vector.y, width: src.width - vector.x, height: src.height - vector.y }; + case ResizeHandleLocation.Top: + return { ...src, y: src.y + vector.y, height: src.height - vector.y }; + case ResizeHandleLocation.TopRight: + return { ...src, y: src.y + vector.y, width: src.width + vector.x, height: src.height - vector.y }; + case ResizeHandleLocation.Right: + return { ...src, width: src.width + vector.x }; + case ResizeHandleLocation.BottomRight: + return { ...src, width: src.width + vector.x, height: src.height + vector.y }; + case ResizeHandleLocation.Bottom: + return { ...src, height: src.height + vector.y }; + case ResizeHandleLocation.BottomLeft: + return { ...src, x: src.x + vector.x, width: src.width - vector.x, height: src.height + vector.y }; + case ResizeHandleLocation.Left: + return { ...src, x: src.x + vector.x, width: src.width - vector.x }; + } + } + + dispose(): void { + this.stopTracking(); + } +} + +export class MoveableResizeHandle extends SResizeHandle implements Locateable { + constructor( + protected handle: SResizeHandle, + override location: ResizeHandleLocation = handle.location, + readonly position = SResizeHandle.getHandlePosition(handle.parent, location) + ) { + super(location, handle.type, handle.hoverFeedback); + this.id = handle.id; + // this only acts as a wrapper so we do not actually add this to the parent but still want the parent reference + (this as any).parent = handle.parent; + } + + opposite(): MoveableResizeHandle { + return new MoveableResizeHandle(this.handle, ResizeHandleLocation.opposite(this.location)); + } +} + +export class MoveableRoutingHandle extends GRoutingHandle implements Locateable { + constructor( + protected handle: GRoutingHandle, + readonly position: Point + ) { + super(); + this.id = handle.id; + // this only acts as a wrapper so we do not actually add this to the parent but still want the parent reference + (this as any).parent = handle.parent; + } +} diff --git a/packages/client/src/features/tools/change-bounds/index.ts b/packages/client/src/features/tools/change-bounds/index.ts index d9902553a..be79d3267 100644 --- a/packages/client/src/features/tools/change-bounds/index.ts +++ b/packages/client/src/features/tools/change-bounds/index.ts @@ -13,8 +13,10 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +export * from './change-bounds-manager'; export * from './change-bounds-tool'; export * from './change-bounds-tool-feedback'; export * from './change-bounds-tool-module'; export * from './change-bounds-tool-move-feedback'; +export * from './change-bounds-tracker'; export * from './view'; diff --git a/packages/client/src/features/tools/change-bounds/view.tsx b/packages/client/src/features/tools/change-bounds/view.tsx index 641b3a4e1..2571b81b8 100644 --- a/packages/client/src/features/tools/change-bounds/view.tsx +++ b/packages/client/src/features/tools/change-bounds/view.tsx @@ -16,7 +16,7 @@ import { IView, Point, RenderingContext, setAttr, svg } from '@eclipse-glsp/sprotty'; import { injectable } from 'inversify'; import { VNode } from 'snabbdom'; -import { ResizeHandleLocation, SResizeHandle, isResizable } from '../../change-bounds/model'; +import { SResizeHandle } from '../../change-bounds/model'; // eslint-disable-next-line @typescript-eslint/no-unused-vars const JSX = { createElement: svg }; @@ -46,19 +46,7 @@ export class SResizeHandleView implements IView { } protected getPosition(handle: SResizeHandle): Point | undefined { - const parent = handle.parent; - if (isResizable(parent)) { - if (handle.location === ResizeHandleLocation.TopLeft) { - return { x: 0, y: 0 }; - } else if (handle.location === ResizeHandleLocation.TopRight) { - return { x: parent.bounds.width, y: 0 }; - } else if (handle.location === ResizeHandleLocation.BottomLeft) { - return { x: 0, y: parent.bounds.height }; - } else if (handle.location === ResizeHandleLocation.BottomRight) { - return { x: parent.bounds.width, y: parent.bounds.height }; - } - } - return undefined; + return Point.subtract(SResizeHandle.getHandlePosition(handle), handle.parent.bounds); } getRadius(): number { diff --git a/packages/client/src/features/tools/edge-edit/edge-edit-tool-feedback.ts b/packages/client/src/features/tools/edge-edit/edge-edit-tool-feedback.ts index 505af94d2..ea979e8eb 100644 --- a/packages/client/src/features/tools/edge-edit/edge-edit-tool-feedback.ts +++ b/packages/client/src/features/tools/edge-edit/edge-edit-tool-feedback.ts @@ -21,7 +21,6 @@ import { CommandReturn, Disposable, EdgeRouterRegistry, - ElementMove, GConnectableElement, GModelElement, GRoutingHandle, @@ -37,17 +36,17 @@ import { hasStringProp, isBoundsAware, isConnectable, - isSelected + isSelected, + toTypeGuard, + typeGuard } from '@eclipse-glsp/sprotty'; import { inject, injectable } from 'inversify'; +import { ChangeBoundsManager, ChangeBoundsTracker, MoveableRoutingHandle } from '..'; import { FeedbackEmitter } from '../../../base'; import { IFeedbackActionDispatcher } from '../../../base/feedback/feedback-action-dispatcher'; import { FeedbackCommand } from '../../../base/feedback/feedback-command'; -import { forEachElement, isRoutable, isRoutingHandle } from '../../../utils/gmodel-util'; +import { forEachElement, getMatchingElements, isRoutable, isRoutingHandle } from '../../../utils/gmodel-util'; import { getAbsolutePosition, toAbsoluteBounds } from '../../../utils/viewpoint-util'; -import { PointPositionUpdater } from '../../change-bounds/point-position-updater'; -import { PositionSnapper } from '../../change-bounds/position-snapper'; -import { useSnap } from '../../change-bounds/snap'; import { addReconnectHandles, removeReconnectHandles } from '../../reconnect/model'; import { FeedbackEdgeEnd, feedbackEdgeEndId, feedbackEdgeId } from '../edge-creation/dangling-edge-feedback'; import { FeedbackEdgeEndMovingMouseListener } from '../edge-creation/edge-creation-tool-feedback'; @@ -255,14 +254,14 @@ export class FeedbackEdgeSourceMovingMouseListener extends MouseListener impleme } export class FeedbackEdgeRouteMovingMouseListener extends MouseListener { - protected pointPositionUpdater: PointPositionUpdater; + protected tracker: ChangeBoundsTracker; constructor( - protected positionSnapper: PositionSnapper, + protected changeBoundsManager: ChangeBoundsManager, protected edgeRouterRegistry?: EdgeRouterRegistry ) { super(); - this.pointPositionUpdater = new PointPositionUpdater(positionSnapper); + this.tracker = this.changeBoundsManager.createTracker(); } override mouseDown(target: GModelElement, event: MouseEvent): Action[] { @@ -271,64 +270,47 @@ export class FeedbackEdgeRouteMovingMouseListener extends MouseListener { const routingHandle = findParentByFeature(target, isRoutingHandle); if (routingHandle !== undefined) { result.push(SwitchRoutingModeAction.create({ elementsToActivate: [target.id] })); - this.pointPositionUpdater.updateLastDragPosition(event); + this.tracker.startTracking(); } else { - this.pointPositionUpdater.resetPosition(); + this.tracker.dispose(); } } return result; } override mouseMove(target: GModelElement, event: MouseEvent): Action[] { - const result: Action[] = []; + super.mouseMove(target, event); if (event.buttons === 0) { return this.mouseUp(target, event); - } - const positionUpdate = this.pointPositionUpdater.updatePosition(target, event); - if (positionUpdate) { - const moveActions = this.handleMoveOnClient(target, positionUpdate, useSnap(event)); - result.push(...moveActions); - } - return result; - } - - protected handleMoveOnClient(target: GModelElement, positionUpdate: Point, isSnap: boolean): Action[] { - const handleMoves: ElementMove[] = []; - target.root.index - .all() - .filter(element => isSelected(element)) - .forEach(element => { - if (isRoutingHandle(element)) { - const elementMove = this.toElementMove(element, positionUpdate, isSnap); - if (elementMove) { - handleMoves.push(elementMove); - } - } - }); - if (handleMoves.length > 0) { - return [MoveAction.create(handleMoves, { animate: false })]; + } else if (this.tracker.isTracking()) { + return this.moveRoutingHandles(target, event); } return []; } - protected toElementMove(element: GRoutingHandle, positionDelta: Point, isSnap: boolean): ElementMove | undefined { - const point = this.getHandlePosition(element); - if (point !== undefined) { - const snappedPoint = this.getSnappedHandlePosition(element, point, isSnap); - return { - elementId: element.id, - fromPosition: point, - toPosition: { - x: snappedPoint.x + positionDelta.x, - y: snappedPoint.y + positionDelta.y - } - }; + protected moveRoutingHandles(target: GModelElement, event: MouseEvent): Action[] { + const routingHandlesToMove = this.getRoutingHandlesToMove(target); + const move = this.tracker.moveElements(routingHandlesToMove, { snap: event, restrict: event }); + if (move.elementMoves.length === 0) { + return []; } - return undefined; + this.tracker.updateTrackingPosition(move); + return [ + MoveAction.create( + move.elementMoves.map(elementMove => ({ elementId: elementMove.element.id, toPosition: elementMove.toPosition })), + { animate: false } + ) + ]; } - protected getSnappedHandlePosition(element: GRoutingHandle, point: Point, isSnap: boolean): Point { - return this.positionSnapper?.snapPosition(point, element, isSnap); + protected getRoutingHandlesToMove(context: GModelElement): MoveableRoutingHandle[] { + const selectedRoutingHandles = getMatchingElements(context.root.index, typeGuard(isRoutingHandle, isSelected)); + return selectedRoutingHandles + .map(handle => { + const position = this.getHandlePosition(handle); + return position ? new MoveableRoutingHandle(handle, position) : undefined; + }) + .filter(toTypeGuard(MoveableRoutingHandle)); } protected getHandlePosition(handle: GRoutingHandle): Point | undefined { @@ -345,7 +327,7 @@ export class FeedbackEdgeRouteMovingMouseListener extends MouseListener { } override mouseUp(_target: GModelElement, _event: MouseEvent): Action[] { - this.pointPositionUpdater.resetPosition(); + this.tracker.dispose(); return []; } } diff --git a/packages/client/src/features/tools/edge-edit/edge-edit-tool.ts b/packages/client/src/features/tools/edge-edit/edge-edit-tool.ts index 566227bad..806b23f8a 100644 --- a/packages/client/src/features/tools/edge-edit/edge-edit-tool.ts +++ b/packages/client/src/features/tools/edge-edit/edge-edit-tool.ts @@ -24,18 +24,19 @@ import { GRoutableElement, GRoutingHandle, ReconnectEdgeOperation, + TYPES, canEditRouting, findParentByFeature, isConnectable, isSelected } from '@eclipse-glsp/sprotty'; import { inject, injectable, optional } from 'inversify'; +import { ChangeBoundsManager } from '..'; import { FeedbackEmitter } from '../../../base'; import { DragAwareMouseListener } from '../../../base/drag-aware-mouse-listener'; import { CursorCSS, cursorFeedbackAction } from '../../../base/feedback/css-feedback'; import { ISelectionListener, SelectionService } from '../../../base/selection-service'; import { calcElementAndRoutingPoints, isRoutable, isRoutingHandle } from '../../../utils/gmodel-util'; -import { PositionSnapper } from '../../change-bounds/position-snapper'; import { GReconnectHandle, isReconnectHandle, isReconnectable, isSourceRoutingHandle, isTargetRoutingHandle } from '../../reconnect/model'; import { BaseEditTool } from '../base-tools'; import { DrawFeedbackEdgeAction, RemoveFeedbackEdgeAction, feedbackEdgeId } from '../edge-creation/dangling-edge-feedback'; @@ -56,7 +57,7 @@ export class EdgeEditTool extends BaseEditTool { @inject(SelectionService) protected selectionService: SelectionService; @inject(AnchorComputerRegistry) protected anchorRegistry: AnchorComputerRegistry; @inject(EdgeRouterRegistry) @optional() readonly edgeRouterRegistry?: EdgeRouterRegistry; - @inject(PositionSnapper) readonly positionSnapper: PositionSnapper; + @inject(TYPES.IChangeBoundsManager) readonly changeBoundsManager: ChangeBoundsManager; protected feedbackEdgeSourceMovingListener: FeedbackEdgeSourceMovingMouseListener; protected feedbackEdgeTargetMovingListener: FeedbackEdgeTargetMovingMouseListener; @@ -73,7 +74,7 @@ export class EdgeEditTool extends BaseEditTool { // install feedback move mouse listener for client-side move updates this.feedbackEdgeSourceMovingListener = new FeedbackEdgeSourceMovingMouseListener(this.anchorRegistry, this.feedbackDispatcher); this.feedbackEdgeTargetMovingListener = new FeedbackEdgeTargetMovingMouseListener(this.anchorRegistry, this.feedbackDispatcher); - this.feedbackMovingListener = new FeedbackEdgeRouteMovingMouseListener(this.positionSnapper, this.edgeRouterRegistry); + this.feedbackMovingListener = new FeedbackEdgeRouteMovingMouseListener(this.changeBoundsManager, this.edgeRouterRegistry); this.toDisposeOnDisable.push( this.edgeEditListener, diff --git a/packages/client/src/features/tools/marquee-selection/marquee-mouse-tool.ts b/packages/client/src/features/tools/marquee-selection/marquee-mouse-tool.ts index 2f7f0a6b1..45edec39d 100644 --- a/packages/client/src/features/tools/marquee-selection/marquee-mouse-tool.ts +++ b/packages/client/src/features/tools/marquee-selection/marquee-mouse-tool.ts @@ -56,6 +56,10 @@ export class MarqueeMouseTool extends BaseEditTool { this.createFeedbackEmitter().add(cursorFeedbackAction(CursorCSS.MARQUEE), cursorFeedbackAction()).submit() ); } + + override get isEditTool(): boolean { + return false; + } } export class MarqueeMouseListener extends DragAwareMouseListener { diff --git a/packages/client/src/features/tools/node-creation/node-creation-tool.ts b/packages/client/src/features/tools/node-creation/node-creation-tool.ts index 595166bbf..f34fe5bd4 100644 --- a/packages/client/src/features/tools/node-creation/node-creation-tool.ts +++ b/packages/client/src/features/tools/node-creation/node-creation-tool.ts @@ -26,7 +26,7 @@ import { isCtrlOrCmd, isMoveable } from '@eclipse-glsp/sprotty'; -import { inject, injectable, optional } from 'inversify'; +import { inject, injectable } from 'inversify'; import '../../../../css/ghost-element.css'; import { FeedbackEmitter } from '../../../base'; import { DragAwareMouseListener } from '../../../base/drag-aware-mouse-listener'; @@ -34,13 +34,12 @@ import { CSS_GHOST_ELEMENT, CSS_HIDDEN, CursorCSS, cursorFeedbackAction } from ' import { EnableDefaultToolsAction } from '../../../base/tool-manager/tool'; import { MoveableElement, isValidMove } from '../../../utils'; import { getAbsolutePosition } from '../../../utils/viewpoint-util'; -import { IMovementRestrictor } from '../../change-bounds/movement-restrictor'; -import { PositionSnapper } from '../../change-bounds/position-snapper'; import { RemoveTemplateElementsAction } from '../../element-template'; import { AddTemplateElementsAction, getTemplateElementId } from '../../element-template/add-template-element'; import { MouseTrackingElementPositionListener, PositioningTool } from '../../element-template/mouse-tracking-element-position-listener'; import { Containable, isContainable } from '../../hints/model'; import { BaseCreationTool } from '../base-tools'; +import { ChangeBoundsManager } from '../change-bounds'; @injectable() export class NodeCreationTool extends BaseCreationTool implements PositioningTool { @@ -48,8 +47,7 @@ export class NodeCreationTool extends BaseCreationTool(elements: F export function removeDescendants(elements: FluentIterable): FluentIterable { return elements.filter(isNotDescendantOfAnyElement(elements)); } + +/** + * Computes the bounds of the given edge, i.e., the bounding box of the edge's source, target, and routing points in absolute coordinates. + * + * @param edge The edge to compute the bounds for. + * @returns the bounds of the edge + */ +export function getAbsoluteEdgeBounds(edge: GEdge): Bounds { + if (!edge.source || !edge.target) { + return edge.bounds; + } + const source = edge.source; + const target = edge.target; + const edgePoints: Bounds[] = []; + edgePoints.push({ ...Dimension.ZERO, ...Bounds.center(source.bounds) }); + edgePoints.push(...edge.routingPoints.map(point => ({ ...Dimension.ZERO, ...point }))); + edgePoints.push({ ...Dimension.ZERO, ...Bounds.center(target.bounds) }); + let bounds = edgePoints.reduce(Bounds.combine); + let current: GModelElement = edge; + while (current instanceof GChildElement) { + const parent = current.parent; + bounds = parent.localToParent(bounds); + current = parent; + } + return bounds; +} diff --git a/packages/client/src/utils/layout-utils.ts b/packages/client/src/utils/layout-utils.ts index 83670d338..52c5d4d8a 100644 --- a/packages/client/src/utils/layout-utils.ts +++ b/packages/client/src/utils/layout-utils.ts @@ -33,6 +33,10 @@ export function minHeight(element: BoundsAwareModelElement): number { return 1; } +export function minDimensions(element: BoundsAwareModelElement): Dimension { + return { width: minWidth(element), height: minHeight(element) }; +} + export function getLayoutOptions(element: GModelElement): ModelLayoutOptions | undefined { const layoutOptions = (element as any).layoutOptions; if (layoutOptions !== undefined) { diff --git a/packages/glsp-sprotty/src/types.ts b/packages/glsp-sprotty/src/types.ts index 1b018bdd0..704e35443 100644 --- a/packages/glsp-sprotty/src/types.ts +++ b/packages/glsp-sprotty/src/types.ts @@ -50,5 +50,6 @@ export const TYPES = { IToolManager: Symbol('IToolManager'), IDebugManager: Symbol('IDebugManager'), Grid: Symbol('Grid'), - IGridManager: Symbol('IGridManager') + IGridManager: Symbol('IGridManager'), + IChangeBoundsManager: Symbol('IChangeBoundsManager') }; diff --git a/packages/protocol/src/action-protocol/model-layout.ts b/packages/protocol/src/action-protocol/model-layout.ts index dd190e984..ce8c281f6 100644 --- a/packages/protocol/src/action-protocol/model-layout.ts +++ b/packages/protocol/src/action-protocol/model-layout.ts @@ -17,7 +17,7 @@ import * as sprotty from 'sprotty-protocol/lib/actions'; import { GModelRootSchema } from '..'; import { hasArrayProp, hasObjectProp } from '../utils/type-util'; import { Action, Operation, RequestAction, ResponseAction } from './base-protocol'; -import { Args, ElementAndAlignment, ElementAndBounds, ElementAndRoutingPoints } from './types'; +import { Args, ElementAndAlignment, ElementAndBounds, ElementAndLayoutData, ElementAndRoutingPoints } from './types'; /** * Sent from the server to the client to request bounds for the given model. The model is rendered invisibly so the bounds can @@ -79,6 +79,11 @@ export interface ComputedBoundsAction extends ResponseAction, sprotty.ComputedBo * The route of the model elements. */ routes?: ElementAndRoutingPoints[]; + + /** + * The layout data of hte model elements. + */ + layoutData?: ElementAndLayoutData[]; } export namespace ComputedBoundsAction { @@ -95,6 +100,7 @@ export namespace ComputedBoundsAction { responseId?: string; alignments?: ElementAndAlignment[]; routes?: ElementAndRoutingPoints[]; + layoutData?: ElementAndLayoutData[]; } = {} ): ComputedBoundsAction { return { diff --git a/packages/protocol/src/action-protocol/types.ts b/packages/protocol/src/action-protocol/types.ts index a614bc362..28d665093 100644 --- a/packages/protocol/src/action-protocol/types.ts +++ b/packages/protocol/src/action-protocol/types.ts @@ -81,6 +81,30 @@ export interface ElementAndRoutingPoints { newRoutingPoints?: Point[]; } +/** + * Data provided by the layouter. + */ +export interface LayoutData { + /** + * The computed minimum size of the element. + */ + computedDimensions?: Dimension; +} + +/** + * The `ElementAndLayoutData` type is used to associate new layout data with a model element, which is referenced via its id. + */ +export interface ElementAndLayoutData { + /** + * The identifier of the element. + */ + elementId: string; + /** + * The data provided by the layouter. + */ + layoutData: LayoutData; +} + /** * The `EditorContext` may be used to represent the current state of the editor for particular actions. * It encompasses the last recorded mouse position, the list of selected elements, and may contain diff --git a/packages/protocol/src/sprotty-geometry-bounds.spec.ts b/packages/protocol/src/sprotty-geometry-bounds.spec.ts index e7b4f66ca..ac3348d53 100644 --- a/packages/protocol/src/sprotty-geometry-bounds.spec.ts +++ b/packages/protocol/src/sprotty-geometry-bounds.spec.ts @@ -14,7 +14,7 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ import { expect } from 'chai'; -import { Point } from 'sprotty-protocol'; +import { Dimension, Point } from 'sprotty-protocol'; import { Bounds } from './sprotty-geometry-bounds'; describe('Bounds', () => { @@ -284,4 +284,36 @@ describe('Bounds', () => { expect(sortedBounds).to.deep.equal([bounds1, bounds2, bounds3]); }); }); + + describe('move', () => { + it('should move the bounds by the given delta', () => { + const bounds: Bounds = { x: 10, y: 20, width: 100, height: 200 }; + const delta: Point = { x: 10, y: 20 }; + const result = Bounds.move(bounds, delta); + expect(result).to.deep.equal({ x: 20, y: 40, width: 100, height: 200 }); + }); + + it('should move the bounds by the given delta with negative values', () => { + const bounds: Bounds = { x: 10, y: 20, width: 100, height: 200 }; + const delta: Point = { x: -10, y: -20 }; + const result = Bounds.move(bounds, delta); + expect(result).to.deep.equal({ x: 0, y: 0, width: 100, height: 200 }); + }); + }); + + describe('resize', () => { + it('should resize the bounds by the given delta', () => { + const bounds: Bounds = { x: 10, y: 20, width: 100, height: 200 }; + const delta: Dimension = { width: 10, height: 20 }; + const result = Bounds.resize(bounds, delta); + expect(result).to.deep.equal({ x: 10, y: 20, width: 110, height: 220 }); + }); + + it('should resize the bounds by the given delta with negative values', () => { + const bounds: Bounds = { x: 10, y: 20, width: 100, height: 200 }; + const delta: Dimension = { width: -10, height: -20 }; + const result = Bounds.resize(bounds, delta); + expect(result).to.deep.equal({ x: 10, y: 20, width: 90, height: 180 }); + }); + }); }); diff --git a/packages/protocol/src/sprotty-geometry-bounds.ts b/packages/protocol/src/sprotty-geometry-bounds.ts index 397da1b11..bcc4ebe0d 100644 --- a/packages/protocol/src/sprotty-geometry-bounds.ts +++ b/packages/protocol/src/sprotty-geometry-bounds.ts @@ -19,6 +19,11 @@ import { Bounds, Dimension, Point } from 'sprotty-protocol/lib/utils/geometry'; declare module 'sprotty-protocol/lib/utils/geometry' { namespace Bounds { + /** + * The empty bounds with valid dimensions. It has x, y, width, and height set to 0. + */ + const ZERO: Bounds; + /** * Checks whether the inner bounds are compeletely encompassed by the outer bounds. * @@ -209,9 +214,32 @@ declare module 'sprotty-protocol/lib/utils/geometry' { * @returns the sorted bounds */ function sortBy(rankFunc: (elem: T) => number, ...bounds: T[]): T[]; + + /** + * Moves the bounds by the given delta. + * @param bounds the bounds to move + * @param delta the delta to move the bounds by + * @returns the moved bounds + */ + function move(bounds: Bounds, delta: Point): Bounds; + + /** + * Resizes the bounds by the given delta. + * @param bounds the bounds to resize + * @param delta the delta to resize the bounds by + * @returns the resized bounds + */ + function resize(bounds: Bounds, delta: Dimension): Bounds; } } +(Bounds as any).ZERO = Object.freeze({ + x: 0, + y: 0, + width: 0, + height: 0 +}); + Bounds.encompasses = (outer: Bounds, inner: Bounds): boolean => Bounds.includes(outer, Bounds.topLeft(inner)) && Bounds.includes(outer, Bounds.bottomRight(inner)); @@ -281,4 +309,12 @@ Bounds.from = (topLeft: Point, bottomRight: Point): Bounds => ({ height: bottomRight.y - topLeft.y }); +Bounds.move = Bounds.translate; + +Bounds.resize = (bounds: Bounds, delta: Dimension): Bounds => ({ + ...bounds, + width: bounds.width + delta.width, + height: bounds.height + delta.height +}); + export { Bounds }; diff --git a/packages/protocol/src/utils/type-util.ts b/packages/protocol/src/utils/type-util.ts index 9d5d740be..c6b70d596 100644 --- a/packages/protocol/src/utils/type-util.ts +++ b/packages/protocol/src/utils/type-util.ts @@ -69,6 +69,11 @@ export type MaybePromise = T | PromiseLike; */ export type TypeGuard = (element: any) => element is T; +/** Utility function to combine two type guards */ +export function typeGuard(one: TypeGuard, other: TypeGuard): TypeGuard { + return (element: any): element is T & G => one(element) && other(element); +} + /** * Utility function that create a typeguard function for a given class constructor. * Essentially this wraps an instance of check as typeguard function. From 7c15c61c0b6669deaabe20a21aa5188bb94e52b7 Mon Sep 17 00:00:00 2001 From: Martin Fleck Date: Tue, 14 May 2024 13:41:23 +0200 Subject: [PATCH 2/3] PR Feedback - Fix issue with wrong coordinates on project bars during move/resize - Fix move during resize for constrained, symmetric resize - Ensure symmetric resize does not trigger deselect of element - Remove !important from CSS rules - Allow undefined unsnap modifier for adopters - Use isSizeable for debug decoration to avoid wrong edge decoration - Remove non-generic getAbsoluteEdgeBounds - Better unify the helper functions for CSS classes - Fix deprecation warnings - Minor cleanup Contributed on behalf of Axon Ivy AG --- packages/client/css/change-bounds.css | 13 +- packages/client/css/helper-lines.css | 6 +- .../src/base/feedback/feeback-emitter.ts | 9 +- .../view-key-tools/movement-key-tool.ts | 16 +- .../src/features/bounds/bounds-module.ts | 2 + .../client/src/features/bounds/layout-data.ts | 2 +- .../point-position-updater.spec.ts | 2 + .../change-bounds/point-position-updater.ts | 3 + .../src/features/change-bounds/tracker.ts | 2 +- .../features/debug/debug-bounds-decorator.tsx | 8 +- ...ouse-tracking-element-position-listener.ts | 57 ++++--- .../helper-lines/helper-line-feedback.ts | 3 +- .../features/select/select-mouse-listener.ts | 16 +- .../change-bounds/change-bounds-manager.ts | 6 +- .../change-bounds-tool-move-feedback.ts | 4 +- .../tools/change-bounds/change-bounds-tool.ts | 54 ++++--- .../change-bounds/change-bounds-tracker.ts | 143 +++++++++--------- packages/client/src/utils/gmodel-util.ts | 69 ++------- .../protocol/src/sprotty-geometry-bounds.ts | 5 +- .../src/sprotty-geometry-dimension.spec.ts | 29 ++++ .../src/sprotty-geometry-dimension.ts | 15 +- .../src/sprotty-geometry-point.spec.ts | 14 ++ .../protocol/src/sprotty-geometry-point.ts | 16 ++ packages/protocol/src/utils/index.ts | 1 + packages/protocol/src/utils/math-util.ts | 19 +++ 25 files changed, 316 insertions(+), 198 deletions(-) create mode 100644 packages/protocol/src/utils/math-util.ts diff --git a/packages/client/css/change-bounds.css b/packages/client/css/change-bounds.css index c001c80cb..099647d13 100644 --- a/packages/client/css/change-bounds.css +++ b/packages/client/css/change-bounds.css @@ -31,10 +31,19 @@ } .sprotty-resize-handle.resize-not-allowed { - fill: var(--glsp-error-foreground) !important; + fill: var(--glsp-error-foreground); } .sprotty g .resize-not-allowed > .sprotty-node { - stroke: var(--glsp-error-foreground) !important; + stroke: var(--glsp-error-foreground); stroke-width: 1.5px; } + +.move-mode .sprotty-projection-bar, +.resize-mode .sprotty-projection-bar { + /** + * We are using mouse events (offsetX, offsetY) in the GLSPMousePositionTracker to calculate the diagram position relative to the parent. + * Other elements result in relative coordinates different from the graph and will therefore interfere with the correct position calculation. + */ + pointer-events: none; +} diff --git a/packages/client/css/helper-lines.css b/packages/client/css/helper-lines.css index 07a7de0cd..967e0f5aa 100644 --- a/packages/client/css/helper-lines.css +++ b/packages/client/css/helper-lines.css @@ -16,8 +16,8 @@ .helper-line { pointer-events: none; - stroke: red; - stroke-width: 1px; + stroke: #f7086c; + stroke-width: 0.5; opacity: 1; } @@ -28,6 +28,6 @@ stroke-linejoin: miter; stroke-linecap: round; stroke: darkblue; - stroke-width: 1px; + stroke-width: 0.5; stroke-dasharray: 2; } diff --git a/packages/client/src/base/feedback/feeback-emitter.ts b/packages/client/src/base/feedback/feeback-emitter.ts index 53971ed89..0833f3fdd 100644 --- a/packages/client/src/base/feedback/feeback-emitter.ts +++ b/packages/client/src/base/feedback/feeback-emitter.ts @@ -32,9 +32,12 @@ export class FeedbackEmitter implements IFeedbackEmitter, Disposable { * once the {@link submit} method is called. * * @param action feedback action - * @param cleanupAction action that undoes the feedback action. This is only triggered when {@link revert} is called. + * @param cleanupAction action that undoes the feedback action. This is only triggered when {@link revert} or {@link dispose} is called. */ - add(action: Action, cleanupAction?: MaybeActions): this { + add(action?: Action, cleanupAction?: MaybeActions): this { + if (!action && !cleanupAction) { + return this; + } const idx = this.feedbackActions.length; this.feedbackActions[idx] = action; if (cleanupAction) { @@ -73,7 +76,7 @@ export class FeedbackEmitter implements IFeedbackEmitter, Disposable { * Registers any pending actions as feedback. Any previously submitted feedback becomes invalid. */ submit(): this { - // with 'arrayOf' we skip undefined entries that are created for non-cleanup actions + // with 'arrayOf' we skip undefined entries that are created for non-cleanup actions or cleanup-only actions const actions = arrayOf(...this.feedbackActions); const cleanupActions = arrayOf(...this.cleanupActions); this.deregistration = this.feedbackDispatcher.registerFeedback(this, actions, () => cleanupActions.flatMap(MaybeActions.asArray)); 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 5d5bfaef0..f48f32fe4 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 @@ -40,7 +40,7 @@ export class MovementKeyTool implements Tool { @inject(SelectionService) selectionService: SelectionService; @inject(TYPES.ISnapper) @optional() readonly snapper?: ISnapper; @inject(TYPES.IActionDispatcher) readonly actionDispatcher: GLSPActionDispatcher; - @optional() @inject(TYPES.Grid) protected grid: Grid; + @inject(TYPES.Grid) @optional() protected grid: Grid; @inject(TYPES.IChangeBoundsManager) readonly changeBoundsManager: ChangeBoundsManager; get id(): string { @@ -116,20 +116,22 @@ export class MoveKeyListener extends KeyListener implements AccessibleKeyShortcu } protected matchesMoveUpKeystroke(event: KeyboardEvent): boolean { - return matchesKeystroke(event, 'ArrowUp') || matchesKeystroke(event, 'ArrowUp', this.tool.changeBoundsManager.unsnapModifier()); + const unsnap = this.tool.changeBoundsManager.unsnapModifier(); + return matchesKeystroke(event, 'ArrowUp') || (!!unsnap && matchesKeystroke(event, 'ArrowUp', unsnap)); } protected matchesMoveDownKeystroke(event: KeyboardEvent): boolean { - return matchesKeystroke(event, 'ArrowDown') || matchesKeystroke(event, 'ArrowDown', this.tool.changeBoundsManager.unsnapModifier()); + const unsnap = this.tool.changeBoundsManager.unsnapModifier(); + return matchesKeystroke(event, 'ArrowDown') || (!!unsnap && matchesKeystroke(event, 'ArrowDown', unsnap)); } protected matchesMoveRightKeystroke(event: KeyboardEvent): boolean { - return ( - matchesKeystroke(event, 'ArrowRight') || matchesKeystroke(event, 'ArrowRight', this.tool.changeBoundsManager.unsnapModifier()) - ); + const unsnap = this.tool.changeBoundsManager.unsnapModifier(); + return matchesKeystroke(event, 'ArrowRight') || (!!unsnap && matchesKeystroke(event, 'ArrowRight', unsnap)); } protected matchesMoveLeftKeystroke(event: KeyboardEvent): boolean { - return matchesKeystroke(event, 'ArrowLeft') || matchesKeystroke(event, 'ArrowLeft', this.tool.changeBoundsManager.unsnapModifier()); + const unsnap = this.tool.changeBoundsManager.unsnapModifier(); + return matchesKeystroke(event, 'ArrowLeft') || (!!unsnap && matchesKeystroke(event, 'ArrowLeft', unsnap)); } } diff --git a/packages/client/src/features/bounds/bounds-module.ts b/packages/client/src/features/bounds/bounds-module.ts index cf86680ae..0cd98911d 100644 --- a/packages/client/src/features/bounds/bounds-module.ts +++ b/packages/client/src/features/bounds/bounds-module.ts @@ -52,5 +52,7 @@ export const boundsModule = new FeatureModule((bind, _unbind, isBound, _rebind) configureLayout(context, HBoxLayouter.KIND, HBoxLayouterExt); configureLayout(context, FreeFormLayouter.KIND, FreeFormLayouter); + // backwards compatibility + // eslint-disable-next-line deprecation/deprecation bind(PositionSnapper).toSelf(); }); diff --git a/packages/client/src/features/bounds/layout-data.ts b/packages/client/src/features/bounds/layout-data.ts index af3eec6e6..34dad4b5f 100644 --- a/packages/client/src/features/bounds/layout-data.ts +++ b/packages/client/src/features/bounds/layout-data.ts @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2024 EclipseSource and others. + * Copyright (c) 2024 Axon Ivy AG and others. * * This program and the accompanying materials are made available under the * terms of the Eclipse Public License v. 2.0 which is available at diff --git a/packages/client/src/features/change-bounds/point-position-updater.spec.ts b/packages/client/src/features/change-bounds/point-position-updater.spec.ts index 0d29442e9..743e4da17 100644 --- a/packages/client/src/features/change-bounds/point-position-updater.spec.ts +++ b/packages/client/src/features/change-bounds/point-position-updater.spec.ts @@ -13,6 +13,8 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +/* eslint-disable import/no-deprecated */ +/* eslint-disable deprecation/deprecation */ import { GModelElement } from '@eclipse-glsp/sprotty'; import { expect } from 'chai'; diff --git a/packages/client/src/features/change-bounds/point-position-updater.ts b/packages/client/src/features/change-bounds/point-position-updater.ts index 1788bf19a..d09991ae7 100644 --- a/packages/client/src/features/change-bounds/point-position-updater.ts +++ b/packages/client/src/features/change-bounds/point-position-updater.ts @@ -14,6 +14,9 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ /* eslint-disable @typescript-eslint/no-shadow */ +/* eslint-disable import/no-deprecated */ +/* eslint-disable deprecation/deprecation */ + import { GModelElement, ISnapper, Point, Writable } from '@eclipse-glsp/sprotty'; import { calculateDeltaBetweenPoints } from '../../utils/gmodel-util'; import { isMouseEvent } from '../../utils/html-utils'; diff --git a/packages/client/src/features/change-bounds/tracker.ts b/packages/client/src/features/change-bounds/tracker.ts index 470a98d57..70049d76a 100644 --- a/packages/client/src/features/change-bounds/tracker.ts +++ b/packages/client/src/features/change-bounds/tracker.ts @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2024 EclipseSource and others. + * Copyright (c) 2024 Axon Ivy AG and others. * * This program and the accompanying materials are made available under the * terms of the Eclipse Public License v. 2.0 which is available at diff --git a/packages/client/src/features/debug/debug-bounds-decorator.tsx b/packages/client/src/features/debug/debug-bounds-decorator.tsx index e59415136..b65ce8c6d 100644 --- a/packages/client/src/features/debug/debug-bounds-decorator.tsx +++ b/packages/client/src/features/debug/debug-bounds-decorator.tsx @@ -15,7 +15,7 @@ ********************************************************************************/ /* eslint-disable max-len */ -import { Bounds, GModelElement, IVNodePostprocessor, Point, TYPES, isBoundsAware, setClass, svg } from '@eclipse-glsp/sprotty'; +import { Bounds, GModelElement, IVNodePostprocessor, Point, TYPES, isSizeable, setClass, svg } from '@eclipse-glsp/sprotty'; import { inject, injectable, optional } from 'inversify'; import { VNode } from 'snabbdom'; import { GGraph } from '../../model'; @@ -33,8 +33,8 @@ export class DebugBoundsDecorator implements IVNodePostprocessor { if (!this.debugManager?.isDebugEnabled) { return vnode; } - if (isBoundsAware(element)) { - this.decorateBoundsAware(vnode, element); + if (isSizeable(element)) { + this.decorateSizeable(vnode, element); } if (element instanceof GGraph) { this.decorateGraph(vnode, element); @@ -67,7 +67,7 @@ export class DebugBoundsDecorator implements IVNodePostprocessor { ); } - protected decorateBoundsAware(vnode: VNode, element: BoundsAwareModelElement): void { + protected decorateSizeable(vnode: VNode, element: BoundsAwareModelElement): void { setClass(vnode, 'debug-bounds', true); vnode.children?.push(this.renderTopLeftCorner(element)); vnode.children?.push(this.renderTopRightCorner(element)); diff --git a/packages/client/src/features/element-template/mouse-tracking-element-position-listener.ts b/packages/client/src/features/element-template/mouse-tracking-element-position-listener.ts index 755c36d66..af5c9bd07 100644 --- a/packages/client/src/features/element-template/mouse-tracking-element-position-listener.ts +++ b/packages/client/src/features/element-template/mouse-tracking-element-position-listener.ts @@ -18,8 +18,15 @@ import { Action, Dimension, GModelElement, MoveAction, Point, isBoundsAware, isM import { DragAwareMouseListener } from '../../base/drag-aware-mouse-listener'; import { CSS_HIDDEN, ModifyCSSFeedbackAction } from '../../base/feedback/css-feedback'; import { FeedbackEmitter } from '../../base/feedback/feeback-emitter'; -import { getAbsolutePosition } from '../../utils'; -import { ChangeBoundsManager, ChangeBoundsTracker, FeedbackAwareTool, MoveFinishedEventAction, TrackedElementMove } from '../tools'; +import { MoveableElement, getAbsolutePosition } from '../../utils'; +import { + CSS_RESIZE_MODE, + ChangeBoundsManager, + ChangeBoundsTracker, + FeedbackAwareTool, + MoveFinishedEventAction, + TrackedElementMove +} from '../tools'; export interface PositioningTool extends FeedbackAwareTool { readonly changeBoundsManager: ChangeBoundsManager; @@ -39,42 +46,56 @@ export class MouseTrackingElementPositionListener extends DragAwareMouseListener this.moveGhostFeedback = this.tool.createFeedbackEmitter(); } + protected getTrackedElement(target: GModelElement, event: MouseEvent): MoveableElement | undefined { + const element = target.root.index.getById(this.elementId); + return !element || !isMoveable(element) ? undefined : element; + } + override mouseMove(target: GModelElement, event: MouseEvent): Action[] { super.mouseMove(target, event); - const element = target.root.index.getById(this.elementId); - if (!element || !isMoveable(element)) { + const element = this.getTrackedElement(target, event); + if (!element) { return []; } if (!this.tracker.isTracking()) { - this.tracker.startTracking(); - const mousePosition = getAbsolutePosition(target, event); - element.position = - this.cursorPosition === 'middle' && isBoundsAware(element) - ? Point.subtract(mousePosition, Dimension.center(element.bounds)) - : mousePosition; + this.initialize(element, target, event); } - // we only validate if we use the position already as a target, otherwise we need to re-evaluate after moving to the center anyway const move = this.tracker.moveElements([element], { snap: event, restrict: event, validate: true }); const elementMove = move.elementMoves[0]; if (!elementMove) { return []; } - this.addMoveFeeback(elementMove); // since we are moving a ghost element that is feedback-only and will be removed anyway, // we just send a MoveFinishedEventAction instead of reseting the position with a MoveAction and the finished flag set to true. - this.moveGhostFeedback - .add( - MoveAction.create([{ elementId: this.elementId, toPosition: elementMove.toPosition }], { animate: false }), - MoveFinishedEventAction.create() - ) - .submit(); + this.moveGhostFeedback.add( + MoveAction.create([{ elementId: this.elementId, toPosition: elementMove.toPosition }], { animate: false }), + MoveFinishedEventAction.create() + ); + this.addMoveFeeback(elementMove); + this.moveGhostFeedback.submit(); this.tracker.updateTrackingPosition(elementMove.moveVector); return []; } + protected initialize(element: MoveableElement, target: GModelElement, event: MouseEvent): void { + this.tracker.startTracking(); + element.position = this.initializeElementPosition(element, target, event); + } + + protected initializeElementPosition(element: MoveableElement, target: GModelElement, event: MouseEvent): Point { + const mousePosition = getAbsolutePosition(target, event); + return this.cursorPosition === 'middle' && isBoundsAware(element) + ? Point.subtract(mousePosition, Dimension.center(element.bounds)) + : mousePosition; + } + protected addMoveFeeback(move: TrackedElementMove): void { this.tool.changeBoundsManager.addRestrictionFeedback(this.moveGhostFeedback, move); this.moveGhostFeedback.add(ModifyCSSFeedbackAction.create({ elements: [move.element.id], remove: [CSS_HIDDEN] })); + this.moveGhostFeedback.add( + ModifyCSSFeedbackAction.create({ add: [CSS_RESIZE_MODE] }), + ModifyCSSFeedbackAction.create({ remove: [CSS_RESIZE_MODE] }) + ); } override dispose(): void { diff --git a/packages/client/src/features/helper-lines/helper-line-feedback.ts b/packages/client/src/features/helper-lines/helper-line-feedback.ts index d4b1b9d49..4ff943787 100644 --- a/packages/client/src/features/helper-lines/helper-line-feedback.ts +++ b/packages/client/src/features/helper-lines/helper-line-feedback.ts @@ -27,6 +27,7 @@ import { Point, TYPES, Viewport, + equalUpTo, findParentByFeature, isBoundsAware, isDecoration, @@ -293,7 +294,7 @@ export class DrawHelperLinesFeedbackCommand extends FeedbackCommand { } protected isMatch(leftCoordinate: number, rightCoordinate: number, epsilon: number): boolean { - return Math.abs(leftCoordinate - rightCoordinate) <= epsilon; + return equalUpTo(leftCoordinate, rightCoordinate, epsilon); } protected log(message: string, ...params: any[]): void { diff --git a/packages/client/src/features/select/select-mouse-listener.ts b/packages/client/src/features/select/select-mouse-listener.ts index 696681e3f..6717fc85e 100644 --- a/packages/client/src/features/select/select-mouse-listener.ts +++ b/packages/client/src/features/select/select-mouse-listener.ts @@ -13,17 +13,23 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { Action, BringToFrontAction, GModelElement, SelectAction, SelectMouseListener } from '@eclipse-glsp/sprotty'; +import { Action, BringToFrontAction, GModelElement, SelectAction, SelectMouseListener, TYPES } from '@eclipse-glsp/sprotty'; +import { inject, injectable, optional } from 'inversify'; import { Ranked } from '../../base/ranked'; import { SelectableElement } from '../../utils'; +import { SResizeHandle } from '../change-bounds'; +import { ChangeBoundsManager } from '../tools'; /** * Ranked select mouse listener that is executed before default mouse listeners when using the RankedMouseTool. * This ensures that default mouse listeners are working on a model that has selection changes already applied. */ +@injectable() export class RankedSelectMouseListener extends SelectMouseListener implements Ranked { rank: number = Ranked.DEFAULT_RANK - 100; /* we want to be executed before all default mouse listeners */ + @inject(TYPES.IChangeBoundsManager) @optional() readonly changeBoundsManager?: ChangeBoundsManager; + protected override handleSelectTarget( selectableTarget: SelectableElement, deselectedElements: GModelElement[], @@ -52,4 +58,12 @@ export class RankedSelectMouseListener extends SelectMouseListener implements Ra result.push(SelectAction.create({ selectedElementsIDs: [], deselectedElementsIDs: deselectedElements.map(e => e.id) })); return result; } + + protected override handleButton(target: GModelElement, event: MouseEvent): (Action | Promise)[] | undefined { + if (target instanceof SResizeHandle && this.changeBoundsManager?.useSymmetricResize(event)) { + // avoid de-selecting elements when resizing with a modifier key + return []; + } + return super.handleButton(target, event); + } } diff --git a/packages/client/src/features/tools/change-bounds/change-bounds-manager.ts b/packages/client/src/features/tools/change-bounds/change-bounds-manager.ts index 705d41f2b..38a5a8cb2 100644 --- a/packages/client/src/features/tools/change-bounds/change-bounds-manager.ts +++ b/packages/client/src/features/tools/change-bounds/change-bounds-manager.ts @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2024 EclipseSource and others. + * Copyright (c) 2024 Axon Ivy AG and others. * * This program and the accompanying materials are made available under the * terms of the Eclipse Public License v. 2.0 which is available at @@ -40,6 +40,8 @@ import { import { IHelperLineManager } from '../../helper-lines'; import { ChangeBoundsTracker, TrackedElementMove } from './change-bounds-tracker'; +export const CSS_RESIZE_MODE = 'resize-mode'; + @injectable() export class ChangeBoundsManager { constructor( @@ -49,7 +51,7 @@ export class ChangeBoundsManager { @optional() @inject(TYPES.IHelperLineManager) readonly helperLineManager?: IHelperLineManager ) {} - unsnapModifier(): KeyboardModifier { + unsnapModifier(): KeyboardModifier | undefined { return 'shift'; } diff --git a/packages/client/src/features/tools/change-bounds/change-bounds-tool-move-feedback.ts b/packages/client/src/features/tools/change-bounds/change-bounds-tool-move-feedback.ts index 9d4f5b516..49c02dd80 100644 --- a/packages/client/src/features/tools/change-bounds/change-bounds-tool-move-feedback.ts +++ b/packages/client/src/features/tools/change-bounds/change-bounds-tool-move-feedback.ts @@ -13,7 +13,7 @@ * * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { Action, Disposable, ElementMove, GModelElement, GModelRoot, MoveAction, Point, findParentByFeature } from '@eclipse-glsp/sprotty'; +import { Action, ElementMove, GModelElement, GModelRoot, MoveAction, Point, findParentByFeature } from '@eclipse-glsp/sprotty'; import { DebouncedFunc, debounce } from 'lodash'; import { ChangeBoundsTracker, TrackedMove } from '.'; @@ -32,7 +32,7 @@ import { MoveFinishedEventAction, MoveInitializedEventAction } from './change-bo * the visual feedback but also the basis for sending the change to the server * (see also `tools/MoveTool`). */ -export class FeedbackMoveMouseListener extends DragAwareMouseListener implements Disposable { +export class FeedbackMoveMouseListener extends DragAwareMouseListener { protected rootElement?: GModelRoot; protected tracker: ChangeBoundsTracker; protected elementId2startPos = new Map(); diff --git a/packages/client/src/features/tools/change-bounds/change-bounds-tool.ts b/packages/client/src/features/tools/change-bounds/change-bounds-tool.ts index 842d095d6..fa2306bdf 100644 --- a/packages/client/src/features/tools/change-bounds/change-bounds-tool.ts +++ b/packages/client/src/features/tools/change-bounds/change-bounds-tool.ts @@ -37,9 +37,16 @@ import { TYPES, findParentByFeature } from '@eclipse-glsp/sprotty'; -import { ChangeBoundsManager, ChangeBoundsTracker, TrackedElementResize, TrackedResize } from '..'; +import { CSS_RESIZE_MODE, ChangeBoundsManager, ChangeBoundsTracker, TrackedElementResize, TrackedResize } from '..'; import { DragAwareMouseListener } from '../../../base/drag-aware-mouse-listener'; -import { CursorCSS, applyCssClasses, cursorFeedbackAction, deleteCssClasses, toggleCssClasses } from '../../../base/feedback/css-feedback'; +import { + CursorCSS, + ModifyCSSFeedbackAction, + applyCssClasses, + cursorFeedbackAction, + deleteCssClasses, + toggleCssClasses +} from '../../../base/feedback/css-feedback'; import { FeedbackEmitter } from '../../../base/feedback/feeback-emitter'; import { ISelectionListener, SelectionService } from '../../../base/selection-service'; import { @@ -125,7 +132,7 @@ export class ChangeBoundsTool extends BaseEditTool { } } -export class ChangeBoundsListener extends DragAwareMouseListener implements ISelectionListener, Disposable { +export class ChangeBoundsListener extends DragAwareMouseListener implements ISelectionListener { static readonly CSS_CLASS_ACTIVE = 'active'; // members for calculating the correct position change @@ -183,8 +190,7 @@ export class ChangeBoundsListener extends DragAwareMouseListener implements ISel this.handleFeedback.submit(); return true; } else { - this.tracker.stopTracking(); - this.handleFeedback.dispose(); + this.dispose(); return false; } } @@ -199,19 +205,14 @@ export class ChangeBoundsListener extends DragAwareMouseListener implements ISel // rely on the FeedbackMoveMouseListener to update the element bounds of selected elements // consider resize handles ourselves if (this.activeResizeHandle && this.tracker.isTracking()) { - const resize = this.tracker.resizeElements(this.activeResizeHandle, { - snap: event, - symmetric: event, - restrict: event, - skipStatic: false - }); + const resize = this.tracker.resizeElements(this.activeResizeHandle, { snap: event, symmetric: event, restrict: event }); this.addResizeFeedback(resize, target, event); - if (resize.elementResizes.length > 0) { - const resizeAction = this.resizeBoundsAction(resize); + const resizeAction = this.resizeBoundsAction(resize); + if (resizeAction.bounds.length > 0) { this.resizeFeedback.add(resizeAction, () => this.resetBounds()); - if (resizeAction.bounds.length > 0) { - this.tracker.updateTrackingPosition(resize.handleMove.moveVector); - } + this.tracker.updateTrackingPosition(resize.handleMove.moveVector); + } else { + this.resizeFeedback.add(undefined, () => this.resetBounds()); } this.resizeFeedback.submit(); } @@ -240,6 +241,12 @@ export class ChangeBoundsListener extends DragAwareMouseListener implements ISel deleteCssClasses(handle, ChangeBoundsListener.CSS_CLASS_ACTIVE) ); + // graph feedback + this.resizeFeedback.add( + ModifyCSSFeedbackAction.create({ add: [CSS_RESIZE_MODE] }), + ModifyCSSFeedbackAction.create({ remove: [CSS_RESIZE_MODE] }) + ); + // cursor feedback const cursorClass = SResizeHandle.getCursorCss(resize.handleMove.element); this.resizeFeedback.add(cursorFeedbackAction(cursorClass), cursorFeedbackAction(CursorCSS.DEFAULT)); @@ -300,13 +307,13 @@ export class ChangeBoundsListener extends DragAwareMouseListener implements ISel const selectedElements = getMatchingElements(target.index, isNonRoutableSelectedMovableBoundsAware); const selectionSet: Set = new Set(selectedElements); const newBounds: ElementAndBounds[] = selectedElements - .filter(element => this.isValidElement(element, selectionSet)) + .filter(element => this.isValidMove(element, selectionSet)) .map(toElementAndBounds); return newBounds.length > 0 ? [ChangeBoundsOperation.create(newBounds)] : []; } - protected isValidElement(element: BoundsAwareModelElement, selectedElements: Set = new Set()): boolean { - return this.tool.changeBoundsManager.isValid(element) && !this.isChildOfSelected(selectedElements, element); + protected isValidMove(element: BoundsAwareModelElement, selectedElements: Set = new Set()): boolean { + return this.tool.changeBoundsManager.hasValidPosition(element) && !this.isChildOfSelected(selectedElements, element); } protected isChildOfSelected(selectedElements: Set, element: GModelElement): boolean { @@ -342,7 +349,7 @@ export class ChangeBoundsListener extends DragAwareMouseListener implements ISel } protected handleResizeOnServer(activeResizeHandle: SResizeHandle): Action[] { - if (this.tool.changeBoundsManager.isValid(activeResizeHandle.parent) && this.initialBounds) { + if (this.initialBounds && this.isValidResize(activeResizeHandle.parent)) { const elementAndBounds = toElementAndBounds(activeResizeHandle.parent); if (!this.initialBounds.newPosition || !elementAndBounds.newPosition) { return []; @@ -361,6 +368,10 @@ export class ChangeBoundsListener extends DragAwareMouseListener implements ISel return []; } + protected isValidResize(element: BoundsAwareModelElement): boolean { + return this.tool.changeBoundsManager.isValid(element); + } + selectionChanged(root: GModelRoot, selectedElements: string[]): void { if (this.activeResizeElement && selectedElements.includes(this.activeResizeElement.id)) { // our active element is still selected, nothing to do @@ -374,8 +385,7 @@ export class ChangeBoundsListener extends DragAwareMouseListener implements ISel return; } } - this.updateResizeElement(root); - this.disposeAllButHandles(); + this.dispose(); } protected isActiveResizeElement(element?: GModelElement): element is GParentElement & BoundsAware { diff --git a/packages/client/src/features/tools/change-bounds/change-bounds-tracker.ts b/packages/client/src/features/tools/change-bounds/change-bounds-tracker.ts index 4b76a1884..63efd0c6b 100644 --- a/packages/client/src/features/tools/change-bounds/change-bounds-tracker.ts +++ b/packages/client/src/features/tools/change-bounds/change-bounds-tracker.ts @@ -69,23 +69,12 @@ export type MoveableElements = export interface TrackedElementMove extends ResolvedElementMove { moveVector: Vector; + sourceVector: Vector; valid: boolean; } export type TypedElementMove = TrackedElementMove & { element: T }; -export namespace ElementMovement { - export function empty(element: T, point = Point.ORIGIN): TypedElementMove { - return { - element, - fromPosition: point, - toPosition: point, - moveVector: Vector.ZERO, - valid: true - }; - } -} - export interface TrackedMove extends Movement { elementMoves: TrackedElementMove[]; valid: boolean; @@ -106,12 +95,12 @@ export interface ResizeOptions extends ElementTrackingOptions { symmetric: boolean | MouseEvent | KeyboardEvent | any; /** - * Avoids resizes smaller than the minimum size which will yield invalid sizes. - * The minimum size takes precedence over the snapped (grid) position. + * Avoids resizes smaller than the minimum size which will result in invalid sizes. + * Please note that the snapping will be applied before the constraining so an element may still be resized to an unsnapped size. * * Default: true. */ - capAtMinimumSize: boolean; + constrainResize: boolean; /** Skip resizes that produce an invalid size. Default: false. */ skipInvalidSize: boolean; @@ -126,7 +115,7 @@ export const DEFAULT_RESIZE_OPTIONS: ResizeOptions = { validate: true, symmetric: true, - capAtMinimumSize: true, + constrainResize: true, skipStatic: true, skipInvalidSize: false, @@ -231,7 +220,7 @@ export class ChangeBoundsTracker { protected calculateElementMove(element: T, vector: Vector, options: MoveOptions): TypedElementMove { const fromPosition = element.position; const toPosition = Point.add(fromPosition, vector); - const move: TypedElementMove = { element, fromPosition, toPosition, valid: true, moveVector: Vector.ZERO }; + const move: TypedElementMove = { element, fromPosition, toPosition, valid: true, moveVector: vector, sourceVector: vector }; if (options.snap) { move.toPosition = this.snapPosition(move, options); @@ -276,21 +265,14 @@ export class ChangeBoundsTracker { return resize; } - // symmetic handle move applies the diagram movement to the opposite handle - const symmetricHandleMove = options.symmetric - ? this.calculateHandleMove(handleMove.element.opposite(), Vector.reverse(update.vector), options) - : undefined; - // calculate resize for each element (typically only one element is resized at a time but customizations are possible) const elementsToResize = this.getResizeableElements(handle, options); for (const element of elementsToResize) { - const elementResize = this.calculateElementResize(element, handleMove, options, symmetricHandleMove); + const elementResize = this.calculateElementResize(element, handleMove, options); if (!this.skipElementResize(elementResize, options)) { resize.elementResizes.push(elementResize); resize.valid.move = resize.valid.move && elementResize.valid.move; resize.valid.size = resize.valid.size && elementResize.valid.size; - // handleMove.toPosition = SResizeHandle.getHandlePosition(elementResize.toBounds, handle.location); - // handleMove.moveVector = Point.vector(handleMove.fromPosition, handleMove.toPosition); } } return resize; @@ -326,11 +308,10 @@ export class ChangeBoundsTracker { protected calculateElementResize( element: ResizableModelElement, handleMove: TrackedHandleMove, - options: ResizeOptions, - symmetricHandleMove?: TrackedHandleMove + options: ResizeOptions ): TrackedElementResize { const fromBounds = element.bounds; - const toBounds = this.calculateElementBounds(element, handleMove, options, symmetricHandleMove); + const toBounds = this.calculateElementBounds(element, handleMove, options); const resize: TrackedElementResize = { element, fromBounds, toBounds, valid: { size: true, move: true } }; if (options.validate) { @@ -341,32 +322,70 @@ export class ChangeBoundsTracker { return resize; } - private calculateElementBounds( - element: ResizableModelElement, - handleMove: TrackedHandleMove, - options: ResizeOptions, - symmetricHandleMove?: TrackedHandleMove - ): Bounds { - if (options.capAtMinimumSize) { - const minimum = this.manager.getMinimumSize(element); - const vector = this.capMoveVector(element.bounds, handleMove, minimum); - let toBounds = this.calculateBounds(element.bounds, handleMove.element.location, vector); - if (symmetricHandleMove) { - const symmetricVector = this.capMoveVector(toBounds, symmetricHandleMove, minimum); - toBounds = this.calculateBounds(toBounds, symmetricHandleMove.element.location, symmetricVector); - } - return toBounds; - } else { - const vector = handleMove.moveVector; - let toBounds = this.calculateBounds(element.bounds, handleMove.element.location, vector); - if (symmetricHandleMove) { - toBounds = this.calculateBounds(toBounds, symmetricHandleMove.element.location, symmetricHandleMove.moveVector); - } + protected calculateElementBounds(element: ResizableModelElement, handleMove: TrackedHandleMove, options: ResizeOptions): Bounds { + let toBounds = this.calculateBounds(element.bounds, handleMove); + if (options.symmetric) { + const symmetricHandleMove = this.calculateSymmetricHandleMove(handleMove, options); + toBounds = this.calculateBounds(toBounds, symmetricHandleMove); + } + if (!options.constrainResize || this.manager.hasValidSize(element, toBounds)) { return toBounds; } + + // we need to adjust to the minimum size but it is not enough to simply set the size + // we need to make sure that the element is still at the expected position + // we therefore constrain the movement vector to actually avoid going below the minimum size + const minimum = this.manager.getMinimumSize(element); + handleMove.moveVector = this.constrainResizeVector(element.bounds, handleMove, minimum); + if (options.symmetric) { + // if we have symmetric resize we want to distribute the constrained movement vector to both sides + // but only for the dimension that was actually resized beyond the minimum + handleMove.moveVector.x = element.bounds.width > minimum.width ? handleMove.moveVector.x / 2 : handleMove.moveVector.x; + handleMove.moveVector.y = element.bounds.height > minimum.height ? handleMove.moveVector.y / 2 : handleMove.moveVector.y; + } + toBounds = this.calculateBounds(element.bounds, handleMove); + if (options.symmetric) { + // since we already distributed the available movement vector, we do not want to snap the symmetric handle move + const symmetricHandleMove = this.calculateSymmetricHandleMove(handleMove, { ...options, snap: false }); + toBounds = this.calculateBounds(toBounds, symmetricHandleMove); + } + return toBounds; + } + + protected calculateSymmetricHandleMove(handleMove: TrackedHandleMove, options: ResizeOptions): TrackedHandleMove { + const moveOptions = this.resolveMoveOptions({ ...options, validate: false, restrict: false }); + return this.calculateElementMove(handleMove.element.opposite(), Vector.reverse(handleMove.moveVector), moveOptions); + } + + protected calculateBounds(src: Readonly, handleMove?: TrackedHandleMove): Bounds { + if (!handleMove || Vector.isZero(handleMove.moveVector)) { + return src; + } + return this.doCalculateBounds(src, handleMove.moveVector, handleMove.element.location); } - protected capMoveVector(src: Bounds, handleMove: TrackedHandleMove, minimum: Dimension): Vector { + protected doCalculateBounds(src: Readonly, vector: Vector, location: ResizeHandleLocation): Bounds { + switch (location) { + case ResizeHandleLocation.TopLeft: + return { x: src.x + vector.x, y: src.y + vector.y, width: src.width - vector.x, height: src.height - vector.y }; + case ResizeHandleLocation.Top: + return { ...src, y: src.y + vector.y, height: src.height - vector.y }; + case ResizeHandleLocation.TopRight: + return { ...src, y: src.y + vector.y, width: src.width + vector.x, height: src.height - vector.y }; + case ResizeHandleLocation.Right: + return { ...src, width: src.width + vector.x }; + case ResizeHandleLocation.BottomRight: + return { ...src, width: src.width + vector.x, height: src.height + vector.y }; + case ResizeHandleLocation.Bottom: + return { ...src, height: src.height + vector.y }; + case ResizeHandleLocation.BottomLeft: + return { ...src, x: src.x + vector.x, width: src.width - vector.x, height: src.height + vector.y }; + case ResizeHandleLocation.Left: + return { ...src, x: src.x + vector.x, width: src.width - vector.x }; + } + } + + protected constrainResizeVector(src: Readonly, handleMove: TrackedHandleMove, minimum: Dimension): Vector { const vector = handleMove.moveVector as Writable; switch (handleMove.element.location) { case ResizeHandleLocation.TopLeft: @@ -401,30 +420,6 @@ export class ChangeBoundsTracker { return vector; } - protected calculateBounds(src: Bounds, location: ResizeHandleLocation, vector: Vector): Writable { - if (Vector.isZero(vector)) { - return src; - } - switch (location) { - case ResizeHandleLocation.TopLeft: - return { x: src.x + vector.x, y: src.y + vector.y, width: src.width - vector.x, height: src.height - vector.y }; - case ResizeHandleLocation.Top: - return { ...src, y: src.y + vector.y, height: src.height - vector.y }; - case ResizeHandleLocation.TopRight: - return { ...src, y: src.y + vector.y, width: src.width + vector.x, height: src.height - vector.y }; - case ResizeHandleLocation.Right: - return { ...src, width: src.width + vector.x }; - case ResizeHandleLocation.BottomRight: - return { ...src, width: src.width + vector.x, height: src.height + vector.y }; - case ResizeHandleLocation.Bottom: - return { ...src, height: src.height + vector.y }; - case ResizeHandleLocation.BottomLeft: - return { ...src, x: src.x + vector.x, width: src.width - vector.x, height: src.height + vector.y }; - case ResizeHandleLocation.Left: - return { ...src, x: src.x + vector.x, width: src.width - vector.x }; - } - } - dispose(): void { this.stopTracking(); } diff --git a/packages/client/src/utils/gmodel-util.ts b/packages/client/src/utils/gmodel-util.ts index fa46ba64b..5ad532956 100644 --- a/packages/client/src/utils/gmodel-util.ts +++ b/packages/client/src/utils/gmodel-util.ts @@ -14,9 +14,7 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ import { - Bounds, BoundsAware, - Dimension, EdgeRouterRegistry, ElementAndBounds, ElementAndRoutingPoints, @@ -42,7 +40,6 @@ import { isSelected, remove } from '@eclipse-glsp/sprotty'; -import { GEdge } from '../model'; /** * Helper type to represent a filter predicate for {@link GModelElement}s. This is used to retrieve @@ -140,9 +137,12 @@ export function isNotUndefined(element: T | undefined): element is T { * @param element The element to which the css classes should be added. * @param cssClasses The set of css classes as string array. */ -export function addCssClasses(element: GModelElement, cssClasses: string[]): void { +export function addCssClasses(element: GModelElement, cssClasses: string[]): void; +export function addCssClasses(element: GModelElement, ...cssClasses: string[]): void; +export function addCssClasses(element: GModelElement, ...cssClasses: string[] | [string[]]): void { + const classes = Array.isArray(cssClasses[0]) ? cssClasses[0] : cssClasses; const elementCssClasses: string[] = element.cssClasses ?? []; - distinctAdd(elementCssClasses, ...cssClasses); + distinctAdd(elementCssClasses, ...classes); element.cssClasses = elementCssClasses; } @@ -151,11 +151,14 @@ export function addCssClasses(element: GModelElement, cssClasses: string[]): voi * @param element The element from which the css classes should be removed. * @param cssClasses The set of css classes as string array. */ -export function removeCssClasses(root: GModelElement, cssClasses: string[]): void { - if (!root.cssClasses || root.cssClasses.length === 0) { +export function removeCssClasses(element: GModelElement, cssClasses: string[]): void; +export function removeCssClasses(element: GModelElement, ...cssClasses: string[]): void; +export function removeCssClasses(element: GModelElement, ...cssClasses: string[] | [string[]]): void { + if (!element.cssClasses || element.cssClasses.length === 0) { return; } - remove(root.cssClasses, ...cssClasses); + const classes = Array.isArray(cssClasses[0]) ? cssClasses[0] : cssClasses; + remove(element.cssClasses, ...classes); } /** @@ -164,9 +167,9 @@ export function removeCssClasses(root: GModelElement, cssClasses: string[]): voi * @param elements The elements to which the css class should be added. * @param cssClass The css class to add. */ -export function addCssClassToElements(elements: GModelElement[], cssClass: string): void { +export function addCssClassToElements(elements: GModelElement[], ...cssClasses: string[]): void { for (const element of elements) { - addCssClasses(element, [cssClass]); + addCssClasses(element, cssClasses); } } @@ -175,31 +178,17 @@ export function addCssClassToElements(elements: GModelElement[], cssClass: strin * @param elements The elements from which the css class should be removed. * @param cssClass The css class to remove. */ -export function removeCssClassOfElements(elements: GModelElement[], cssClass: string): void { +export function removeCssClassOfElements(elements: GModelElement[], ...cssClasses: string[]): void { for (const element of elements) { - removeCssClasses(element, [cssClass]); + removeCssClasses(element, cssClasses); } } -/** - * Adds a css class to a {@link GModelElement}. - */ -export function addCssClass(element: GModelElement, cssClass: string): void { - addCssClassToElements([element], cssClass); -} - -/** - * Removes a css class from a {@link GModelElement}. - */ -export function removeCssClass(element: GModelElement, cssClass: string): void { - removeCssClassOfElements([element], cssClass); -} - /** * Toggles a css class on a {@link GModelElement} based on the given toggle flag. */ export function toggleCssClass(element: GModelElement, cssClass: string, toggle: boolean): void { - return toggle ? addCssClass(element, cssClass) : removeCssClass(element, cssClass); + return toggle ? addCssClasses(element, cssClass) : removeCssClasses(element, cssClass); } export function isNonRoutableSelectedMovableBoundsAware(element: GModelElement): element is SelectableBoundsAware { @@ -463,29 +452,3 @@ export function isNotDescendantOfAnyElement(elements: F export function removeDescendants(elements: FluentIterable): FluentIterable { return elements.filter(isNotDescendantOfAnyElement(elements)); } - -/** - * Computes the bounds of the given edge, i.e., the bounding box of the edge's source, target, and routing points in absolute coordinates. - * - * @param edge The edge to compute the bounds for. - * @returns the bounds of the edge - */ -export function getAbsoluteEdgeBounds(edge: GEdge): Bounds { - if (!edge.source || !edge.target) { - return edge.bounds; - } - const source = edge.source; - const target = edge.target; - const edgePoints: Bounds[] = []; - edgePoints.push({ ...Dimension.ZERO, ...Bounds.center(source.bounds) }); - edgePoints.push(...edge.routingPoints.map(point => ({ ...Dimension.ZERO, ...point }))); - edgePoints.push({ ...Dimension.ZERO, ...Bounds.center(target.bounds) }); - let bounds = edgePoints.reduce(Bounds.combine); - let current: GModelElement = edge; - while (current instanceof GChildElement) { - const parent = current.parent; - bounds = parent.localToParent(bounds); - current = parent; - } - return bounds; -} diff --git a/packages/protocol/src/sprotty-geometry-bounds.ts b/packages/protocol/src/sprotty-geometry-bounds.ts index bcc4ebe0d..c18fedd67 100644 --- a/packages/protocol/src/sprotty-geometry-bounds.ts +++ b/packages/protocol/src/sprotty-geometry-bounds.ts @@ -49,7 +49,7 @@ declare module 'sprotty-protocol/lib/utils/geometry' { * @param right right bounds * @returns true if the two bounds are equal */ - function equals(left: Bounds, right: Bounds): boolean; + function equals(left: Bounds, right: Bounds, eps?: number): boolean; /** * Returns the x-coordinate of the left edge of the bounds. @@ -259,7 +259,8 @@ Bounds.overlap = (one: Bounds, other: Bounds, touch?: boolean): boolean => { otherBottomRight.y > oneTopLeft.y; }; -Bounds.equals = (left: Bounds, right: Bounds): boolean => Point.equals(left, right) && Dimension.equals(left, right); +Bounds.equals = (left: Bounds, right: Bounds, eps?: number): boolean => + Point.equals(left, right, eps) && Dimension.equals(left, right, eps); Bounds.left = (bounds: Bounds): number => bounds.x; diff --git a/packages/protocol/src/sprotty-geometry-dimension.spec.ts b/packages/protocol/src/sprotty-geometry-dimension.spec.ts index 1ed63d658..a9256d116 100644 --- a/packages/protocol/src/sprotty-geometry-dimension.spec.ts +++ b/packages/protocol/src/sprotty-geometry-dimension.spec.ts @@ -83,6 +83,27 @@ describe('Dimension', () => { const isEqual = Dimension.equals(dimension1, dimension2); expect(isEqual).to.be.false; }); + + it('should return false if the dimensions have different width', () => { + const dimension1: Dimension = { width: 10, height: 20 }; + const dimension2: Dimension = { width: 5, height: 20 }; + const isEqual = Dimension.equals(dimension1, dimension2); + expect(isEqual).to.be.false; + }); + + it('should return false if the dimensions have different height', () => { + const dimension1: Dimension = { width: 10, height: 20 }; + const dimension2: Dimension = { width: 10, height: 10 }; + const isEqual = Dimension.equals(dimension1, dimension2); + expect(isEqual).to.be.false; + }); + + it('should consider epsilon', () => { + const dimension1: Dimension = { width: 10, height: 20 }; + const dimension2: Dimension = { width: 10.0001, height: 20.0001 }; + const isEqual = Dimension.equals(dimension1, dimension2, 0.001); + expect(isEqual).to.be.true; + }); }); describe('fromPoint', () => { @@ -92,4 +113,12 @@ describe('Dimension', () => { expect(dimension).to.deep.equal({ width: 10, height: 20 }); }); }); + + describe('area', () => { + it('should compute the area of the dimension', () => { + const dimension: Dimension = { width: 10, height: 20 }; + const area = Dimension.area(dimension); + expect(area).to.equal(200); + }); + }); }); diff --git a/packages/protocol/src/sprotty-geometry-dimension.ts b/packages/protocol/src/sprotty-geometry-dimension.ts index c9582c2e7..8705f6e5a 100644 --- a/packages/protocol/src/sprotty-geometry-dimension.ts +++ b/packages/protocol/src/sprotty-geometry-dimension.ts @@ -16,6 +16,7 @@ /* eslint-disable @typescript-eslint/no-shadow */ import { Dimension, Point } from 'sprotty-protocol/lib/utils/geometry'; +import { equalUpTo } from './utils/math-util'; declare module 'sprotty-protocol/lib/utils/geometry' { namespace Dimension { @@ -77,9 +78,10 @@ declare module 'sprotty-protocol/lib/utils/geometry' { * Checks if two dimensions are equal. Two dimensions are equal if their `width` and `height` are equal. * @param left the left dimension * @param right the right dimension + * @param eps the epsilon value * @returns true if the dimensions are equal, false otherwise */ - function equals(left: Dimension, right: Dimension): boolean; + function equals(left: Dimension, right: Dimension, eps?: number): boolean; /** * Creates a new dimension from the given point. The `width` and `height` of the new dimension are the `x` and `y` of the point. @@ -87,6 +89,13 @@ declare module 'sprotty-protocol/lib/utils/geometry' { * @returns new dimension */ function fromPoint(point: Point): Dimension; + + /** + * Computes the area of the given dimension. + * @param dimension the dimension + * @returns the area of the dimension + */ + function area(dimension: Dimension): number; } } @@ -106,7 +115,9 @@ Dimension.map = (dimension: T, callbackfn: (value: number, width: callbackfn(dimension.width, 'width'), height: callbackfn(dimension.height, 'height') }); -Dimension.equals = (left: Dimension, right: Dimension): boolean => left.width === right.width && left.height === right.height; +Dimension.equals = (left: Dimension, right: Dimension, eps?: number): boolean => + equalUpTo(left.width, right.width, eps) && equalUpTo(left.height, right.height, eps); Dimension.fromPoint = (point: Point): Dimension => ({ width: point.x, height: point.y }); +Dimension.area = (dimension: Dimension): number => dimension.width * dimension.height; export { Dimension }; diff --git a/packages/protocol/src/sprotty-geometry-point.spec.ts b/packages/protocol/src/sprotty-geometry-point.spec.ts index fa47011f3..2611482a8 100644 --- a/packages/protocol/src/sprotty-geometry-point.spec.ts +++ b/packages/protocol/src/sprotty-geometry-point.spec.ts @@ -113,4 +113,18 @@ describe('Point', () => { expect(Point.moveTowards(from, vector)).to.deep.equal(expectedMovement); }); }); + + describe('equals', () => { + it('returns true for equal points', () => { + expect(Point.equals({ x: 1, y: 2 }, { x: 1, y: 2 })).to.be.true; + }); + + it('returns false for different points', () => { + expect(Point.equals({ x: 1, y: 2 }, { x: 1, y: 3 })).to.be.false; + }); + + it('returns true up to an epsilon', () => { + expect(Point.equals({ x: 1, y: 2 }, { x: 1.0001, y: 2.0001 }, 0.001)).to.be.true; + }); + }); }); diff --git a/packages/protocol/src/sprotty-geometry-point.ts b/packages/protocol/src/sprotty-geometry-point.ts index 5c30ac096..76912944f 100644 --- a/packages/protocol/src/sprotty-geometry-point.ts +++ b/packages/protocol/src/sprotty-geometry-point.ts @@ -17,9 +17,14 @@ import { Point } from 'sprotty-protocol/lib/utils/geometry'; import { AnyObject, Movement, Vector, hasNumberProp } from './utils'; +import { equalUpTo } from './utils/math-util'; declare module 'sprotty-protocol/lib/utils/geometry' { namespace Point { + /** + * Type guard to check if the given object is a point. + * @param point the object to be checked + */ function is(point: any): point is Point; /** * The absolute variant of that point, i.e., each coordinate uses its absolute value. @@ -33,6 +38,15 @@ declare module 'sprotty-protocol/lib/utils/geometry' { * @param point the point to be checked for validity */ function isValid(point?: Point): point is Point; + + /** + * Checks whether the given points are equal up to a certain epsilon. + * @param one the first point + * @param other the second point + * @param eps the epsilon value + */ + function equals(one: Point, other: Point, eps?: number): boolean; + /** * The absolute variant of that point, i.e., each coordinate uses its absolute value. * @@ -135,4 +149,6 @@ Point.moveTowards = (from: Point, vector: Vector): Movement => { return { from, to, vector, direction: dir }; }; +Point.equals = (one: Point, other: Point, eps?: number): boolean => equalUpTo(one.x, other.x, eps) && equalUpTo(one.y, other.y, eps); + export { Point }; diff --git a/packages/protocol/src/utils/index.ts b/packages/protocol/src/utils/index.ts index e9ae9ef0e..d5d3ba3c8 100644 --- a/packages/protocol/src/utils/index.ts +++ b/packages/protocol/src/utils/index.ts @@ -19,4 +19,5 @@ export * from './event'; export * from './geometry-movement'; export * from './geometry-util'; export * from './geometry-vector'; +export * from './math-util'; export * from './type-util'; diff --git a/packages/protocol/src/utils/math-util.ts b/packages/protocol/src/utils/math-util.ts new file mode 100644 index 000000000..7b54b0c61 --- /dev/null +++ b/packages/protocol/src/utils/math-util.ts @@ -0,0 +1,19 @@ +/******************************************************************************** + * Copyright (c) 2024 Axon Ivy AG and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +export function equalUpTo(one: number, other: number, epsilon: number = Number.EPSILON): boolean { + return Math.abs(one - other) <= epsilon; +} From eafbaddbc1d72ae5fa3f1ad045895a111b85df58 Mon Sep 17 00:00:00 2001 From: Martin Fleck Date: Thu, 16 May 2024 13:44:42 +0200 Subject: [PATCH 3/3] PR Review - Add missing @param doc - Do not debug decoratable elements - Adapt color of helper line --- packages/client/css/helper-lines.css | 4 ++-- .../features/debug/debug-bounds-decorator.tsx | 20 ++++++++++++++----- .../protocol/src/sprotty-geometry-bounds.ts | 1 + .../src/sprotty-geometry-dimension.ts | 2 +- .../protocol/src/sprotty-geometry-point.ts | 2 +- 5 files changed, 20 insertions(+), 9 deletions(-) diff --git a/packages/client/css/helper-lines.css b/packages/client/css/helper-lines.css index 967e0f5aa..d83ccb374 100644 --- a/packages/client/css/helper-lines.css +++ b/packages/client/css/helper-lines.css @@ -16,8 +16,8 @@ .helper-line { pointer-events: none; - stroke: #f7086c; - stroke-width: 0.5; + stroke: #1d80d1; + stroke-width: 1; opacity: 1; } diff --git a/packages/client/src/features/debug/debug-bounds-decorator.tsx b/packages/client/src/features/debug/debug-bounds-decorator.tsx index b65ce8c6d..07380f3c2 100644 --- a/packages/client/src/features/debug/debug-bounds-decorator.tsx +++ b/packages/client/src/features/debug/debug-bounds-decorator.tsx @@ -15,7 +15,7 @@ ********************************************************************************/ /* eslint-disable max-len */ -import { Bounds, GModelElement, IVNodePostprocessor, Point, TYPES, isSizeable, setClass, svg } from '@eclipse-glsp/sprotty'; +import { Bounds, GModelElement, IVNodePostprocessor, Point, TYPES, isDecoration, isSizeable, setClass, svg } from '@eclipse-glsp/sprotty'; import { inject, injectable, optional } from 'inversify'; import { VNode } from 'snabbdom'; import { GGraph } from '../../model'; @@ -25,6 +25,8 @@ import { DebugManager } from './debug-manager'; // eslint-disable-next-line @typescript-eslint/no-unused-vars const JSX = { createElement: svg }; +export const CSS_DEBUG_BOUNDS = 'debug-bounds'; + @injectable() export class DebugBoundsDecorator implements IVNodePostprocessor { @inject(TYPES.IDebugManager) @optional() protected debugManager?: DebugManager; @@ -33,10 +35,10 @@ export class DebugBoundsDecorator implements IVNodePostprocessor { if (!this.debugManager?.isDebugEnabled) { return vnode; } - if (isSizeable(element)) { + if (isSizeable(element) && this.shouldDecorateSizeable(element)) { this.decorateSizeable(vnode, element); } - if (element instanceof GGraph) { + if (element instanceof GGraph && this.shouldDecorateGraph(element)) { this.decorateGraph(vnode, element); } return vnode; @@ -48,8 +50,12 @@ export class DebugBoundsDecorator implements IVNodePostprocessor { return 5; } + protected shouldDecorateGraph(graph: GGraph): boolean { + return true; + } + protected decorateGraph(vnode: VNode, graph: GGraph): void { - setClass(vnode, 'debug-bounds', true); + setClass(vnode, CSS_DEBUG_BOUNDS, true); const svgChild = vnode.children?.find(child => typeof child !== 'string' && child.sel === 'svg') as VNode | undefined; const group = svgChild?.children?.find(child => typeof child !== 'string' && child.sel === 'g') as VNode | undefined; group?.children?.push(this.renderOrigin(graph)); @@ -67,8 +73,12 @@ export class DebugBoundsDecorator implements IVNodePostprocessor { ); } + protected shouldDecorateSizeable(element: BoundsAwareModelElement): boolean { + return !isDecoration(element); + } + protected decorateSizeable(vnode: VNode, element: BoundsAwareModelElement): void { - setClass(vnode, 'debug-bounds', true); + setClass(vnode, CSS_DEBUG_BOUNDS, true); vnode.children?.push(this.renderTopLeftCorner(element)); vnode.children?.push(this.renderTopRightCorner(element)); vnode.children?.push(this.renderBottomLeftCorner(element)); diff --git a/packages/protocol/src/sprotty-geometry-bounds.ts b/packages/protocol/src/sprotty-geometry-bounds.ts index c18fedd67..8e8a6b671 100644 --- a/packages/protocol/src/sprotty-geometry-bounds.ts +++ b/packages/protocol/src/sprotty-geometry-bounds.ts @@ -47,6 +47,7 @@ declare module 'sprotty-protocol/lib/utils/geometry' { * Checks whether the two bounds are equal. * @param left left bounds * @param right right bounds + * @param eps the epsilon for the comparison * @returns true if the two bounds are equal */ function equals(left: Bounds, right: Bounds, eps?: number): boolean; diff --git a/packages/protocol/src/sprotty-geometry-dimension.ts b/packages/protocol/src/sprotty-geometry-dimension.ts index 8705f6e5a..2c9e0e878 100644 --- a/packages/protocol/src/sprotty-geometry-dimension.ts +++ b/packages/protocol/src/sprotty-geometry-dimension.ts @@ -78,7 +78,7 @@ declare module 'sprotty-protocol/lib/utils/geometry' { * Checks if two dimensions are equal. Two dimensions are equal if their `width` and `height` are equal. * @param left the left dimension * @param right the right dimension - * @param eps the epsilon value + * @param eps @param eps the epsilon for the comparison * @returns true if the dimensions are equal, false otherwise */ function equals(left: Dimension, right: Dimension, eps?: number): boolean; diff --git a/packages/protocol/src/sprotty-geometry-point.ts b/packages/protocol/src/sprotty-geometry-point.ts index 76912944f..fcb52060b 100644 --- a/packages/protocol/src/sprotty-geometry-point.ts +++ b/packages/protocol/src/sprotty-geometry-point.ts @@ -43,7 +43,7 @@ declare module 'sprotty-protocol/lib/utils/geometry' { * Checks whether the given points are equal up to a certain epsilon. * @param one the first point * @param other the second point - * @param eps the epsilon value + * @param eps @param eps the epsilon for the comparison */ function equals(one: Point, other: Point, eps?: number): boolean;