diff --git a/examples/workflow-standalone/app/example1.wf b/examples/workflow-standalone/app/example1.wf index 5500e816..c9538dfa 100644 --- a/examples/workflow-standalone/app/example1.wf +++ b/examples/workflow-standalone/app/example1.wf @@ -162,6 +162,12 @@ "id": "edge_task_Push_fork_1", "sourceId": "task_Push", "targetId": "fork_1", + "args": { + "edgeSourcePointX": 142.921875, + "edgeSourcePointY": 115, + "edgeTargetPointX": 169.5, + "edgeTargetPointY": 115 + }, "children": [] }, { @@ -171,6 +177,12 @@ "id": "edge_fork1_task_ChkTp", "sourceId": "fork_1", "targetId": "task_ChkTp", + "args": { + "edgeSourcePointX": 179.9129171124612, + "edgeSourcePointY": 117.84892269711828, + "edgeTargetPointX": 235.356884765625, + "edgeTargetPointY": 150 + }, "children": [] }, { @@ -291,6 +303,12 @@ "id": "edge_task_ChkWt_decision1", "sourceId": "task_ChkWt", "targetId": "decision_1", + "args": { + "edgeSourcePointX": 303.1103515625, + "edgeSourcePointY": 65.49209855270233, + "edgeTargetPointX": 329.68729039942264, + "edgeTargetPointY": 65.80682404455813 + }, "children": [] }, { @@ -300,6 +318,12 @@ "id": "edge_fork1_task_ChkWt", "sourceId": "fork_1", "targetId": "task_ChkWt", + "args": { + "edgeSourcePointX": 179.91511482913953, + "edgeSourcePointY": 112.16070426477935, + "edgeTargetPointX": 235.588623046875, + "edgeTargetPointY": 80 + }, "children": [] }, { @@ -309,6 +333,12 @@ "id": "edge_task_ChkTp_decision2", "sourceId": "task_ChkTp", "targetId": "decision2", + "args": { + "edgeSourcePointX": 302.4482421875, + "edgeSourcePointY": 165.48627182195702, + "edgeTargetPointX": 329.686567408344, + "edgeTargetPointY": 165.80756987952083 + }, "children": [] }, { @@ -581,6 +611,12 @@ "sourceId": "decision_1", "targetId": "task_RflWt", "probability": "medium", + "args": { + "edgeSourcePointX": 358.0649068859051, + "edgeSourcePointY": 61.42000425502652, + "edgeTargetPointX": 390, + "edgeTargetPointY": 49.29702709813207 + }, "children": [] }, { @@ -593,6 +629,12 @@ "sourceId": "decision_1", "targetId": "task_WtOK", "probability": "medium", + "args": { + "edgeSourcePointX": 358.34775282071627, + "edgeSourcePointY": 70.28861664860428, + "edgeTargetPointX": 390, + "edgeTargetPointY": 81.28206267799604 + }, "children": [] }, { @@ -605,6 +647,12 @@ "sourceId": "decision2", "targetId": "task_KeepTp", "probability": "medium", + "args": { + "edgeSourcePointX": 359.4355670963364, + "edgeSourcePointY": 162.83422159210613, + "edgeTargetPointX": 390, + "edgeTargetPointY": 155.6324249695934 + }, "children": [] }, { @@ -617,6 +665,12 @@ "sourceId": "decision2", "targetId": "task_PreHeat", "probability": "medium", + "args": { + "edgeSourcePointX": 358.5845250668064, + "edgeSourcePointY": 170.0444811296957, + "edgeTargetPointX": 390, + "edgeTargetPointY": 180.14095238095237 + }, "children": [] }, { @@ -651,8 +705,8 @@ "radiusTopRight": 5 }, "position": { - "x": 600, - "y": 100 + "x": 620, + "y": 70 }, "name": "Brew", "taskType": "automated", @@ -754,6 +808,12 @@ "id": "edge_task_RflWt_merge_1", "sourceId": "task_RflWt", "targetId": "merge_1", + "args": { + "edgeSourcePointX": 465.32421875, + "edgeSourcePointY": 48.21658670322138, + "edgeTargetPointX": 503.68447295370714, + "edgeTargetPointY": 61.678170083823204 + }, "children": [] }, { @@ -763,6 +823,12 @@ "id": "edge_task_WTOK_merge_1", "sourceId": "task_WtOK", "targetId": "merge_1", + "args": { + "edgeSourcePointX": 468.9931640625, + "edgeSourcePointY": 81.75889455235128, + "edgeTargetPointX": 503.5431283350021, + "edgeTargetPointY": 70.17612721864288 + }, "children": [] }, { @@ -772,6 +838,12 @@ "id": "edge_task_KeepTp_merge_2", "sourceId": "task_KeepTp", "targetId": "merge_2", + "args": { + "edgeSourcePointX": 480.248046875, + "edgeSourcePointY": 156.71676105147495, + "edgeTargetPointX": 502.8141762197149, + "edgeTargetPointY": 162.576210746933 + }, "children": [] }, { @@ -781,6 +853,12 @@ "id": "edege_task_PreHeat_merge_2", "sourceId": "task_PreHeat", "targetId": "merge_2", + "args": { + "edgeSourcePointX": 477.49245689655174, + "edgeSourcePointY": 180, + "edgeTargetPointX": 503.79614626691165, + "edgeTargetPointY": 170.43689569610422 + }, "children": [] }, { @@ -790,6 +868,12 @@ "id": "edge_merge_2_join_1", "sourceId": "merge_2", "targetId": "join_1", + "args": { + "edgeSourcePointX": 524.1864130470989, + "edgeSourcePointY": 157.4794476448563, + "edgeTargetPointX": 560.5150734393876, + "edgeTargetPointY": 119.66798478757615 + }, "children": [] }, { @@ -799,6 +883,12 @@ "id": "edge_merge_1_join_1", "sourceId": "merge_1", "targetId": "join_1", + "args": { + "edgeSourcePointX": 524.3535533905932, + "edgeSourcePointY": 74.35355339059328, + "edgeTargetPointX": 560.4797799427402, + "edgeTargetPointY": 110.47977994274005 + }, "children": [] }, { @@ -808,6 +898,217 @@ "id": "edge_join_1_task_Brew", "sourceId": "join_1", "targetId": "task_Brew", + "args": { + "edgeSourcePointX": 570.1677977303013, + "edgeSourcePointY": 113.3075388189342, + "edgeTargetPointX": 620, + "edgeTargetPointY": 96.98742098344671 + }, + "children": [] + }, + { + "cssClasses": [ + "category" + ], + "type": "category", + "id": "aa086b23-67cb-42d3-a262-5c070793c681", + "layout": "vbox", + "layoutOptions": { + "hAlign": "center", + "hGrab": false, + "vGrab": false, + "prefWidth": 230, + "prefHeight": 345 + }, + "position": { + "x": 590, + "y": 260 + }, + "name": "Category0", + "args": { + "radiusTopLeft": 5, + "radiusBottomLeft": 5, + "radiusBottomRight": 5, + "radiusTopRight": 5 + }, + "size": { + "width": 230, + "height": 345 + }, + "children": [ + { + "cssClasses": [], + "type": "comp:header", + "id": "aa086b23-67cb-42d3-a262-5c070793c681_header", + "layout": "hbox", + "layoutOptions": {}, + "position": { + "x": 77.5966796875, + "y": 5 + }, + "size": { + "width": 74.806640625, + "height": 26 + }, + "children": [ + { + "cssClasses": [], + "type": "label:heading", + "alignment": { + "x": 32.296875, + "y": 13 + }, + "id": "aa086b23-67cb-42d3-a262-5c070793c681_classname", + "text": "Category0", + "position": { + "x": 5, + "y": 5 + }, + "size": { + "width": 64.806640625, + "height": 16 + }, + "children": [] + } + ] + }, + { + "cssClasses": [], + "type": "struct", + "id": "aa086b23-67cb-42d3-a262-5c070793c681_struct", + "layout": "freeform", + "layoutOptions": { + "hAlign": "left", + "hGrab": true, + "vGrab": true + }, + "position": { + "x": 5, + "y": 32 + }, + "size": { + "width": 220, + "height": 308 + }, + "children": [ + { + "cssClasses": [ + "task", + "automated" + ], + "type": "task:automated", + "id": "8132f80b-1c8a-482e-bbbb-d61ce6e96300", + "layout": "hbox", + "args": { + "radiusTopLeft": 5, + "radiusBottomLeft": 5, + "radiusBottomRight": 5, + "radiusTopRight": 5 + }, + "position": { + "x": 50, + "y": 180 + }, + "name": "AutomatedTask8", + "taskType": "automated", + "layoutOptions": { + "paddingRight": 10, + "prefWidth": 146.49609375, + "prefHeight": 68 + }, + "size": { + "width": 146.49609375, + "height": 68 + }, + "children": [ + { + "cssClasses": [], + "type": "icon", + "id": "8132f80b-1c8a-482e-bbbb-d61ce6e96300_icon", + "position": { + "x": 5, + "y": 24 + }, + "size": { + "width": 25, + "height": 20 + }, + "children": [] + }, + { + "cssClasses": [], + "type": "label:heading", + "alignment": { + "x": 53.1484375, + "y": 13 + }, + "id": "8132f80b-1c8a-482e-bbbb-d61ce6e96300_classname", + "text": "AutomatedTask8", + "position": { + "x": 31, + "y": 26 + }, + "size": { + "width": 105.49609375, + "height": 16 + }, + "children": [] + } + ] + } + ] + } + ] + }, + { + "cssClasses": [ + "forkOrJoin" + ], + "type": "activityNode:fork", + "id": "2c1fcc07-588c-48dd-a0e8-760727fba96c", + "position": { + "x": 600, + "y": 210 + }, + "nodeType": "forkNode", + "size": { + "width": 10, + "height": 50 + }, + "children": [] + }, + { + "cssClasses": [ + "forkOrJoin" + ], + "type": "activityNode:fork", + "id": "32cea494-6db9-4474-99f8-06b063518f84", + "position": { + "x": 790, + "y": 210 + }, + "nodeType": "forkNode", + "size": { + "width": 10, + "height": 50 + }, + "children": [] + }, + { + "cssClasses": [ + "forkOrJoin" + ], + "type": "activityNode:fork", + "id": "0dcb6fb8-4d81-4cfd-a3de-77f4a1f2e1be", + "position": { + "x": 870, + "y": 310 + }, + "nodeType": "forkNode", + "size": { + "width": 10, + "height": 50 + }, "children": [] } ] diff --git a/packages/client/src/features/change-bounds/model.ts b/packages/client/src/features/change-bounds/model.ts index 1216e7c0..5c7f8c9f 100644 --- a/packages/client/src/features/change-bounds/model.ts +++ b/packages/client/src/features/change-bounds/model.ts @@ -23,11 +23,10 @@ import { Point, hoverFeedbackFeature, isBoundsAware, - isMoveable, isSelectable } from '@eclipse-glsp/sprotty'; import { CursorCSS } from '../../base/feedback/css-feedback'; -import { BoundsAwareModelElement, MoveableElement, ResizableModelElement } from '../../utils/gmodel-util'; +import type { ResizableModelElement } from '../../utils/gmodel-util'; export const resizeFeature = Symbol('resizeFeature'); @@ -105,10 +104,6 @@ export namespace ResizeHandleLocation { } } -export function isBoundsAwareMoveable(element: GModelElement): element is BoundsAwareModelElement & MoveableElement { - return isMoveable(element) && isBoundsAware(element); -} - export class GResizeHandle extends GChildElement implements Hoverable { static readonly TYPE = 'resize-handle'; diff --git a/packages/client/src/features/change-bounds/move-element-handler.ts b/packages/client/src/features/change-bounds/move-element-handler.ts index 4eb438f1..939e14f8 100644 --- a/packages/client/src/features/change-bounds/move-element-handler.ts +++ b/packages/client/src/features/change-bounds/move-element-handler.ts @@ -18,7 +18,6 @@ import { Action, ChangeBoundsOperation, ElementAndBounds, - ElementMove, IActionDispatcher, IActionHandler, ICommand, @@ -27,17 +26,29 @@ import { MoveViewportAction, Point, TYPES, - isBoundsAware + isBoundsAware, + type Bounds, + type ElementMove, + type GModelElement } from '@eclipse-glsp/sprotty'; import { inject, injectable, optional, postConstruct } from 'inversify'; import { DebouncedFunc, debounce } from 'lodash'; import { EditorContextService } from '../../base/editor-context-service'; import { IFeedbackActionDispatcher } from '../../base/feedback/feedback-action-dispatcher'; import { FeedbackEmitter } from '../../base/feedback/feedback-emitter'; -import { SelectableBoundsAware, getElements, isSelectableAndBoundsAware } from '../../utils/gmodel-util'; +import { + SelectableBoundsAware, + getElements, + isNonRoutableSelectedMovableBoundsAware, + isNotUndefined, + type MoveableElement +} from '../../utils/gmodel-util'; import { isValidMove } from '../../utils/layout-utils'; import { outsideOfViewport } from '../../utils/viewpoint-util'; import { IMovementRestrictor } from '../change-bounds/movement-restrictor'; +import type { IChangeBoundsManager } from '../tools/change-bounds/change-bounds-manager'; +import { TrackedElementResize, type ChangeBoundsTracker } from '../tools/change-bounds/change-bounds-tracker'; +import { GResizeHandle } from './model'; import { MoveElementRelativeAction } from './move-element-action'; /** @@ -45,6 +56,10 @@ import { MoveElementRelativeAction } from './move-element-action'; */ @injectable() export class MoveElementHandler implements IActionHandler { + @inject(TYPES.IChangeBoundsManager) + protected readonly changeBoundsManager: IChangeBoundsManager; + protected tracker: ChangeBoundsTracker; + @inject(EditorContextService) protected editorContextService: EditorContextService; @@ -68,10 +83,12 @@ export class MoveElementHandler implements IActionHandler { @postConstruct() protected init(): void { this.moveFeedback = this.feedbackDispatcher.createEmitter(); + this.tracker = this.changeBoundsManager.createTracker(); } handle(action: Action): void | Action | ICommand { - if (MoveElementRelativeAction.is(action)) { + if (MoveElementRelativeAction.is(action) && action.elementIds.length > 0) { + this.tracker.startTracking(this.editorContextService.modelRoot); this.handleMoveElement(action); } } @@ -84,7 +101,7 @@ export class MoveElementHandler implements IActionHandler { const viewportActions: Action[] = []; const elementMoves: ElementMove[] = []; - const elements = getElements(viewport.index, action.elementIds, isSelectableAndBoundsAware); + const elements = getElements(viewport.index, action.elementIds, this.isValidMoveable); for (const element of elements) { const newPosition = this.getTargetBounds(element, action); elementMoves.push({ @@ -103,12 +120,41 @@ export class MoveElementHandler implements IActionHandler { viewportActions.push(MoveViewportAction.create({ moveX: action.moveX, moveY: action.moveY })); } } - this.dispatcher.dispatchAll(viewportActions); - const moveAction = MoveAction.create(elementMoves, { animate: false }); - this.moveFeedback.add(moveAction).submit(); + this.moveFeedback.add(this.createMoveAction(elementMoves)); + + const newBounds = elementMoves.map(this.toElementAndBounds.bind(this)).filter(isNotUndefined); + const wraps = this.tracker.wrap( + elements.map(element => { + const bounds = newBounds.find(b => b.elementId === element.id)!; + const toBounds: Bounds = { + ...element.bounds, + ...bounds.newSize, + ...bounds.newPosition + }; + return { + element: element, + fromBounds: element.bounds, + toBounds + }; + }), + { + validate: true + } + ); + + this.moveFeedback.add(TrackedElementResize.createFeedbackActions(Object.values(wraps ?? {}))); + this.moveFeedback.submit(); + + if (Object.keys(wraps).length > 0) { + newBounds.push( + ...Object.values(wraps) + .filter(resize => !action.elementIds.includes(resize.element.id)) + .map(TrackedElementResize.toElementAndBounds) + ); + } - this.scheduleChangeBounds(this.toElementAndBounds(elementMoves)); + this.scheduleChangeBounds(newBounds); } protected getTargetBounds(element: SelectableBoundsAware, action: MoveElementRelativeAction): Point { @@ -129,28 +175,35 @@ export class MoveElementHandler implements IActionHandler { this.moveFeedback.dispose(); this.dispatcher.dispatchAll([ChangeBoundsOperation.create(elementAndBounds)]); this.debouncedChangeBounds = undefined; + this.tracker.dispose(); }, 300); this.debouncedChangeBounds(); } - protected toElementAndBounds(elementMoves: ElementMove[]): ElementAndBounds[] { - const elementBounds: ElementAndBounds[] = []; - for (const elementMove of elementMoves) { - const element = this.editorContextService.modelRoot.index.getById(elementMove.elementId); - if (element && isBoundsAware(element)) { - elementBounds.push({ - elementId: elementMove.elementId, - newSize: { - height: element.bounds.height, - width: element.bounds.width - }, - newPosition: { - x: elementMove.toPosition.x, - y: elementMove.toPosition.y - } - }); - } + protected createMoveAction(moves: ElementMove[]): Action { + return MoveAction.create(moves, { animate: false }); + } + + protected isValidMoveable(element?: GModelElement): element is MoveableElement & SelectableBoundsAware { + return !!element && isNonRoutableSelectedMovableBoundsAware(element) && !(element instanceof GResizeHandle); + } + + protected toElementAndBounds(elementMove: ElementMove): ElementAndBounds | undefined { + const element = this.editorContextService.modelRoot.index.getById(elementMove.elementId); + if (element && isBoundsAware(element)) { + return { + elementId: elementMove.elementId, + newSize: { + height: element.bounds.height, + width: element.bounds.width + }, + newPosition: { + x: elementMove.toPosition.x, + y: elementMove.toPosition.y + } + }; } - return elementBounds; + + return undefined; } } 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 32909f3e..3a5cc616 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 @@ -34,7 +34,7 @@ import { getAbsolutePosition } from '../../utils/viewpoint-util'; import { FeedbackAwareTool } from '../tools/base-tools'; import { IChangeBoundsManager } from '../tools/change-bounds/change-bounds-manager'; import { MoveFinishedEventAction } from '../tools/change-bounds/change-bounds-tool-feedback'; -import { ChangeBoundsTracker, TrackedMove } from '../tools/change-bounds/change-bounds-tracker'; +import { TrackedMove, type ChangeBoundsTracker } from '../tools/change-bounds/change-bounds-tracker'; export interface PositioningTool extends FeedbackAwareTool { readonly changeBoundsManager: IChangeBoundsManager; @@ -42,7 +42,7 @@ export interface PositioningTool extends FeedbackAwareTool { export class MouseTrackingElementPositionListener extends DragAwareMouseListener { protected moveGhostFeedback: FeedbackEmitter; - protected tracker: ChangeBoundsTracker; + protected changeBoundsTracker: ChangeBoundsTracker; protected toDispose = new DisposableCollection(); constructor( @@ -52,7 +52,7 @@ export class MouseTrackingElementPositionListener extends DragAwareMouseListener protected editorContext?: EditorContextService ) { super(); - this.tracker = this.tool.changeBoundsManager.createTracker(); + this.changeBoundsTracker = this.tool.changeBoundsManager.createTracker(); this.moveGhostFeedback = this.tool.createFeedbackEmitter(); this.toDispose.push(this.moveGhostFeedback); const modelRootChangedListener = editorContext?.onModelRootChanged(newRoot => this.modelRootChanged(newRoot)); @@ -72,11 +72,16 @@ export class MouseTrackingElementPositionListener extends DragAwareMouseListener if (!element) { return []; } - const isInitializing = !this.tracker.isTracking(); + const isInitializing = !this.changeBoundsTracker.isTracking(); if (isInitializing) { this.initialize(element, ctx, event); } - const move = this.tracker.moveElements([element], { snap: event, restrict: event, skipStatic: !isInitializing }); + const move = this.changeBoundsTracker.moveElements([element], { + snap: event, + restrict: event, + skipStatic: !isInitializing, + wrap: false + }); const elementMove = move.elementMoves[0]; if (!elementMove) { return []; @@ -89,12 +94,12 @@ export class MouseTrackingElementPositionListener extends DragAwareMouseListener ); this.addMoveFeedback(move, ctx, event); this.moveGhostFeedback.submit(); - this.tracker.updateTrackingPosition(elementMove.moveVector); + this.changeBoundsTracker.updateTrackingPosition(elementMove.moveVector); return []; } protected initialize(element: MoveableElement, target: GModelElement, event: MouseEvent): void { - this.tracker.startTracking(); + this.changeBoundsTracker.startTracking(target.root); element.position = this.initializeElementPosition(element, target, event); } @@ -112,7 +117,7 @@ export class MouseTrackingElementPositionListener extends DragAwareMouseListener protected modelRootChanged(root: Readonly): void { // stop the tracking once we receive a new model root and ensure proper alignment with next next mouse move - this.tracker.stopTracking(); + this.changeBoundsTracker.stopTracking(); } override dispose(): void { diff --git a/packages/client/src/features/layout/layout-elements-action.ts b/packages/client/src/features/layout/layout-elements-action.ts index 646a6d0a..0c3f26be 100644 --- a/packages/client/src/features/layout/layout-elements-action.ts +++ b/packages/client/src/features/layout/layout-elements-action.ts @@ -34,9 +34,9 @@ import { } from '@eclipse-glsp/sprotty'; import { inject, injectable, optional } from 'inversify'; import { SelectionService } from '../../base/selection-service'; -import { BoundsAwareModelElement, getElements } from '../../utils/gmodel-util'; +import { BoundsAwareModelElement, getElements, isBoundsAwareMoveable } from '../../utils/gmodel-util'; import { toValidElementAndBounds, toValidElementMove } from '../../utils/layout-utils'; -import { isBoundsAwareMoveable, isResizable } from '../change-bounds/model'; +import { isResizable } from '../change-bounds/model'; import { IMovementRestrictor } from '../change-bounds/movement-restrictor'; /** 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 3909b31b..38067a79 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 @@ -211,24 +211,24 @@ export class ChangeBoundsManager implements IChangeBoundsManager { return !isLocateable(element) || isValidMove(element, position ?? element.position, this.movementRestrictor); } - hasValidSize(element: GModelElement, size?: Dimension): boolean { + hasValidSize(element: GModelElement, size?: Dimension, options?: { useComputedDimensions?: boolean }): boolean { if (!isBoundsAware(element)) { return true; } const dimension: Dimension = size ?? element.bounds; - const minimum = this.getMinimumSize(element); + const minimum = this.getMinimumSize(element, options); if (dimension.width < minimum.width || dimension.height < minimum.height) { return false; } return true; } - getMinimumSize(element: GModelElement): Dimension { + getMinimumSize(element: GModelElement, options: { useComputedDimensions?: boolean } = { useComputedDimensions: true }): Dimension { if (!isBoundsAware(element)) { return Dimension.EMPTY; } const definedMinimum = minDimensions(element); - const computedMinimum = LayoutAware.getComputedDimensions(element); + const computedMinimum = options.useComputedDimensions ? LayoutAware.getComputedDimensions(element) : undefined; return computedMinimum ? { width: Math.max(definedMinimum.width, computedMinimum.width), @@ -268,7 +268,14 @@ export class ChangeBoundsManager implements IChangeBoundsManager { // restriction feedback on each element trackedMove.elementMoves.forEach(move => this.addMoveRestrictionFeedback(feedback, move, ctx, event)); - + // restriction feedback on each element + Object.values(trackedMove.wrapResizes ?? {}).forEach(elementResize => { + this.addMoveRestrictionFeedback(feedback, elementResize, ctx, event); + feedback.add( + toggleCssClasses(elementResize.element, !elementResize.valid.size, CSS_RESTRICTED_RESIZE), + deleteCssClasses(elementResize.element, CSS_RESTRICTED_RESIZE) + ); + }); return feedback; } @@ -296,6 +303,7 @@ export class ChangeBoundsManager implements IChangeBoundsManager { deleteCssClasses(elementResize.element, CSS_RESTRICTED_RESIZE) ); }); + return feedback; } 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 31c8c8f5..3eea4840 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 @@ -31,7 +31,7 @@ import { import { GResizeHandle } from '../../change-bounds/model'; import { ChangeBoundsTool } from './change-bounds-tool'; import { MoveFinishedEventAction, MoveInitializedEventAction } from './change-bounds-tool-feedback'; -import { ChangeBoundsTracker, TrackedMove } from './change-bounds-tracker'; +import { TrackedElementResize, TrackedMove, type ChangeBoundsTracker } from './change-bounds-tracker'; /** * This mouse listener provides visual feedback for moving by sending client-side @@ -79,7 +79,7 @@ export class FeedbackMoveMouseListener extends DragAwareMouseListener implements } const moveable = findParentByFeature(target, this.isValidMoveable); if (moveable !== undefined) { - this.tracker.startTracking(); + this.tracker.startTracking(target.root); this.scheduleMoveInitialized(); } else { this.tracker.stopTracking(); @@ -91,7 +91,7 @@ export class FeedbackMoveMouseListener extends DragAwareMouseListener implements this.pendingMoveInitialized = debounce(() => { this.moveInitialized(); this.pendingMoveInitialized = undefined; - }, 750); + }, this.moveInitializationTimeout()); this.pendingMoveInitialized(); } @@ -143,6 +143,7 @@ export class FeedbackMoveMouseListener extends DragAwareMouseListener implements // cancel any pending move this.pendingMoveInitialized?.cancel(); this.moveFeedback.add(this.createMoveAction(move), () => this.resetElementPositions(target)); + this.moveFeedback.add(this.createWrapAction(move)); this.addMoveFeedback(move, target, event); this.tracker.updateTrackingPosition(move); this.moveFeedback.submit(); @@ -157,6 +158,10 @@ export class FeedbackMoveMouseListener extends DragAwareMouseListener implements ); } + protected createWrapAction(trackedMove: TrackedMove): Action { + return TrackedElementResize.createFeedbackActions(Object.values(trackedMove.wrapResizes ?? {}), { validOnly: false }); + } + protected addMoveFeedback(trackedMove: TrackedMove, ctx: GModelElement, event: MouseEvent): void { this.tool.changeBoundsManager.addMoveFeedback(this.moveFeedback, trackedMove, ctx, event); } @@ -220,7 +225,7 @@ export class FeedbackMoveMouseListener extends DragAwareMouseListener implements this.pendingMoveInitialized?.cancel(); this.moveInitializedFeedback.dispose(); this.moveFeedback.dispose(); - this.tracker.dispose(); + this.tracker.stopTracking(); 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 7a1c7853..266401ef 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 @@ -21,12 +21,10 @@ import { ChangeBoundsOperation, ChangeRoutingPointsOperation, CompoundOperation, - Dimension, Disposable, EdgeRouterRegistry, ElementAndBounds, ElementAndRoutingPoints, - GChildElement, GConnectableElement, GModelElement, GModelRoot, @@ -34,7 +32,6 @@ import { KeyListener, MouseListener, Operation, - Point, TYPES, findParentByFeature } from '@eclipse-glsp/sprotty'; @@ -48,8 +45,11 @@ import { ResizableModelElement, SelectableBoundsAware, calcElementAndRoutingPoints, + getBoundsChangedAncestorsAndElement, getMatchingElements, + isDescendant, isNonRoutableSelectedMovableBoundsAware, + isNotUndefined, toElementAndBounds } from '../../../utils/gmodel-util'; import { LocalRequestBoundsAction } from '../../bounds/local-bounds'; @@ -166,7 +166,6 @@ export class ChangeBoundsListener extends DragAwareMouseListener implements ISel static readonly CSS_CLASS_ACTIVE = CSS_ACTIVE_HANDLE; // members for calculating the correct position change - protected initialBounds: ElementAndBounds | undefined; protected tracker: ChangeBoundsTracker; // members for resize mode @@ -189,6 +188,8 @@ export class ChangeBoundsListener extends DragAwareMouseListener implements ISel if (event.button !== 0 || target instanceof GModelRoot) { return []; } + console.log('Starting resize tracking for element:', this.activeResizeElement); + this.tracker.startTracking(target.root); // check if we have a resize handle (only single-selection) this.updateResizeElement(target, event); return []; @@ -198,14 +199,6 @@ export class ChangeBoundsListener extends DragAwareMouseListener implements ISel this.activeResizeHandle = target instanceof GResizeHandle ? target : undefined; this.activeResizeElement = this.activeResizeHandle?.parent ?? this.findResizeElement(target); if (this.activeResizeElement) { - if (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 @@ -220,6 +213,7 @@ export class ChangeBoundsListener extends DragAwareMouseListener implements ISel this.handleFeedback.submit(); return true; } else { + console.log('No resizable element found for target:', target); this.disposeResize(); return false; } @@ -235,10 +229,15 @@ 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 }); + const resize = this.tracker.resizeElements(this.activeResizeHandle, { + snap: event, + symmetric: event, + restrict: event + }); const resizeAction = this.resizeBoundsAction(resize); if (resizeAction.bounds.length > 0) { - this.resizeFeedback.add(resizeAction, () => this.resetBounds()); + this.resizeFeedback.add(resizeAction, () => this.resetResizeBounds()); + this.resizeFeedback.add(this.createWrapAction(resize)); this.tracker.updateTrackingPosition(resize.handleMove.moveVector); this.addResizeFeedback(resize, target, event); this.resizeFeedback.submit(); @@ -253,6 +252,10 @@ export class ChangeBoundsListener extends DragAwareMouseListener implements ISel return SetBoundsFeedbackAction.create(elementResizes.map(elementResize => this.toElementAndBounds(elementResize))); } + protected createWrapAction(tracked: TrackedResize): SetBoundsFeedbackAction { + return TrackedElementResize.createFeedbackActions(Object.values(tracked.wrapResizes ?? {})); + } + protected toElementAndBounds(elementResize: TrackedElementResize): ElementAndBounds { return { elementId: elementResize.element.id, @@ -265,11 +268,20 @@ export class ChangeBoundsListener extends DragAwareMouseListener implements ISel this.tool.changeBoundsManager.addResizeFeedback(this.resizeFeedback, resize, target, event); } - protected resetBounds(): Action[] { + protected resetResizeBounds(): Action[] { + const initialBounds = this.tracker.getInitialBoundsTracker(); + + if (!this.activeResizeElement || initialBounds.isEmpty) { + return [MoveFinishedEventAction.create()]; + } + // 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()]; + const affectedElements = getBoundsChangedAncestorsAndElement(this.activeResizeElement, initialBounds.get()); + const elementsAndBounds: ElementAndBounds[] = affectedElements + .map(element => initialBounds.getElementAndBoundsById(element.id)) + .filter(isNotUndefined); + + return [SetBoundsFeedbackAction.create(elementsAndBounds), MoveFinishedEventAction.create()]; } override draggingMouseUp(target: GModelElement, event: MouseEvent): Action[] { @@ -281,11 +293,13 @@ export class ChangeBoundsListener extends DragAwareMouseListener implements ISel actions.push(...this.handleMoveOnServer(target)); } this.disposeResize({ keepHandles: true }); + this.disposeTracker(); return actions; } override nonDraggingMouseUp(element: GModelElement, event: MouseEvent): Action[] { this.disposeResize({ keepHandles: true }); + this.disposeTracker(); return super.nonDraggingMouseUp(element, event); } @@ -300,11 +314,22 @@ export class ChangeBoundsListener extends DragAwareMouseListener implements ISel protected getElementsToMove(target: GModelElement): SelectableBoundsAware[] { const selectedElements = getMatchingElements(target.index, isNonRoutableSelectedMovableBoundsAware); const selectionSet: Set = new Set(selectedElements); - const elementsToMove = selectedElements.filter(element => this.isValidMove(element, selectionSet)); - if (this.tool.movementOptions.allElementsNeedToBeValid && elementsToMove.length !== selectionSet.size) { + // filter out children of selected elements to avoid duplicate move operations + // only the top-most selected elements are considered for move operations + const topElements = selectedElements.filter(element => !this.isChildOfSelected(selectionSet, element)); + const affectedElementSet: Set = new Set(); + + const initialBounds = this.tracker.getInitialBoundsTracker().get(); + for (const topElement of topElements) { + const affectedElements = getBoundsChangedAncestorsAndElement(topElement, initialBounds); + affectedElements.forEach(element => affectedElementSet.add(element)); + } + + const elementsToMove = Array.from(affectedElementSet).filter(element => this.isValidMove(element)); + if (this.tool.movementOptions.allElementsNeedToBeValid && elementsToMove.length !== affectedElementSet.size) { return []; } - return elementsToMove; + return elementsToMove as any[]; } protected handleMoveElementsOnServer(elementsToMove: SelectableBoundsAware[]): Operation[] { @@ -312,20 +337,17 @@ export class ChangeBoundsListener extends DragAwareMouseListener implements ISel return newBounds.length > 0 ? [ChangeBoundsOperation.create(newBounds)] : []; } - protected isValidMove(element: BoundsAwareModelElement, selectedElements: Set = new Set()): boolean { - return this.tool.changeBoundsManager.hasValidPosition(element) && !this.isChildOfSelected(selectedElements, element); + protected isValidMove(element: BoundsAwareModelElement): boolean { + return this.tool.changeBoundsManager.hasValidPosition(element); } protected isChildOfSelected(selectedElements: Set, element: GModelElement): boolean { - if (selectedElements.size === 0) { - return false; - } - while (element instanceof GChildElement) { - element = element.parent; - if (selectedElements.has(element)) { + for (const selectedElement of selectedElements) { + if (isDescendant(element, selectedElement)) { return true; } } + return false; } @@ -349,20 +371,14 @@ export class ChangeBoundsListener extends DragAwareMouseListener implements ISel } protected handleResizeOnServer(activeResizeHandle: GResizeHandle): Action[] { - if (this.initialBounds && this.isValidResize(activeResizeHandle.parent)) { - const elementAndBounds = toElementAndBounds(activeResizeHandle.parent); - if (!this.initialBounds.newPosition || !elementAndBounds.newPosition) { - return []; - } - if ( - !Point.equals(this.initialBounds.newPosition, elementAndBounds.newPosition) || - !Dimension.equals(this.initialBounds.newSize, elementAndBounds.newSize) - ) { - // UX: we do not want the element positions to be reset to their start as they will be moved to their start and - // only afterwards moved by the move action again, leading to a ping-pong movement. - // We therefore clear our element map so that they cannot be reset. - this.initialBounds = undefined; - return [ChangeBoundsOperation.create([elementAndBounds])]; + const initialBounds = this.tracker.getInitialBoundsTracker(); + if (!initialBounds.isEmpty) { + const affectedElements = getBoundsChangedAncestorsAndElement(activeResizeHandle.parent, initialBounds.get()); + + if (affectedElements.length > 0 && affectedElements.every(this.isValidResize.bind(this))) { + initialBounds.clear(); + const newBounds: ElementAndBounds[] = affectedElements.map(toElementAndBounds); + return [ChangeBoundsOperation.create(newBounds)]; } } return []; @@ -397,14 +413,18 @@ export class ChangeBoundsListener extends DragAwareMouseListener implements ISel this.handleFeedback.dispose(); } this.resizeFeedback.dispose(); - this.tracker.dispose(); this.activeResizeElement = undefined; this.activeResizeHandle = undefined; - this.initialBounds = undefined; + } + + protected disposeTracker(): void { + console.log('Disposing change bounds tracker'); + this.tracker.dispose(); } override dispose(): void { this.disposeResize(); + this.disposeTracker(); super.dispose(); } } 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 1e48bfd7..b397fabd 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 @@ -29,10 +29,23 @@ import { hasBooleanProp, hasObjectProp, isBoundsAware, - isMoveable + isMoveable, + type ElementAndBounds, + type GModelRoot } from '@eclipse-glsp/sprotty'; -import { BoundsAwareModelElement, MoveableElement, ResizableModelElement, getElements } from '../../../utils/gmodel-util'; -import { GResizeHandle, ResizeHandleLocation } from '../../change-bounds/model'; +import { + BoundsAwareModelElement, + MoveableElement, + ResizableModelElement, + buildAncestorOrder, + findAncestors, + findDescendants, + getElements, + getMatchingElements, + isBoundsAwareMoveable +} from '../../../utils/gmodel-util'; +import { SetBoundsFeedbackAction } from '../../bounds/set-bounds-feedback-command'; +import { GResizeHandle, ResizeHandleLocation, isResizable } from '../../change-bounds/model'; import { DiagramMovementCalculator } from '../../change-bounds/tracker'; import { ChangeBoundsManager } from './change-bounds-manager'; @@ -43,6 +56,8 @@ export interface ElementTrackingOptions { restrict: boolean | MouseEvent | KeyboardEvent | any; /** Validate operation. Default: true */ validate: boolean; + /** Wrap operation. Default: true */ + wrap: boolean; /** Skip operations that do not trigger change. Default: true */ skipStatic: boolean; @@ -57,6 +72,7 @@ export const DEFAULT_MOVE_OPTIONS: MoveOptions = { snap: true, restrict: true, validate: true, + wrap: true, skipStatic: true, skipInvalid: false @@ -93,12 +109,16 @@ export interface TrackedMove extends Movement { elementMoves: TrackedElementMove[]; valid: boolean; options: MoveOptions; + wrapResizes?: Record; } export namespace TrackedMove { export function is(obj: any): obj is TrackedMove { return Movement.is(obj) && hasBooleanProp(obj, 'valid'); } + export function isValid(move: TrackedMove): boolean { + return move.valid && (!move.wrapResizes || TrackedElementResize.isValidMove(...Object.values(move.wrapResizes))); + } } export interface ResizeOptions extends ElementTrackingOptions { @@ -128,6 +148,7 @@ export const DEFAULT_RESIZE_OPTIONS: ResizeOptions = { restrict: true, validate: true, symmetric: true, + wrap: true, constrainResize: true, @@ -154,6 +175,27 @@ export namespace TrackedElementResize { isBoundsAware(obj.element) && hasObjectProp(obj, 'fromBounds') && hasObjectProp(obj, 'toBounds') && hasObjectProp(obj, 'valid') ); } + export function toElementAndBounds(resize: TrackedElementResize): ElementAndBounds { + return { + elementId: resize.element.id, + newSize: resize.toBounds, + newPosition: resize.toBounds + }; + } + export function createFeedbackActions( + resizes: TrackedElementResize[], + options: { validOnly: boolean } = { validOnly: true } + ): 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 = options.validOnly ? resizes.filter(elementResize => elementResize.valid.size) : resizes; + return SetBoundsFeedbackAction.create(elementResizes.map(TrackedElementResize.toElementAndBounds)); + } + export function isValidMove(...resize: TrackedElementResize[]): boolean { + return resize.every(r => r.valid.move); + } + export function isValidSize(...resize: TrackedElementResize[]): boolean { + return resize.every(r => r.valid.size); + } } export interface TrackedResize extends Movement { @@ -164,17 +206,63 @@ export interface TrackedResize extends Movement { move: boolean; }; options: ResizeOptions; + wrapResizes?: Record; +} + +interface ChangeBoundsChanges { + element: GModelElement; + fromBounds: Bounds; + toBounds: Bounds; +} + +export class InitialBoundsTracker { + protected readonly initialBounds = new Map(); + + process(root: GModelRoot): void { + const elements = getMatchingElements(root.index, isBoundsAware); + + elements.forEach(element => { + this.initialBounds.set(element.id, element.bounds); + }); + } + + get(): Record { + return Object.fromEntries(this.initialBounds); + } + + getElement(id: string): Bounds | undefined { + return this.initialBounds.get(id); + } + + getElementAndBoundsById(id: string): ElementAndBounds | undefined { + const bounds = this.initialBounds.get(id); + return bounds ? { elementId: id, newSize: bounds, newPosition: bounds } : undefined; + } + + getElementAndBounds(): ElementAndBounds[] { + return Array.from(this.initialBounds.entries()).map(([id, bounds]) => ({ elementId: id, newSize: bounds, newPosition: bounds })); + } + + get isEmpty(): boolean { + return this.initialBounds.size === 0; + } + + clear(): void { + this.initialBounds.clear(); + } } export class ChangeBoundsTracker { protected diagramMovement: DiagramMovementCalculator; + protected initialBoundsTracker = new InitialBoundsTracker(); constructor(readonly manager: ChangeBoundsManager) { this.diagramMovement = new DiagramMovementCalculator(manager.positionTracker); } - startTracking(): this { + startTracking(root: GModelRoot): this { this.diagramMovement.init(); + this.initialBoundsTracker.process(root); return this; } @@ -189,13 +277,19 @@ export class ChangeBoundsTracker { stopTracking(): this { this.diagramMovement.dispose(); + this.initialBoundsTracker.clear(); return this; } + getInitialBoundsTracker(): InitialBoundsTracker { + return this.initialBoundsTracker; + } + // // MOVE // + lastTrackedElementMove: TrackedMove | undefined; moveElements(elements: MoveableElements, opts?: Partial): TrackedMove { const options = this.resolveMoveOptions(opts); const update = this.calculateDiagramMovement(); @@ -215,6 +309,29 @@ export class ChangeBoundsTracker { move.valid &&= elementMove.valid; } } + + // if wrapping is enabled, calculate the required resize for all wrapping elements + if (options?.wrap && move.elementMoves.length > 0) { + move.wrapResizes = this.wrap( + move.elementMoves.map(m => ({ + element: m.element, + fromBounds: { + ...(isBoundsAware(m.element) ? m.element.bounds : Bounds.EMPTY), + ...m.fromPosition + }, + toBounds: { ...(isBoundsAware(m.element) ? m.element.bounds : Bounds.EMPTY), ...m.toPosition } + })), + options + ); + + move.valid = TrackedElementResize.isValidMove(...Object.values(move.wrapResizes)); + console.log('ChangeBoundsTracker.moveElements', move); + } + + if (move.elementMoves.length > 0) { + this.lastTrackedElementMove = move; + } + return move; } @@ -257,6 +374,7 @@ export class ChangeBoundsTracker { } move.moveVector = Point.vector(move.fromPosition, move.toPosition); + return move; } @@ -277,11 +395,14 @@ export class ChangeBoundsTracker { // RESIZE // + lastTrackedResize: TrackedResize | undefined; resizeElements(handle: GResizeHandle, 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: [] }; + this.lastTrackedResize = resize; + if (Vector.isZero(handleMove.moveVector) && options.skipStatic) { // no movement detected so elements won't be moved, exit early return resize; @@ -297,6 +418,29 @@ export class ChangeBoundsTracker { resize.valid.size = resize.valid.size && elementResize.valid.size; } } + + // if wrapping is enabled, calculate the required resize for all wrapping elements + if (options?.wrap && resize.elementResizes.length > 0) { + resize.wrapResizes = this.wrap( + resize.elementResizes.map(r => ({ + element: r.element, + fromBounds: r.fromBounds, + toBounds: r.toBounds + })), + options + ); + + const wrapResizes = Object.values(resize.wrapResizes); + if (wrapResizes.length > 0) { + resize.valid.move = TrackedElementResize.isValidMove(...wrapResizes); + resize.valid.size = TrackedElementResize.isValidSize(...wrapResizes); + + resize.elementResizes = wrapResizes; + } + + console.log('ChangeBoundsTracker.resizeElements', resize); + } + return resize; } @@ -442,7 +586,184 @@ export class ChangeBoundsTracker { return vector; } + // + // WRAP + // + + wrap(changes: ChangeBoundsChanges[], options: Partial): Record { + const trackedElementResizes: Record = {}; + const initialBounds = this.initialBoundsTracker.get(); + + // First pass: collect all parents that need to be processed + const parentsToProcess = buildAncestorOrder( + changes.flatMap(change => change.element), + isResizable + ); + + if (parentsToProcess.length === 0) { + return trackedElementResizes; + } + + // Second pass: prepare all direct element moves + for (const change of changes) { + if (!isBoundsAwareMoveable(change.element)) { + continue; + } + + const element = change.element as BoundsAwareModelElement; + const fromBounds = initialBounds[element.id] ?? element.bounds; + const toBounds = { + ...(isBoundsAware(element) ? element.bounds : Bounds.EMPTY), + ...change.toBounds + }; + + trackedElementResizes[element.id] = { + element: element, + fromBounds: { ...fromBounds }, + toBounds: { ...toBounds }, + valid: { size: true, move: true } + }; + } + + // Third pass: move parents based on their children + for (const parent of parentsToProcess) { + const ancestors = this.getWrapperAncenstors(parent); + const parentInitialBounds = this.getParentInitialBounds(parent, ancestors, initialBounds); + const parentBounds = trackedElementResizes[parent.id]?.toBounds ?? parent.bounds; + + // Find bounding box around all children + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + const children = findDescendants(parent, isBoundsAwareMoveable, 1); + + for (const child of children) { + const bounds = trackedElementResizes[child.id]?.toBounds ?? child.bounds; + minX = Math.min(minX, Bounds.left(bounds)); + minY = Math.min(minY, Bounds.top(bounds)); + maxX = Math.max(maxX, Bounds.right(bounds)); + maxY = Math.max(maxY, Bounds.bottom(bounds)); + } + + const childrenBounds = { + x: minX, + y: minY, + width: maxX, + height: maxY + } as Bounds; + + const newX = Math.min(parentInitialBounds.x, Bounds.left(parentBounds) + Bounds.left(childrenBounds)); + const newY = Math.min(parentInitialBounds.y, Bounds.top(parentBounds) + Bounds.top(childrenBounds)); + const newPos: Point = { x: newX, y: newY }; + + const newWidth = Math.max( + parentInitialBounds.width, + parentInitialBounds.width + Bounds.left(parentInitialBounds) - Bounds.left({ ...parentBounds, ...newPos }), + childrenBounds.width + ); + const newHeight = Math.max( + parentInitialBounds.height, + parentInitialBounds.height + Bounds.top(parentInitialBounds) - Bounds.top({ ...parentBounds, ...newPos }), + childrenBounds.height + ); + const newDimension: Dimension = { width: newWidth, height: newHeight }; + + const toBounds: Bounds = { + x: newPos.x, + y: newPos.y, + width: newDimension.width, + height: newDimension.height + }; + + trackedElementResizes[parent.id] = { + element: parent, + fromBounds: { ...parent.bounds }, + toBounds: { ...toBounds }, + valid: { size: true, move: true } + }; + } + + // Fourth pass: adjust all children based on the parent movement + for (const parent of parentsToProcess) { + const children = findDescendants(parent, isBoundsAwareMoveable, 1); + const trackedParent = trackedElementResizes[parent.id]; + + const parentFromBounds = trackedParent.fromBounds; + const parentToBounds = trackedParent.toBounds; + + for (const child of children) { + const childInitialBounds = initialBounds[child.id] ?? child.bounds; + const childBounds = trackedElementResizes[child.id]?.toBounds ?? child.bounds; + const deltaX = Bounds.left(parentToBounds) - Bounds.left(parentFromBounds); + const deltaY = Bounds.top(parentToBounds) - Bounds.top(parentFromBounds); + + trackedElementResizes[child.id] = { + element: child, + fromBounds: { ...childInitialBounds }, + toBounds: { + ...childBounds, + x: Math.max(childBounds.x - deltaX, 0), + y: Math.max(childBounds.y - deltaY, 0) + }, + valid: { size: true, move: true } + }; + } + } + + // Validate + if (options.validate) { + for (const resize of Object.values(trackedElementResizes)) { + resize.valid.size = this.manager.hasValidSize(resize.element, resize.toBounds, { useComputedDimensions: false }); + resize.valid.move = this.manager.hasValidPosition(resize.element, resize.toBounds); + } + } + + return trackedElementResizes; + } + + /** + * Returns the resizable elements that are wrapping the given element. + * It needs to be ordered from the inner to the outer element. + */ + protected getWrapperAncenstors(element: GModelElement): ResizableModelElement[] { + return findAncestors(element, isResizable); + } + + protected getParentInitialBounds( + parent: ResizableModelElement, + ancestors: ResizableModelElement[], + initialBounds: Record + ): Bounds { + const parentInitialBounds = structuredClone(initialBounds[parent.id] ?? parent.bounds); + + if (ancestors.length === 0) { + return parentInitialBounds; + } + + // If we have ancestors, we need to calculate the initial bounds based on the ancestors + let deltaX = 0; + let deltaY = 0; + for (const ancestor of ancestors) { + const ancestorInitialBounds = initialBounds[ancestor.id] ?? ancestor.bounds; + const ancestorBounds = ancestor.bounds; + + deltaX += Bounds.left(ancestorBounds) - Bounds.left(ancestorInitialBounds); + deltaY += Bounds.top(ancestorBounds) - Bounds.top(ancestorInitialBounds); + } + + return { + x: parentInitialBounds.x - deltaX, + y: parentInitialBounds.y - deltaY, + width: parentInitialBounds.width, + height: parentInitialBounds.height + }; + } + dispose(): void { + this.lastTrackedElementMove = undefined; + this.lastTrackedResize = undefined; this.stopTracking(); } } 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 e8f74af5..a69d207f 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 @@ -272,7 +272,7 @@ export class FeedbackEdgeRouteMovingMouseListener extends DragAwareMouseListener const routingHandle = findParentByFeature(target, isRoutingHandle); if (routingHandle !== undefined) { result.push(SwitchRoutingModeAction.create({ elementsToActivate: [target.id] })); - this.tracker.startTracking(); + this.tracker.startTracking(target.root); } else { this.tracker.dispose(); } @@ -290,7 +290,7 @@ export class FeedbackEdgeRouteMovingMouseListener extends DragAwareMouseListener protected moveRoutingHandles(target: GModelElement, event: MouseEvent): Action[] { const routingHandlesToMove = this.getRoutingHandlesToMove(target); - const move = this.tracker.moveElements(routingHandlesToMove, { snap: event, restrict: event }); + const move = this.tracker.moveElements(routingHandlesToMove, { snap: event, restrict: event, wrap: false }); if (move.elementMoves.length === 0) { return []; } diff --git a/packages/client/src/utils/gmodel-util.ts b/packages/client/src/utils/gmodel-util.ts index 654c57ab..203ff1c0 100644 --- a/packages/client/src/utils/gmodel-util.ts +++ b/packages/client/src/utils/gmodel-util.ts @@ -15,32 +15,34 @@ ********************************************************************************/ import { BoundsAware, + Dimension, + distinctAdd, EdgeRouterRegistry, ElementAndBounds, ElementAndRoutingPoints, FluentIterable, GChildElement, + getAbsoluteBounds, + getZoom, GModelElement, GModelElementSchema, GParentElement, GRoutableElement, GRoutingHandle, + isBoundsAware, + isMoveable, + isSelectable, + isSelected, Locateable, ModelIndexImpl, Point, + remove, RoutedPoint, Selectable, TypeGuard, - distinctAdd, - getAbsoluteBounds, - getZoom, - isBoundsAware, - isMoveable, - isSelectable, - isSelected, - remove + type Bounds } from '@eclipse-glsp/sprotty'; -import { ResizeHandleLocation } from '../features/change-bounds/model'; +import type { ResizeHandleLocation } from '../features/change-bounds/model'; /** * Helper type to represent a filter predicate for {@link GModelElement}s. This is used to retrieve @@ -330,6 +332,10 @@ export interface ResizableModelElement extends GParentElement, Resizable { resizeLocations?: ResizeHandleLocation[]; } +export function isBoundsAwareMoveable(element: GModelElement): element is BoundsAwareModelElement & MoveableElement { + return isMoveable(element) && isBoundsAware(element); +} + /** * Helper function to translate a given {@link GModelElement} into its corresponding {@link ElementAndBounds} representation. * @param element The element to translate. @@ -499,6 +505,14 @@ export function getDescendantIds(element?: GModelElement, skip?: (t: GModelEleme return ids; } +export function isDescendant(element: GModelElement, potentialAncestor: GModelElement): boolean { + let descendants = getDescendantIds(potentialAncestor); + // Remove self id to avoid that an element is considered a descendant of itself + descendants = descendants.filter(id => !id.startsWith(potentialAncestor.id)); + + return descendants.includes(element.id); +} + /** * Returns a filter function that checks if the given element is not a descendant of any of the given elements. * @@ -527,3 +541,221 @@ export function isNotDescendantOfAnyElement(elements: F export function removeDescendants(elements: FluentIterable): FluentIterable { return elements.filter(isNotDescendantOfAnyElement(elements)); } + +/** + * Finds all ancestors of the given element that match the given predicate. + * @param element The element to start from. + * @param predicate The predicate to match against. + * @param skip Optional function to skip certain elements. + * @returns An array of matching ancestors. + */ +export function findAncestors( + element: GModelElement, + predicate: (t: GModelElement) => t is T, + skip?: (t: GModelElement) => boolean +): T[] { + const ancestors: T[] = []; + let current: GModelElement | undefined = element; + while (current !== undefined) { + if (current instanceof GChildElement) { + current = current.parent; + } else { + current = undefined; + } + + if (current !== undefined && !skip?.(current) && predicate(current)) { + ancestors.push(current); + } + } + return ancestors; +} + +export function getBoundsChangedAncestorsAndElement( + sourceElement: BoundsAwareModelElement, + initialBounds: Record, + pred?: (element: BoundsAwareModelElement) => boolean +): BoundsAwareModelElement[] { + if (Object.keys(initialBounds).length === 0) { + return []; + } + + function hasChanged(element: ElementAndBounds): boolean { + const initialBound = initialBounds[element.elementId]; + if (!initialBound) { + return false; + } + + if (!element.newPosition || !element.newSize) { + return false; + } + + return !Point.equals(initialBound, element.newPosition) || !Dimension.equals(initialBound, element.newSize); + } + + const ancestors = findAncestors(sourceElement, (element): element is BoundsAwareModelElement => { + if (!isBoundsAwareMoveable(element)) { + return false; + } + + if (!hasChanged(toElementAndBounds(element))) { + return false; + } + + return pred ? pred(element) : true; + }); + + if (hasChanged(toElementAndBounds(sourceElement)) && (!pred || pred(sourceElement))) { + ancestors.unshift(sourceElement); + } + + return ancestors; +} + +/** + * Build an ancestors list for N nodes (in input order), with this shape: + * - No shared ancestor: [n1 parents..., n2 parents..., ...] + * - Shared ancestor A: [n1 parents until A, n2 parents until A, A and A's ancestors...] + * + * Notes: + * - Starting nodes are EXCLUDED (only ancestors). + * - `include` decides which elements make it into the final output. + * - Duplicates are removed by object reference (same object => kept once). + */ +export function buildAncestorOrder(nodes: GModelElement[], predicate: (t: GModelElement) => t is T): T[] { + if (!nodes.length) { + return []; + } + + // Raw chain (NO filtering), starting from the parent of `n`. + // Used to compute LCA and ordering boundaries robustly. + const rawChainOf = (n: GModelElement): GModelElement[] => { + const out: GModelElement[] = []; + const seen = new Set(); // guard against cycles + if (!(n instanceof GChildElement)) { + return out; + } + + let cur: GModelElement | undefined = n.parent; + while (cur && !seen.has(cur)) { + out.push(cur); + seen.add(cur); + if (cur instanceof GChildElement) { + cur = cur.parent; + } + } + return out; + }; + + const rawChains = nodes.map(rawChainOf); + + // Lowest Common Ancestor across ALL nodes, computed from RAW (unfiltered) chains. + const lca = (() => { + if (rawChains.length < 2) { + return undefined; + } + const sets = rawChains.map(ch => new Set(ch)); + for (const cand of rawChains[0]) { + if (sets.every(s => s.has(cand))) { + return cand; + } + } + return undefined; + })(); + + const result: T[] = []; + const seenOut = new Set(); + + const pushOnce = (x: GModelElement) => { + if (!seenOut.has(x) && predicate(x)) { + seenOut.add(x); + result.push(x); + } + }; + + if (!lca) { + // No global LCA → concatenate each filtered chain fully. + for (const ch of rawChains) { + for (const n of ch) { + pushOnce(n); + } + } + return result; + } + + // With global LCA: + // For each node's RAW chain, push items up to (excluding) the LCA, + // but only add those that pass `include`. + for (let i = 0; i < rawChains.length; i++) { + const raw = rawChains[i]; + for (const n of raw) { + if (n === lca) { + break; + } + pushOnce(n); + } + } + + // Then append LCA and its ancestors once (still respecting `include`). + for (const n of rawChainOf(lca)) { + // rawChainOf({ parent: lca }) yields [lca, ...lca's ancestors] + pushOnce(n); + } + + return result; +} + +/** + * Finds all descendants of the given element that match the given predicate at a specific depth level. + * The depth is calculated relative to matching parent elements, ignoring non-matching intermediate elements. + * @param element The element to start from. + * @param predicate The predicate to match against. + * @param maxDepth The max depth level to find matching elements at. + * @param skip Optional function to skip certain elements. + * @returns An array of matching descendants at the specified depth level. + */ +export function findDescendants( + element: GModelElement, + predicate: (t: GModelElement) => t is T, + maxDepth: number, + skip?: (t: GModelElement) => boolean +): (GChildElement & T)[] { + const descendants: (GChildElement & T)[] = []; + + function collectDescendants(current: GModelElement, currentDepth: number): void { + // Skip the root element itself and any elements marked to skip + if (current === element || skip?.(current)) { + // Continue traversing children even if we skip this element + if (current instanceof GParentElement) { + for (const child of current.children) { + collectDescendants(child, currentDepth); + } + } + return; + } + + // Check if current element matches predicate and is a child element + if (predicate(current) && current instanceof GChildElement) { + // If we're at the target depth, add to results + if (currentDepth < maxDepth) { + descendants.push(current); + } + + // Continue searching at the next depth level + if (current instanceof GParentElement) { + for (const child of current.children) { + collectDescendants(child, currentDepth + 1); + } + } + } else { + // Element doesn't match predicate, continue traversing without incrementing depth + if (current instanceof GParentElement) { + for (const child of current.children) { + collectDescendants(child, currentDepth); + } + } + } + } + + collectDescendants(element, 0); + return descendants; +}