diff --git a/CHANGELOG.md b/CHANGELOG.md index a7bf751d4..828131903 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,8 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `waitForMeasurements` option on service methods — `addNodes`, `addEdges`, `updateNode`, `updateNodes`, `updateNodeData`, `updateEdge`, `updateEdges`, `updateEdgeData` on `NgDiagramModelService`, `resizeNode` on `NgDiagramNodeService` and `paste` on `NgDiagramClipboardService` accept `options?: { waitForMeasurements?: boolean }`; when set, the returned promise resolves only after the elements affected by the change have been measured — useful whenever the next step depends on real dimensions (for example `zoomToFit()` or `centerOnNode()`). The option exists only on methods whose changes can trigger measurements; deletions and other model-only operations have nothing to measure, so awaiting the method itself is already enough there. Inside an already active transaction the option is ignored with a console warning — pass `{ waitForMeasurements: true }` to the transaction itself instead ([#769](https://github.com/synergycodes/ng-diagram/pull/769)) - **Customizable runtime-property stripping** – `initializeModel` and `initializeModelAdapter` accept an optional `InitializeModelOptions` parameter to control which properties are stripped on initialization and `toJSON()`. Overriding the defaults can break the diagram — use at your own risk ([#760](https://github.com/synergycodes/ng-diagram/pull/760)) - **Resize snap offset** — new `computeSnapOffsetForNodeSize` and `defaultResizeSnapOffset` options on `SnappingConfig`. Snapped node sizes now follow the sequence `offset + n * snap` per axis, so a node with a 60px header and a 50px vertical resize snap can snap to 60, 110, 160, … instead of 50, 100, 150, …. Defaults to `{ width: 0, height: 0 }` ([#765](https://github.com/synergycodes/ng-diagram/issues/765), [#770](https://github.com/synergycodes/ng-diagram/pull/770)) — thanks [@logan-brd](https://github.com/logan-brd) for the suggestion! 🙏 - - **`NgDiagramService.transaction` always returns the commit promise** — the synchronous-callback overload used to return `void`, and for some async callbacks the commit promise was silently discarded; all overloads now return `Promise`, so awaiting a transaction reliably waits for its commit ([#769](https://github.com/synergycodes/ng-diagram/pull/769)) +- **Cancel in-progress gestures** – new `NgDiagramService.cancelActiveInteraction()` aborts the active linking, drag, resize, rotate or pan gesture immediately and restores the pre-gesture state: dragged nodes snap back to their initial positions, resized/rotated nodes regain their original geometry, and the temporary edge is discarded (state cleared, document listeners removed, no need to wait for pointer release). Bound to Escape by default via the new `cancelInteraction` shortcut action. The `edgeDrawEnded` event gains a `cancelled` reason, and `nodeDragEnded`/`nodeResizeEnded`/`nodeRotateEnded` gain an optional `cancelReason` field ([#747](https://github.com/synergycodes/ng-diagram/issues/747), [#766](https://github.com/synergycodes/ng-diagram/pull/766)) ### Fixed @@ -32,6 +32,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Dangling edges survive persistence** — `initializeModel` and `initializeModelAdapter` no longer strip the authored `sourcePosition`/`targetPosition` of an edge's free endpoint (empty `source`/`target`), and `toJSON()` now includes them in the serialized output, so dangling edges load from a persisted model the same way they work when added at runtime; no more "Invalid edge coordinates detected" for valid dangling edges on init ([#751](https://github.com/synergycodes/ng-diagram/issues/751), [#760](https://github.com/synergycodes/ng-diagram/pull/760)) - **Port `side`/`type` no longer stay stale after a port moves** — recreating a port with the same id in a different place (e.g. toggling a port between a `side: 'left'` and a `side: 'right'` block) now updates `measuredPorts` with the new `side`/`type`, so edges anchor to the correct side; measured `size`/`position` keep coming from the DOM as before. The same applies to edge labels re-registered with a changed `positionOnEdge` ([#750](https://github.com/synergycodes/ng-diagram/issues/750), [#763](https://github.com/synergycodes/ng-diagram/pull/763)) - **Group with children jumping on resize snap** — resizing a group that contains child nodes from the bottom/right edge no longer moves the group when a resize snap is configured ([#765](https://github.com/synergycodes/ng-diagram/issues/765), [#770](https://github.com/synergycodes/ng-diagram/pull/770)) — thanks [@logan-brd](https://github.com/logan-brd) for the issue submission! 🙏 +- **Keyboard shortcuts work when a gesture starts with focus outside the diagram** — the resize/rotate handles stop the pointerdown propagation, which used to skip the diagram's focus grab; starting a resize right after clicking an external control (e.g. a toolbar button) left every shortcut dead — in particular Escape could not cancel the gesture. The diagram now takes focus on any pointerdown inside it ([#766](https://github.com/synergycodes/ng-diagram/pull/766)) +- **Touch gestures stay exclusive under virtualization** — on touch devices with virtualization enabled, nodes and ports leaving the rendered area during a pan or pinch-zoom no longer reset the internal gesture-exclusivity state, so a stray touch can no longer start a second gesture (drag, resize, linking) in the middle of an active one ([#766](https://github.com/synergycodes/ng-diagram/pull/766)) - **Resize snapping no longer cuts group children** — with `allowResizeBelowChildrenBounds: false`, a snapped group size that would land inside the children bounds now rounds up to the next snap value that still contains the children ([#770](https://github.com/synergycodes/ng-diagram/pull/770)) ## [1.2.4] - 2026-06-02 diff --git a/apps/docs/src/content/docs/api/Internals/DraggingActionState.md b/apps/docs/src/content/docs/api/Internals/DraggingActionState.md index ee09a12ff..7337bc156 100644 --- a/apps/docs/src/content/docs/api/Internals/DraggingActionState.md +++ b/apps/docs/src/content/docs/api/Internals/DraggingActionState.md @@ -19,6 +19,14 @@ Key is node ID, value is the accumulated delta that hasn't been applied due to s *** +### cancelReason? + +> `optional` **cancelReason**: `"cancelled"` + +Set when the drag is aborted; carried into `nodeDragEnded`. + +*** + ### modifiers > **modifiers**: [`InputModifiers`](/docs/api/types/configuration/shortcuts/inputmodifiers/) diff --git a/apps/docs/src/content/docs/api/Internals/ResizeActionState.md b/apps/docs/src/content/docs/api/Internals/ResizeActionState.md index 17f5d2073..a45402b39 100644 --- a/apps/docs/src/content/docs/api/Internals/ResizeActionState.md +++ b/apps/docs/src/content/docs/api/Internals/ResizeActionState.md @@ -10,6 +10,14 @@ State tracking a node resize operation in progress. ## Properties +### cancelReason? + +> `optional` **cancelReason**: `"cancelled"` + +Set when the resize is aborted; carried into `nodeResizeEnded`. + +*** + ### resizingNode > **resizingNode**: [`Node`](/docs/api/types/model/node/) diff --git a/apps/docs/src/content/docs/api/Internals/RotationActionState.md b/apps/docs/src/content/docs/api/Internals/RotationActionState.md index 9fc73de93..af2f9665a 100644 --- a/apps/docs/src/content/docs/api/Internals/RotationActionState.md +++ b/apps/docs/src/content/docs/api/Internals/RotationActionState.md @@ -10,6 +10,14 @@ State tracking a node rotation operation in progress. ## Properties +### cancelReason? + +> `optional` **cancelReason**: `"cancelled"` + +Set when the rotation is aborted; carried into `nodeRotateEnded`. + +*** + ### initialNodeAngle > **initialNodeAngle**: `number` diff --git a/apps/docs/src/content/docs/api/Services/NgDiagramService.md b/apps/docs/src/content/docs/api/Services/NgDiagramService.md index 651d6e369..2ad9a4bae 100644 --- a/apps/docs/src/content/docs/api/Services/NgDiagramService.md +++ b/apps/docs/src/content/docs/api/Services/NgDiagramService.md @@ -161,6 +161,41 @@ True if events are enabled. *** +### cancelActiveInteraction() + +> **cancelActiveInteraction**(): `Promise`\<`boolean`\> + +Aborts the in-progress gesture (linking, drag, resize, rotate or pan): +removes its listeners immediately, restores the state it modified +(positions, size, angle, temporary edge — the viewport is not rolled +back) and fires the corresponding "ended" event with the `cancelled` +reason. + +No-op when nothing is active, when the gesture is already completing, or +while a transaction is active (refused with a console warning — cancel +after it settles). + +Bound to Escape by default via the `cancelInteraction` shortcut action — +see [configureShortcuts](/docs/api/utilities/configureshortcuts/). + +#### Returns + +`Promise`\<`boolean`\> + +Promise resolving to whether anything was torn down + +#### Example + +```typescript +ngDiagramService.cancelActiveInteraction(); +``` + +#### Since + +1.3.0 + +*** + ### getDefaultRouting() > **getDefaultRouting**(): `string` diff --git a/apps/docs/src/content/docs/api/Types/Configuration/Shortcuts/KeyboardActionName.md b/apps/docs/src/content/docs/api/Types/Configuration/Shortcuts/KeyboardActionName.md index d22a791af..43db8a151 100644 --- a/apps/docs/src/content/docs/api/Types/Configuration/Shortcuts/KeyboardActionName.md +++ b/apps/docs/src/content/docs/api/Types/Configuration/Shortcuts/KeyboardActionName.md @@ -6,6 +6,6 @@ prev: false title: "KeyboardActionName" --- -> **KeyboardActionName** = [`KeyboardMoveSelectionAction`](/docs/api/types/configuration/shortcuts/keyboardmoveselectionaction/) \| [`KeyboardPanAction`](/docs/api/types/configuration/shortcuts/keyboardpanaction/) \| [`KeyboardZoomAction`](/docs/api/types/configuration/shortcuts/keyboardzoomaction/) \| `Extract`\<`InputEventName`, `"cut"` \| `"paste"` \| `"copy"` \| `"deleteSelection"` \| `"undo"` \| `"redo"` \| `"selectAll"`\> +> **KeyboardActionName** = [`KeyboardMoveSelectionAction`](/docs/api/types/configuration/shortcuts/keyboardmoveselectionaction/) \| [`KeyboardPanAction`](/docs/api/types/configuration/shortcuts/keyboardpanaction/) \| [`KeyboardZoomAction`](/docs/api/types/configuration/shortcuts/keyboardzoomaction/) \| `Extract`\<`InputEventName`, `"cut"` \| `"paste"` \| `"copy"` \| `"deleteSelection"` \| `"undo"` \| `"redo"` \| `"selectAll"` \| `"cancelInteraction"`\> Keyboard action names that can be triggered by keyboard events diff --git a/apps/docs/src/content/docs/api/Types/Events/EdgeDrawCancelReason.md b/apps/docs/src/content/docs/api/Types/Events/EdgeDrawCancelReason.md index 3194131c0..8b797838d 100644 --- a/apps/docs/src/content/docs/api/Types/Events/EdgeDrawCancelReason.md +++ b/apps/docs/src/content/docs/api/Types/Events/EdgeDrawCancelReason.md @@ -6,10 +6,12 @@ prev: false title: "EdgeDrawCancelReason" --- -> **EdgeDrawCancelReason** = `"noTarget"` \| `"invalidConnection"` \| `"invalidTarget"` +> **EdgeDrawCancelReason** = `"noTarget"` \| `"invalidConnection"` \| `"invalidTarget"` \| `"cancelled"` Reason an edge draw gesture was cancelled. - `noTarget` — the user released on empty space (no target node/port snapped) - `invalidConnection` — `validateConnection()` returned false - `invalidTarget` — the target node doesn't exist or the target port has wrong type +- `cancelled` — the gesture was aborted programmatically (e.g. Esc key, + [NgDiagramService.cancelActiveInteraction](/docs/api/services/ngdiagramservice/#cancelactiveinteraction)) diff --git a/apps/docs/src/content/docs/api/Types/Events/GestureCancelReason.md b/apps/docs/src/content/docs/api/Types/Events/GestureCancelReason.md new file mode 100644 index 000000000..4aad291c1 --- /dev/null +++ b/apps/docs/src/content/docs/api/Types/Events/GestureCancelReason.md @@ -0,0 +1,15 @@ +--- +version: "since v1.3.0" +editUrl: false +next: false +prev: false +title: "GestureCancelReason" +--- + +> **GestureCancelReason** = `"cancelled"` + +Reason an interactive gesture (drag, resize, rotation) ended without a normal +pointer release. + +- `cancelled` — the gesture was aborted programmatically (e.g. Esc key, + [NgDiagramService.cancelActiveInteraction](/docs/api/services/ngdiagramservice/#cancelactiveinteraction)) diff --git a/apps/docs/src/content/docs/api/Types/Events/NodeDragEndedEvent.md b/apps/docs/src/content/docs/api/Types/Events/NodeDragEndedEvent.md index c2db1cc21..fdd0079d2 100644 --- a/apps/docs/src/content/docs/api/Types/Events/NodeDragEndedEvent.md +++ b/apps/docs/src/content/docs/api/Types/Events/NodeDragEndedEvent.md @@ -13,6 +13,14 @@ Nodes will have their final positions when this event is received. ## Properties +### cancelReason? + +> `optional` **cancelReason**: `"cancelled"` + +Present when the drag ended without a normal pointer release + +*** + ### nodes > **nodes**: [`Node`](/docs/api/types/model/node/)[] diff --git a/apps/docs/src/content/docs/api/Types/Events/NodeResizeEndedEvent.md b/apps/docs/src/content/docs/api/Types/Events/NodeResizeEndedEvent.md index 49efdce08..1ba6d1c99 100644 --- a/apps/docs/src/content/docs/api/Types/Events/NodeResizeEndedEvent.md +++ b/apps/docs/src/content/docs/api/Types/Events/NodeResizeEndedEvent.md @@ -13,6 +13,14 @@ The node will have its final size when this event is received. ## Properties +### cancelReason? + +> `optional` **cancelReason**: `"cancelled"` + +Present when the resize ended without a normal pointer release + +*** + ### node > **node**: [`Node`](/docs/api/types/model/node/) diff --git a/apps/docs/src/content/docs/api/Types/Events/NodeRotateEndedEvent.md b/apps/docs/src/content/docs/api/Types/Events/NodeRotateEndedEvent.md index bafcf279b..a27697a9c 100644 --- a/apps/docs/src/content/docs/api/Types/Events/NodeRotateEndedEvent.md +++ b/apps/docs/src/content/docs/api/Types/Events/NodeRotateEndedEvent.md @@ -13,6 +13,14 @@ The node will have its final angle when this event is received. ## Properties +### cancelReason? + +> `optional` **cancelReason**: `"cancelled"` + +Present when the rotation ended without a normal pointer release + +*** + ### node > **node**: [`Node`](/docs/api/types/model/node/) diff --git a/apps/docs/src/content/docs/api/Types/Middleware/ModelActionType.md b/apps/docs/src/content/docs/api/Types/Middleware/ModelActionType.md index 899a91649..a920becf2 100644 --- a/apps/docs/src/content/docs/api/Types/Middleware/ModelActionType.md +++ b/apps/docs/src/content/docs/api/Types/Middleware/ModelActionType.md @@ -6,7 +6,7 @@ prev: false title: "ModelActionType" --- -> **ModelActionType** = `"init"` \| `"changeSelection"` \| `"moveNodesBy"` \| `"deleteSelection"` \| `"addNodes"` \| `"updateNode"` \| `"updateNodes"` \| `"deleteNodes"` \| `"clearModel"` \| `"paletteDropNode"` \| `"addEdges"` \| `"updateEdge"` \| `"deleteEdges"` \| `"deleteElements"` \| `"addEdgeLabelsBulk"` \| `"updateEdgeLabelsBulk"` \| `"deleteEdgeLabelsBulk"` \| `"addPortsBulk"` \| `"updatePortsBulk"` \| `"deletePortsBulk"` \| `"paste"` \| `"moveViewport"` \| `"resizeNode"` \| `"resizeNodeStart"` \| `"resizeNodeStop"` \| `"startLinking"` \| `"moveTemporaryEdge"` \| `"finishLinking"` \| `"zoom"` \| `"changeZOrder"` \| `"rotateNodeTo"` \| `"rotateNodeStart"` \| `"rotateNodeStop"` \| `"highlightGroup"` \| `"highlightGroupClear"` \| `"moveNodes"` \| `"moveNodesStart"` \| `"moveNodesStop"` \| `"selectEnd"` +> **ModelActionType** = `"init"` \| `"changeSelection"` \| `"moveNodesBy"` \| `"deleteSelection"` \| `"addNodes"` \| `"updateNode"` \| `"updateNodes"` \| `"deleteNodes"` \| `"clearModel"` \| `"paletteDropNode"` \| `"addEdges"` \| `"updateEdge"` \| `"deleteEdges"` \| `"deleteElements"` \| `"addEdgeLabelsBulk"` \| `"updateEdgeLabelsBulk"` \| `"deleteEdgeLabelsBulk"` \| `"addPortsBulk"` \| `"updatePortsBulk"` \| `"deletePortsBulk"` \| `"paste"` \| `"moveViewport"` \| `"resizeNode"` \| `"resizeNodeStart"` \| `"resizeNodeStop"` \| `"cancelResize"` \| `"startLinking"` \| `"moveTemporaryEdge"` \| `"finishLinking"` \| `"zoom"` \| `"changeZOrder"` \| `"rotateNodeTo"` \| `"rotateNodeStart"` \| `"rotateNodeStop"` \| `"cancelRotate"` \| `"highlightGroup"` \| `"highlightGroupClear"` \| `"moveNodes"` \| `"moveNodesStart"` \| `"moveNodesStop"` \| `"cancelDrag"` \| `"selectEnd"` Individual model action type that can trigger middleware execution. These represent all possible operations that modify the diagram state. diff --git a/apps/docs/src/content/docs/api/_readme.md b/apps/docs/src/content/docs/api/_readme.md index 113ca1268..ced8e3a1f 100644 --- a/apps/docs/src/content/docs/api/_readme.md +++ b/apps/docs/src/content/docs/api/_readme.md @@ -121,6 +121,7 @@ title: "ng-diagram" - [SelectionRotatedEvent](/docs/api/types/events/selectionrotatedevent/) - [ViewportChangedEvent](/docs/api/types/events/viewportchangedevent/) - [EdgeDrawCancelReason](/docs/api/types/events/edgedrawcancelreason/) +- [GestureCancelReason](/docs/api/types/events/gesturecancelreason/) ## Types/Geometry diff --git a/apps/docs/src/content/docs/guides/shortcut-manager.mdx b/apps/docs/src/content/docs/guides/shortcut-manager.mdx index bfdc76fd1..05e99e8c8 100644 --- a/apps/docs/src/content/docs/guides/shortcut-manager.mdx +++ b/apps/docs/src/content/docs/guides/shortcut-manager.mdx @@ -15,26 +15,27 @@ All available shortcut actions with their default key bindings. See [`ShortcutAc | Action | Default Shortcut | Description | | ---------------------------- | ----------------------------------------- | --------------------------------------------------------------------------- | +| `boxSelection` | Shift held | Enable box selection mode (pointer only) | +| `cancelInteraction` | Escape | Cancel the in-progress interaction (linking, drag, resize, rotate, pan) | | `copy` | Ctrl/Cmd + C | Copy selected elements to clipboard | | `cut` | Ctrl/Cmd + X | Cut selected elements to clipboard | -| `paste` | Ctrl/Cmd + V | Paste elements from clipboard | | `deleteSelection` | Delete or Backspace | Delete currently selected elements | -| `selectAll` | Ctrl/Cmd + A | Select all elements in the diagram | -| `boxSelection` | Shift held | Enable box selection mode (pointer only) | -| `multiSelection` | Ctrl/Cmd held | Multi-selection mode (pointer only) | -| `keyboardMoveSelectionUp` | | Move selected elements up | | `keyboardMoveSelectionDown` | | Move selected elements down | | `keyboardMoveSelectionLeft` | | Move selected elements left | | `keyboardMoveSelectionRight` | | Move selected elements right | -| `keyboardPanUp` | | Pan viewport up | +| `keyboardMoveSelectionUp` | | Move selected elements up | | `keyboardPanDown` | | Pan viewport down | | `keyboardPanLeft` | | Pan viewport left | | `keyboardPanRight` | | Pan viewport right | -| `zoom` | Ctrl/Cmd + Wheel | Increase or decrease diagram viewport scale | +| `keyboardPanUp` | | Pan viewport up | | `keyboardZoomIn` | = | Increase diagram viewport scale | | `keyboardZoomOut` | - | Decrease diagram viewport scale | -| `undo` | Ctrl/Cmd + Z | Undo last action (not implemented by default; requires custom model) | +| `multiSelection` | Ctrl/Cmd held | Multi-selection mode (pointer only) | +| `paste` | Ctrl/Cmd + V | Paste elements from clipboard | | `redo` | Ctrl/Cmd + Y | Redo last undone action (not implemented by default; requires custom model) | +| `selectAll` | Ctrl/Cmd + A | Select all elements in the diagram | +| `undo` | Ctrl/Cmd + Z | Undo last action (not implemented by default; requires custom model) | +| `zoom` | Ctrl/Cmd + Wheel | Increase or decrease diagram viewport scale | ## Basic Usage diff --git a/apps/e2e/tests/cancel-interaction.spec.ts b/apps/e2e/tests/cancel-interaction.spec.ts new file mode 100644 index 000000000..bdf07ca1b --- /dev/null +++ b/apps/e2e/tests/cancel-interaction.spec.ts @@ -0,0 +1,333 @@ +import type { Model } from 'ng-diagram'; +import { expect, test } from './fixtures/diagram'; +import type { Diagram } from './fixtures/diagram'; +import { pair } from './fixtures/models'; + +/** + * cancelActiveInteraction() and its default Escape binding: every gesture + * (drag, linking, resize, rotate, pan) aborts immediately — the touched state + * rolls back, document-level listeners are gone before the pointer release, + * the gesture-ended events carry the cancelled reason — and Escape stays + * non-intrusive when there is nothing to cancel. + */ + +/** Record every gesture-ended event as `kind:reason`, in emission order. */ +const recordEndedEvents = (diagram: Diagram) => + diagram.page.evaluate(() => { + const stash = window as unknown as { __endedEvents?: string[] }; + stash.__endedEvents = []; + const service = window.__diagram!.diagram; + service.addEventListener('nodeDragEnded', (e) => + stash.__endedEvents!.push(`drag:${e.cancelReason ?? 'completed'}`) + ); + service.addEventListener('nodeResizeEnded', (e) => + stash.__endedEvents!.push(`resize:${e.cancelReason ?? 'completed'}`) + ); + service.addEventListener('nodeRotateEnded', (e) => + stash.__endedEvents!.push(`rotate:${e.cancelReason ?? 'completed'}`) + ); + service.addEventListener('edgeDrawEnded', (e) => + stash.__endedEvents!.push(`link:${e.success ? 'drawn' : (e.reason ?? 'none')}`) + ); + }); + +const endedEvents = (diagram: Diagram) => + diagram.page.evaluate(() => (window as unknown as { __endedEvents?: string[] }).__endedEvents ?? []); + +/** 200×120 box at (100,100) with fixed size — resize rollback is exact. */ +const box: Partial = { + nodes: [ + { + id: 'box', + position: { x: 100, y: 100 }, + size: { width: 200, height: 120 }, + autoSize: false, + resizable: true, + data: { label: 'box' }, + }, + ], + edges: [], +}; + +/** Auto-sized node — the resize gesture disables autoSize, cancel must bring it back. */ +const autoBox: Partial = { + nodes: [ + { + id: 'auto', + position: { x: 100, y: 100 }, + autoSize: true, + resizable: true, + data: { label: 'auto' }, + }, + ], + edges: [], +}; + +/** Rotatable node with a non-zero starting angle — rollback to 30° is unambiguous. */ +const spin: Partial = { + nodes: [ + { + id: 'spin', + position: { x: 150, y: 150 }, + size: { width: 140, height: 80 }, + autoSize: false, + rotatable: true, + angle: 30, + data: { label: 'spin' }, + }, + ], + edges: [], +}; + +test.describe('Escape cancels the in-flight gesture', () => { + test('drag: nodes snap back, listeners are gone, nodeDragEnded reports cancelled', async ({ diagram }) => { + await diagram.load({ model: pair }); + await recordEndedEvents(diagram); + const before = await diagram.nodePosition('node-a'); + + const start = await diagram.centerOf(diagram.node('node-a'), 'node "node-a"'); + await diagram.beginDrag(start, { x: start.x + 90, y: start.y + 60 }); + await expect.poll(() => diagram.nodePosition('node-a')).not.toEqual(before); + + await diagram.page.keyboard.press('Escape'); + + await expect.poll(() => diagram.nodePosition('node-a')).toEqual(before); + await expect.poll(() => endedEvents(diagram)).toEqual(['drag:cancelled']); + await expect.poll(async () => (await diagram.diagram.actionState()).dragging).toBeUndefined(); + // Cancel aborts the move and nothing else — the selection stays + expect((await diagram.model.getNodeById('node-a'))?.selected).toBe(true); + + // Listeners were removed on cancel: further moves and the release are inert + await diagram.page.mouse.move(start.x + 200, start.y + 150, { steps: 4 }); + await diagram.nextFrame(); + expect(await diagram.nodePosition('node-a')).toEqual(before); + await diagram.page.mouse.up(); + await diagram.nextFrame(); + expect(await endedEvents(diagram)).toEqual(['drag:cancelled']); + }); + + test('linking: the temporary edge is discarded and edgeDrawEnded reports cancelled', async ({ diagram }) => { + await diagram.load({ model: pair }); + await recordEndedEvents(diagram); + + const from = await diagram.centerOf(diagram.port('node-a', 'port-right'), 'port node-a/port-right'); + await diagram.beginDrag(from, { x: from.x + 120, y: from.y + 90 }); + await expect(diagram.edge('TEMPORARY_EDGE')).toBeAttached(); + + await diagram.page.keyboard.press('Escape'); + + await expect(diagram.allEdges).toHaveCount(0); + await expect.poll(() => endedEvents(diagram)).toEqual(['link:cancelled']); + await expect.poll(async () => (await diagram.diagram.actionState()).linking).toBeUndefined(); + + await diagram.page.mouse.up(); + await diagram.nextFrame(); + expect(await diagram.model.edges()).toEqual([]); + expect(await endedEvents(diagram)).toEqual(['link:cancelled']); + }); + + test('resize: size and position roll back exactly, nodeResizeEnded reports cancelled', async ({ diagram }) => { + await diagram.load({ model: box }); + await recordEndedEvents(diagram); + await diagram.node('box').click(); + + // Top-left handle changes both size and position — rollback must restore both + const handle = await diagram.centerOf( + diagram.node('box').locator('.resize-handle--top-left'), + 'top-left resize handle' + ); + await diagram.beginDrag(handle, { x: handle.x + 30, y: handle.y + 20 }); + await expect + .poll(async () => (await diagram.model.getNodeById('box'))?.size) + .not.toEqual({ + width: 200, + height: 120, + }); + + await diagram.page.keyboard.press('Escape'); + + await expect.poll(async () => (await diagram.model.getNodeById('box'))?.size).toEqual({ width: 200, height: 120 }); + expect((await diagram.model.getNodeById('box'))?.position).toEqual({ x: 100, y: 100 }); + await expect.poll(() => endedEvents(diagram)).toEqual(['resize:cancelled']); + + await diagram.page.mouse.up(); + await diagram.nextFrame(); + expect(await endedEvents(diagram)).toEqual(['resize:cancelled']); + }); + + test('resize: Escape works when the gesture starts with focus outside the diagram', async ({ diagram }) => { + await diagram.load({ model: box }); + await diagram.node('box').click(); + // Focus leaves the diagram — like clicking a toolbar button right before resizing. + // The resize handle stops pointerdown propagation, so only the capture-phase + // focus grab brings the keyboard back to the diagram. + await diagram.page.evaluate(() => { + const button = document.createElement('button'); + document.body.appendChild(button); + button.focus(); + }); + + const handle = await diagram.centerOf( + diagram.node('box').locator('.resize-handle--bottom-right'), + 'bottom-right resize handle' + ); + await diagram.beginDrag(handle, { x: handle.x + 40, y: handle.y + 30 }); + await expect + .poll(async () => (await diagram.model.getNodeById('box'))?.size) + .not.toEqual({ + width: 200, + height: 120, + }); + + await diagram.page.keyboard.press('Escape'); + + await expect.poll(async () => (await diagram.model.getNodeById('box'))?.size).toEqual({ width: 200, height: 120 }); + await diagram.page.mouse.up(); + }); + + test('resize: a cancelled gesture gives autoSize back', async ({ diagram }) => { + await diagram.load({ model: autoBox }); + await diagram.node('auto').click(); + // Wait for the measured size — autoSize nodes get their size from the DOM + await expect.poll(async () => (await diagram.model.getNodeById('auto'))?.size).toBeDefined(); + const measured = (await diagram.model.getNodeById('auto'))!.size; + + const handle = await diagram.centerOf( + diagram.node('auto').locator('.resize-handle--bottom-right'), + 'bottom-right resize handle' + ); + await diagram.beginDrag(handle, { x: handle.x + 40, y: handle.y + 30 }); + await expect.poll(async () => (await diagram.model.getNodeById('auto'))?.autoSize).toBe(false); + + await diagram.page.keyboard.press('Escape'); + + await expect.poll(async () => (await diagram.model.getNodeById('auto'))?.autoSize).toBe(true); + await expect.poll(async () => (await diagram.model.getNodeById('auto'))?.size).toEqual(measured); + await diagram.page.mouse.up(); + }); + + test('rotate: the angle rolls back exactly, nodeRotateEnded reports cancelled', async ({ diagram }) => { + await diagram.load({ model: spin }); + await recordEndedEvents(diagram); + await diagram.node('spin').click(); + + const handle = await diagram.centerOf(diagram.node('spin').locator('.ng-diagram-rotate-handle'), 'rotate handle'); + await diagram.beginDrag(handle, { x: handle.x + 120, y: handle.y + 120 }); + await expect.poll(async () => (await diagram.model.getNodeById('spin'))?.angle).not.toBe(30); + + await diagram.page.keyboard.press('Escape'); + + await expect.poll(async () => (await diagram.model.getNodeById('spin'))?.angle).toBe(30); + await expect.poll(() => endedEvents(diagram)).toEqual(['rotate:cancelled']); + + await diagram.page.mouse.up(); + await diagram.nextFrame(); + expect(await endedEvents(diagram)).toEqual(['rotate:cancelled']); + }); + + test('pan: the gesture stops but the viewport is NOT rolled back', async ({ diagram }) => { + await diagram.load({ model: pair }); + const initial = await diagram.viewport.viewport(); + + const containerBox = await diagram.container.boundingBox(); + if (!containerBox) throw new Error('container has no bounding box'); + const corner = { + x: containerBox.x + containerBox.width - 30, + y: containerBox.y + containerBox.height - 30, + }; + await diagram.beginDrag(corner, { x: corner.x - 60, y: corner.y - 40 }); + await expect.poll(async () => (await diagram.viewport.viewport()).x).not.toBe(initial.x); + + await diagram.page.keyboard.press('Escape'); + + await expect.poll(async () => (await diagram.diagram.actionState()).panning).toBeUndefined(); + const frozen = await diagram.viewport.viewport(); + expect(frozen).not.toEqual(initial); // navigation state is deliberately kept + + // Listeners were removed on cancel: further moves and the release are inert + await diagram.page.mouse.move(corner.x - 200, corner.y - 150, { steps: 4 }); + await diagram.nextFrame(); + expect(await diagram.viewport.viewport()).toEqual(frozen); + await diagram.page.mouse.up(); + await diagram.nextFrame(); + expect(await diagram.viewport.viewport()).toEqual(frozen); + }); +}); + +test.describe('cancelActiveInteraction()', () => { + test('aborts a pointer drag programmatically and resolves true', async ({ diagram }) => { + await diagram.load({ model: pair }); + const before = await diagram.nodePosition('node-a'); + + const start = await diagram.centerOf(diagram.node('node-a'), 'node "node-a"'); + await diagram.beginDrag(start, { x: start.x + 80, y: start.y + 50 }); + await expect.poll(() => diagram.nodePosition('node-a')).not.toEqual(before); + + expect(await diagram.diagram.cancelActiveInteraction()).toBe(true); + + await expect.poll(() => diagram.nodePosition('node-a')).toEqual(before); + await diagram.page.mouse.up(); + }); + + test('aborts manual linking started via startLinking()', async ({ diagram }) => { + await diagram.load({ model: pair }); + + await diagram.page.evaluate(() => { + const node = window.__diagram!.model.getNodeById('node-a'); + window.__diagram!.diagram.startLinking(node!, 'port-right'); + }); + await diagram.page.mouse.move(300, 300); + await expect(diagram.edge('TEMPORARY_EDGE')).toBeAttached(); + + expect(await diagram.diagram.cancelActiveInteraction()).toBe(true); + + await expect(diagram.allEdges).toHaveCount(0); + // The manual-linking document listeners are gone: a click completes nothing + await diagram.clickCanvas(); + await diagram.nextFrame(); + expect(await diagram.model.edges()).toEqual([]); + }); + + test('resolves false and emits nothing when no gesture is active', async ({ diagram }) => { + await diagram.load({ model: pair }); + await recordEndedEvents(diagram); + + expect(await diagram.diagram.cancelActiveInteraction()).toBe(false); + + await diagram.nextFrame(); + expect(await endedEvents(diagram)).toEqual([]); + }); +}); + +test.describe('Escape stays non-intrusive', () => { + test('the key is swallowed only while something is cancellable', async ({ diagram }) => { + await diagram.load({ model: pair }); + // Registered after the library's own document listener, so it observes the + // final defaultPrevented verdict for every Escape press + await diagram.page.evaluate(() => { + const stash = window as unknown as { __escSwallowed?: boolean[] }; + stash.__escSwallowed = []; + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') stash.__escSwallowed!.push(e.defaultPrevented); + }); + }); + + // Focus the diagram without leaving any gesture active… + await diagram.clickCanvas(); + await diagram.page.keyboard.press('Escape'); // …nothing to cancel → not swallowed + + const start = await diagram.centerOf(diagram.node('node-a'), 'node "node-a"'); + await diagram.beginDrag(start, { x: start.x + 60, y: start.y + 40 }); + await diagram.page.keyboard.press('Escape'); // mid-drag → swallowed + await diagram.page.mouse.up(); + + // A normally completed gesture must leave nothing cancellable behind — + // a lingering interaction registration would swallow this Escape too + await diagram.dragNode('node-a', { x: 40, y: 30 }); + await diagram.page.keyboard.press('Escape'); + + await expect + .poll(() => diagram.page.evaluate(() => (window as unknown as { __escSwallowed?: boolean[] }).__escSwallowed)) + .toEqual([false, true, false]); + }); +}); diff --git a/apps/e2e/tests/fixtures/diagram.ts b/apps/e2e/tests/fixtures/diagram.ts index f6cf3b3c2..a766a4ccd 100644 --- a/apps/e2e/tests/fixtures/diagram.ts +++ b/apps/e2e/tests/fixtures/diagram.ts @@ -104,6 +104,18 @@ export class Diagram { return this.node(nodeId).locator(`[data-port-id="${cssEscape(portId)}"]`); } + /** Model position of a node (flow coordinates). */ + async nodePosition(id: string): Promise { + const node = await this.model.getNodeById(id); + if (!node) throw new Error(`node "${id}" not in model`); + return { x: node.position.x, y: node.position.y }; + } + + /** Center of a locator's bounding box (client px). */ + async centerOf(locator: Locator, label: string): Promise { + return centerOf(await requireBox(locator, label)); + } + // ────────────────────────────────────────────────────────────────────── // Gestures (DOM-level) // ────────────────────────────────────────────────────────────────────── @@ -144,16 +156,7 @@ export class Diagram { y: box.y + box.height * (handle === 'top' || handle === 'bottom' ? 0.5 : 0.3), } : centerOf(box); - const end = { x: start.x + delta.x, y: start.y + delta.y }; - const mid = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 }; - await this.page.mouse.move(start.x, start.y); - await this.page.mouse.down(); - await this.page.mouse.move(mid.x, mid.y, { steps: 6 }); - await this.page.mouse.move(end.x, end.y, { steps: 6 }); - // pointermove delivery is frame-aligned in Chromium — give the final - // coalesced move a frame to arrive before releasing the pointer - await this.page.evaluate(() => new Promise(requestAnimationFrame)); - await this.page.mouse.up(); + await this.pointerDrag(start, { x: start.x + delta.x, y: start.y + delta.y }); } /** Pan the canvas from an empty corner by a delta. */ @@ -163,13 +166,26 @@ export class Diagram { await this.pointerDrag(start, { x: start.x + delta.x, y: start.y + delta.y }); } - /** Pointer drag with an intermediate move so the diagram registers a drag rather than a click. */ - private async pointerDrag(from: Point, to: Point): Promise { + /** Begin a drag: pointer-down plus moves WITHOUT the release — the gesture stays in flight. */ + async beginDrag(from: Point, to: Point): Promise { const mid = { x: (from.x + to.x) / 2, y: (from.y + to.y) / 2 }; await this.page.mouse.move(from.x, from.y); await this.page.mouse.down(); await this.page.mouse.move(mid.x, mid.y, { steps: 6 }); await this.page.mouse.move(to.x, to.y, { steps: 6 }); + // pointermove delivery is frame-aligned in Chromium — give the final + // coalesced move a frame to arrive before the caller proceeds + await this.nextFrame(); + } + + /** Waits one animation frame in the page. */ + nextFrame(): Promise { + return this.page.evaluate(() => new Promise(requestAnimationFrame)); + } + + /** Pointer drag with an intermediate move so the diagram registers a drag rather than a click. */ + private async pointerDrag(from: Point, to: Point): Promise { + await this.beginDrag(from, to); await this.page.mouse.up(); } } diff --git a/packages/ng-diagram/api-report/ng-diagram.api.md b/packages/ng-diagram/api-report/ng-diagram.api.md index 5ab6c0a62..31637a45c 100644 --- a/packages/ng-diagram/api-report/ng-diagram.api.md +++ b/packages/ng-diagram/api-report/ng-diagram.api.md @@ -197,6 +197,7 @@ export class DiagramSelectionDirective extends ObjectSelectionDirective { // @public export interface DraggingActionState { accumulatedDeltas: Map; + cancelReason?: GestureCancelReason; modifiers: InputModifiers; movementStarted: boolean; nodeIds: string[]; @@ -228,7 +229,7 @@ export interface Edge { } // @public -export type EdgeDrawCancelReason = 'noTarget' | 'invalidConnection' | 'invalidTarget'; +export type EdgeDrawCancelReason = 'noTarget' | 'invalidConnection' | 'invalidTarget' | 'cancelled'; // @public export interface EdgeDrawEndedEvent { @@ -389,6 +390,9 @@ export interface FlowStateUpdate { renderedNodeIds?: string[]; } +// @public +export type GestureCancelReason = 'cancelled'; + // @public export interface GroupingConfig { canGroup: (node: Node_2, group: Node_2) => boolean; @@ -448,10 +452,13 @@ export interface InvalidateMeasurementsOptions { } // @public -export type KeyboardActionName = KeyboardMoveSelectionAction | KeyboardPanAction | KeyboardZoomAction | Extract; +export type KeyboardActionName = KeyboardMoveSelectionAction | KeyboardPanAction | KeyboardZoomAction | Extract; // @public (undocumented) -export class KeyboardInputsDirective { +export class KeyboardInputsDirective implements OnDestroy { + constructor(); + // (undocumented) + ngOnDestroy(): void; // (undocumented) onKeyDown(event: KeyboardEvent): void; // (undocumented) @@ -661,7 +668,7 @@ export interface Model { } // @public -export type ModelActionType = 'init' | 'changeSelection' | 'moveNodesBy' | 'deleteSelection' | 'addNodes' | 'updateNode' | 'updateNodes' | 'deleteNodes' | 'clearModel' | 'paletteDropNode' | 'addEdges' | 'updateEdge' | 'deleteEdges' | 'deleteElements' | 'addEdgeLabelsBulk' | 'updateEdgeLabelsBulk' | 'deleteEdgeLabelsBulk' | 'addPortsBulk' | 'updatePortsBulk' | 'deletePortsBulk' | 'paste' | 'moveViewport' | 'resizeNode' | 'resizeNodeStart' | 'resizeNodeStop' | 'startLinking' | 'moveTemporaryEdge' | 'finishLinking' | 'zoom' | 'changeZOrder' | 'rotateNodeTo' | 'rotateNodeStart' | 'rotateNodeStop' | 'highlightGroup' | 'highlightGroupClear' | 'moveNodes' | 'moveNodesStart' | 'moveNodesStop' | 'selectEnd'; +export type ModelActionType = 'init' | 'changeSelection' | 'moveNodesBy' | 'deleteSelection' | 'addNodes' | 'updateNode' | 'updateNodes' | 'deleteNodes' | 'clearModel' | 'paletteDropNode' | 'addEdges' | 'updateEdge' | 'deleteEdges' | 'deleteElements' | 'addEdgeLabelsBulk' | 'updateEdgeLabelsBulk' | 'deleteEdgeLabelsBulk' | 'addPortsBulk' | 'updatePortsBulk' | 'deletePortsBulk' | 'paste' | 'moveViewport' | 'resizeNode' | 'resizeNodeStart' | 'resizeNodeStop' | 'cancelResize' | 'startLinking' | 'moveTemporaryEdge' | 'finishLinking' | 'zoom' | 'changeZOrder' | 'rotateNodeTo' | 'rotateNodeStart' | 'rotateNodeStop' | 'cancelRotate' | 'highlightGroup' | 'highlightGroupClear' | 'moveNodes' | 'moveNodesStart' | 'moveNodesStop' | 'cancelDrag' | 'selectEnd'; // @public export type ModelActionTypes = LooseAutocomplete[]; @@ -1240,6 +1247,7 @@ export class NgDiagramService extends NgDiagramBaseService { addEventListener(event: K, callback: EventListener_2): UnsubscribeFn; addEventListenerOnce(event: K, callback: EventListener_2): UnsubscribeFn; areEventsEnabled(): boolean; + cancelActiveInteraction(): Promise; readonly config: Signal>>; getDefaultRouting(): string; getEnvironment(): EnvironmentInfo; @@ -1306,6 +1314,7 @@ export { Node_2 as Node } // @public export interface NodeDragEndedEvent { + cancelReason?: GestureCancelReason; nodes: Node_2[]; } @@ -1336,6 +1345,7 @@ export interface NodeResizedEvent { // @public export interface NodeResizeEndedEvent { + cancelReason?: GestureCancelReason; node: Node_2; } @@ -1346,6 +1356,7 @@ export interface NodeResizeStartedEvent { // @public export interface NodeRotateEndedEvent { + cancelReason?: GestureCancelReason; node: Node_2; } @@ -1494,6 +1505,7 @@ export interface Rect { // @public export interface ResizeActionState { + cancelReason?: GestureCancelReason; resizingNode: Node_2; startHeight: number; startNodePositionX: number; @@ -1512,6 +1524,7 @@ export interface ResizeConfig { // @public export interface RotationActionState { + cancelReason?: GestureCancelReason; initialNodeAngle: number; nodeId: string; startAngle: number; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/index.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/index.ts index bc7a47643..869edd553 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/index.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/index.ts @@ -25,6 +25,7 @@ import { deleteSelection } from './delete-selection'; import { highlightGroup, highlightGroupClear } from './highlight-group'; import { init } from './init'; import { + cancelLinking, finishLinking, finishLinkingToPosition, moveTemporaryEdge, @@ -81,6 +82,7 @@ export const commands: CommandMap = { startLinking, moveTemporaryEdge, finishLinking, + cancelLinking, finishLinkingToPosition, startLinkingFromPosition, resizeNode, diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/cancel-linking.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/cancel-linking.test.ts new file mode 100644 index 000000000..671cbe8f8 --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/cancel-linking.test.ts @@ -0,0 +1,130 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { FlowCore } from '../../../../flow-core'; +import type { CommandHandler, Edge, LinkingActionState } from '../../../../types'; +import type { InternalLinkingActionState } from '../../../../types/action-state.interface'; +import { cancelLinking } from '../cancel-linking'; + +describe('cancelLinking', () => { + let mockCommandHandler: CommandHandler; + let mockFlowCore: { + applyUpdate: ReturnType; + actionStateManager: { + linking: LinkingActionState | null; + clearLinking: ReturnType; + }; + }; + + const mockTemporaryEdge: Edge = { + id: 'temp-edge', + source: 'source-node', + sourcePort: 'source-port', + target: '', + targetPosition: { x: 150, y: 250 }, + data: {}, + }; + + beforeEach(() => { + vi.clearAllMocks(); + + mockFlowCore = { + applyUpdate: vi.fn().mockResolvedValue(undefined), + actionStateManager: { + linking: null, + clearLinking: vi.fn(), + }, + }; + + mockCommandHandler = { + flowCore: mockFlowCore as unknown as FlowCore, + emit: vi.fn(), + } as unknown as CommandHandler; + }); + + it('should do nothing when no linking is in progress', async () => { + await cancelLinking(mockCommandHandler); + + expect(mockFlowCore.applyUpdate).not.toHaveBeenCalled(); + expect(mockFlowCore.actionStateManager.clearLinking).not.toHaveBeenCalled(); + }); + + it('should set the cancelled reason and clear the linking state', async () => { + const linking: LinkingActionState = { + sourceNodeId: 'source-node', + sourcePortId: 'source-port', + temporaryEdge: mockTemporaryEdge, + }; + mockFlowCore.actionStateManager.linking = linking; + + await cancelLinking(mockCommandHandler); + + expect(linking.cancelReason).toBe('cancelled'); + expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith({}, 'finishLinking'); + expect(mockFlowCore.actionStateManager.clearLinking).toHaveBeenCalled(); + }); + + it('should use the temporary edge end as the drop position', async () => { + const linking: LinkingActionState = { + sourceNodeId: 'source-node', + sourcePortId: 'source-port', + temporaryEdge: mockTemporaryEdge, + }; + mockFlowCore.actionStateManager.linking = linking; + + await cancelLinking(mockCommandHandler); + + expect(linking.dropPosition).toEqual({ x: 150, y: 250 }); + }); + + it('should no-op when a finishLinking already owns the teardown', async () => { + const linking: InternalLinkingActionState = { + sourceNodeId: 'source-node', + sourcePortId: 'source-port', + temporaryEdge: mockTemporaryEdge, + _finishing: true, + }; + mockFlowCore.actionStateManager.linking = linking; + + await cancelLinking(mockCommandHandler); + + expect(linking.cancelReason).toBeUndefined(); + expect(mockFlowCore.applyUpdate).not.toHaveBeenCalled(); + expect(mockFlowCore.actionStateManager.clearLinking).not.toHaveBeenCalled(); + }); + + it('should not clear a linking state that replaced the cancelled one mid-pass', async () => { + const linking: InternalLinkingActionState = { + sourceNodeId: 'source-node', + sourcePortId: 'source-port', + temporaryEdge: mockTemporaryEdge, + _gestureId: 1, + }; + mockFlowCore.actionStateManager.linking = linking; + mockFlowCore.applyUpdate.mockImplementation(async () => { + // A new linking gesture starts while the cancelled finish pass is suspended + mockFlowCore.actionStateManager.linking = { + sourceNodeId: 'other-node', + sourcePortId: 'other-port', + temporaryEdge: null, + _gestureId: 2, + } as InternalLinkingActionState; + }); + + await cancelLinking(mockCommandHandler); + + expect(mockFlowCore.actionStateManager.clearLinking).not.toHaveBeenCalled(); + }); + + it('should fall back to a zero drop position without a temporary edge', async () => { + const linking: LinkingActionState = { + sourceNodeId: 'source-node', + sourcePortId: 'source-port', + temporaryEdge: null, + }; + mockFlowCore.actionStateManager.linking = linking; + + await cancelLinking(mockCommandHandler); + + expect(linking.dropPosition).toEqual({ x: 0, y: 0 }); + expect(mockFlowCore.actionStateManager.clearLinking).toHaveBeenCalled(); + }); +}); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/cancel-linking.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/cancel-linking.ts new file mode 100644 index 000000000..5d88c4a2c --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/cancel-linking.ts @@ -0,0 +1,38 @@ +import type { CommandHandler } from '../../../types'; +import type { InternalLinkingActionState } from '../../../types/action-state.interface'; +import { runCancelledFinishPass } from './finish-linking'; +import { clearLinkingForGesture } from './linking-gesture'; + +export interface CancelLinkingCommand { + name: 'cancelLinking'; +} + +/** + * Aborts an in-progress linking gesture without creating an edge. + * + * Removes the temporary edge, clears the linking action state and lets the + * `edgeDrawEnded` event fire with the `cancelled` reason. No-op when no + * linking is in progress. + */ +export const cancelLinking = async (commandHandler: CommandHandler): Promise => { + const linking = commandHandler.flowCore.actionStateManager.linking as InternalLinkingActionState | undefined; + + // No linking, or a finishLinking/another cancel already owns the teardown. + if (!linking || linking._finishing) { + return; + } + + linking._finishing = true; + const gestureId = linking._gestureId; + linking.cancelReason = 'cancelled'; + linking.dropPosition ??= linking.temporaryEdge?.targetPosition ?? { x: 0, y: 0 }; + + // The empty pass emits edgeDrawEnded and erases the temporary edge (see + // runCancelledFinishPass); the stamped clear in finally survives a throwing + // middleware and spares a linking that replaced this one mid-pass. + try { + await runCancelledFinishPass(commandHandler); + } finally { + clearLinkingForGesture(commandHandler.flowCore.actionStateManager, gestureId); + } +}; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/finish-linking.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/finish-linking.ts index fa1bd873f..fa98d4022 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/finish-linking.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/finish-linking.ts @@ -9,12 +9,10 @@ export interface FinishLinkingCommand { position?: Point; } -// Empty 'finishLinking' pass: the event-emitter middleware turns the linking -// state (cancelReason, dropPosition) into an edgeDrawEnded event, and the -// commit schedules a redraw that runs after finally has cleared the linking -// state — which is what erases the temporary edge (it is rendered from action -// state, not the model). -const runCancelledFinishPass = async (commandHandler: CommandHandler): Promise => { +// Empty 'finishLinking' pass: the emitter turns the linking state into an +// edgeDrawEnded event and the commit's redraw erases the temporary edge. +// Callers clear the state themselves (stamped, in their finally). +export const runCancelledFinishPass = async (commandHandler: CommandHandler): Promise => { await commandHandler.flowCore.applyUpdate({}, 'finishLinking'); }; @@ -51,6 +49,9 @@ export const finishLinking = async (commandHandler: CommandHandler, command: Fin return; } + // Claims the teardown — a cancelLinking racing this finish must no-op instead + // of overwriting the reason and emitting a second edgeDrawEnded. + linking._finishing = true; const gestureId = linking._gestureId; // Clear in finally — a user callback below can throw, and a stranded linking diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/index.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/index.ts index 5f512dd65..d0a2ad258 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/index.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/index.ts @@ -1,4 +1,5 @@ // Commands +export * from './cancel-linking'; export * from './finish-linking'; export * from './finish-linking-to-position'; export * from './move-temporary-edge'; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/event-manager/event-types.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/event-manager/event-types.ts index f1a490c58..c5c17802b 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/event-manager/event-types.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/event-manager/event-types.ts @@ -267,12 +267,27 @@ export interface EdgeDrawnEvent { * - `noTarget` — the user released on empty space (no target node/port snapped) * - `invalidConnection` — `validateConnection()` returned false * - `invalidTarget` — the target node doesn't exist or the target port has wrong type + * - `cancelled` — the gesture was aborted programmatically (e.g. Esc key, + * {@link NgDiagramService.cancelActiveInteraction}) * * @public * @since 1.2.0 * @category Types/Events */ -export type EdgeDrawCancelReason = 'noTarget' | 'invalidConnection' | 'invalidTarget'; +export type EdgeDrawCancelReason = 'noTarget' | 'invalidConnection' | 'invalidTarget' | 'cancelled'; + +/** + * Reason an interactive gesture (drag, resize, rotation) ended without a normal + * pointer release. + * + * - `cancelled` — the gesture was aborted programmatically (e.g. Esc key, + * {@link NgDiagramService.cancelActiveInteraction}) + * + * @public + * @since 1.3.0 + * @category Types/Events + */ +export type GestureCancelReason = 'cancelled'; /** * Event payload emitted when an edge draw gesture ends, regardless of outcome. @@ -436,6 +451,8 @@ export interface NodeResizeStartedEvent { export interface NodeResizeEndedEvent { /** The node that was resized, with its final size */ node: Node; + /** Present when the resize ended without a normal pointer release */ + cancelReason?: GestureCancelReason; } /** @@ -466,6 +483,8 @@ export interface NodeRotateStartedEvent { export interface NodeRotateEndedEvent { /** The node that was rotated, with its final angle */ node: Node; + /** Present when the rotation ended without a normal pointer release */ + cancelReason?: GestureCancelReason; } /** @@ -496,6 +515,8 @@ export interface NodeDragStartedEvent { export interface NodeDragEndedEvent { /** Nodes that were dragged, with their final positions */ nodes: Node[]; + /** Present when the drag ended without a normal pointer release */ + cancelReason?: GestureCancelReason; } /** diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/event-manager/index.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/event-manager/index.ts index 18bbf149d..f0ec0fc8b 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/event-manager/index.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/event-manager/index.ts @@ -7,6 +7,7 @@ export type { EdgeDrawEndedEvent, EdgeDrawnEvent, EventListener, + GestureCancelReason, GroupMembershipChangedEvent, NodeDragEndedEvent, NodeDragStartedEvent, diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.test.ts index a1b58ad35..bae7a1166 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.test.ts @@ -5,6 +5,7 @@ import { FlowCore } from './flow-core'; import type { InputEventsRouter } from './input-events'; import { MiddlewareManager } from './middleware-manager/middleware-manager'; import { mockEdge, mockEnvironment, mockMetadata, mockNode } from './test-utils'; +import type { DraggingActionState } from './types/action-state.interface'; import type { Edge } from './types/edge.interface'; import type { FlowConfig } from './types/flow-config.interface'; import type { Metadata } from './types/metadata.interface'; @@ -14,6 +15,13 @@ import type { Node } from './types/node.interface'; import type { Renderer } from './types/renderer.interface'; import type { TransactionOptions } from './types/transaction.interface'; +const draggingState = (movementStarted = true): DraggingActionState => ({ + nodeIds: [], + modifiers: { primary: false, secondary: false, shift: false, meta: false }, + accumulatedDeltas: new Map(), + movementStarted, +}); + vi.mock('./updater/init-updater/init-updater', () => ({ InitUpdater: vi.fn(() => ({ start: vi.fn((_nodes, _edges, onComplete) => { @@ -115,6 +123,7 @@ describe('FlowCore', () => { emit: vi.fn(), register: vi.fn(), registerDefaultCallbacks: vi.fn(), + cancel: vi.fn().mockResolvedValue(true), } as unknown as InputEventsRouter; // Reset all mocks @@ -268,6 +277,131 @@ describe('FlowCore', () => { }); }); + describe('cancelActiveInteraction', () => { + it('should return false and cancel nothing when no gesture is active', async () => { + const result = await flowCore.cancelActiveInteraction(); + + expect(result).toBe(false); + expect(mockEventRouter.cancel).not.toHaveBeenCalled(); + }); + + it('should run registered interaction cleanups', async () => { + const cleanup = vi.fn(); + flowCore.registerInteractionCleanup(cleanup); + + const result = await flowCore.cancelActiveInteraction(); + + expect(cleanup).toHaveBeenCalledTimes(1); + expect(result).toBe(true); + }); + + it('should not run unregistered cleanups', async () => { + const cleanup = vi.fn(); + const unregister = flowCore.registerInteractionCleanup(cleanup); + unregister(); + + await flowCore.cancelActiveInteraction(); + + expect(cleanup).not.toHaveBeenCalled(); + }); + + it('should not run a cleanup twice across two cancellations', async () => { + const cleanup = vi.fn(); + flowCore.registerInteractionCleanup(cleanup); + + await flowCore.cancelActiveInteraction(); + await flowCore.cancelActiveInteraction(); + + expect(cleanup).toHaveBeenCalledTimes(1); + }); + + it('should route linking cancellation to the linking handler', async () => { + flowCore.actionStateManager.linking = { sourceNodeId: 'n1', sourcePortId: 'p1', temporaryEdge: null }; + + const result = await flowCore.cancelActiveInteraction(); + + expect(mockEventRouter.cancel).toHaveBeenCalledWith('linking'); + expect(result).toBe(true); + }); + + it('should cancel every active gesture', async () => { + flowCore.actionStateManager.dragging = draggingState(); + flowCore.actionStateManager.panning = { active: true }; + + await flowCore.cancelActiveInteraction(); + + expect(mockEventRouter.cancel).toHaveBeenCalledWith('pointerMoveSelection'); + expect(mockEventRouter.cancel).toHaveBeenCalledWith('panning'); + expect(mockEventRouter.cancel).not.toHaveBeenCalledWith('resize'); + expect(mockEventRouter.cancel).not.toHaveBeenCalledWith('rotate'); + }); + + it('should still cancel the remaining gestures and rethrow when one cancel fails', async () => { + flowCore.actionStateManager.dragging = draggingState(); + flowCore.actionStateManager.panning = { active: true }; + const error = new Error('cancel failed'); + (mockEventRouter.cancel as Mock).mockImplementation((name: string) => + name === 'pointerMoveSelection' ? Promise.reject(error) : Promise.resolve() + ); + + await expect(flowCore.cancelActiveInteraction()).rejects.toBe(error); + + expect(mockEventRouter.cancel).toHaveBeenCalledWith('panning'); + }); + }); + + describe('hasActiveInteraction', () => { + it('should return false when nothing is active', () => { + expect(flowCore.hasActiveInteraction()).toBe(false); + }); + + it('should return true for each active gesture state', () => { + flowCore.actionStateManager.linking = { sourceNodeId: 'n1', sourcePortId: 'p1', temporaryEdge: null }; + expect(flowCore.hasActiveInteraction()).toBe(true); + flowCore.actionStateManager.clearLinking(); + + flowCore.actionStateManager.dragging = draggingState(false); + expect(flowCore.hasActiveInteraction()).toBe(true); + flowCore.actionStateManager.clearDragging(); + + flowCore.actionStateManager.resize = { + startWidth: 1, + startHeight: 1, + startX: 0, + startY: 0, + startNodePositionX: 0, + startNodePositionY: 0, + resizingNode: mockNode, + }; + expect(flowCore.hasActiveInteraction()).toBe(true); + flowCore.actionStateManager.clearResize(); + + flowCore.actionStateManager.rotation = { startAngle: 0, initialNodeAngle: 0, nodeId: 'n1' }; + expect(flowCore.hasActiveInteraction()).toBe(true); + flowCore.actionStateManager.clearRotation(); + + flowCore.actionStateManager.panning = { active: true }; + expect(flowCore.hasActiveInteraction()).toBe(true); + flowCore.actionStateManager.clearPanning(); + + expect(flowCore.hasActiveInteraction()).toBe(false); + }); + + it('should return true when only a listener cleanup is registered', () => { + flowCore.registerInteractionCleanup(vi.fn()); + + expect(flowCore.hasActiveInteraction()).toBe(true); + }); + + it('should return false again after cancelActiveInteraction tears down registered cleanups', async () => { + flowCore.registerInteractionCleanup(vi.fn()); + + await flowCore.cancelActiveInteraction(); + + expect(flowCore.hasActiveInteraction()).toBe(false); + }); + }); + describe('get model', () => { it('should return the current model', () => { expect(flowCore.model).toBe(mockModelAdapter); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.ts index 8b2e92fb7..4e1e00a97 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.ts @@ -4,6 +4,7 @@ import { EdgeRoutingManager } from './edge-routing-manager'; import { EventManager } from './event-manager'; import { createFlowConfig } from './flow-config/default-flow-config'; import { InputEventsRouter } from './input-events'; +import { InteractionCoordinator } from './interaction-coordinator/interaction-coordinator'; import { LabelBatchProcessor } from './label-batch-processor/label-batch-processor'; import { MeasurementTracker, MeasurementTrackingConfig } from './measurement-tracker/measurement-tracker'; import { MiddlewareManager } from './middleware-manager/middleware-manager'; @@ -72,6 +73,7 @@ export class FlowCore { readonly shortcutManager: ShortcutManager; readonly measurementTracker: MeasurementTracker; + private readonly interactionCoordinator: InteractionCoordinator; private readonly directRenderStrategy: DirectRenderStrategy; private readonly virtualizedRenderStrategy: VirtualizedRenderStrategy; @@ -102,6 +104,7 @@ export class FlowCore { this.virtualizedRenderStrategy = new VirtualizedRenderStrategy(this); this.middlewareManager = new MiddlewareManager(this, middlewares); this.transactionManager = new TransactionManager(this); + this.interactionCoordinator = new InteractionCoordinator(this); this.portBatchProcessor = new PortBatchProcessor(this.getNodeById.bind(this)); this.labelBatchProcessor = new LabelBatchProcessor(this.getEdgeById.bind(this)); this.measurementTracker = new MeasurementTracker(); @@ -478,6 +481,48 @@ export class FlowCore { return this.modelLookup.getEdgeById(edgeId); } + /** + * Registers a cleanup for the gesture that is starting; it runs when + * {@link cancelActiveInteraction} aborts the gesture. The caller must + * unregister it in its own normal teardown, or stale cleanups accumulate. + * + * @returns Function that unregisters the callback + */ + registerInteractionCleanup(cleanup: () => void): () => void { + return this.interactionCoordinator.registerInteractionCleanup(cleanup); + } + + /** + * Whether {@link cancelActiveInteraction} is mid-flight — its rollback has + * not committed yet, so input read now could capture geometry it is about + * to rewrite. + */ + isCancellingInteraction(): boolean { + return this.interactionCoordinator.isCancellingInteraction(); + } + + /** + * Whether an interactive gesture (linking, dragging, resizing, rotating, + * panning) is currently in progress, or a gesture's listener cleanup is + * still registered. + */ + hasActiveInteraction(): boolean { + return this.interactionCoordinator.hasActiveInteraction(); + } + + /** + * Aborts the in-progress gesture (linking, drag, resize, rotate or pan): + * removes its listeners, restores the state it modified (the viewport is + * not rolled back) and fires the "ended" event with the `cancelled` reason. + * No-op when nothing is active, when the gesture is already completing, or + * while a transaction is active (refused with a console warning). + * + * @returns Whether any gesture or registered cleanup was torn down + */ + cancelActiveInteraction(): Promise { + return this.interactionCoordinator.cancelActiveInteraction(); + } + /** * Gets all nodes in a range from a point * @param point Point to check from diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/cancel-interaction/cancel-interaction.handler.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/cancel-interaction/cancel-interaction.handler.ts new file mode 100644 index 000000000..f602f5eb7 --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/cancel-interaction/cancel-interaction.handler.ts @@ -0,0 +1,12 @@ +import { BaseInputEvent } from '../../input-events.interface'; +import { EventHandler } from '../event-handler'; + +/** + * Aborts whatever interactive gesture is currently in progress + * (linking, dragging, resizing, rotating, panning). + */ +export class CancelInteractionEventHandler extends EventHandler { + async handle(): Promise { + await this.flow.cancelActiveInteraction(); + } +} diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/cancel-interaction/cancel-interaction.integration.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/cancel-interaction/cancel-interaction.integration.test.ts new file mode 100644 index 000000000..c6c99325e --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/cancel-interaction/cancel-interaction.integration.test.ts @@ -0,0 +1,538 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { FlowCore } from '../../../flow-core'; +import type { + Edge, + EnvironmentInfo, + Metadata, + Middleware, + ModelActionTypes, + ModelAdapter, + Node, + Renderer, +} from '../../../types'; +import { macrotask, mockEnvironment } from '../../../test-utils'; +import { InputEventsRouter } from '../../input-events.router'; +import type { InputEventName, InputModifiers } from '../../input-events.interface'; + +/** + * Integration tests for cancelActiveInteraction() using a real FlowCore — + * real middleware pipeline, transaction manager, command handler and event + * manager. The per-handler unit tests all mock these collaborators, so this + * file is what actually verifies that a cancelled gesture rolls back through + * the merged `cancelDrag`/`cancelResize`/`cancelRotate` transaction and that + * the "ended" events fire with the `cancelled` reason and restored geometry. + */ + +class TestInputEventsRouter extends InputEventsRouter {} + +const environment: EnvironmentInfo = { + ...mockEnvironment, + now: () => 0, + generateId: (() => { + let i = 0; + return () => `generated-${i++}`; + })(), +}; + +const modifiers: InputModifiers = { primary: false, secondary: false, shift: false, meta: false }; + +let eventId = 0; +/** Emits a gesture phase with the shared boilerplate fields filled in. */ +const emitGesture = (router: InputEventsRouter, event: { name: InputEventName } & Record) => + router.emit({ id: `e${++eventId}`, timestamp: 0, modifiers, ...event }); + +function createModelAdapter(nodes: Node[], edges: Edge[] = []): ModelAdapter { + let state = { nodes, edges, metadata: { viewport: { x: 0, y: 0, scale: 1 } } as Metadata }; + const callbacks = new Set<(changes: { nodes: Node[]; edges: Edge[]; metadata: Metadata }) => void>(); + const notify = () => callbacks.forEach((cb) => cb({ ...state })); + + return { + destroy: () => {}, + getNodes: () => state.nodes, + getEdges: () => state.edges, + getMetadata: () => state.metadata, + updateNodes: (nodesOrFn: Node[] | ((nodes: Node[]) => Node[])) => { + state = { ...state, nodes: typeof nodesOrFn === 'function' ? nodesOrFn(state.nodes) : nodesOrFn }; + notify(); + }, + updateEdges: (edgesOrFn: Edge[] | ((edges: Edge[]) => Edge[])) => { + state = { ...state, edges: typeof edgesOrFn === 'function' ? edgesOrFn(state.edges) : edgesOrFn }; + notify(); + }, + updateMetadata: (metadataOrFn: Metadata | ((metadata: Metadata) => Metadata)) => { + state = { + ...state, + metadata: typeof metadataOrFn === 'function' ? metadataOrFn(state.metadata) : metadataOrFn, + }; + notify(); + }, + onChange: (cb) => callbacks.add(cb), + unregisterOnChange: (cb) => callbacks.delete(cb), + undo: () => {}, + redo: () => {}, + toJSON: () => JSON.stringify(state), + }; +} + +function createFlowCore(nodes: Node[], edges: Edge[] = []) { + const renderer: Renderer = { draw: vi.fn() }; + const router = new TestInputEventsRouter(); + const flowCore = new FlowCore(createModelAdapter(nodes, edges), renderer, router, environment); + + const observedActionTypes: ModelActionTypes[] = []; + const spyMiddleware: Middleware = { + name: 'action-type-spy', + execute: (context, next) => { + observedActionTypes.push([...context.modelActionTypes]); + next(); + }, + }; + flowCore.middlewareManager.register(spyMiddleware); + + return { flowCore, router, observedActionTypes }; +} + +const draggableNode = (overrides: Partial = {}): Node => ({ + id: 'n1', + type: 'node', + selected: true, + position: { x: 10, y: 20 }, + data: {}, + ...overrides, +}); + +/** Starts a drag on `node` and moves it past the threshold to (150,180). */ +const startDrag = async (flowCore: FlowCore, router: InputEventsRouter, node: Node) => { + emitGesture(router, { + name: 'pointerMoveSelection', + phase: 'start', + target: node, + targetType: 'node', + lastInputPoint: { x: 100, y: 100 }, + panningForce: null, + }); + await emitGesture(router, { + name: 'pointerMoveSelection', + phase: 'continue', + target: node, + targetType: 'node', + lastInputPoint: { x: 150, y: 180 }, + panningForce: null, + }); + await macrotask(); +}; + +describe('cancelActiveInteraction (integration)', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('returns false and emits nothing when no gesture is active', async () => { + const { flowCore, observedActionTypes } = createFlowCore([draggableNode()]); + + const result = await flowCore.cancelActiveInteraction(); + + expect(result).toBe(false); + expect(flowCore.hasActiveInteraction()).toBe(false); + expect(observedActionTypes).toHaveLength(0); + }); + + describe('drag', () => { + it('rolls the dragged node back and emits nodeDragEnded with the cancelled reason', async () => { + const node = draggableNode(); + const { flowCore, router, observedActionTypes } = createFlowCore([node]); + const dragEnded = vi.fn(); + flowCore.eventManager.on('nodeDragEnded', dragEnded); + + await startDrag(flowCore, router, node); + expect(flowCore.getNodeById('n1')?.position).toEqual({ x: 60, y: 100 }); + expect(flowCore.hasActiveInteraction()).toBe(true); + + observedActionTypes.length = 0; + const result = await flowCore.cancelActiveInteraction(); + + expect(result).toBe(true); + expect(flowCore.getNodeById('n1')?.position).toEqual({ x: 10, y: 20 }); + expect(flowCore.actionStateManager.isDragging()).toBe(false); + expect(flowCore.hasActiveInteraction()).toBe(false); + + expect(dragEnded).toHaveBeenCalledTimes(1); + const payload = dragEnded.mock.calls[0][0]; + expect(payload.cancelReason).toBe('cancelled'); + expect(payload.nodes[0].position).toEqual({ x: 10, y: 20 }); + + // The rollback must land as ONE update whose action types carry the + // cancelDrag marker alongside the merged command types. + expect(observedActionTypes).toHaveLength(1); + expect(observedActionTypes[0]).toContain('cancelDrag'); + expect(observedActionTypes[0]).toContain('moveNodesStop'); + }); + + it('does not add a cancel reason to a normally-ended drag', async () => { + const node = draggableNode(); + const { flowCore, router } = createFlowCore([node]); + const dragEnded = vi.fn(); + flowCore.eventManager.on('nodeDragEnded', dragEnded); + + await startDrag(flowCore, router, node); + await emitGesture(router, { + name: 'pointerMoveSelection', + phase: 'end', + target: node, + targetType: 'node', + lastInputPoint: { x: 150, y: 180 }, + panningForce: null, + }); + await macrotask(); + + expect(dragEnded).toHaveBeenCalledTimes(1); + expect(dragEnded.mock.calls[0][0].cancelReason).toBeUndefined(); + expect(flowCore.getNodeById('n1')?.position).toEqual({ x: 60, y: 100 }); + }); + + it('is idempotent — a second cancel is a no-op', async () => { + const node = draggableNode(); + const { flowCore, router } = createFlowCore([node]); + const dragEnded = vi.fn(); + flowCore.eventManager.on('nodeDragEnded', dragEnded); + + await startDrag(flowCore, router, node); + expect(await flowCore.cancelActiveInteraction()).toBe(true); + expect(await flowCore.cancelActiveInteraction()).toBe(false); + + expect(dragEnded).toHaveBeenCalledTimes(1); + }); + }); + + describe('resize', () => { + it('restores size, position and autoSize and emits nodeResizeEnded with the cancelled reason', async () => { + const node = draggableNode({ size: { width: 200, height: 100 }, autoSize: true }); + const { flowCore, router, observedActionTypes } = createFlowCore([node]); + const resizeEnded = vi.fn(); + flowCore.eventManager.on('nodeResizeEnded', resizeEnded); + + await emitGesture(router, { + name: 'resize', + phase: 'start', + target: node, + targetType: 'node', + direction: 'bottom-right', + lastInputPoint: { x: 100, y: 100 }, + }); + await emitGesture(router, { + name: 'resize', + phase: 'continue', + target: node, + targetType: 'node', + direction: 'bottom-right', + lastInputPoint: { x: 150, y: 140 }, + }); + await macrotask(); + + const resized = flowCore.getNodeById('n1'); + expect(resized?.size).toEqual({ width: 250, height: 140 }); + expect(resized?.autoSize).toBe(false); + + observedActionTypes.length = 0; + const result = await flowCore.cancelActiveInteraction(); + + expect(result).toBe(true); + const restored = flowCore.getNodeById('n1'); + expect(restored?.size).toEqual({ width: 200, height: 100 }); + expect(restored?.position).toEqual({ x: 10, y: 20 }); + expect(restored?.autoSize).toBe(true); + expect(flowCore.actionStateManager.isResizing()).toBe(false); + + expect(resizeEnded).toHaveBeenCalledTimes(1); + expect(resizeEnded.mock.calls[0][0].cancelReason).toBe('cancelled'); + expect(resizeEnded.mock.calls[0][0].node.size).toEqual({ width: 200, height: 100 }); + + expect(observedActionTypes).toHaveLength(1); + expect(observedActionTypes[0]).toContain('cancelResize'); + expect(observedActionTypes[0]).toContain('resizeNodeStop'); + }); + }); + + describe('rotate', () => { + it('restores the angle and emits nodeRotateEnded with the cancelled reason', async () => { + const node = draggableNode({ + angle: 30, + size: { width: 100, height: 50 }, + measuredBounds: { x: 10, y: 20, width: 100, height: 50 }, + }); + const { flowCore, router, observedActionTypes } = createFlowCore([node]); + const rotateEnded = vi.fn(); + flowCore.eventManager.on('nodeRotateEnded', rotateEnded); + + await emitGesture(router, { + name: 'rotate', + phase: 'start', + target: node, + targetType: 'node', + center: { x: 60, y: 45 }, + lastInputPoint: { x: 200, y: 45 }, + }); + await emitGesture(router, { + name: 'rotate', + phase: 'continue', + target: node, + targetType: 'node', + center: { x: 60, y: 45 }, + lastInputPoint: { x: 60, y: 200 }, + }); + await macrotask(); + + expect(flowCore.getNodeById('n1')?.angle).not.toBe(30); + expect(flowCore.actionStateManager.isRotating()).toBe(true); + + observedActionTypes.length = 0; + const result = await flowCore.cancelActiveInteraction(); + + expect(result).toBe(true); + expect(flowCore.getNodeById('n1')?.angle).toBe(30); + expect(flowCore.actionStateManager.isRotating()).toBe(false); + + expect(rotateEnded).toHaveBeenCalledTimes(1); + expect(rotateEnded.mock.calls[0][0].cancelReason).toBe('cancelled'); + + expect(observedActionTypes).toHaveLength(1); + expect(observedActionTypes[0]).toContain('cancelRotate'); + expect(observedActionTypes[0]).toContain('rotateNodeStop'); + }); + }); + + describe('linking', () => { + it('discards the temporary edge and emits edgeDrawEnded with the cancelled reason', async () => { + const node = draggableNode(); + const { flowCore, router } = createFlowCore([node]); + const edgeDrawEnded = vi.fn(); + flowCore.eventManager.on('edgeDrawEnded', edgeDrawEnded); + + emitGesture(router, { + name: 'linking', + phase: 'start', + target: node, + targetType: 'node', + portId: undefined, + lastInputPoint: { x: 10, y: 20 }, + }); + await macrotask(); + emitGesture(router, { + name: 'linking', + phase: 'continue', + target: node, + targetType: 'node', + portId: undefined, + lastInputPoint: { x: 120, y: 90 }, + }); + await macrotask(); + + expect(flowCore.actionStateManager.isLinking()).toBe(true); + + const result = await flowCore.cancelActiveInteraction(); + + expect(result).toBe(true); + expect(flowCore.actionStateManager.isLinking()).toBe(false); + expect(flowCore.getState().edges).toHaveLength(0); + + expect(edgeDrawEnded).toHaveBeenCalledTimes(1); + const payload = edgeDrawEnded.mock.calls[0][0]; + expect(payload.success).toBe(false); + expect(payload.reason).toBe('cancelled'); + }); + }); + + describe('panning', () => { + it('stops panning without rolling back the viewport', async () => { + const { flowCore, router } = createFlowCore([draggableNode({ selected: false })]); + + emitGesture(router, { + name: 'panning', + phase: 'start', + target: undefined, + targetType: 'diagram', + lastInputPoint: { x: 100, y: 100 }, + }); + await emitGesture(router, { + name: 'panning', + phase: 'continue', + target: undefined, + targetType: 'diagram', + lastInputPoint: { x: 130, y: 110 }, + }); + await macrotask(); + + const viewportAfterPan = { ...flowCore.getState().metadata.viewport }; + expect(flowCore.actionStateManager.isPanning()).toBe(true); + + const result = await flowCore.cancelActiveInteraction(); + + expect(result).toBe(true); + expect(flowCore.actionStateManager.isPanning()).toBe(false); + // Viewport is navigation state — deliberately NOT rolled back. + expect(flowCore.getState().metadata.viewport).toEqual(viewportAfterPan); + }); + }); + + describe('cancel vs concurrent work', () => { + it('refuses to cancel a drag whose pointerup end is already in flight', async () => { + const node = draggableNode(); + const { flowCore, router } = createFlowCore([node]); + const dragEnded = vi.fn(); + flowCore.eventManager.on('nodeDragEnded', dragEnded); + + // Parks the end's moveNodesStop pass so the cancel arrives mid-end + let release: () => void = () => undefined; + flowCore.middlewareManager.register({ + name: 'slow-stop', + execute: async (context, next) => { + if (context.modelActionTypes.includes('moveNodesStop')) { + await new Promise((resolve) => { + release = resolve; + }); + } + await next(); + }, + }); + + await startDrag(flowCore, router, node); + const endPromise = emitGesture(router, { + name: 'pointerMoveSelection', + phase: 'end', + target: node, + targetType: 'node', + lastInputPoint: { x: 150, y: 180 }, + panningForce: null, + }); + await macrotask(); + + expect(await flowCore.cancelActiveInteraction()).toBe(false); + + release(); + await endPromise; + await macrotask(); + + // The completed drop stands: one normally-labeled ended event, no rollback + expect(flowCore.getNodeById('n1')?.position).toEqual({ x: 60, y: 100 }); + expect(dragEnded).toHaveBeenCalledTimes(1); + expect(dragEnded.mock.calls[0][0].cancelReason).toBeUndefined(); + }); + + it('refuses to cancel while a transaction is active, works after it settles', async () => { + const node = draggableNode(); + const { flowCore, router } = createFlowCore([node]); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + await startDrag(flowCore, router, node); + + let releaseTx: () => void = () => undefined; + const txPromise = flowCore.transaction('appTransaction', async () => { + await new Promise((resolve) => { + releaseTx = resolve; + }); + }); + await macrotask(); + + expect(await flowCore.cancelActiveInteraction()).toBe(false); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('cancelActiveInteraction')); + expect(flowCore.actionStateManager.isDragging()).toBe(true); + expect(flowCore.getNodeById('n1')?.position).toEqual({ x: 60, y: 100 }); + + releaseTx(); + await txPromise; + + expect(await flowCore.cancelActiveInteraction()).toBe(true); + expect(flowCore.getNodeById('n1')?.position).toEqual({ x: 10, y: 20 }); + warn.mockRestore(); + }); + + it('a concurrent second cancel is a no-op', async () => { + const node = draggableNode(); + const { flowCore, router } = createFlowCore([node]); + const dragEnded = vi.fn(); + flowCore.eventManager.on('nodeDragEnded', dragEnded); + + await startDrag(flowCore, router, node); + const [first, second] = await Promise.all([ + flowCore.cancelActiveInteraction(), + flowCore.cancelActiveInteraction(), + ]); + + expect(first).toBe(true); + expect(second).toBe(false); + expect(dragEnded).toHaveBeenCalledTimes(1); + expect(flowCore.getNodeById('n1')?.position).toEqual({ x: 10, y: 20 }); + }); + + it('a resize started while the cancel rollback is still committing does not capture stale geometry', async () => { + const node = draggableNode({ size: { width: 200, height: 100 }, autoSize: false }); + const { flowCore, router } = createFlowCore([node]); + + // Parks the cancelResize rollback pass so the second gesture can race it + let release: () => void = () => undefined; + flowCore.middlewareManager.register({ + name: 'slow-cancel', + execute: async (context, next) => { + if (context.modelActionTypes.includes('cancelResize')) { + await new Promise((resolve) => { + release = resolve; + }); + } + await next(); + }, + }); + + const resizeEvent = (phase: string, lastInputPoint: { x: number; y: number }) => + emitGesture(router, { + name: 'resize', + phase, + target: node, + targetType: 'node', + direction: 'bottom-right', + lastInputPoint, + }); + + await resizeEvent('start', { x: 100, y: 100 }); + await resizeEvent('continue', { x: 150, y: 140 }); + await macrotask(); + expect(flowCore.getNodeById('n1')?.size).toEqual({ width: 250, height: 140 }); + + const cancelPromise = flowCore.cancelActiveInteraction(); + + // Second resize begins before the rollback commits — it must not start + // from the pre-rollback 250x140 + resizeEvent('start', { x: 100, y: 100 }); + resizeEvent('continue', { x: 110, y: 110 }); + + // Let the rollback pass reach the parking middleware before releasing it + await macrotask(); + release(); + await cancelPromise; + await macrotask(); + + expect(flowCore.getNodeById('n1')?.size).toEqual({ width: 200, height: 100 }); + + // Once the cancel has settled, the next resize starts from the rolled-back size + await resizeEvent('start', { x: 100, y: 100 }); + await resizeEvent('continue', { x: 120, y: 115 }); + await macrotask(); + expect(flowCore.getNodeById('n1')?.size).toEqual({ width: 220, height: 115 }); + }); + + it('drag cancel restores group children moved with the group', async () => { + const group = draggableNode({ id: 'grp', position: { x: 100, y: 100 }, isGroup: true }); + const child = draggableNode({ id: 'child', selected: false, groupId: 'grp', position: { x: 120, y: 130 } }); + const { flowCore, router } = createFlowCore([group, child]); + + await startDrag(flowCore, router, group); + expect(flowCore.getNodeById('grp')?.position).toEqual({ x: 150, y: 180 }); + expect(flowCore.getNodeById('child')?.position).toEqual({ x: 170, y: 210 }); + + expect(await flowCore.cancelActiveInteraction()).toBe(true); + + expect(flowCore.getNodeById('grp')?.position).toEqual({ x: 100, y: 100 }); + expect(flowCore.getNodeById('child')?.position).toEqual({ x: 120, y: 130 }); + }); + }); +}); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/event-handler.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/event-handler.ts index e423f3721..d3bd9da85 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/event-handler.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/event-handler.ts @@ -2,7 +2,39 @@ import { FlowCore } from '../../flow-core'; import { BaseInputEvent } from '../input-events.interface'; export abstract class EventHandler { + /** + * State whose end/cancel claimed the teardown. Never reset — a fresh state + * object per gesture means a stale claim cannot match a live one. + */ + private claimedTeardownState: unknown; + constructor(protected readonly flow: FlowCore) {} + /** Handles one input event. Invoked un-awaited — implementations must tolerate interleaving at every await. */ abstract handle(event: TEvent): void | Promise; + + /** + * Claims the teardown of the gesture owning `state`, so a racing cancel() + * (or a second one) no-ops. Requires one state object per gesture lifetime — + * linking stamps its replaced state instead, drag uses `DragGesture.ended`. + */ + protected claimTeardown(state: unknown): void { + this.claimedTeardownState = state; + } + + /** Whether `state`'s teardown was already claimed by an in-flight end or cancel. */ + protected isTeardownClaimed(state: unknown): boolean { + return state !== undefined && state === this.claimedTeardownState; + } + + /** + * Aborts the tracked gesture without the side effects of a normal `end` + * (no edge creation, no group drop). Default: no-op. + * + * @returns Whether anything was torn down — `false` when there is no gesture + * or its teardown is already claimed by an in-flight end or cancel. + */ + cancel(): boolean | Promise { + return false; + } } diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/linking/linking.handler.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/linking/linking.handler.ts index 62f791e50..2035f480a 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/linking/linking.handler.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/linking/linking.handler.ts @@ -1,4 +1,5 @@ import { createLinkingState } from '../../../command-handler/commands/linking/linking-gesture'; +import type { InternalLinkingActionState } from '../../../types/action-state.interface'; import { EventHandler } from '../event-handler'; import { LinkingInputEvent } from './linking.event'; @@ -19,6 +20,9 @@ Documentation: https://www.ngdiagram.dev/docs/guides/edges/edges/ export class LinkingEventHandler extends EventHandler { handle(event: LinkingInputEvent): void { + if (this.flow.isCancellingInteraction()) { + return; + } switch (event.phase) { case 'start': { const sourceNodeId = event.target?.id; @@ -65,4 +69,15 @@ export class LinkingEventHandler extends EventHandler { } } } + + override async cancel(): Promise { + const linking = this.flow.actionStateManager.linking as InternalLinkingActionState | undefined; + // No linking, or a finishLinking/another cancel already owns the teardown. + if (!linking || linking._finishing) { + return false; + } + + await this.flow.commandHandler.emit('cancelLinking'); + return true; + } } diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/linking/linking.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/linking/linking.test.ts index 09c2027b6..f27ebe1ef 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/linking/linking.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/linking/linking.test.ts @@ -44,6 +44,7 @@ describe('LinkingEventHandler', () => { mockFlowCore = { commandHandler: mockCommandHandler, + isCancellingInteraction: () => false, environment: mockEnvironment, actionStateManager: mockActionStateManager, clientToFlowPosition: mockClientToFlowPosition, @@ -204,6 +205,7 @@ describe('LinkingEventHandler', () => { mockFlowCore = { commandHandler: mockCommandHandler, + isCancellingInteraction: () => false, environment: mockEnvironment, actionStateManager: mockActionStateManager, clientToFlowPosition: mockClientToFlowPosition, diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/panning.handler.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/panning.handler.ts index 1011d42dd..f28ca3170 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/panning.handler.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/panning.handler.ts @@ -10,6 +10,9 @@ export class PanningEventHandler extends EventHandler { private lastPoint: Point | undefined; handle(event: PanningEvent): void { + if (this.flow.isCancellingInteraction()) { + return; + } switch (event.phase) { case 'start': { this.lastPoint = event.lastInputPoint; @@ -35,4 +38,13 @@ export class PanningEventHandler extends EventHandler { } } } + + override cancel(): boolean { + if (!this.flow.actionStateManager.isPanning()) { + return false; + } + this.lastPoint = undefined; + this.flow.actionStateManager.clearPanning(); + return true; + } } diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/panning.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/panning.test.ts index 7a4ec3643..d03b8a71d 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/panning.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/panning.test.ts @@ -43,6 +43,7 @@ describe('PanningEventHandler', () => { getState: vi.fn(), applyUpdate: vi.fn(), commandHandler: mockCommandHandler, + isCancellingInteraction: () => false, actionStateManager: mockActionStateManager, environment: mockEnvironment, } as unknown as FlowCore; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/virtualized-panning.handler.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/virtualized-panning.handler.ts index a70a7817e..a0ff9544f 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/virtualized-panning.handler.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/virtualized-panning.handler.ts @@ -16,6 +16,9 @@ export class VirtualizedPanningEventHandler extends EventHandler { private rafScheduled = false; handle(event: PanningEvent): void { + if (this.flow.isCancellingInteraction()) { + return; + } switch (event.phase) { case 'start': { this.lastPoint = event.lastInputPoint; @@ -48,6 +51,17 @@ export class VirtualizedPanningEventHandler extends EventHandler { } } + override cancel(): boolean { + if (!this.flow.actionStateManager.isPanning()) { + return false; + } + this.accumulatedDelta = { x: 0, y: 0 }; + this.lastPoint = undefined; + this.rafScheduled = false; + this.flow.actionStateManager.clearPanning(); + return true; + } + /** * Schedules a RAF callback to flush accumulated delta. * Only one callback is scheduled at a time. diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/virtualized-panning.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/virtualized-panning.test.ts index cb3e164ea..6122653d4 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/virtualized-panning.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/virtualized-panning.test.ts @@ -51,6 +51,7 @@ describe('VirtualizedPanningEventHandler', () => { getState: vi.fn(), applyUpdate: vi.fn(), commandHandler: mockCommandHandler, + isCancellingInteraction: () => false, actionStateManager: mockActionStateManager, environment: mockEnvironment, } as unknown as FlowCore; @@ -213,4 +214,24 @@ describe('VirtualizedPanningEventHandler', () => { }); }); }); + + describe('cancel', () => { + it('should do nothing and return false when no pan is active', () => { + expect(instance.cancel()).toBe(false); + + expect(mockFlowCore.actionStateManager.clearPanning).not.toHaveBeenCalled(); + }); + + it('should clear the panning state and drop the accumulated delta', () => { + instance.handle(getSamplePanningEvent({ phase: 'start' })); + instance.handle(getSamplePanningEvent({ phase: 'continue', lastInputPoint: { x: 130, y: 110 } })); + + expect(instance.cancel()).toBe(true); + + expect(mockFlowCore.actionStateManager.clearPanning).toHaveBeenCalled(); + // The delta accumulated before the cancel must not jump the viewport later + flushRAF(); + expect(mockCommandHandler.emit).not.toHaveBeenCalledWith('moveViewportBy', expect.anything()); + }); + }); }); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/pointer-move-selection/pointer-move-selection.handler.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/pointer-move-selection/pointer-move-selection.handler.ts index 36baf0b8a..4ff6b0dcd 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/pointer-move-selection/pointer-move-selection.handler.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/pointer-move-selection/pointer-move-selection.handler.ts @@ -5,26 +5,29 @@ import { isGroup, sortNodesByZIndex } from '../../../utils'; import { EventHandler } from '../event-handler'; import { PointerMoveSelectionEvent } from './pointer-move-selection.event'; -export const MOVE_THRESHOLD = 5; // to find out if move was intended +export const MOVE_THRESHOLD = 5; // px of pointer travel before a click becomes a drag /** - * One drag gesture's private input-tracking state — a fresh object per gesture, - * written only by this handler, so object identity reliably answers "same - * gesture?". Deliberately not in actionState.dragging: nothing outside this - * handler reads these fields, and actionState is public API where every write - * emits actionStateChanged. + * One drag gesture's private state — a fresh object per gesture, so identity + * answers "same gesture?". Deliberately not in actionState.dragging: that is + * public API and every write emits actionStateChanged. */ interface DragGesture { startPoint: Point; lastPointerPosition: Point; hasMoved: boolean; ended: boolean; + /** Pre-drag node positions captured at threshold crossing — cancel() restores them. */ + initialPositions?: Map; } export class PointerMoveSelectionEventHandler extends EventHandler { private gesture: DragGesture | null = null; async handle(event: PointerMoveSelectionEvent) { + if (this.flow.isCancellingInteraction()) { + return; + } switch (event.phase) { case 'start': { const flowPosition = this.flow.clientToFlowPosition(event.lastInputPoint); @@ -67,6 +70,8 @@ export class PointerMoveSelectionEventHandler extends EventHandler n.id); if (crossedThreshold) { gesture.hasMoved = true; + // Snapshot before the first delta is applied. + gesture.initialPositions = new Map(selectedNodesWithChildren.map((n) => [n.id, { ...n.position }])); this.flow.actionStateManager.dragging = { nodeIds: draggedNodeIds, modifiers: { ...event.modifiers }, @@ -134,8 +139,8 @@ export class PointerMoveSelectionEventHandler extends EventHandler { + const gesture = this.gesture; + const dragging = this.flow.actionStateManager.dragging; + if (!gesture && !dragging) { + return false; + } + // The normal 'end' phase (or a previous cancel) already owns this gesture's + // teardown — cancelling now would roll back a completed drop and stamp its + // ended event as cancelled. + if (gesture?.ended) { + return false; + } + + const needsStop = !!dragging && (gesture?.hasMoved ?? false); + const needsHighlightClear = !!this.flow.actionStateManager.highlightGroup; + + // Marks the gesture dead for any suspended 'continue' passes — a delta + // must not be applied after the abort. + if (gesture) { + gesture.ended = true; + } + + if (needsStop && dragging) { + // Not a dead write: the NodeDragEndedEmitter reads the reason from this + // live object during the moveNodesStop pass below. + dragging.cancelReason = 'cancelled'; + } + + if (needsStop || needsHighlightClear) { + // An aborted drag must not change group membership — no drop handling. + await this.flow.transaction('cancelDrag', async (tx) => { + if (needsStop) { + const initialPositions = gesture?.initialPositions; + if (initialPositions?.size) { + await tx.emit('updateNodes', { + nodes: [...initialPositions].map(([id, position]) => ({ id, position })), + }); + } + await tx.emit('moveNodesStop', { nodeIds: dragging?.nodeIds ?? [] }); + } + if (needsHighlightClear) { + await tx.emit('highlightGroupClear'); + } + }); + } + + // A new drag may have started while the transaction above was suspended — + // its fresh state must stay intact. + if (dragging && this.flow.actionStateManager.dragging === dragging) { + this.flow.actionStateManager.clearDragging(); + } + if (this.gesture === gesture) { + this.gesture = null; + } + return true; + } + private updateGroupHighlightOnDrag(tx: TransactionContext, point: Point, selectedNodes: Node[]): void { const topLevelGroupNode = this.getTopGroupAtPoint(point); const currentHighlightedGroup = this.flow.actionStateManager.highlightGroup?.highlightedGroupId; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/pointer-move-selection/pointer-move-selection.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/pointer-move-selection/pointer-move-selection.test.ts index be4691256..c58526360 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/pointer-move-selection/pointer-move-selection.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/pointer-move-selection/pointer-move-selection.test.ts @@ -48,6 +48,7 @@ describe('PointerMoveSelectionEventHandler', () => { dragging: DraggingActionState | undefined; highlightGroup: HighlightGroupActionState | undefined; clearDragging: ReturnType; + isDragging: ReturnType; }; let mockGetState: ReturnType; let lastInputPointOverThreshold = { x: 100, y: 100 }; @@ -69,6 +70,7 @@ describe('PointerMoveSelectionEventHandler', () => { dragging: undefined, highlightGroup: undefined, clearDragging: vi.fn(), + isDragging: vi.fn(() => !!mockActionStateManager.dragging), }; mockGetState = vi.fn().mockReturnValue({ nodes: [], edges: [] }); @@ -76,6 +78,7 @@ describe('PointerMoveSelectionEventHandler', () => { mockFlowCore = { getState: mockGetState, commandHandler: { emit: mockEmit }, + isCancellingInteraction: () => false, environment: mockEnvironment, clientToFlowPosition: vi.fn(({ x, y }) => ({ x, y })), modelLookup: mockModelLookup, @@ -991,4 +994,187 @@ describe('PointerMoveSelectionEventHandler', () => { expect(mockEmit.mock.calls.some((call) => call[0] === 'moveNodesStop')).toBe(false); }); }); + + describe('cancel', () => { + it('should do nothing when no drag is in progress', async () => { + await handler.cancel(); + + expect(mockEmit).not.toHaveBeenCalled(); + expect(mockActionStateManager.clearDragging).not.toHaveBeenCalled(); + }); + + it('should clear dragging without moveNodesStop when the threshold was not crossed', async () => { + handler.handle(getSamplePointerMoveSelectionEvent({ phase: 'start' })); + + await handler.cancel(); + + expect(mockEmit).not.toHaveBeenCalledWith('moveNodesStop'); + expect(mockActionStateManager.clearDragging).toHaveBeenCalled(); + }); + + it('should set the cancelled reason and emit moveNodesStop after an actual drag', async () => { + handler.handle(getSamplePointerMoveSelectionEvent({ phase: 'start' })); + await handler.handle( + getSamplePointerMoveSelectionEvent({ phase: 'continue', lastInputPoint: lastInputPointOverThreshold }) + ); + + await handler.cancel(); + + expect(mockActionStateManager.dragging?.cancelReason).toBe('cancelled'); + expect(mockEmit).toHaveBeenCalledWith('moveNodesStop', { nodeIds: expect.any(Array) }); + expect(mockActionStateManager.clearDragging).toHaveBeenCalled(); + }); + + it('should snap dragged nodes back to their pre-drag positions', async () => { + handler.handle(getSamplePointerMoveSelectionEvent({ phase: 'start' })); + await handler.handle( + getSamplePointerMoveSelectionEvent({ phase: 'continue', lastInputPoint: lastInputPointOverThreshold }) + ); + + await handler.cancel(); + + expect(mockEmit).toHaveBeenCalledWith('updateNodes', { + nodes: [{ id: mockNode.id, position: mockNode.position }], + }); + // The restore must happen before the drag-ended lifecycle command + const calls = mockEmit.mock.calls.map(([name]) => name); + expect(calls.indexOf('updateNodes')).toBeLessThan(calls.indexOf('moveNodesStop')); + expect(mockFlowCore.transaction).toHaveBeenCalledWith('cancelDrag', expect.any(Function)); + }); + + it('should not change group membership when cancelled over a group', async () => { + handler.handle(getSamplePointerMoveSelectionEvent({ phase: 'start' })); + await handler.handle( + getSamplePointerMoveSelectionEvent({ phase: 'continue', lastInputPoint: lastInputPointOverThreshold }) + ); + + await handler.cancel(); + + expect(mockEmit).not.toHaveBeenCalledWith('addToGroup', expect.anything()); + expect(mockEmit).not.toHaveBeenCalledWith('removeFromGroup', expect.anything()); + }); + + it('should clear an active group highlight', async () => { + handler.handle(getSamplePointerMoveSelectionEvent({ phase: 'start' })); + await handler.handle( + getSamplePointerMoveSelectionEvent({ phase: 'continue', lastInputPoint: lastInputPointOverThreshold }) + ); + mockActionStateManager.highlightGroup = { highlightedGroupId: mockGroupNode.id }; + + await handler.cancel(); + + expect(mockEmit).toHaveBeenCalledWith('highlightGroupClear'); + }); + + it('should kill a continue pass that resumes while the cancel rollback is still suspended', async () => { + let releaseStart: () => void = () => undefined; + let releaseStop: () => void = () => undefined; + mockEmit.mockImplementation(async (name: string) => { + if (name === 'moveNodesStart') { + await new Promise((resolve) => { + releaseStart = resolve; + }); + } + if (name === 'moveNodesStop') { + await new Promise((resolve) => { + releaseStop = resolve; + }); + } + }); + + handler.handle(getSamplePointerMoveSelectionEvent({ phase: 'start' })); + const continuePromise = handler.handle( + getSamplePointerMoveSelectionEvent({ phase: 'continue', lastInputPoint: lastInputPointOverThreshold }) + ); + + // Cancel parks on its own moveNodesStop; the continue then resumes while + // this.gesture still points at the cancelled gesture — only gesture.ended + // stops it from applying a move after the rollback. + const cancelPromise = handler.cancel(); + releaseStart(); + await continuePromise; + + expect(mockEmit).not.toHaveBeenCalledWith('moveNodesBy', expect.any(Object)); + + releaseStop(); + await cancelPromise; + }); + + it('should refuse to cancel while the normal end phase is in flight', async () => { + let releaseStop: () => void = () => undefined; + mockEmit.mockImplementation(async (name: string) => { + if (name === 'moveNodesStop') { + await new Promise((resolve) => { + releaseStop = resolve; + }); + } + }); + + handler.handle(getSamplePointerMoveSelectionEvent({ phase: 'start' })); + await handler.handle( + getSamplePointerMoveSelectionEvent({ phase: 'continue', lastInputPoint: lastInputPointOverThreshold }) + ); + const endPromise = handler.handle( + getSamplePointerMoveSelectionEvent({ phase: 'end', lastInputPoint: lastInputPointOverThreshold }) + ); + await macrotask(); + mockEmit.mockClear(); + + await expect(handler.cancel()).resolves.toBe(false); + + // The completed gesture is left to its end phase: no rollback, no cancel stamp + expect(mockEmit).not.toHaveBeenCalledWith('updateNodes', expect.anything()); + expect(mockActionStateManager.dragging?.cancelReason).toBeUndefined(); + expect(mockFlowCore.transaction).not.toHaveBeenCalledWith('cancelDrag', expect.any(Function)); + + releaseStop(); + await endPromise; + }); + + it('should not clobber a new drag that starts while the cancel rollback is suspended', async () => { + mockEmit.mockImplementation(async (name: string) => { + if (name === 'moveNodesStop') { + await macrotask(); + } + }); + + handler.handle(getSamplePointerMoveSelectionEvent({ phase: 'start' })); + await handler.handle( + getSamplePointerMoveSelectionEvent({ phase: 'continue', lastInputPoint: lastInputPointOverThreshold }) + ); + + const cancelPromise = handler.cancel(); + // A new drag starts while the cancel rollback is suspended on moveNodesStop — + // its fresh state must survive the cancel's cleanup. + handler.handle(getSamplePointerMoveSelectionEvent({ phase: 'start', lastInputPoint: { x: 200, y: 200 } })); + const freshDragging = mockActionStateManager.dragging; + await cancelPromise; + + expect(mockActionStateManager.clearDragging).not.toHaveBeenCalled(); + expect(mockActionStateManager.dragging).toBe(freshDragging); + mockEmit.mockClear(); + + // The new gesture must still be alive: crossing the threshold moves nodes + await handler.handle( + getSamplePointerMoveSelectionEvent({ phase: 'continue', lastInputPoint: { x: 220, y: 220 } }) + ); + + expect(mockEmit).toHaveBeenCalledWith('moveNodesBy', { + delta: { x: 20, y: 20 }, + nodes: [mockNode], + }); + }); + + it('should ignore continue events after cancellation', async () => { + handler.handle(getSamplePointerMoveSelectionEvent({ phase: 'start' })); + await handler.cancel(); + mockEmit.mockClear(); + + await handler.handle( + getSamplePointerMoveSelectionEvent({ phase: 'continue', lastInputPoint: lastInputPointOverThreshold }) + ); + + expect(mockEmit).not.toHaveBeenCalledWith('moveNodesStart'); + }); + }); }); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/resize/resize.handler.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/resize/resize.handler.ts index 2f008240e..a931488a3 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/resize/resize.handler.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/resize/resize.handler.ts @@ -17,6 +17,9 @@ Documentation: https://www.ngdiagram.dev/docs/guides/nodes/resizing/`; export class ResizeEventHandler extends EventHandler { async handle(event: ResizeEvent): Promise { + if (this.flow.isCancellingInteraction()) { + return; + } if (!event.target) { throw new Error(RESIZE_MISSING_TARGET_ERROR(event)); } @@ -121,6 +124,7 @@ export class ResizeEventHandler extends EventHandler { } case 'end': { const resizeState = this.flow.actionStateManager.resize; + this.claimTeardown(resizeState); try { await this.flow.commandHandler.emit('resizeNodeStop', { nodeId: resizeState?.resizingNode.id }); } finally { @@ -135,4 +139,37 @@ export class ResizeEventHandler extends EventHandler { } } } + + override async cancel(): Promise { + const resize = this.flow.actionStateManager.resize; + if (!resize || this.isTeardownClaimed(resize)) { + return false; + } + this.claimTeardown(resize); + + resize.cancelReason = 'cancelled'; + + // Restore the exact pre-resize geometry (updateNode instead of resizeNode + // so min-size constraints and snapping can't distort the original values) + // along with the autoSize flag the resize gesture disabled. + const { startWidth, startHeight, startNodePositionX, startNodePositionY, resizingNode } = resize; + await this.flow.transaction('cancelResize', async (tx) => { + await tx.emit('updateNode', { + id: resizingNode.id, + nodeChanges: { + size: { width: startWidth, height: startHeight }, + position: { x: startNodePositionX, y: startNodePositionY }, + autoSize: resizingNode.autoSize, + }, + }); + await tx.emit('resizeNodeStop', { nodeId: resizingNode.id }); + }); + + // A new resize may have started while the transaction above was suspended — + // the identity guard keeps its fresh state intact. + if (this.flow.actionStateManager.resize === resize) { + this.flow.actionStateManager.clearResize(); + } + return true; + } } diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/resize/resize.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/resize/resize.test.ts index 2824f1909..6bebfa0b0 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/resize/resize.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/resize/resize.test.ts @@ -28,6 +28,7 @@ function createResizeEvent(overrides: Partial = {}): ResizeEvent { describe('ResizeEventHandler', () => { let handler: ResizeEventHandler; let mockEmit: ReturnType; + let mockTransaction: ReturnType; let mockActionStateManager: { resize: ResizeActionState | undefined; clearResize: ReturnType; @@ -36,6 +37,10 @@ describe('ResizeEventHandler', () => { beforeEach(() => { vi.clearAllMocks(); mockEmit = vi.fn(); + mockTransaction = vi.fn().mockImplementation(async (_name, callback) => { + const txContext = { emit: mockEmit }; + return await callback(txContext); + }); mockActionStateManager = { resize: undefined, @@ -50,9 +55,11 @@ describe('ResizeEventHandler', () => { const mockFlowCore = { commandHandler: { emit: mockEmit }, + isCancellingInteraction: () => false, clientToFlowPosition: vi.fn(({ x, y }) => ({ x, y })), getNodeById: vi.fn().mockReturnValue(nodeWithSize), actionStateManager: mockActionStateManager, + transaction: mockTransaction, } as unknown as FlowCore; handler = new ResizeEventHandler(mockFlowCore); @@ -124,6 +131,7 @@ describe('ResizeEventHandler', () => { const mockFlowCore = { commandHandler: { emit: mockEmit }, + isCancellingInteraction: () => false, clientToFlowPosition: vi.fn(({ x, y }) => ({ x, y })), getNodeById: vi.fn().mockReturnValue(nodeWithoutSize), actionStateManager: mockActionStateManager, @@ -172,4 +180,96 @@ describe('ResizeEventHandler', () => { expect(callOrder).toEqual(['resizeNodeStop', 'clearResize']); }); }); + + describe('cancel', () => { + it('should do nothing when no resize is in progress', async () => { + await handler.cancel(); + + expect(mockEmit).not.toHaveBeenCalled(); + expect(mockActionStateManager.clearResize).not.toHaveBeenCalled(); + }); + + it('should set the cancelled reason, emit resizeNodeStop and clear the state', async () => { + await handler.handle(createResizeEvent({ phase: 'start' })); + const resizeState = mockActionStateManager.resize; + + await handler.cancel(); + + expect(resizeState?.cancelReason).toBe('cancelled'); + expect(mockEmit).toHaveBeenCalledWith('resizeNodeStop', { nodeId: 'node1' }); + expect(mockActionStateManager.clearResize).toHaveBeenCalled(); + }); + + it('should restore the pre-resize size, position and autoSize', async () => { + await handler.handle(createResizeEvent({ phase: 'start' })); + await handler.handle( + createResizeEvent({ phase: 'continue', direction: 'bottom-right', lastInputPoint: { x: 150, y: 140 } }) + ); + + await handler.cancel(); + + expect(mockEmit).toHaveBeenCalledWith('updateNode', { + id: 'node1', + nodeChanges: { + size: { width: 200, height: 100 }, + position: mockNode.position, + autoSize: undefined, + }, + }); + const calls = mockEmit.mock.calls.map(([name]) => name); + expect(calls.indexOf('updateNode')).toBeLessThan(calls.indexOf('resizeNodeStop')); + }); + + it('should roll back inside a cancelResize transaction', async () => { + await handler.handle(createResizeEvent({ phase: 'start' })); + + await handler.cancel(); + + expect(mockTransaction).toHaveBeenCalledWith('cancelResize', expect.any(Function)); + }); + + it('should refuse to cancel while the normal end phase is in flight', async () => { + let releaseStop: () => void = () => undefined; + mockEmit.mockImplementation(async (name: string) => { + if (name === 'resizeNodeStop') { + await new Promise((resolve) => { + releaseStop = resolve; + }); + } + }); + + await handler.handle(createResizeEvent({ phase: 'start' })); + const endPromise = handler.handle(createResizeEvent({ phase: 'end' })); + + await expect(handler.cancel()).resolves.toBe(false); + + // The completed gesture is left to its end phase: no rollback, no cancel stamp + expect(mockActionStateManager.resize?.cancelReason).toBeUndefined(); + expect(mockTransaction).not.toHaveBeenCalledWith('cancelResize', expect.any(Function)); + + releaseStop(); + await endPromise; + }); + + it('should not clear a resize that started while the cancel rollback was suspended', async () => { + mockEmit.mockImplementation(async (name: string) => { + if (name === 'resizeNodeStop') { + await macrotask(); + } + }); + + await handler.handle(createResizeEvent({ phase: 'start' })); + const cancelPromise = handler.cancel(); + + // A new resize starts while the cancel rollback is suspended on resizeNodeStop + await handler.handle(createResizeEvent({ phase: 'start' })); + const newState = mockActionStateManager.resize; + expect(newState).toBeDefined(); + + await cancelPromise; + + expect(mockActionStateManager.clearResize).not.toHaveBeenCalled(); + expect(mockActionStateManager.resize).toBe(newState); + }); + }); }); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/rotate/rotate.handler.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/rotate/rotate.handler.ts index ca7b9287f..f174f4515 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/rotate/rotate.handler.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/rotate/rotate.handler.ts @@ -19,6 +19,9 @@ Documentation: https://www.ngdiagram.dev/docs/guides/nodes/rotation/`; export class RotateEventHandler extends EventHandler { async handle(event: RotateInputEvent): Promise { + if (this.flow.isCancellingInteraction()) { + return; + } const { center, lastInputPoint, target, phase } = event; if (!target) { throw new Error(ROTATE_MISSING_TARGET_ERROR(event)); @@ -77,6 +80,7 @@ export class RotateEventHandler extends EventHandler { case 'end': { const rotationState = this.flow.actionStateManager.rotation; + this.claimTeardown(rotationState); try { await this.flow.commandHandler.emit('rotateNodeStop', { nodeId: rotationState?.nodeId }); } finally { @@ -91,4 +95,31 @@ export class RotateEventHandler extends EventHandler { } } } + + override async cancel(): Promise { + const rotation = this.flow.actionStateManager.rotation; + if (!rotation || this.isTeardownClaimed(rotation)) { + return false; + } + this.claimTeardown(rotation); + + rotation.cancelReason = 'cancelled'; + + // Restore the exact pre-rotation angle (updateNode instead of rotateNodeTo + // so angle snapping can't distort the original value). + await this.flow.transaction('cancelRotate', async (tx) => { + await tx.emit('updateNode', { + id: rotation.nodeId, + nodeChanges: { angle: rotation.initialNodeAngle }, + }); + await tx.emit('rotateNodeStop', { nodeId: rotation.nodeId }); + }); + + // A new rotation may have started while the transaction above was suspended — + // the identity guard keeps its fresh state intact. + if (this.flow.actionStateManager.rotation === rotation) { + this.flow.actionStateManager.clearRotation(); + } + return true; + } } diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/rotate/rotate.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/rotate/rotate.test.ts index a7773dd54..9568914c7 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/rotate/rotate.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/rotate/rotate.test.ts @@ -52,9 +52,14 @@ describe('RotateEventHandler', () => { }; flowCore = { commandHandler: mockCommandHandler, + isCancellingInteraction: () => false, actionStateManager: mockActionStateManager, clientToFlowPosition: vi.fn().mockImplementation((point) => point), getNodeById: vi.fn().mockReturnValue(node), + transaction: vi.fn().mockImplementation(async (_name, callback) => { + const txContext = { emit: mockCommandHandler.emit }; + return await callback(txContext); + }), } as unknown as FlowCore; instance = new RotateEventHandler(flowCore); vi.clearAllMocks(); @@ -218,4 +223,91 @@ describe('RotateEventHandler', () => { }); }); }); + + describe('cancel', () => { + it('should do nothing when no rotation is in progress', async () => { + await instance.cancel(); + + expect(mockCommandHandler.emit).not.toHaveBeenCalled(); + expect(mockActionStateManager.clearRotation).not.toHaveBeenCalled(); + }); + + it('should set the cancelled reason, emit rotateNodeStop and clear the state', async () => { + const rotation: RotationActionState = { startAngle: 45, initialNodeAngle: 30, nodeId: 'test-node' }; + mockActionStateManager.rotation = rotation; + + await instance.cancel(); + + expect(rotation.cancelReason).toBe('cancelled'); + expect(mockCommandHandler.emit).toHaveBeenCalledWith('rotateNodeStop', { nodeId: 'test-node' }); + expect(mockActionStateManager.clearRotation).toHaveBeenCalled(); + }); + + it('should restore the pre-rotation angle', async () => { + const rotation: RotationActionState = { startAngle: 45, initialNodeAngle: 30, nodeId: 'test-node' }; + mockActionStateManager.rotation = rotation; + + await instance.cancel(); + + expect(mockCommandHandler.emit).toHaveBeenCalledWith('updateNode', { + id: 'test-node', + nodeChanges: { angle: 30 }, + }); + const calls = mockCommandHandler.emit.mock.calls.map(([name]) => name); + expect(calls.indexOf('updateNode')).toBeLessThan(calls.indexOf('rotateNodeStop')); + }); + + it('should roll back inside a cancelRotate transaction', async () => { + mockActionStateManager.rotation = { startAngle: 45, initialNodeAngle: 30, nodeId: 'test-node' }; + + await instance.cancel(); + + expect(flowCore.transaction).toHaveBeenCalledWith('cancelRotate', expect.any(Function)); + }); + + it('should refuse to cancel while the normal end phase is in flight', async () => { + let releaseStop: () => void = () => undefined; + mockCommandHandler.emit.mockImplementation(async (name: string) => { + if (name === 'rotateNodeStop') { + await new Promise((resolve) => { + releaseStop = resolve; + }); + } + }); + + mockActionStateManager.rotation = { startAngle: 45, initialNodeAngle: 30, nodeId: 'test-node' }; + const endPromise = instance.handle(getSampleRotateEvent({ target: node, phase: 'end' })); + + await expect(instance.cancel()).resolves.toBe(false); + + // The completed gesture is left to its end phase: no rollback, no cancel stamp + expect(mockActionStateManager.rotation?.cancelReason).toBeUndefined(); + expect(flowCore.transaction).not.toHaveBeenCalledWith('cancelRotate', expect.any(Function)); + + releaseStop(); + await endPromise; + }); + + it('should not clear a rotation that started while the cancel rollback was suspended', async () => { + vi.mocked(NgDiagramMath.angleBetweenPoints).mockReturnValue(45); + mockCommandHandler.emit.mockImplementation(async (name: string) => { + if (name === 'rotateNodeStop') { + await macrotask(); + } + }); + + mockActionStateManager.rotation = { startAngle: 45, initialNodeAngle: 30, nodeId: 'test-node' }; + const cancelPromise = instance.cancel(); + + // A new rotation starts while the cancel rollback is suspended on rotateNodeStop + await instance.handle(getSampleRotateEvent({ target: node, phase: 'start' })); + const newState = mockActionStateManager.rotation; + expect(newState).toBeDefined(); + + await cancelPromise; + + expect(mockActionStateManager.clearRotation).not.toHaveBeenCalled(); + expect(mockActionStateManager.rotation).toBe(newState); + }); + }); }); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/input-events.interface.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/input-events.interface.ts index 87ed67fb2..55fca2457 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/input-events.interface.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/input-events.interface.ts @@ -21,7 +21,8 @@ export type InputEventName = | 'paletteDrop' | 'boxSelection' | 'undo' - | 'redo'; + | 'redo' + | 'cancelInteraction'; export type InputEventPhase = 'start' | 'continue' | 'end'; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/input-events.router.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/input-events.router.ts index 8c3c30be6..345d65167 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/input-events.router.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/input-events.router.ts @@ -1,5 +1,6 @@ import { FlowCore } from '../flow-core'; import { BoxSelectionEventHandler } from './handlers/box-selection/box-selection.handler'; +import { CancelInteractionEventHandler } from './handlers/cancel-interaction/cancel-interaction.handler'; import { CopyEventHandler } from './handlers/copy/copy.handler'; import { CutEventHandler } from './handlers/cut/cut.handler'; import { DeleteSelectionEventHandler } from './handlers/delete-selection/delete-selection.handler'; @@ -58,9 +59,20 @@ export abstract class InputEventsRouter { this.register('boxSelection', new BoxSelectionEventHandler(flow)); this.register('undo', new UndoEventHandler(flow)); this.register('redo', new RedoEventHandler(flow)); + this.register('cancelInteraction', new CancelInteractionEventHandler(flow)); } hasHandler(eventName: InputEventName): boolean { return !!this.handlers[eventName]; } + + /** + * Aborts the gesture tracked by the handler registered for `eventName`. + * No-op when the handler is missing or has no gesture in progress. + * + * @returns Whether the handler actually tore anything down + */ + async cancel(eventName: InputEventName): Promise { + return (await this.handlers[eventName]?.cancel()) ?? false; + } } diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/interaction-coordinator/interaction-coordinator.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/interaction-coordinator/interaction-coordinator.test.ts new file mode 100644 index 000000000..848483ebf --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/interaction-coordinator/interaction-coordinator.test.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { FlowCore } from '../flow-core'; +import { InteractionCoordinator } from './interaction-coordinator'; + +describe('InteractionCoordinator', () => { + let coordinator: InteractionCoordinator; + let cancel: ReturnType; + let isActive: ReturnType; + let gestureStates: { linking: boolean; dragging: boolean; resizing: boolean; rotating: boolean; panning: boolean }; + + beforeEach(() => { + cancel = vi.fn().mockResolvedValue(true); + isActive = vi.fn().mockReturnValue(false); + gestureStates = { linking: false, dragging: false, resizing: false, rotating: false, panning: false }; + + const mockFlowCore = { + actionStateManager: { + isLinking: () => gestureStates.linking, + isDragging: () => gestureStates.dragging, + isResizing: () => gestureStates.resizing, + isRotating: () => gestureStates.rotating, + isPanning: () => gestureStates.panning, + }, + transactionManager: { isActive }, + inputEventsRouter: { cancel }, + } as unknown as FlowCore; + + coordinator = new InteractionCoordinator(mockFlowCore); + }); + + it('should run the registered cleanups before cancelling the handlers', async () => { + const callOrder: string[] = []; + coordinator.registerInteractionCleanup(() => callOrder.push('cleanup')); + cancel.mockImplementation(async (name: string) => { + callOrder.push(`cancel:${name}`); + return true; + }); + gestureStates.dragging = true; + + await coordinator.cancelActiveInteraction(); + + expect(callOrder).toEqual(['cleanup', 'cancel:pointerMoveSelection']); + }); + + it('should report cancelling only while a cancel is in flight', async () => { + let release: () => void = () => undefined; + cancel.mockImplementation( + () => + new Promise((resolve) => { + release = () => resolve(true); + }) + ); + gestureStates.resizing = true; + + expect(coordinator.isCancellingInteraction()).toBe(false); + const cancelPromise = coordinator.cancelActiveInteraction(); + expect(coordinator.isCancellingInteraction()).toBe(true); + + release(); + await cancelPromise; + expect(coordinator.isCancellingInteraction()).toBe(false); + }); + + it('should refuse while a transaction is active: warn, touch nothing', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const cleanup = vi.fn(); + coordinator.registerInteractionCleanup(cleanup); + gestureStates.dragging = true; + isActive.mockReturnValue(true); + + expect(await coordinator.cancelActiveInteraction()).toBe(false); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('cancelActiveInteraction')); + expect(cleanup).not.toHaveBeenCalled(); + expect(cancel).not.toHaveBeenCalled(); + expect(coordinator.hasActiveInteraction()).toBe(true); + warn.mockRestore(); + }); + + it('should return false from a re-entrant cancel without side effects', async () => { + let release: () => void = () => undefined; + cancel.mockImplementation( + () => + new Promise((resolve) => { + release = () => resolve(true); + }) + ); + gestureStates.panning = true; + + const first = coordinator.cancelActiveInteraction(); + const second = await coordinator.cancelActiveInteraction(); + + expect(second).toBe(false); + release(); + expect(await first).toBe(true); + expect(cancel).toHaveBeenCalledTimes(1); + }); + + it('should release the latch when a handler cancel rejects', async () => { + cancel.mockRejectedValue(new Error('handler failed')); + gestureStates.rotating = true; + + await expect(coordinator.cancelActiveInteraction()).rejects.toThrow('handler failed'); + + expect(coordinator.isCancellingInteraction()).toBe(false); + }); +}); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/interaction-coordinator/interaction-coordinator.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/interaction-coordinator/interaction-coordinator.ts new file mode 100644 index 000000000..9ff0ca44e --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/interaction-coordinator/interaction-coordinator.ts @@ -0,0 +1,95 @@ +import type { FlowCore } from '../flow-core'; +import type { InputEventName } from '../input-events'; + +/** + * Coordinates gesture cancellation across the two layers: the view layer + * registers a cleanup for the document-level listeners of the gesture in + * progress, the core side aborts the gesture handlers — and + * {@link cancelActiveInteraction} runs both in the right order. Owned by + * {@link FlowCore}, which exposes every method on its facade. + */ +export class InteractionCoordinator { + private readonly cleanups = new Set<() => void>(); + private cancelling = false; + + /** + * Every cancellable gesture: the action-state probe paired with the input + * event name its handler is registered under. A new cancellable gesture + * joins {@link hasActiveInteraction} and {@link cancelActiveInteraction} by + * adding one entry here. + */ + private readonly cancellableGestures: readonly { event: InputEventName; isActive: () => boolean }[] = [ + { event: 'linking', isActive: () => this.flowCore.actionStateManager.isLinking() }, + { event: 'pointerMoveSelection', isActive: () => this.flowCore.actionStateManager.isDragging() }, + { event: 'resize', isActive: () => this.flowCore.actionStateManager.isResizing() }, + { event: 'rotate', isActive: () => this.flowCore.actionStateManager.isRotating() }, + { event: 'panning', isActive: () => this.flowCore.actionStateManager.isPanning() }, + ]; + + constructor(private readonly flowCore: FlowCore) {} + + /** See {@link FlowCore.registerInteractionCleanup}. */ + registerInteractionCleanup(cleanup: () => void): () => void { + this.cleanups.add(cleanup); + return () => { + this.cleanups.delete(cleanup); + }; + } + + /** See {@link FlowCore.isCancellingInteraction}. */ + isCancellingInteraction(): boolean { + return this.cancelling; + } + + /** See {@link FlowCore.hasActiveInteraction}. */ + hasActiveInteraction(): boolean { + return this.cancellableGestures.some((gesture) => gesture.isActive()) || this.cleanups.size > 0; + } + + /** See {@link FlowCore.cancelActiveInteraction}. */ + async cancelActiveInteraction(): Promise { + if (this.cancelling) { + return false; + } + if (this.flowCore.transactionManager.isActive()) { + console.warn( + '[ngDiagram] cancelActiveInteraction() called while a transaction is active — ignored. The rollback would merge into the transaction and could be discarded with it; await the transaction and cancel afterwards.' + ); + return false; + } + + const activeGestures = this.cancellableGestures + .filter((gesture) => gesture.isActive()) + .map((gesture) => gesture.event); + + this.cancelling = true; + try { + // Tear down document-level listeners first so no further pointer events + // reach the gesture handlers while (or after) they are being cancelled. + const cleanups = [...this.cleanups]; + this.cleanups.clear(); + for (const cleanup of cleanups) { + cleanup(); + } + + // One failing cancel must not leave the remaining gestures active — cancel + // them all, then rethrow the first failure. + let cancelledAny = false; + const errors: unknown[] = []; + for (const gesture of activeGestures) { + try { + cancelledAny = (await this.flowCore.inputEventsRouter.cancel(gesture)) || cancelledAny; + } catch (error) { + errors.push(error); + } + } + if (errors.length > 0) { + throw errors[0]; + } + + return cancelledAny || cleanups.length > 0; + } finally { + this.cancelling = false; + } + } +} diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/__tests__/node-drag-lifecycle.emitter.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/__tests__/node-drag-lifecycle.emitter.test.ts index 6632c2613..2c709f72f 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/__tests__/node-drag-lifecycle.emitter.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/__tests__/node-drag-lifecycle.emitter.test.ts @@ -5,7 +5,7 @@ import type { DraggingActionState, MiddlewareContext, Node } from '../../../../. import { NodeDragEndedEmitter, NodeDragStartedEmitter } from '../node-drag-lifecycle.emitter'; interface MockActionStateManager { - dragging: Pick | undefined; + dragging: Pick | undefined; } function createContext( @@ -163,6 +163,15 @@ describe('NodeDragEndedEmitter', () => { expect(emitSpy).toHaveBeenCalledOnce(); expect(emitSpy).toHaveBeenCalledWith('nodeDragEnded', { nodes: [node] }); }); + + it('should include the cancel reason when the drag was aborted', () => { + mockActionStateManager.dragging = { nodeIds: ['node1'], cancelReason: 'cancelled' }; + + emitter.emit(context, eventManager); + + const node = context.nodesMap.get('node1')!; + expect(emitSpy).toHaveBeenCalledWith('nodeDragEnded', { nodes: [node], cancelReason: 'cancelled' }); + }); }); describe('node resolution from actionState', () => { diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/node-drag-lifecycle.emitter.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/node-drag-lifecycle.emitter.ts index 90f667164..68ee75505 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/node-drag-lifecycle.emitter.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/node-drag-lifecycle.emitter.ts @@ -45,7 +45,10 @@ export class NodeDragEndedEmitter implements EventEmitter { return; } - const nodeIds = context.initialUpdate.gestureNodeIds ?? context.actionStateManager.dragging?.nodeIds; + const dragging = context.actionStateManager.dragging; + // Prefer the pass's own capture over the (possibly newer-gesture) live + // state — see FlowStateUpdate.gestureNodeIds. + const nodeIds = context.initialUpdate.gestureNodeIds ?? dragging?.nodeIds; if (!nodeIds || nodeIds.length === 0) { return; } @@ -55,6 +58,9 @@ export class NodeDragEndedEmitter implements EventEmitter { return; } - eventManager.deferredEmit('nodeDragEnded', { nodes }); + eventManager.deferredEmit('nodeDragEnded', { + nodes, + ...(dragging?.cancelReason && { cancelReason: dragging.cancelReason }), + }); } } diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/node-resize-lifecycle.emitter.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/node-resize-lifecycle.emitter.ts index b2754b6a9..516e1c694 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/node-resize-lifecycle.emitter.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/node-resize-lifecycle.emitter.ts @@ -34,7 +34,10 @@ export class NodeResizeEndedEmitter implements EventEmitter { return; } - const nodeId = context.initialUpdate.gestureNodeIds?.[0] ?? context.actionStateManager.resize?.resizingNode.id; + const resize = context.actionStateManager.resize; + // Prefer the pass's own capture over the (possibly newer-gesture) live + // state — see FlowStateUpdate.gestureNodeIds. + const nodeId = context.initialUpdate.gestureNodeIds?.[0] ?? resize?.resizingNode.id; if (!nodeId) { return; } @@ -44,6 +47,9 @@ export class NodeResizeEndedEmitter implements EventEmitter { return; } - eventManager.deferredEmit('nodeResizeEnded', { node }); + eventManager.deferredEmit('nodeResizeEnded', { + node, + ...(resize?.cancelReason && { cancelReason: resize.cancelReason }), + }); } } diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/node-rotate-lifecycle.emitter.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/node-rotate-lifecycle.emitter.ts index a5d3397b8..2123dfb46 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/node-rotate-lifecycle.emitter.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/node-rotate-lifecycle.emitter.ts @@ -34,7 +34,10 @@ export class NodeRotateEndedEmitter implements EventEmitter { return; } - const nodeId = context.initialUpdate.gestureNodeIds?.[0] ?? context.actionStateManager.rotation?.nodeId; + const rotation = context.actionStateManager.rotation; + // Prefer the pass's own capture over the (possibly newer-gesture) live + // state — see FlowStateUpdate.gestureNodeIds. + const nodeId = context.initialUpdate.gestureNodeIds?.[0] ?? rotation?.nodeId; if (!nodeId) { return; } @@ -44,6 +47,9 @@ export class NodeRotateEndedEmitter implements EventEmitter { return; } - eventManager.deferredEmit('nodeRotateEnded', { node }); + eventManager.deferredEmit('nodeRotateEnded', { + node, + ...(rotation?.cancelReason && { cancelReason: rotation.cancelReason }), + }); } } diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/shortcut-manager/default-shortcuts.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/shortcut-manager/default-shortcuts.ts index c5984b64d..f38bb376c 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/shortcut-manager/default-shortcuts.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/shortcut-manager/default-shortcuts.ts @@ -24,6 +24,12 @@ export const DEFAULT_SHORTCUTS = [ bindings: [{ key: 'Delete' }, { key: 'Backspace' }], }, + // Abort the in-progress interactive gesture (linking, drag, resize, rotate, pan) + { + actionName: 'cancelInteraction', + bindings: [{ key: 'Escape' }], + }, + // Move selected nodes with arrow keys { actionName: 'keyboardMoveSelectionUp', diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/action-state.interface.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/action-state.interface.ts index e36de5433..e2dc7d2d0 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/action-state.interface.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/action-state.interface.ts @@ -1,4 +1,4 @@ -import type { EdgeDrawCancelReason } from '../event-manager/event-types'; +import type { EdgeDrawCancelReason, GestureCancelReason } from '../event-manager/event-types'; import type { InputModifiers } from '../input-events/input-events.interface'; import type { Edge } from './edge.interface'; import type { Node } from './node.interface'; @@ -26,6 +26,8 @@ export interface ResizeActionState { startNodePositionY: number; /** Reference to the node being resized. */ resizingNode: Node; + /** Set when the resize is aborted; carried into `nodeResizeEnded`. */ + cancelReason?: GestureCancelReason; } /** @@ -61,6 +63,8 @@ export interface LinkingActionState { export interface InternalLinkingActionState extends LinkingActionState { /** Monotonic id of the linking gesture this state belongs to. */ _gestureId?: number; + /** Set while finishLinking/cancelLinking is tearing this gesture down — the other must no-op. */ + _finishing?: boolean; } /** @@ -103,6 +107,8 @@ export interface RotationActionState { initialNodeAngle: number; /** ID of the node being rotated. */ nodeId: string; + /** Set when the rotation is aborted; carried into `nodeRotateEnded`. */ + cancelReason?: GestureCancelReason; } /** @@ -127,6 +133,8 @@ export interface DraggingActionState { * `false` when the drag state is first created (on pointer down), `true` once movement exceeds the threshold. */ movementStarted: boolean; + /** Set when the drag is aborted; carried into `nodeDragEnded`. */ + cancelReason?: GestureCancelReason; } /** diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/command-handler.interface.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/command-handler.interface.ts index d21cf2c5f..47d87d074 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/command-handler.interface.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/command-handler.interface.ts @@ -24,6 +24,7 @@ import { DeleteSelectionCommand } from '../command-handler/commands/delete-selec import { HighlightGroupClearCommand, HighlightGroupCommand } from '../command-handler/commands/highlight-group'; import { InitCommand } from '../command-handler/commands/init'; import { + CancelLinkingCommand, FinishLinkingCommand, FinishLinkingToPositionCommand, MoveTemporaryEdgeCommand, @@ -81,6 +82,7 @@ export type Command = | StartLinkingCommand | MoveTemporaryEdgeCommand | FinishLinkingCommand + | CancelLinkingCommand | StartLinkingFromPositionCommand | FinishLinkingToPositionCommand | ResizeNodeCommand diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/middleware.interface.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/middleware.interface.ts index 19d915237..e97c4add6 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/middleware.interface.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/middleware.interface.ts @@ -67,6 +67,7 @@ export type ModelActionType = | 'resizeNode' | 'resizeNodeStart' | 'resizeNodeStop' + | 'cancelResize' | 'startLinking' | 'moveTemporaryEdge' | 'finishLinking' @@ -75,11 +76,13 @@ export type ModelActionType = | 'rotateNodeTo' | 'rotateNodeStart' | 'rotateNodeStop' + | 'cancelRotate' | 'highlightGroup' | 'highlightGroupClear' | 'moveNodes' | 'moveNodesStart' | 'moveNodesStop' + | 'cancelDrag' | 'selectEnd'; /** diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/shortcut-action.interface.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/shortcut-action.interface.ts index b89197ae7..37257ed62 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/shortcut-action.interface.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/shortcut-action.interface.ts @@ -62,7 +62,10 @@ export type KeyboardActionName = | KeyboardMoveSelectionAction | KeyboardPanAction | KeyboardZoomAction - | Extract; + | Extract< + InputEventName, + 'cut' | 'paste' | 'copy' | 'deleteSelection' | 'undo' | 'redo' | 'selectAll' | 'cancelInteraction' + >; /** * All valid action names for shortcuts diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/ng-diagram-minimap-navigation.directive.spec.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/ng-diagram-minimap-navigation.directive.spec.ts new file mode 100644 index 000000000..28a3b132e --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/ng-diagram-minimap-navigation.directive.spec.ts @@ -0,0 +1,99 @@ +import { TestBed } from '@angular/core/testing'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { NgDiagramViewportService } from '../../public-services/ng-diagram-viewport.service'; +import { FlowCoreProviderService } from '../../services'; +import { NgDiagramMinimapNavigationDirective } from './ng-diagram-minimap-navigation.directive'; + +function makePointerEvent(overrides: Partial = {}): PointerEvent { + return { + button: 0, + pointerId: 1, + clientX: 10, + clientY: 10, + preventDefault: vi.fn(), + target: { + setPointerCapture: vi.fn(), + hasPointerCapture: vi.fn().mockReturnValue(true), + releasePointerCapture: vi.fn(), + }, + ...overrides, + } as unknown as PointerEvent; +} + +describe('NgDiagramMinimapNavigationDirective (cancel integration)', () => { + let directive: NgDiagramMinimapNavigationDirective; + let moveViewportBy: ReturnType; + let clearPanning: ReturnType; + let registerInteractionCleanup: ReturnType; + let unregister: ReturnType; + let registeredCleanups: (() => void)[]; + + beforeEach(() => { + moveViewportBy = vi.fn(); + clearPanning = vi.fn(); + unregister = vi.fn(); + registeredCleanups = []; + registerInteractionCleanup = vi.fn().mockImplementation((cleanup: () => void) => { + registeredCleanups.push(cleanup); + return unregister; + }); + + const actionStateManager = { panning: undefined as { active: boolean } | undefined, clearPanning }; + + TestBed.configureTestingModule({ + providers: [ + NgDiagramMinimapNavigationDirective, + { provide: NgDiagramViewportService, useValue: { moveViewportBy } }, + { + provide: FlowCoreProviderService, + useValue: { + isInitialized: () => true, + provide: () => ({ actionStateManager, registerInteractionCleanup }), + }, + }, + ], + }); + + directive = TestBed.inject(NgDiagramMinimapNavigationDirective); + }); + + it('registers an interaction cleanup when the drag starts, once per gesture', () => { + directive.onPointerDown(makePointerEvent()); + directive.onPointerDown(makePointerEvent()); + + expect(registerInteractionCleanup).toHaveBeenCalledTimes(1); + }); + + it('the registered cleanup stops the drag: listeners removed, pointer capture released', () => { + const event = makePointerEvent(); + directive.onPointerDown(event); + + registeredCleanups[0](); + + expect(unregister).toHaveBeenCalledTimes(1); + expect( + (event.target as unknown as { releasePointerCapture: ReturnType }).releasePointerCapture + ).toHaveBeenCalled(); + // Listeners are gone — a pointermove after the cancel moves nothing + document.dispatchEvent(new Event('pointermove')); + expect(moveViewportBy).not.toHaveBeenCalled(); + }); + + it('normal pointerup unregisters the cleanup and clears the panning state', () => { + directive.onPointerDown(makePointerEvent()); + + document.dispatchEvent(new Event('pointerup')); + + expect(unregister).toHaveBeenCalledTimes(1); + expect(clearPanning).toHaveBeenCalled(); + }); + + it('destroy mid-drag clears the panning state it set', () => { + directive.onPointerDown(makePointerEvent()); + + directive.ngOnDestroy(); + + expect(unregister).toHaveBeenCalledTimes(1); + expect(clearPanning).toHaveBeenCalled(); + }); +}); diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/ng-diagram-minimap-navigation.directive.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/ng-diagram-minimap-navigation.directive.ts index cc100d40a..b11b048c6 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/ng-diagram-minimap-navigation.directive.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/ng-diagram-minimap-navigation.directive.ts @@ -13,6 +13,8 @@ interface DragState { isDragging: boolean; lastPosition: Point; pointerId: number | null; + /** Kept so the cancel path can release the capture without an event. */ + captureElement: Element | null; } /** @@ -44,14 +46,23 @@ export class NgDiagramMinimapNavigationDirective implements OnDestroy { isDragging: false, lastPosition: { x: 0, y: 0 }, pointerId: null, + captureElement: null, }; + private unregisterInteractionCleanup: (() => void) | null = null; + ngOnDestroy(): void { - this.removeDocumentListeners(); + // Destroyed mid-drag: the pointerup will never come, so the panning state + // this directive set must be cleared here. + if (this.dragState.isDragging && this.flowCoreProvider.isInitialized()) { + this.setPanningState(false); + } + this.removeListeners(); } onPointerDown(event: PointerEvent): void { - if (event.button !== 0) { + // Re-entry guard: a second pointerdown mid-gesture would orphan the previous interaction-cleanup registration. + if (event.button !== 0 || this.dragState.isDragging) { return; } @@ -62,6 +73,12 @@ export class NgDiagramMinimapNavigationDirective implements OnDestroy { this.dragState.lastPosition = { x: event.clientX, y: event.clientY }; this.setPanningState(true); this.attachDocumentListeners(); + // cancelActiveInteraction() must be able to stop a minimap drag like any + // other pan: core clears the panning state, this cleanup tears down the + // listeners and the pointer capture. + this.unregisterInteractionCleanup = this.flowCoreProvider + .provide() + .registerInteractionCleanup(() => this.removeListeners()); } private onPointerMove = (event: PointerEvent): void => { @@ -76,29 +93,33 @@ export class NgDiagramMinimapNavigationDirective implements OnDestroy { this.viewportService.moveViewportBy(viewportDelta.x, viewportDelta.y); }; - private onPointerUp = (event: PointerEvent): void => { - this.dragState.isDragging = false; + private onPointerUp = (): void => { this.setPanningState(false); - this.releasePointer(event); - this.removeDocumentListeners(); + this.removeListeners(); }; + private removeListeners(): void { + this.unregisterInteractionCleanup?.(); + this.unregisterInteractionCleanup = null; + this.dragState.isDragging = false; + this.releasePointer(); + this.removeDocumentListeners(); + } + private capturePointer(event: PointerEvent): void { const target = event.target as Element; target.setPointerCapture(event.pointerId); this.dragState.pointerId = event.pointerId; + this.dragState.captureElement = target; } - private releasePointer(event: PointerEvent): void { - if (this.dragState.pointerId === null) { - return; - } - - const target = event.target as Element; - if (target.hasPointerCapture(this.dragState.pointerId)) { - target.releasePointerCapture(this.dragState.pointerId); + private releasePointer(): void { + const { captureElement, pointerId } = this.dragState; + if (captureElement && pointerId !== null && captureElement.hasPointerCapture(pointerId)) { + captureElement.releasePointerCapture(pointerId); } this.dragState.pointerId = null; + this.dragState.captureElement = null; } private attachDocumentListeners(): void { diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/keyboard-inputs/keyboard-inputs.directive.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/keyboard-inputs/keyboard-inputs.directive.ts index 013fca848..e53d888a9 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/keyboard-inputs/keyboard-inputs.directive.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/keyboard-inputs/keyboard-inputs.directive.ts @@ -1,4 +1,4 @@ -import { Directive, ElementRef, inject } from '@angular/core'; +import { Directive, ElementRef, inject, OnDestroy } from '@angular/core'; import { InputEventName } from '../../../../core/src'; import { FlowCoreProviderService } from '../../../services/flow-core-provider/flow-core-provider.service'; @@ -15,11 +15,10 @@ import { ZoomAction } from './keyboard-actions/zoom.action'; providers: [PanningAction, MovingAction, PasteAction, ZoomAction], host: { '(document:keydown)': 'onKeyDown($event)', - '(pointerdown)': 'onPointerDown()', tabindex: '0', }, }) -export class KeyboardInputsDirective { +export class KeyboardInputsDirective implements OnDestroy { private readonly flowCoreProvider = inject(FlowCoreProviderService); private readonly inputEventsRouter = inject(InputEventsRouterService); private readonly keyboardActions: KeyboardAction[] = [ @@ -30,6 +29,19 @@ export class KeyboardInputsDirective { ]; private readonly elementRef = inject>(ElementRef); + private readonly grabFocus = (): void => this.onPointerDown(); + + constructor() { + // Capture phase: inner handlers may stopPropagation() on pointerdown, which + // would skip a bubble-phase focus grab — a gesture started with focus + // outside the diagram would leave every shortcut dead. + this.elementRef.nativeElement.addEventListener('pointerdown', this.grabFocus, true); + } + + ngOnDestroy(): void { + this.elementRef.nativeElement.removeEventListener('pointerdown', this.grabFocus, true); + } + onPointerDown(): void { if (!this.elementRef.nativeElement.contains(document.activeElement)) { this.elementRef.nativeElement.focus(); @@ -49,13 +61,19 @@ export class KeyboardInputsDirective { modifiers: baseEvent.modifiers, }); - if (shortcuts.length === 0 || this.isInputFieldFocused(event)) { + // Don't swallow the key's default behavior (e.g. Escape closing a ) + // when the only match is cancelInteraction and there is nothing to cancel. + const actionableShortcuts = shortcuts.filter( + (shortcut) => shortcut.actionName !== 'cancelInteraction' || flowCore.hasActiveInteraction() + ); + + if (actionableShortcuts.length === 0 || this.isInputFieldFocused(event)) { return; } event.preventDefault(); - for (const shortcut of shortcuts) { + for (const shortcut of actionableShortcuts) { const matchingAction = this.keyboardActions.find((action) => action.canHandle(shortcut, flowCore)); const event = matchingAction && matchingAction.createEvent(shortcut, baseEvent, flowCore); diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/linking/linking.directive.spec.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/linking/linking.directive.spec.ts new file mode 100644 index 000000000..14434a9f8 --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/linking/linking.directive.spec.ts @@ -0,0 +1,101 @@ +import { Component } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { FlowCoreProviderService } from '../../../services'; +import { LinkingEventService } from '../../../services/input-events/linking-event.service'; +import { TouchEventsStateService } from '../../../services/touch-events-state-service/touch-events-state-service.service'; +import { DiagramEventName, type PointerInputEvent } from '../../../types'; +import { LinkingInputDirective } from './linking.directive'; + +@Component({ + template: `
`, + standalone: true, + imports: [LinkingInputDirective], +}) +class HostComponent {} + +function makePointerEvent(overrides: Partial = {}): PointerInputEvent { + return { + clientX: 10, + clientY: 10, + boxSelectionHandled: false, + ...overrides, + } as unknown as PointerInputEvent; +} + +describe('LinkingInputDirective (shared touch marker ownership)', () => { + let fixture: ComponentFixture; + let directive: LinkingInputDirective; + let touchState: TouchEventsStateService; + let clearLinking: ReturnType; + let registerInteractionCleanup: ReturnType; + let unregister: ReturnType; + + beforeEach(() => { + clearLinking = vi.fn(); + unregister = vi.fn(); + registerInteractionCleanup = vi.fn().mockReturnValue(unregister); + + const mockLinkingEventService = { + emitStart: vi.fn(), + emitContinue: vi.fn(), + emitEnd: vi.fn(), + }; + const mockFlowCoreProvider = { + isInitialized: () => true, + provide: () => ({ + actionStateManager: { clearLinking, isLinking: () => false }, + registerInteractionCleanup, + }), + }; + + TestBed.configureTestingModule({ + imports: [HostComponent], + providers: [{ provide: FlowCoreProviderService, useValue: mockFlowCoreProvider }, TouchEventsStateService], + }); + // LinkingEventService is a directive-level provider — override it there + TestBed.overrideProvider(LinkingEventService, { useValue: mockLinkingEventService }); + + fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + directive = fixture.debugElement.query(By.directive(LinkingInputDirective)).injector.get(LinkingInputDirective); + touchState = TestBed.inject(TouchEventsStateService); + }); + + it('leaves a marker owned by another gesture alone when destroyed as a bystander', () => { + // Simulates virtualization destroying this port's component during a touch pan + touchState.currentEvent.set(DiagramEventName.Panning); + + fixture.destroy(); + + expect(touchState.currentEvent()).toBe(DiagramEventName.Panning); + expect(clearLinking).not.toHaveBeenCalled(); + }); + + it('clears the shared touch marker on normal pointerup', () => { + directive.onPointerDown(makePointerEvent()); + expect(touchState.currentEvent()).toBe(DiagramEventName.Linking); + + directive.onPointerUp(makePointerEvent()); + + expect(touchState.currentEvent()).toBeNull(); + expect(unregister).toHaveBeenCalledTimes(1); + }); + + it('clears its own marker and the linking state when destroyed mid-gesture', () => { + directive.onPointerDown(makePointerEvent()); + + fixture.destroy(); + + expect(touchState.currentEvent()).toBeNull(); + expect(clearLinking).toHaveBeenCalled(); + }); + + it('does not re-register the cleanup on a second pointerdown mid-gesture', () => { + directive.onPointerDown(makePointerEvent()); + directive.onPointerDown(makePointerEvent()); + + expect(registerInteractionCleanup).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/linking/linking.directive.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/linking/linking.directive.ts index 1c07df1ff..31bf90853 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/linking/linking.directive.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/linking/linking.directive.ts @@ -20,21 +20,20 @@ export class LinkingInputDirective implements OnDestroy { private target = signal(undefined); private edgePanningInterval: number | null = null; + private unregisterInteractionCleanup: (() => void) | null = null; private gestureActive = false; portId = input.required(); ngOnDestroy(): void { - this.cleanup(); + const wasMidGesture = this.gestureActive; + this.removeListeners(); // Destroyed mid-gesture (e.g. the source node was deleted while linking): the // pointerup will never be routed and finishLinking will never run. The state // must be cleared here — a stranded linking state permanently disables linking, // because shouldHandle refuses to start while isLinking() is true. - if (this.gestureActive) { - this.gestureActive = false; - if (this.flowCoreProviderService.isInitialized()) { - this.flowCoreProviderService.provide().actionStateManager.clearLinking(); - } + if (wasMidGesture && this.flowCoreProviderService.isInitialized()) { + this.flowCoreProviderService.provide().actionStateManager.clearLinking(); } } @@ -43,7 +42,8 @@ export class LinkingInputDirective implements OnDestroy { } onPointerDown($event: PointerInputEvent) { - if (!this.shouldHandle($event)) { + // Re-entry guard: a second pointerdown mid-gesture would orphan the previous interaction-cleanup registration. + if (this.gestureActive || !this.shouldHandle($event)) { return; } @@ -53,6 +53,9 @@ export class LinkingInputDirective implements OnDestroy { document.addEventListener('pointermove', this.onPointerMove); document.addEventListener('pointerup', this.onPointerUp); + this.unregisterInteractionCleanup = this.flowCoreProviderService + .provide() + .registerInteractionCleanup(() => this.removeListeners()); this.linkingEventService.emitStart($event, this.target(), this.portId()); } @@ -89,9 +92,8 @@ export class LinkingInputDirective implements OnDestroy { }; onPointerUp = ($event: PointerInputEvent) => { - this.gestureActive = false; this.linkingEventService.emitEnd($event, this.target(), this.portId()); - this.cleanup(); + this.removeListeners(); }; private shouldHandle(event: PointerInputEvent) { @@ -107,8 +109,16 @@ export class LinkingInputDirective implements OnDestroy { ); } - private cleanup() { - this.touchEventsStateService.clearCurrentEvent(); + private removeListeners() { + this.unregisterInteractionCleanup?.(); + this.unregisterInteractionCleanup = null; + // The shared marker keeps concurrent gestures out (panningHandled() etc.), so + // only its writer may clear it. gestureActive marks that writer — set only by + // this instance's own pointerdown, so a bystander's destroy skips the clear. + if (this.gestureActive) { + this.gestureActive = false; + this.touchEventsStateService.clearCurrentEvent(); + } document.removeEventListener('pointermove', this.onPointerMove); document.removeEventListener('pointerup', this.onPointerUp); this.stopEdgePanning(); diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/panning/panning.directive.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/panning/panning.directive.ts index 75ad8b095..48b20bbdb 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/panning/panning.directive.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/panning/panning.directive.ts @@ -19,9 +19,18 @@ export class PanningDirective implements OnDestroy { private readonly diagramService = inject(NgDiagramService); private readonly flowCoreProvider = inject(FlowCoreProviderService); + private unregisterInteractionCleanup: (() => void) | null = null; + private gestureActive = false; + ngOnDestroy(): void { - document.removeEventListener('pointermove', this.onMouseMove); - document.removeEventListener('pointerup', this.onPointerUp); + const wasMidGesture = this.gestureActive; + this.removeListeners(); + // Destroyed mid-gesture: the pointerup will never be routed, so the panning + // state must be cleared here — a leaked panning state keeps + // hasActiveInteraction() true forever. + if (wasMidGesture && this.flowCoreProvider.isInitialized()) { + this.flowCoreProvider.provide().actionStateManager.clearPanning(); + } } onPointerDown(event: PointerInputEvent): void { @@ -29,6 +38,10 @@ export class PanningDirective implements OnDestroy { if (event.pointerType === 'touch') { return; } + // Re-entry guard: a second pointerdown mid-gesture would orphan the previous interaction-cleanup registration. + if (this.gestureActive) { + return; + } if (!this.inputEventsRouter.eventGuards.withPrimaryButton(event) || !this.shouldHandle(event)) { return; } @@ -50,15 +63,18 @@ export class PanningDirective implements OnDestroy { }, }); + this.gestureActive = true; document.addEventListener('pointermove', this.onMouseMove); document.addEventListener('pointerup', this.onPointerUp); + this.unregisterInteractionCleanup = this.flowCoreProvider + .provide() + .registerInteractionCleanup(() => this.removeListeners()); } onPointerUp = (event: PointerEvent): void => { if (!this.inputEventsRouter.eventGuards.withPrimaryButton(event)) { return; } - this.toggleGrabbingCursor(false); event.preventDefault(); event.stopPropagation(); @@ -113,9 +129,17 @@ export class PanningDirective implements OnDestroy { }); }; - private finishPanning(event: PointerInputEvent): void { + private removeListeners(): void { + this.gestureActive = false; + this.unregisterInteractionCleanup?.(); + this.unregisterInteractionCleanup = null; document.removeEventListener('pointermove', this.onMouseMove); document.removeEventListener('pointerup', this.onPointerUp); + this.toggleGrabbingCursor(false); + } + + private finishPanning(event: PointerInputEvent): void { + this.removeListeners(); const baseEvent = this.inputEventsRouter.getBaseEvent(event); this.inputEventsRouter.emit({ diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/pointer-move-selection/pointer-move-selection.directive.spec.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/pointer-move-selection/pointer-move-selection.directive.spec.ts new file mode 100644 index 000000000..7fb4baea9 --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/pointer-move-selection/pointer-move-selection.directive.spec.ts @@ -0,0 +1,139 @@ +import { Component } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Node } from '../../../../core/src'; +import { NgDiagramComponent } from '../../../components/diagram/ng-diagram.component'; +import { FlowCoreProviderService } from '../../../services'; +import { InputEventsRouterService } from '../../../services/input-events/input-events-router.service'; +import { TouchEventsStateService } from '../../../services/touch-events-state-service/touch-events-state-service.service'; +import { DiagramEventName, type PointerInputEvent } from '../../../types/pointer-event'; +import { PointerMoveSelectionDirective } from './pointer-move-selection.directive'; + +@Component({ + template: `
`, + standalone: true, + imports: [PointerMoveSelectionDirective], +}) +class HostComponent { + node = { id: 'n1', type: 'node', position: { x: 0, y: 0 }, data: {} } as Node; +} + +function makePointerEvent(overrides: Partial = {}): PointerInputEvent { + return { + clientX: 10, + clientY: 10, + zoomingHandled: false, + linkingHandled: false, + rotateHandled: false, + boxSelectionHandled: false, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + ...overrides, + } as unknown as PointerInputEvent; +} + +describe('PointerMoveSelectionDirective (shared touch marker ownership)', () => { + let fixture: ComponentFixture; + let directive: PointerMoveSelectionDirective; + let touchState: TouchEventsStateService; + let registerInteractionCleanup: ReturnType; + let unregister: ReturnType; + let clearDragging: ReturnType; + + beforeEach(() => { + unregister = vi.fn(); + registerInteractionCleanup = vi.fn().mockReturnValue(unregister); + clearDragging = vi.fn(); + const mockRouter = { + getBaseEvent: () => ({ + id: 'id', + timestamp: 0, + modifiers: { primary: false, secondary: false, shift: false, meta: false }, + }), + emit: vi.fn(), + eventGuards: { + withPrimaryButton: vi.fn().mockReturnValue(true), + }, + }; + const mockFlowCoreProvider = { + isInitialized: () => true, + provide: () => ({ + config: { nodeDraggingEnabled: true }, + actionStateManager: { clearDragging }, + registerInteractionCleanup, + }), + }; + const mockDiagramComponent = { + getBoundingClientRect: () => ({ left: 0, top: 0, right: 100, bottom: 100, width: 100, height: 100 }), + }; + + TestBed.configureTestingModule({ + imports: [HostComponent], + providers: [ + { provide: InputEventsRouterService, useValue: mockRouter }, + { provide: FlowCoreProviderService, useValue: mockFlowCoreProvider }, + { provide: NgDiagramComponent, useValue: mockDiagramComponent }, + TouchEventsStateService, + ], + }); + + fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + directive = fixture.debugElement + .query(By.directive(PointerMoveSelectionDirective)) + .injector.get(PointerMoveSelectionDirective); + touchState = TestBed.inject(TouchEventsStateService); + }); + + it('clears the shared touch marker on normal pointerup', () => { + directive.onPointerDown(makePointerEvent()); + expect(touchState.currentEvent()).toBe(DiagramEventName.Move); + + directive.onPointerUp(makePointerEvent() as unknown as PointerEvent); + + expect(touchState.currentEvent()).toBeNull(); + }); + + it('leaves a marker owned by another gesture alone when destroyed as a bystander', () => { + // Simulates virtualization destroying this node's component during a touch pan + touchState.currentEvent.set(DiagramEventName.Panning); + + fixture.destroy(); + + expect(touchState.currentEvent()).toBe(DiagramEventName.Panning); + }); + + it('clears its own marker and the dragging state when destroyed mid-gesture', () => { + directive.onPointerDown(makePointerEvent()); + + fixture.destroy(); + + expect(touchState.currentEvent()).toBeNull(); + expect(clearDragging).toHaveBeenCalled(); + }); + + it('does not clear the dragging state when destroyed as a bystander', () => { + fixture.destroy(); + + expect(clearDragging).not.toHaveBeenCalled(); + }); + + it('unregisters its interaction cleanup on normal pointerup', () => { + directive.onPointerDown(makePointerEvent()); + expect(unregister).not.toHaveBeenCalled(); + + directive.onPointerUp(makePointerEvent() as unknown as PointerEvent); + + // A lingering registration would make hasActiveInteraction() true forever + // and swallow every subsequent Escape press + expect(unregister).toHaveBeenCalledTimes(1); + }); + + it('does not re-register the cleanup on a second pointerdown mid-gesture', () => { + directive.onPointerDown(makePointerEvent()); + directive.onPointerDown(makePointerEvent()); + + expect(registerInteractionCleanup).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/pointer-move-selection/pointer-move-selection.directive.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/pointer-move-selection/pointer-move-selection.directive.ts index 670ffdd62..3936d5293 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/pointer-move-selection/pointer-move-selection.directive.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/pointer-move-selection/pointer-move-selection.directive.ts @@ -24,14 +24,26 @@ export class PointerMoveSelectionDirective implements OnDestroy { private edgePanningInterval: number | null = null; private cachedDiagramRect: DOMRect | null = null; + private unregisterInteractionCleanup: (() => void) | null = null; + private gestureActive = false; ngOnDestroy() { - document.removeEventListener('pointermove', this.onPointerMove); - document.removeEventListener('pointerup', this.onPointerUp); - this.stopEdgePanning(); + const wasMidGesture = this.gestureActive; + this.removeListeners(); + // Destroyed mid-gesture (e.g. the dragged node was deleted): the pointerup + // will never be routed, so the dragging state must be cleared here — a + // leaked dragging state keeps hasActiveInteraction() true forever. + if (wasMidGesture && this.flowCoreProvider.isInitialized()) { + this.flowCoreProvider.provide().actionStateManager.clearDragging(); + } } onPointerDown(event: PointerInputEvent): void { + // Re-entry guard: a second pointerdown mid-gesture would orphan the previous interaction-cleanup registration. + if (this.gestureActive) { + return; + } + if (!this.shouldHandle(event)) { return; } @@ -45,6 +57,7 @@ export class PointerMoveSelectionDirective implements OnDestroy { return; } + this.gestureActive = true; this.touchEventsStateService.currentEvent.set(DiagramEventName.Move); this.cachedDiagramRect = this.diagramComponent.getBoundingClientRect(); event.moveSelectionHandled = true; @@ -65,6 +78,9 @@ export class PointerMoveSelectionDirective implements OnDestroy { document.addEventListener('pointermove', this.onPointerMove); document.addEventListener('pointerup', this.onPointerUp); + this.unregisterInteractionCleanup = this.flowCoreProvider + .provide() + .registerInteractionCleanup(() => this.removeListeners()); } onPointerUp = (event: PointerEvent): void => { @@ -127,16 +143,29 @@ export class PointerMoveSelectionDirective implements OnDestroy { }); }; + private removeListeners(): void { + this.unregisterInteractionCleanup?.(); + this.unregisterInteractionCleanup = null; + document.removeEventListener('pointermove', this.onPointerMove); + document.removeEventListener('pointerup', this.onPointerUp); + this.stopEdgePanning(); + this.cachedDiagramRect = null; + // The shared marker keeps concurrent gestures out (panningHandled() etc.), so + // only its writer may clear it. gestureActive marks that writer — set only by + // this instance's own pointerdown, so a bystander's destroy skips the clear. + if (this.gestureActive) { + this.gestureActive = false; + this.touchEventsStateService.clearCurrentEvent(); + } + } + private finishDragging(event: PointerInputEvent): void { const targetData = this.targetData(); if (!targetData) { return; } - document.removeEventListener('pointermove', this.onPointerMove); - document.removeEventListener('pointerup', this.onPointerUp); - this.stopEdgePanning(); - this.cachedDiagramRect = null; + this.removeListeners(); const baseEvent = this.inputEventsRouter.getBaseEvent(event); this.inputEventsRouter.emit({ @@ -151,8 +180,6 @@ export class PointerMoveSelectionDirective implements OnDestroy { }, currentDiagramEdge: null, }); - - this.touchEventsStateService.clearCurrentEvent(); } private shouldHandle(event: PointerInputEvent): boolean { diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/resize/resize.directive.spec.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/resize/resize.directive.spec.ts new file mode 100644 index 000000000..008e9c3f5 --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/resize/resize.directive.spec.ts @@ -0,0 +1,121 @@ +import { Component } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Node } from '../../../../core/src'; +import { FlowCoreProviderService } from '../../../services'; +import { InputEventsRouterService } from '../../../services/input-events/input-events-router.service'; +import { TouchEventsStateService } from '../../../services/touch-events-state-service/touch-events-state-service.service'; +import { DiagramEventName, type PointerInputEvent } from '../../../types/pointer-event'; +import { ResizeDirective } from './resize.directive'; + +@Component({ + template: `
`, + standalone: true, + imports: [ResizeDirective], +}) +class HostComponent { + node = { id: 'n1', type: 'node', position: { x: 0, y: 0 }, data: {} } as Node; +} + +function makePointerEvent(overrides: Partial = {}): PointerInputEvent { + return { + clientX: 10, + clientY: 10, + boxSelectionHandled: false, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + ...overrides, + } as unknown as PointerInputEvent; +} + +describe('ResizeDirective (shared touch marker ownership)', () => { + let fixture: ComponentFixture; + let directive: ResizeDirective; + let touchState: TouchEventsStateService; + let clearResize: ReturnType; + let registerInteractionCleanup: ReturnType; + let unregister: ReturnType; + + beforeEach(() => { + clearResize = vi.fn(); + unregister = vi.fn(); + registerInteractionCleanup = vi.fn().mockReturnValue(unregister); + + const mockRouter = { + getBaseEvent: () => ({ + id: 'id', + timestamp: 0, + modifiers: { primary: false, secondary: false, shift: false, meta: false }, + }), + emit: vi.fn(), + }; + const mockFlowCoreProvider = { + isInitialized: () => true, + provide: () => ({ + actionStateManager: { clearResize }, + registerInteractionCleanup, + }), + }; + + TestBed.configureTestingModule({ + imports: [HostComponent], + providers: [ + { provide: InputEventsRouterService, useValue: mockRouter }, + { provide: FlowCoreProviderService, useValue: mockFlowCoreProvider }, + TouchEventsStateService, + ], + }); + + fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + directive = fixture.debugElement.query(By.directive(ResizeDirective)).injector.get(ResizeDirective); + touchState = TestBed.inject(TouchEventsStateService); + }); + + it('clears the shared touch marker on normal pointerup', () => { + directive.onPointerDown(makePointerEvent()); + expect(touchState.currentEvent()).toBe(DiagramEventName.Resize); + + directive.onPointerUp(makePointerEvent() as unknown as PointerEvent); + + expect(touchState.currentEvent()).toBeNull(); + }); + + it('leaves a marker owned by another gesture alone when destroyed as a bystander', () => { + // Simulates virtualization destroying this node's component during a touch pan + touchState.currentEvent.set(DiagramEventName.Panning); + + fixture.destroy(); + + expect(touchState.currentEvent()).toBe(DiagramEventName.Panning); + expect(clearResize).not.toHaveBeenCalled(); + }); + + it('clears its own marker and the resize state when destroyed mid-gesture', () => { + directive.onPointerDown(makePointerEvent()); + + fixture.destroy(); + + expect(touchState.currentEvent()).toBeNull(); + expect(clearResize).toHaveBeenCalled(); + }); + + it('unregisters its interaction cleanup on normal pointerup', () => { + directive.onPointerDown(makePointerEvent()); + expect(unregister).not.toHaveBeenCalled(); + + directive.onPointerUp(makePointerEvent() as unknown as PointerEvent); + + // A lingering registration would make hasActiveInteraction() true forever + // and swallow every subsequent Escape press + expect(unregister).toHaveBeenCalledTimes(1); + }); + + it('does not re-register the cleanup on a second pointerdown mid-gesture', () => { + directive.onPointerDown(makePointerEvent()); + directive.onPointerDown(makePointerEvent()); + + expect(registerInteractionCleanup).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/resize/resize.directive.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/resize/resize.directive.ts index fbbbedc1e..a69605007 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/resize/resize.directive.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/resize/resize.directive.ts @@ -21,22 +21,21 @@ export class ResizeDirective implements OnDestroy { direction = input.required(); targetData = input.required(); + private unregisterInteractionCleanup: (() => void) | null = null; + ngOnDestroy() { - document.removeEventListener('pointermove', this.onPointerMove); - document.removeEventListener('pointerup', this.onPointerUp); + const wasMidGesture = this.gestureActive; + this.removeListeners(); // Destroyed mid-gesture (e.g. the node was deleted while resizing): the pointerup // will never be routed, so the resize state must be cleared here — a leaked // resize state suppresses every subsequent node size measurement. - if (this.gestureActive) { - this.gestureActive = false; - this.touchEventsStateService.clearCurrentEvent(); - if (this.flowCoreProvider.isInitialized()) { - this.flowCoreProvider.provide().actionStateManager.clearResize(); - } + if (wasMidGesture && this.flowCoreProvider.isInitialized()) { + this.flowCoreProvider.provide().actionStateManager.clearResize(); } } onPointerDown(event: PointerInputEvent): void { - if (!this.shouldHandle(event)) { + // Re-entry guard: a second pointerdown mid-gesture would orphan the previous interaction-cleanup registration. + if (this.gestureActive || !this.shouldHandle(event)) { return; } @@ -48,6 +47,9 @@ export class ResizeDirective implements OnDestroy { document.addEventListener('pointermove', this.onPointerMove); document.addEventListener('pointerup', this.onPointerUp); + this.unregisterInteractionCleanup = this.flowCoreProvider + .provide() + .registerInteractionCleanup(() => this.removeListeners()); const baseEvent = this.inputEventsRouter.getBaseEvent(event); this.inputEventsRouter.emit({ @@ -64,12 +66,22 @@ export class ResizeDirective implements OnDestroy { }); } - onPointerUp = (event: PointerEvent) => { - this.gestureActive = false; + private removeListeners(): void { + this.unregisterInteractionCleanup?.(); + this.unregisterInteractionCleanup = null; document.removeEventListener('pointermove', this.onPointerMove); document.removeEventListener('pointerup', this.onPointerUp); + // The shared marker keeps concurrent gestures out (panningHandled() etc.), so + // only its writer may clear it. gestureActive marks that writer — set only by + // this instance's own pointerdown, so a bystander's destroy skips the clear. + if (this.gestureActive) { + this.gestureActive = false; + this.touchEventsStateService.clearCurrentEvent(); + } + } - this.touchEventsStateService.clearCurrentEvent(); + onPointerUp = (event: PointerEvent) => { + this.removeListeners(); const baseEvent = this.inputEventsRouter.getBaseEvent(event); this.inputEventsRouter.emit({ diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/rotate/rotate.directive.spec.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/rotate/rotate.directive.spec.ts new file mode 100644 index 000000000..b36de3d00 --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/rotate/rotate.directive.spec.ts @@ -0,0 +1,110 @@ +import { Component } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Node } from '../../../../core/src'; +import { FlowCoreProviderService } from '../../../services'; +import { InputEventsRouterService } from '../../../services/input-events/input-events-router.service'; +import { TouchEventsStateService } from '../../../services/touch-events-state-service/touch-events-state-service.service'; +import { DiagramEventName, type PointerInputEvent } from '../../../types'; +import { RotateHandleDirective } from './rotate.directive'; + +@Component({ + template: `
`, + standalone: true, + imports: [RotateHandleDirective], +}) +class HostComponent { + node = { id: 'n1', type: 'node', position: { x: 0, y: 0 }, size: { width: 100, height: 50 }, data: {} } as Node; +} + +function makePointerEvent(overrides: Partial = {}): PointerInputEvent { + return { + clientX: 10, + clientY: 10, + boxSelectionHandled: false, + ...overrides, + } as unknown as PointerInputEvent; +} + +describe('RotateHandleDirective (shared touch marker ownership)', () => { + let fixture: ComponentFixture; + let directive: RotateHandleDirective; + let touchState: TouchEventsStateService; + let clearRotation: ReturnType; + let registerInteractionCleanup: ReturnType; + let unregister: ReturnType; + + beforeEach(() => { + clearRotation = vi.fn(); + unregister = vi.fn(); + registerInteractionCleanup = vi.fn().mockReturnValue(unregister); + + const mockRouter = { + getBaseEvent: () => ({ + id: 'id', + timestamp: 0, + modifiers: { primary: false, secondary: false, shift: false, meta: false }, + }), + emit: vi.fn(), + }; + const mockFlowCoreProvider = { + isInitialized: () => true, + provide: () => ({ + actionStateManager: { clearRotation }, + registerInteractionCleanup, + }), + }; + + TestBed.configureTestingModule({ + imports: [HostComponent], + providers: [ + { provide: InputEventsRouterService, useValue: mockRouter }, + { provide: FlowCoreProviderService, useValue: mockFlowCoreProvider }, + TouchEventsStateService, + ], + }); + + fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + directive = fixture.debugElement.query(By.directive(RotateHandleDirective)).injector.get(RotateHandleDirective); + touchState = TestBed.inject(TouchEventsStateService); + }); + + it('leaves a marker owned by another gesture alone when destroyed as a bystander', () => { + // Simulates virtualization destroying this handle's component during a touch pan + touchState.currentEvent.set(DiagramEventName.Panning); + + fixture.destroy(); + + expect(touchState.currentEvent()).toBe(DiagramEventName.Panning); + expect(clearRotation).not.toHaveBeenCalled(); + }); + + it('clears the shared touch marker on normal pointerup', () => { + directive.onPointerDown(makePointerEvent()); + expect(touchState.currentEvent()).toBe(DiagramEventName.Rotate); + + directive.onPointerUp(makePointerEvent()); + + expect(touchState.currentEvent()).toBeNull(); + expect(unregister).toHaveBeenCalledTimes(1); + }); + + it('clears its own marker and the rotation state when destroyed mid-gesture', () => { + directive.onPointerDown(makePointerEvent()); + expect(touchState.currentEvent()).toBe(DiagramEventName.Rotate); + + fixture.destroy(); + + expect(touchState.currentEvent()).toBeNull(); + expect(clearRotation).toHaveBeenCalled(); + }); + + it('does not re-register the cleanup on a second pointerdown mid-gesture', () => { + directive.onPointerDown(makePointerEvent()); + directive.onPointerDown(makePointerEvent()); + + expect(registerInteractionCleanup).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/rotate/rotate.directive.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/rotate/rotate.directive.ts index 662dd1119..fb487f7d4 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/rotate/rotate.directive.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/rotate/rotate.directive.ts @@ -20,32 +20,33 @@ export class RotateHandleDirective implements OnDestroy { targetData = input(); + private unregisterInteractionCleanup: (() => void) | null = null; + ngOnDestroy() { - this.cleanup(); + const wasMidGesture = this.gestureActive; + this.removeListeners(); // Destroyed mid-gesture (e.g. the node was deleted while rotating): the pointerup // will never be routed, so the rotation state must be cleared here. - if (this.gestureActive) { - this.gestureActive = false; - if (this.flowCoreProvider.isInitialized()) { - this.flowCoreProvider.provide().actionStateManager.clearRotation(); - } + if (wasMidGesture && this.flowCoreProvider.isInitialized()) { + this.flowCoreProvider.provide().actionStateManager.clearRotation(); } } onPointerDown($event: PointerInputEvent) { - if (!this.shouldHandle($event)) { + // Re-entry guard: a second pointerdown mid-gesture would orphan the previous interaction-cleanup registration. + if (this.gestureActive || !this.shouldHandle($event)) { return; } - $event.rotateHandled = true; - this.gestureActive = true; - this.touchEventsStateService.currentEvent.set(DiagramEventName.Rotate); - const targetData = this.targetData(); if (!targetData) { return; } + $event.rotateHandled = true; + this.gestureActive = true; + this.touchEventsStateService.currentEvent.set(DiagramEventName.Rotate); + const baseEvent = this.inputEventsRouter.getBaseEvent($event); this.inputEventsRouter.emit({ ...baseEvent, @@ -62,6 +63,9 @@ export class RotateHandleDirective implements OnDestroy { document.addEventListener('pointermove', this.onPointerMove); document.addEventListener('pointerup', this.onPointerUp); document.addEventListener('pointercancel', this.onPointerCancel); + this.unregisterInteractionCleanup = this.flowCoreProvider + .provide() + .registerInteractionCleanup(() => this.removeListeners()); } onPointerMove = ($event: PointerInputEvent) => { @@ -92,7 +96,8 @@ export class RotateHandleDirective implements OnDestroy { }; onPointerUp = ($event: PointerInputEvent) => { - this.gestureActive = false; + this.removeListeners(); + const targetData = this.targetData(); if (!targetData) { return; @@ -110,29 +115,10 @@ export class RotateHandleDirective implements OnDestroy { }, center: this.getNodeCenter(targetData), }); - this.cleanup(); }; onPointerCancel = ($event: PointerInputEvent) => { - this.gestureActive = false; - const targetData = this.targetData(); - if (!targetData) { - return; - } - - const baseEvent = this.inputEventsRouter.getBaseEvent($event); - this.inputEventsRouter.emit({ - ...baseEvent, - name: 'rotate', - phase: 'end', - target: targetData, - lastInputPoint: { - x: $event.clientX, - y: $event.clientY, - }, - center: this.getNodeCenter(targetData), - }); - this.cleanup(); + this.onPointerUp($event); }; private shouldHandle(event: PointerInputEvent) { @@ -143,8 +129,16 @@ export class RotateHandleDirective implements OnDestroy { ); } - private cleanup() { - this.touchEventsStateService.clearCurrentEvent(); + private removeListeners() { + this.unregisterInteractionCleanup?.(); + this.unregisterInteractionCleanup = null; + // The shared marker keeps concurrent gestures out (panningHandled() etc.), so + // only its writer may clear it. gestureActive marks that writer — set only by + // this instance's own pointerdown, so a bystander's destroy skips the clear. + if (this.gestureActive) { + this.gestureActive = false; + this.touchEventsStateService.clearCurrentEvent(); + } document.removeEventListener('pointermove', this.onPointerMove); document.removeEventListener('pointerup', this.onPointerUp); document.removeEventListener('pointercancel', this.onPointerCancel); diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/public-services/ng-diagram.service.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/public-services/ng-diagram.service.ts index 1cced99bf..fc02a168d 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/public-services/ng-diagram.service.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/public-services/ng-diagram.service.ts @@ -215,6 +215,37 @@ export class NgDiagramService extends NgDiagramBaseService { this.manualLinkingService.startLinking(node, portId); } + // ============================== + // Interaction Control + // ============================== + + /** + * Aborts the in-progress gesture (linking, drag, resize, rotate or pan): + * removes its listeners immediately, restores the state it modified + * (positions, size, angle, temporary edge — the viewport is not rolled + * back) and fires the corresponding "ended" event with the `cancelled` + * reason. + * + * No-op when nothing is active, when the gesture is already completing, or + * while a transaction is active (refused with a console warning — cancel + * after it settles). + * + * Bound to Escape by default via the `cancelInteraction` shortcut action — + * see {@link configureShortcuts}. + * + * @example + * ```typescript + * ngDiagramService.cancelActiveInteraction(); + * ``` + * + * @returns Promise resolving to whether anything was torn down + * + * @since 1.3.0 + */ + cancelActiveInteraction(): Promise { + return this.flowCore.cancelActiveInteraction(); + } + // ============================== // Event Management // ============================== diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/manual-linking.service.spec.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/manual-linking.service.spec.ts new file mode 100644 index 000000000..a92d3acc5 --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/manual-linking.service.spec.ts @@ -0,0 +1,77 @@ +import { TestBed } from '@angular/core/testing'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Node } from '../../../core/src'; +import { CursorPositionTrackerService } from '../cursor-position-tracker/cursor-position-tracker.service'; +import { FlowCoreProviderService } from '../flow-core-provider/flow-core-provider.service'; +import { LinkingEventService } from './linking-event.service'; +import { ManualLinkingService } from './manual-linking.service'; + +describe('ManualLinkingService', () => { + let service: ManualLinkingService; + let emitContinue: ReturnType; + let registerInteractionCleanup: ReturnType; + let unregister: ReturnType; + let registeredCleanups: (() => void)[]; + + const node = { id: 'n1', type: 'node', position: { x: 0, y: 0 }, data: {} } as Node; + + beforeEach(() => { + emitContinue = vi.fn(); + unregister = vi.fn(); + registeredCleanups = []; + registerInteractionCleanup = vi.fn().mockImplementation((cleanup: () => void) => { + registeredCleanups.push(cleanup); + return unregister; + }); + + TestBed.configureTestingModule({ + providers: [ + ManualLinkingService, + { + provide: LinkingEventService, + useValue: { emitStart: vi.fn(), emitContinue, emitEnd: vi.fn() }, + }, + { + provide: CursorPositionTrackerService, + useValue: { getLastPosition: () => ({ x: 0, y: 0 }) }, + }, + { + provide: FlowCoreProviderService, + useValue: { isInitialized: () => true, provide: () => ({ registerInteractionCleanup }) }, + }, + ], + }); + + service = TestBed.inject(ManualLinkingService); + }); + + it('registers an interaction cleanup when linking starts', () => { + service.startLinking(node); + + expect(registerInteractionCleanup).toHaveBeenCalledTimes(1); + }); + + it('cleans up the previous linking when startLinking is called again mid-flight', () => { + service.startLinking(node); + service.startLinking(node); + + // The first registration must be released, not orphaned in FlowCore's registry + expect(unregister).toHaveBeenCalledTimes(1); + expect(registerInteractionCleanup).toHaveBeenCalledTimes(2); + + // Listeners are not stacked: one pointermove -> one continue + document.dispatchEvent(new Event('pointermove')); + expect(emitContinue).toHaveBeenCalledTimes(1); + }); + + it('the registered cleanup removes the document listeners', () => { + service.startLinking(node); + document.dispatchEvent(new Event('pointermove')); + expect(emitContinue).toHaveBeenCalledTimes(1); + + registeredCleanups[0](); + + document.dispatchEvent(new Event('pointermove')); + expect(emitContinue).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/manual-linking.service.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/manual-linking.service.ts index 0455fed2f..1b3f3027d 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/manual-linking.service.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/manual-linking.service.ts @@ -2,17 +2,23 @@ import { inject, Injectable } from '@angular/core'; import { Node } from '../../../core/src'; import { PointerInputEvent } from '../../types'; import { CursorPositionTrackerService } from '../cursor-position-tracker/cursor-position-tracker.service'; +import { FlowCoreProviderService } from '../flow-core-provider/flow-core-provider.service'; import { LinkingEventService } from './linking-event.service'; @Injectable() export class ManualLinkingService { private readonly linkingEventService = inject(LinkingEventService); private readonly cursorPositionTrackerService = inject(CursorPositionTrackerService); + private readonly flowCoreProvider = inject(FlowCoreProviderService); private node: Node | undefined; private portId: string | undefined; + private unregisterInteractionCleanup: (() => void) | null = null; /** Call this method to start linking from your custom logic */ startLinking(node: Node, portId?: string) { + // A previous manual linking still in flight would leave its document + // listeners and its interaction-cleanup entry orphaned — latest call wins. + this.removeListeners(); this.node = node; this.portId = portId; const position = this.cursorPositionTrackerService.getLastPosition(); @@ -28,6 +34,9 @@ export class ManualLinkingService { document.addEventListener('click', this.onDocumentClick, true); document.addEventListener('touchmove', this.onTouchMove, { passive: false }); document.addEventListener('touchend', this.onTouchEnd, { passive: false }); + this.unregisterInteractionCleanup = this.flowCoreProvider + .provide() + .registerInteractionCleanup(() => this.removeListeners()); } private onPointerMove = (event: PointerEvent) => { @@ -60,16 +69,18 @@ export class ManualLinkingService { clientY: touch.clientY, } as PointerInputEvent; - this.cleanup(); + this.removeListeners(); this.linkingEventService.emitEnd(mockEvent, this.node, this.portId); }; private onDocumentClick = (event: MouseEvent) => { - this.cleanup(); + this.removeListeners(); this.linkingEventService.emitEnd(event as PointerInputEvent, this.node, this.portId); }; - private cleanup() { + private removeListeners() { + this.unregisterInteractionCleanup?.(); + this.unregisterInteractionCleanup = null; document.removeEventListener('pointermove', this.onPointerMove); document.removeEventListener('click', this.onDocumentClick, true); document.removeEventListener('touchmove', this.onTouchMove); diff --git a/packages/ng-diagram/projects/ng-diagram/src/public-api.ts b/packages/ng-diagram/projects/ng-diagram/src/public-api.ts index 1c05ee028..6cf5ac4e0 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/public-api.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/public-api.ts @@ -114,6 +114,7 @@ export type { FlowConfig, FlowState, FlowStateUpdate, + GestureCancelReason, GroupingConfig, GroupMembershipChangedEvent, GroupNode,