From c91f2a0551ee8ff505df2f2e3a91810fc4ea1058 Mon Sep 17 00:00:00 2001 From: Jacek Debek Date: Tue, 14 Jul 2026 13:48:04 +0200 Subject: [PATCH 01/18] Cancelling live interactions --- CHANGELOG.md | 1 + .../docs/api/Internals/DraggingActionState.md | 17 + .../docs/api/Internals/ResizeActionState.md | 8 + .../docs/api/Internals/RotationActionState.md | 8 + .../docs/api/Services/NgDiagramService.md | 41 ++ .../Shortcuts/KeyboardActionName.md | 2 +- .../api/Types/Events/EdgeDrawCancelReason.md | 4 +- .../api/Types/Events/GestureCancelReason.md | 15 + .../api/Types/Events/NodeDragEndedEvent.md | 8 + .../api/Types/Events/NodeResizeEndedEvent.md | 8 + .../api/Types/Events/NodeRotateEndedEvent.md | 8 + .../api/Types/Middleware/ModelActionType.md | 2 +- apps/docs/src/content/docs/api/_readme.md | 1 + .../ng-diagram/api-report/ng-diagram.api.md | 17 +- .../src/command-handler/commands/index.ts | 2 + .../linking/__tests__/cancel-linking.test.ts | 90 ++++ .../commands/linking/cancel-linking.ts | 26 ++ .../commands/linking/finish-linking.ts | 10 +- .../command-handler/commands/linking/index.ts | 1 + .../src/core/src/event-manager/event-types.ts | 23 +- .../src/core/src/event-manager/index.ts | 1 + .../ng-diagram/src/core/src/flow-core.test.ts | 123 ++++++ .../ng-diagram/src/core/src/flow-core.ts | 72 +++- .../cancel-interaction.handler.ts | 16 + .../cancel-interaction.integration.test.ts | 408 ++++++++++++++++++ .../input-events/handlers/event-handler.ts | 12 + .../handlers/linking/linking.handler.ts | 8 + .../handlers/panning/panning.handler.ts | 5 + .../panning/virtualized-panning.handler.ts | 7 + .../pointer-move-selection.handler.ts | 43 ++ .../pointer-move-selection.test.ts | 86 ++++ .../handlers/resize/resize.handler.ts | 27 ++ .../handlers/resize/resize.test.ts | 54 +++ .../handlers/rotate/rotate.handler.ts | 21 + .../handlers/rotate/rotate.test.ts | 46 ++ .../input-events/input-events.interface.ts | 3 +- .../src/input-events/input-events.router.ts | 10 + .../node-drag-lifecycle.emitter.test.ts | 11 +- .../emitters/node-drag-lifecycle.emitter.ts | 8 +- .../emitters/node-resize-lifecycle.emitter.ts | 8 +- .../emitters/node-rotate-lifecycle.emitter.ts | 8 +- .../src/shortcut-manager/default-shortcuts.ts | 6 + .../core/src/types/action-state.interface.ts | 13 +- .../src/types/command-handler.interface.ts | 2 + .../core/src/types/middleware.interface.ts | 3 + .../src/types/shortcut-action.interface.ts | 5 +- .../keyboard-inputs.directive.ts | 10 +- .../input-events/linking/linking.directive.ts | 6 + .../input-events/panning/panning.directive.ts | 18 +- .../pointer-move-selection.directive.ts | 25 +- .../input-events/resize/resize.directive.ts | 19 +- .../input-events/rotate/rotate.directive.ts | 9 + .../lib/public-services/ng-diagram.service.ts | 37 ++ .../input-events/manual-linking.service.ts | 8 + .../projects/ng-diagram/src/public-api.ts | 1 + 55 files changed, 1393 insertions(+), 38 deletions(-) create mode 100644 apps/docs/src/content/docs/api/Types/Events/GestureCancelReason.md create mode 100644 packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/cancel-linking.test.ts create mode 100644 packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/cancel-linking.ts create mode 100644 packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/cancel-interaction/cancel-interaction.handler.ts create mode 100644 packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/cancel-interaction/cancel-interaction.integration.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a92453b0..8f35911a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Remove ports from default nodes** – this is now possible to remove ports from default nodes ([#759](https://github.com/synergycodes/ng-diagram/pull/759)) +- **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 ### Fixed diff --git a/apps/docs/src/content/docs/api/Internals/DraggingActionState.md b/apps/docs/src/content/docs/api/Internals/DraggingActionState.md index ee09a12ff..ea4b17b5f 100644 --- a/apps/docs/src/content/docs/api/Internals/DraggingActionState.md +++ b/apps/docs/src/content/docs/api/Internals/DraggingActionState.md @@ -19,6 +19,23 @@ 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`. + +*** + +### initialPositions? + +> `optional` **initialPositions**: `Map`\<`string`, [`Point`](/docs/api/types/geometry/point/)\> + +Positions of the dragged nodes captured when the move threshold was crossed, +used to restore them when the drag is cancelled. + +*** + ### 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 febeece26..9eb2967b1 100644 --- a/apps/docs/src/content/docs/api/Services/NgDiagramService.md +++ b/apps/docs/src/content/docs/api/Services/NgDiagramService.md @@ -161,6 +161,47 @@ True if events are enabled. *** +### cancelActiveInteraction() + +> **cancelActiveInteraction**(): `Promise`\<`boolean`\> + +Aborts the interactive gesture currently in progress — linking, dragging, +resizing, rotating or panning. + +The gesture is torn down immediately: its action state is cleared, its +document-level pointer listeners are removed (no need to wait for pointer +release) and the corresponding "ended" event (`edgeDrawEnded`, +`nodeDragEnded`, `nodeResizeEnded`, `nodeRotateEnded`) fires with the +`cancelled` reason. Diagram state modified by the gesture is restored: +dragged nodes snap back to their pre-drag positions, a resized node gets +its original size/position/autoSize back, a rotated node its original +angle, and the temporary edge of a linking gesture is discarded. Panning +only stops — the viewport is navigation state and is not rolled back. +No-op when nothing is active. + +Bound to the Escape key by default via the `cancelInteraction` shortcut +action; rebind or disable it with [configureShortcuts](/docs/api/utilities/configureshortcuts/). + +#### Returns + +`Promise`\<`boolean`\> + +Promise resolving to whether any gesture or registered listener +cleanup was torn down. + +#### Example + +```typescript +// Abort the temporary edge / drag preview from custom logic +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 3daf642ed..ef1367669 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/packages/ng-diagram/api-report/ng-diagram.api.md b/packages/ng-diagram/api-report/ng-diagram.api.md index e99ff1167..8e7ec5520 100644 --- a/packages/ng-diagram/api-report/ng-diagram.api.md +++ b/packages/ng-diagram/api-report/ng-diagram.api.md @@ -197,6 +197,8 @@ export class DiagramSelectionDirective extends ObjectSelectionDirective { // @public export interface DraggingActionState { accumulatedDeltas: Map; + cancelReason?: GestureCancelReason; + initialPositions?: Map; modifiers: InputModifiers; movementStarted: boolean; nodeIds: string[]; @@ -228,7 +230,7 @@ export interface Edge { } // @public -export type EdgeDrawCancelReason = 'noTarget' | 'invalidConnection' | 'invalidTarget'; +export type EdgeDrawCancelReason = 'noTarget' | 'invalidConnection' | 'invalidTarget' | 'cancelled'; // @public export interface EdgeDrawEndedEvent { @@ -387,6 +389,9 @@ export interface FlowStateUpdate { renderedNodeIds?: string[]; } +// @public +export type GestureCancelReason = 'cancelled'; + // @public export interface GroupingConfig { canGroup: (node: Node_2, group: Node_2) => boolean; @@ -440,7 +445,7 @@ export interface InvalidateMeasurementsOptions { } // @public -export type KeyboardActionName = KeyboardMoveSelectionAction | KeyboardPanAction | KeyboardZoomAction | Extract; +export type KeyboardActionName = KeyboardMoveSelectionAction | KeyboardPanAction | KeyboardZoomAction | Extract; // @public (undocumented) export class KeyboardInputsDirective { @@ -653,7 +658,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[]; @@ -1212,6 +1217,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; @@ -1278,6 +1284,7 @@ export { Node_2 as Node } // @public export interface NodeDragEndedEvent { + cancelReason?: GestureCancelReason; nodes: Node_2[]; } @@ -1308,6 +1315,7 @@ export interface NodeResizedEvent { // @public export interface NodeResizeEndedEvent { + cancelReason?: GestureCancelReason; node: Node_2; } @@ -1318,6 +1326,7 @@ export interface NodeResizeStartedEvent { // @public export interface NodeRotateEndedEvent { + cancelReason?: GestureCancelReason; node: Node_2; } @@ -1466,6 +1475,7 @@ export interface Rect { // @public export interface ResizeActionState { + cancelReason?: GestureCancelReason; resizingNode: Node_2; startHeight: number; startNodePositionX: number; @@ -1484,6 +1494,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..feaebb557 --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/cancel-linking.test.ts @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { FlowCore } from '../../../../flow-core'; +import type { CommandHandler, Edge, LinkingActionState } from '../../../../types'; +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 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..f1482d507 --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/cancel-linking.ts @@ -0,0 +1,26 @@ +import type { CommandHandler } from '../../../types'; +import { clearTemporaryEdge } from './finish-linking'; + +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; + + if (!linking) { + return; + } + + linking.cancelReason = 'cancelled'; + linking.dropPosition ??= linking.temporaryEdge?.targetPosition ?? { x: 0, y: 0 }; + + await clearTemporaryEdge(commandHandler); +}; 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 b845ad54d..568beb132 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 @@ -7,7 +7,15 @@ export interface FinishLinkingCommand { position?: Point; } -const clearTemporaryEdge = async (commandHandler: CommandHandler): Promise => { +/** + * Ends a linking gesture without creating an edge: removes the temporary edge + * and clears the linking action state. Runs under the `finishLinking` action + * type so the `edgeDrawEnded` emitter observes every gesture ending — set + * `linking.cancelReason` before calling to mark the ending as a cancellation. + * + * @internal + */ +export const clearTemporaryEdge = async (commandHandler: CommandHandler): Promise => { await commandHandler.flowCore.applyUpdate({}, 'finishLinking'); commandHandler.flowCore.actionStateManager.clearLinking(); }; 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 3fb916596..0c9633a2d 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 @@ -115,6 +115,7 @@ describe('FlowCore', () => { emit: vi.fn(), register: vi.fn(), registerDefaultCallbacks: vi.fn(), + cancel: vi.fn().mockResolvedValue(undefined), } as unknown as InputEventsRouter; // Reset all mocks @@ -224,6 +225,128 @@ 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 = { + nodeIds: [], + modifiers: { primary: false, secondary: false, shift: false, meta: false }, + accumulatedDeltas: new Map(), + movementStarted: true, + }; + 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'); + }); + }); + + 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 = { + nodeIds: [], + modifiers: { primary: false, secondary: false, shift: false, meta: false }, + accumulatedDeltas: new Map(), + movementStarted: 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 7987442d1..88456c74c 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 @@ -3,7 +3,7 @@ import { CommandHandler } from './command-handler/command-handler'; 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 { InputEventsRouter, type InputEventName } from './input-events'; import { LabelBatchProcessor } from './label-batch-processor/label-batch-processor'; import { MeasurementTracker } from './measurement-tracker/measurement-tracker'; import { MiddlewareManager } from './middleware-manager/middleware-manager'; @@ -439,6 +439,76 @@ export class FlowCore { return this.modelLookup.getEdgeById(edgeId); } + private readonly interactionCleanups = new Set<() => void>(); + + /** + * Registers a cleanup callback for the gesture that is starting — typically + * the removal of document-level pointer listeners owned by the view layer. + * + * The callback runs when {@link cancelActiveInteraction} aborts the gesture. + * The caller must invoke the returned unregister function in its own normal + * teardown (pointer release) so stale callbacks don't accumulate. + * + * @param cleanup Callback tearing down the gesture's listeners + * @returns Function that unregisters the callback + */ + registerInteractionCleanup(cleanup: () => void): () => void { + this.interactionCleanups.add(cleanup); + return () => { + this.interactionCleanups.delete(cleanup); + }; + } + + /** + * 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.actionStateManager.isLinking() || + this.actionStateManager.isDragging() || + this.actionStateManager.isResizing() || + this.actionStateManager.isRotating() || + this.actionStateManager.isPanning() || + this.interactionCleanups.size > 0 + ); + } + + /** + * Aborts the interactive gesture currently in progress (linking, dragging, + * resizing, rotating or panning). + * + * Runs the registered listener cleanups, restores the diagram state the + * gesture modified (node positions/size/angle, temporary edge), clears the + * gesture's action state and lets the corresponding "ended" event fire with + * the `cancelled` reason. No-op when nothing is active. + * + * @returns Whether any gesture or registered listener cleanup was torn down + */ + async cancelActiveInteraction(): Promise { + const activeGestures: InputEventName[] = []; + if (this.actionStateManager.isLinking()) activeGestures.push('linking'); + if (this.actionStateManager.isDragging()) activeGestures.push('pointerMoveSelection'); + if (this.actionStateManager.isResizing()) activeGestures.push('resize'); + if (this.actionStateManager.isRotating()) activeGestures.push('rotate'); + if (this.actionStateManager.isPanning()) activeGestures.push('panning'); + + // 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.interactionCleanups]; + this.interactionCleanups.clear(); + for (const cleanup of cleanups) { + cleanup(); + } + + for (const gesture of activeGestures) { + await this.inputEventsRouter.cancel(gesture); + } + + return activeGestures.length > 0 || cleanups.length > 0; + } + /** * 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..98f26cd54 --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/cancel-interaction/cancel-interaction.handler.ts @@ -0,0 +1,16 @@ +import { BaseInputEvent } from '../../input-events.interface'; +import { EventHandler } from '../event-handler'; + +/** + * Aborts whatever interactive gesture is currently in progress + * (linking, dragging, resizing, rotating, panning). + * + * Bound to the `cancelInteraction` shortcut action (Escape by default) and + * reachable programmatically via `NgDiagramService.cancelActiveInteraction()`. + * No-op when nothing is active. + */ +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..fe39be43e --- /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,408 @@ +/* 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 { InputEventsRouter } from '../../input-events.router'; +import type { 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 = { + os: 'MacOS', + browser: 'Chrome', + runtime: 'web', + now: () => 0, + generateId: (() => { + let i = 0; + return () => `generated-${i++}`; + })(), +}; + +const modifiers: InputModifiers = { primary: false, secondary: false, shift: false, meta: false }; + +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 }; +} + +/** Flushes the fire-and-forget async work the input handlers schedule. */ +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + +const draggableNode = (overrides: Partial = {}): Node => ({ + id: 'n1', + type: 'node', + selected: true, + position: { x: 10, y: 20 }, + data: {}, + ...overrides, +}); + +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', () => { + const startDrag = async (flowCore: FlowCore, router: InputEventsRouter, node: Node) => { + router.emit({ + name: 'pointerMoveSelection', + phase: 'start', + id: 'e1', + timestamp: 0, + modifiers, + target: node, + targetType: 'node', + lastInputPoint: { x: 100, y: 100 }, + panningForce: null, + }); + await router.emit({ + name: 'pointerMoveSelection', + phase: 'continue', + id: 'e2', + timestamp: 0, + modifiers, + target: node, + targetType: 'node', + lastInputPoint: { x: 150, y: 180 }, + panningForce: null, + }); + await settle(); + }; + + 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 router.emit({ + name: 'pointerMoveSelection', + phase: 'end', + id: 'e3', + timestamp: 0, + modifiers, + target: node, + targetType: 'node', + lastInputPoint: { x: 150, y: 180 }, + panningForce: null, + }); + await settle(); + + 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 router.emit({ + name: 'resize', + phase: 'start', + id: 'e1', + timestamp: 0, + modifiers, + target: node, + targetType: 'node', + direction: 'bottom-right', + lastInputPoint: { x: 100, y: 100 }, + }); + await router.emit({ + name: 'resize', + phase: 'continue', + id: 'e2', + timestamp: 0, + modifiers, + target: node, + targetType: 'node', + direction: 'bottom-right', + lastInputPoint: { x: 150, y: 140 }, + }); + await settle(); + + 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 router.emit({ + name: 'rotate', + phase: 'start', + id: 'e1', + timestamp: 0, + modifiers, + target: node, + targetType: 'node', + center: { x: 60, y: 45 }, + lastInputPoint: { x: 200, y: 45 }, + }); + await router.emit({ + name: 'rotate', + phase: 'continue', + id: 'e2', + timestamp: 0, + modifiers, + target: node, + targetType: 'node', + center: { x: 60, y: 45 }, + lastInputPoint: { x: 60, y: 200 }, + }); + await settle(); + + 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); + + router.emit({ + name: 'linking', + phase: 'start', + id: 'e1', + timestamp: 0, + modifiers, + target: node, + targetType: 'node', + portId: undefined, + lastInputPoint: { x: 10, y: 20 }, + }); + await settle(); + router.emit({ + name: 'linking', + phase: 'continue', + id: 'e2', + timestamp: 0, + modifiers, + target: node, + targetType: 'node', + portId: undefined, + lastInputPoint: { x: 120, y: 90 }, + }); + await settle(); + + 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 })]); + + router.emit({ + name: 'panning', + phase: 'start', + id: 'e1', + timestamp: 0, + modifiers, + target: undefined, + targetType: 'diagram', + lastInputPoint: { x: 100, y: 100 }, + }); + await router.emit({ + name: 'panning', + phase: 'continue', + id: 'e2', + timestamp: 0, + modifiers, + target: undefined, + targetType: 'diagram', + lastInputPoint: { x: 130, y: 110 }, + }); + await settle(); + + 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); + }); + }); +}); 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..3ee79dc51 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 @@ -5,4 +5,16 @@ export abstract class EventHandler { constructor(protected readonly flow: FlowCore) {} abstract handle(event: TEvent): void | Promise; + + /** + * Aborts the gesture this handler is currently tracking, without the side + * effects of a normal `end` phase (no edge creation, no group drop, …). + * + * Gesture handlers override this to clear their action state, reset internal + * tracking and let the corresponding "ended" event fire with a cancel reason. + * The default is a no-op for handlers without an in-progress gesture concept. + */ + cancel(): void | Promise { + // No-op by default. + } } 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 e8253918e..e2e0b6415 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 @@ -64,4 +64,12 @@ export class LinkingEventHandler extends EventHandler { } } } + + override async cancel(): Promise { + if (!this.flow.actionStateManager.isLinking()) { + return; + } + + await this.flow.commandHandler.emit('cancelLinking'); + } } 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..065859f0f 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 @@ -35,4 +35,9 @@ export class PanningEventHandler extends EventHandler { } } } + + override cancel(): void { + this.lastPoint = undefined; + this.flow.actionStateManager.clearPanning(); + } } 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..f5f7a00b2 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 @@ -48,6 +48,13 @@ export class VirtualizedPanningEventHandler extends EventHandler { } } + override cancel(): void { + this.accumulatedDelta = { x: 0, y: 0 }; + this.lastPoint = undefined; + this.rafScheduled = false; + this.flow.actionStateManager.clearPanning(); + } + /** * 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/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 07b337c4d..467c0b2cc 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 @@ -52,6 +52,8 @@ export class PointerMoveSelectionEventHandler extends EventHandler [n.id, { ...n.position }])), }; await this.flow.commandHandler.emit('moveNodesStart'); } @@ -95,6 +97,47 @@ export class PointerMoveSelectionEventHandler extends EventHandler { + if (this.isMoving || this.flow.actionStateManager.isDragging()) { + const dragging = this.flow.actionStateManager.dragging; + const needsStop = this.hasMoved; + const needsHighlightClear = !!this.flow.actionStateManager.highlightGroup; + + if (needsStop && dragging) { + // Must be set BEFORE the transaction commits — the NodeDragEndedEmitter + // reads it from the action state during middleware execution. + dragging.cancelReason = 'cancelled'; + } + + if (needsStop || needsHighlightClear) { + // Unlike the 'end' phase, skip the drop handling — an aborted drag must + // not change group membership. + await this.flow.transaction('cancelDrag', async (tx) => { + if (needsStop) { + // Snap the dragged nodes back to where they were before the drag. + const initialPositions = dragging?.initialPositions; + if (initialPositions?.size) { + await tx.emit('updateNodes', { + nodes: [...initialPositions].map(([id, position]) => ({ id, position })), + }); + } + await tx.emit('moveNodesStop'); + } + if (needsHighlightClear) { + await tx.emit('highlightGroupClear'); + } + }); + } + + this.flow.actionStateManager.clearDragging(); + } + + this.lastPointerPosition = undefined; + this.startPoint = undefined; + this.isMoving = false; + this.hasMoved = false; + } + 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 6735c5f8c..b1290879e 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: [] }); @@ -765,4 +767,88 @@ describe('PointerMoveSelectionEventHandler', () => { expect(mockEmit).not.toHaveBeenCalledWith('moveNodesStop'); }); }); + + 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'); + 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 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 eac4316af..b5729d888 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 @@ -126,4 +126,31 @@ export class ResizeEventHandler extends EventHandler { } } } + + override async cancel(): Promise { + const resize = this.flow.actionStateManager.resize; + if (!resize) { + return; + } + + 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'); + }); + + this.flow.actionStateManager.clearResize(); + } } 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 d47315725..5b1fe9fc3 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, @@ -53,6 +58,7 @@ describe('ResizeEventHandler', () => { clientToFlowPosition: vi.fn(({ x, y }) => ({ x, y })), getNodeById: vi.fn().mockReturnValue(nodeWithSize), actionStateManager: mockActionStateManager, + transaction: mockTransaction, } as unknown as FlowCore; handler = new ResizeEventHandler(mockFlowCore); @@ -123,4 +129,52 @@ 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'); + 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)); + }); + }); }); 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 b47cd25bf..a203400f3 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 @@ -82,4 +82,25 @@ export class RotateEventHandler extends EventHandler { } } } + + override async cancel(): Promise { + const rotation = this.flow.actionStateManager.rotation; + if (!rotation) { + return; + } + + 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'); + }); + + this.flow.actionStateManager.clearRotation(); + } } 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 89c964585..e8a938648 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 @@ -55,6 +55,10 @@ describe('RotateEventHandler', () => { 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(); @@ -164,4 +168,46 @@ 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'); + 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)); + }); + }); }); 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..98b38cd18 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,18 @@ 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. + */ + async cancel(eventName: InputEventName): Promise { + await this.handlers[eventName]?.cancel(); + } } 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 513dd417c..53cb8e3f7 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 @@ -43,7 +43,8 @@ export class NodeDragEndedEmitter implements EventEmitter { return; } - const nodeIds = context.actionStateManager.dragging?.nodeIds; + const dragging = context.actionStateManager.dragging; + const nodeIds = dragging?.nodeIds; if (!nodeIds || nodeIds.length === 0) { return; } @@ -53,6 +54,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 3fdf994db..b83bb7f8f 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 @@ -32,7 +32,8 @@ export class NodeResizeEndedEmitter implements EventEmitter { return; } - const resizingNode = context.actionStateManager.resize?.resizingNode; + const resize = context.actionStateManager.resize; + const resizingNode = resize?.resizingNode; if (!resizingNode) { return; } @@ -42,6 +43,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 391a27564..bb78f5661 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 @@ -32,7 +32,8 @@ export class NodeRotateEndedEmitter implements EventEmitter { return; } - const nodeId = context.actionStateManager.rotation?.nodeId; + const rotation = context.actionStateManager.rotation; + const nodeId = rotation?.nodeId; if (!nodeId) { return; } @@ -42,6 +43,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 c3139dae9..4b95af7f5 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; } /** @@ -88,6 +90,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; } /** @@ -112,6 +116,13 @@ export interface DraggingActionState { * `false` when the drag state is first created (on pointer down), `true` once movement exceeds the threshold. */ movementStarted: boolean; + /** + * Positions of the dragged nodes captured when the move threshold was crossed, + * used to restore them when the drag is cancelled. + */ + initialPositions?: Map; + /** 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 9833a179f..611de9079 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 08e4fde54..33b678f3c 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/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..9db79ba3e 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 @@ -49,13 +49,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.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/linking/linking.directive.ts index 1bb982393..e370e9386 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,6 +20,7 @@ export class LinkingInputDirective implements OnDestroy { private target = signal(undefined); private edgePanningInterval: number | null = null; + private unregisterInteractionCleanup: (() => void) | null = null; portId = input.required(); @@ -41,6 +42,9 @@ export class LinkingInputDirective implements OnDestroy { document.addEventListener('pointermove', this.onPointerMove); document.addEventListener('pointerup', this.onPointerUp); + this.unregisterInteractionCleanup = this.flowCoreProviderService + .provide() + .registerInteractionCleanup(() => this.cleanup()); this.linkingEventService.emitStart($event, this.target(), this.portId()); } @@ -95,6 +99,8 @@ export class LinkingInputDirective implements OnDestroy { } private cleanup() { + this.unregisterInteractionCleanup?.(); + this.unregisterInteractionCleanup = null; this.touchEventsStateService.clearCurrentEvent(); document.removeEventListener('pointermove', this.onPointerMove); document.removeEventListener('pointerup', this.onPointerUp); 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..f6fa72ce7 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,10 @@ export class PanningDirective implements OnDestroy { private readonly diagramService = inject(NgDiagramService); private readonly flowCoreProvider = inject(FlowCoreProviderService); + private unregisterInteractionCleanup: (() => void) | null = null; + ngOnDestroy(): void { - document.removeEventListener('pointermove', this.onMouseMove); - document.removeEventListener('pointerup', this.onPointerUp); + this.removeListeners(); } onPointerDown(event: PointerInputEvent): void { @@ -52,13 +53,15 @@ export class PanningDirective implements OnDestroy { 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 +116,16 @@ export class PanningDirective implements OnDestroy { }); }; - private finishPanning(event: PointerInputEvent): void { + private removeListeners(): void { + 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.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/pointer-move-selection/pointer-move-selection.directive.ts index 670ffdd62..d55d1a084 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,11 +24,10 @@ export class PointerMoveSelectionDirective implements OnDestroy { private edgePanningInterval: number | null = null; private cachedDiagramRect: DOMRect | null = null; + private unregisterInteractionCleanup: (() => void) | null = null; ngOnDestroy() { - document.removeEventListener('pointermove', this.onPointerMove); - document.removeEventListener('pointerup', this.onPointerUp); - this.stopEdgePanning(); + this.removeListeners(); } onPointerDown(event: PointerInputEvent): void { @@ -65,6 +64,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 +129,23 @@ 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; + 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 +160,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.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/resize/resize.directive.ts index 80fac3ec1..fe4701909 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 @@ -1,6 +1,7 @@ import { Directive, inject, input, OnDestroy } from '@angular/core'; import { Node, ResizeDirection } from '../../../../core/src'; +import { FlowCoreProviderService } from '../../../services/flow-core-provider/flow-core-provider.service'; 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'; @@ -15,12 +16,14 @@ import { DiagramEventName, type PointerInputEvent } from '../../../types/pointer export class ResizeDirective implements OnDestroy { private readonly inputEventsRouter = inject(InputEventsRouterService); private readonly touchEventsStateService = inject(TouchEventsStateService); + private readonly flowCoreProvider = inject(FlowCoreProviderService); direction = input.required(); targetData = input.required(); + private unregisterInteractionCleanup: (() => void) | null = null; + ngOnDestroy() { - document.removeEventListener('pointermove', this.onPointerMove); - document.removeEventListener('pointerup', this.onPointerUp); + this.removeListeners(); } onPointerDown(event: PointerInputEvent): void { if (!this.shouldHandle(event)) { @@ -34,6 +37,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({ @@ -50,11 +56,16 @@ export class ResizeDirective implements OnDestroy { }); } - onPointerUp = (event: PointerEvent) => { + private removeListeners(): void { + this.unregisterInteractionCleanup?.(); + this.unregisterInteractionCleanup = null; document.removeEventListener('pointermove', this.onPointerMove); document.removeEventListener('pointerup', this.onPointerUp); - 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.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/rotate/rotate.directive.ts index 5b6244514..68a91f7fe 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 @@ -1,5 +1,6 @@ import { Directive, inject, input, OnDestroy } from '@angular/core'; import { Node } from '../../../../core/src'; +import { FlowCoreProviderService } from '../../../services/flow-core-provider/flow-core-provider.service'; 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, PointerInputEvent } from '../../../types'; @@ -14,9 +15,12 @@ import { DiagramEventName, PointerInputEvent } from '../../../types'; export class RotateHandleDirective implements OnDestroy { private readonly inputEventsRouter = inject(InputEventsRouterService); private readonly touchEventsStateService = inject(TouchEventsStateService); + private readonly flowCoreProvider = inject(FlowCoreProviderService); targetData = input(); + private unregisterInteractionCleanup: (() => void) | null = null; + ngOnDestroy() { this.cleanup(); } @@ -50,6 +54,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.cleanup()); } onPointerMove = ($event: PointerInputEvent) => { @@ -130,6 +137,8 @@ export class RotateHandleDirective implements OnDestroy { } private cleanup() { + this.unregisterInteractionCleanup?.(); + this.unregisterInteractionCleanup = null; this.touchEventsStateService.clearCurrentEvent(); document.removeEventListener('pointermove', this.onPointerMove); document.removeEventListener('pointerup', this.onPointerUp); 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 e17c41a2c..de84e59aa 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,43 @@ export class NgDiagramService extends NgDiagramBaseService { this.manualLinkingService.startLinking(node, portId); } + // ============================== + // Interaction Control + // ============================== + + /** + * Aborts the interactive gesture currently in progress — linking, dragging, + * resizing, rotating or panning. + * + * The gesture is torn down immediately: its action state is cleared, its + * document-level pointer listeners are removed (no need to wait for pointer + * release) and the corresponding "ended" event (`edgeDrawEnded`, + * `nodeDragEnded`, `nodeResizeEnded`, `nodeRotateEnded`) fires with the + * `cancelled` reason. Diagram state modified by the gesture is restored: + * dragged nodes snap back to their pre-drag positions, a resized node gets + * its original size/position/autoSize back, a rotated node its original + * angle, and the temporary edge of a linking gesture is discarded. Panning + * only stops — the viewport is navigation state and is not rolled back. + * No-op when nothing is active. + * + * Bound to the Escape key by default via the `cancelInteraction` shortcut + * action; rebind or disable it with {@link configureShortcuts}. + * + * @example + * ```typescript + * // Abort the temporary edge / drag preview from custom logic + * ngDiagramService.cancelActiveInteraction(); + * ``` + * + * @returns Promise resolving to whether any gesture or registered listener + * cleanup 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.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/manual-linking.service.ts index 0455fed2f..20865dc0f 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,14 +2,17 @@ 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) { @@ -28,6 +31,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.cleanup()); } private onPointerMove = (event: PointerEvent) => { @@ -70,6 +76,8 @@ export class ManualLinkingService { }; private cleanup() { + 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 e241670fb..f2998bceb 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/public-api.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/public-api.ts @@ -108,6 +108,7 @@ export type { FlowConfig, FlowState, FlowStateUpdate, + GestureCancelReason, GroupingConfig, GroupMembershipChangedEvent, GroupNode, From bdbd60fc4839680fe7581453b3dab343709fd906 Mon Sep 17 00:00:00 2001 From: Jacek Debek Date: Mon, 3 Aug 2026 06:56:19 +0200 Subject: [PATCH 02/18] Merge with await logic --- .../commands/linking/cancel-linking.ts | 16 +++++++++++++--- .../pointer-move-selection.test.ts | 2 +- .../handlers/resize/resize.handler.ts | 2 +- .../input-events/handlers/resize/resize.test.ts | 2 +- .../handlers/rotate/rotate.handler.ts | 2 +- .../input-events/handlers/rotate/rotate.test.ts | 2 +- 6 files changed, 18 insertions(+), 8 deletions(-) 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 index f1482d507..5ec48ab86 100644 --- 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 @@ -1,5 +1,7 @@ import type { CommandHandler } from '../../../types'; -import { clearTemporaryEdge } from './finish-linking'; +import type { InternalLinkingActionState } from '../../../types/action-state.interface'; +import { runCancelledFinishPass } from './finish-linking'; +import { clearLinkingForGesture } from './linking-gesture'; export interface CancelLinkingCommand { name: 'cancelLinking'; @@ -13,14 +15,22 @@ export interface CancelLinkingCommand { * linking is in progress. */ export const cancelLinking = async (commandHandler: CommandHandler): Promise => { - const linking = commandHandler.flowCore.actionStateManager.linking; + const linking = commandHandler.flowCore.actionStateManager.linking as InternalLinkingActionState | undefined; if (!linking) { return; } + const gestureId = linking._gestureId; linking.cancelReason = 'cancelled'; linking.dropPosition ??= linking.temporaryEdge?.targetPosition ?? { x: 0, y: 0 }; - await clearTemporaryEdge(commandHandler); + // Mirror finishLinking: run the 'finishLinking' pass so the edgeDrawEnded + // emitter observes the cancellation, then clear the linking state in finally + // (gesture-stamped, so a state replaced mid-gesture is not wrongly cleared). + try { + await runCancelledFinishPass(commandHandler); + } finally { + clearLinkingForGesture(commandHandler.flowCore.actionStateManager, gestureId); + } }; 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 178cf4d2b..a414afd5b 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 @@ -1020,7 +1020,7 @@ describe('PointerMoveSelectionEventHandler', () => { await handler.cancel(); expect(mockActionStateManager.dragging?.cancelReason).toBe('cancelled'); - expect(mockEmit).toHaveBeenCalledWith('moveNodesStop'); + expect(mockEmit).toHaveBeenCalledWith('moveNodesStop', { nodeIds: expect.any(Array) }); expect(mockActionStateManager.clearDragging).toHaveBeenCalled(); }); 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 816df95fb..bc475b94b 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 @@ -157,7 +157,7 @@ export class ResizeEventHandler extends EventHandler { autoSize: resizingNode.autoSize, }, }); - await tx.emit('resizeNodeStop'); + await tx.emit('resizeNodeStop', { nodeId: resizingNode.id }); }); this.flow.actionStateManager.clearResize(); 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 89334c5a7..144e85279 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 @@ -194,7 +194,7 @@ describe('ResizeEventHandler', () => { await handler.cancel(); expect(resizeState?.cancelReason).toBe('cancelled'); - expect(mockEmit).toHaveBeenCalledWith('resizeNodeStop'); + expect(mockEmit).toHaveBeenCalledWith('resizeNodeStop', { nodeId: 'node1' }); expect(mockActionStateManager.clearResize).toHaveBeenCalled(); }); 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 471b530ee..d2a005996 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 @@ -107,7 +107,7 @@ export class RotateEventHandler extends EventHandler { id: rotation.nodeId, nodeChanges: { angle: rotation.initialNodeAngle }, }); - await tx.emit('rotateNodeStop'); + await tx.emit('rotateNodeStop', { nodeId: rotation.nodeId }); }); this.flow.actionStateManager.clearRotation(); 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 a5aa2a1a2..e78b5a4c2 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 @@ -238,7 +238,7 @@ describe('RotateEventHandler', () => { await instance.cancel(); expect(rotation.cancelReason).toBe('cancelled'); - expect(mockCommandHandler.emit).toHaveBeenCalledWith('rotateNodeStop'); + expect(mockCommandHandler.emit).toHaveBeenCalledWith('rotateNodeStop', { nodeId: 'test-node' }); expect(mockActionStateManager.clearRotation).toHaveBeenCalled(); }); From 43f7aa58a7236c3e33a333cd01354124aab41d9d Mon Sep 17 00:00:00 2001 From: Jacek Debek Date: Mon, 3 Aug 2026 06:59:13 +0200 Subject: [PATCH 03/18] Update changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1c42fb4a..36be1242d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Remove ports from default nodes** – this is now possible to remove ports from default nodes ([#759](https://github.com/synergycodes/ng-diagram/pull/759)) - `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)) - **`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 +- **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 ([#766](https://github.com/synergycodes/ng-diagram/pull/766)) ### Fixed From b14797da0df239e4c1374e412ff724ed4e526e94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Ja=C5=BAwa?= Date: Wed, 5 Aug 2026 11:05:07 +0200 Subject: [PATCH 04/18] Canceling hardening --- CHANGELOG.md | 2 +- .../content/docs/guides/shortcut-manager.mdx | 1 + .../ng-diagram/src/core/src/flow-core.test.ts | 18 ++++++++++ .../ng-diagram/src/core/src/flow-core.ts | 20 +++++++++-- .../pointer-move-selection.handler.ts | 10 ++++-- .../pointer-move-selection.test.ts | 34 +++++++++++++++++++ .../handlers/resize/resize.handler.ts | 6 +++- .../handlers/resize/resize.test.ts | 21 ++++++++++++ .../handlers/rotate/rotate.handler.ts | 6 +++- .../handlers/rotate/rotate.test.ts | 22 ++++++++++++ 10 files changed, 132 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02c5c0887..4c0d6e8a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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 ([#766](https://github.com/synergycodes/ng-diagram/pull/766)) +- **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 diff --git a/apps/docs/src/content/docs/guides/shortcut-manager.mdx b/apps/docs/src/content/docs/guides/shortcut-manager.mdx index bfdc76fd1..591045ce3 100644 --- a/apps/docs/src/content/docs/guides/shortcut-manager.mdx +++ b/apps/docs/src/content/docs/guides/shortcut-manager.mdx @@ -19,6 +19,7 @@ All available shortcut actions with their default key bindings. See [`ShortcutAc | `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 | +| `cancelInteraction` | Escape | Cancel the in-progress interaction (linking, drag, resize, rotate, pan) | | `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) | 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 1b6ab6b74..492581116 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 @@ -332,6 +332,24 @@ describe('FlowCore', () => { 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 = { + nodeIds: [], + modifiers: { primary: false, secondary: false, shift: false, meta: false }, + accumulatedDeltas: new Map(), + movementStarted: true, + }; + 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', () => { 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 99b9385e1..916e4ab2c 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 @@ -72,6 +72,8 @@ export class FlowCore { readonly shortcutManager: ShortcutManager; readonly measurementTracker: MeasurementTracker; + private readonly interactionCleanups = new Set<() => void>(); + private readonly directRenderStrategy: DirectRenderStrategy; private readonly virtualizedRenderStrategy: VirtualizedRenderStrategy; @@ -478,8 +480,6 @@ export class FlowCore { return this.modelLookup.getEdgeById(edgeId); } - private readonly interactionCleanups = new Set<() => void>(); - /** * Registers a cleanup callback for the gesture that is starting — typically * the removal of document-level pointer listeners owned by the view layer. @@ -541,8 +541,22 @@ export class FlowCore { cleanup(); } + // One failing cancel must not leave the remaining gestures active — cancel + // them all, then rethrow the first failure. + let firstError: unknown; + let failed = false; for (const gesture of activeGestures) { - await this.inputEventsRouter.cancel(gesture); + try { + await this.inputEventsRouter.cancel(gesture); + } catch (error) { + if (!failed) { + failed = true; + firstError = error; + } + } + } + if (failed) { + throw firstError; } return activeGestures.length > 0 || cleanups.length > 0; 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 13da19889..a091b8314 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 @@ -193,10 +193,16 @@ export class PointerMoveSelectionEventHandler extends EventHandler { expect(mockEmit).toHaveBeenCalledWith('highlightGroupClear'); }); + 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(); 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 bc475b94b..fcf1adf74 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 @@ -160,6 +160,10 @@ export class ResizeEventHandler extends EventHandler { await tx.emit('resizeNodeStop', { nodeId: resizingNode.id }); }); - this.flow.actionStateManager.clearResize(); + // 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(); + } } } 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 144e85279..0056a54d4 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 @@ -225,5 +225,26 @@ describe('ResizeEventHandler', () => { expect(mockTransaction).toHaveBeenCalledWith('cancelResize', expect.any(Function)); }); + + 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 d2a005996..3f4e0b9ef 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 @@ -110,6 +110,10 @@ export class RotateEventHandler extends EventHandler { await tx.emit('rotateNodeStop', { nodeId: rotation.nodeId }); }); - this.flow.actionStateManager.clearRotation(); + // 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(); + } } } 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 e78b5a4c2..05834dec6 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 @@ -263,5 +263,27 @@ describe('RotateEventHandler', () => { expect(flowCore.transaction).toHaveBeenCalledWith('cancelRotate', expect.any(Function)); }); + + 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); + }); }); }); From f5d4a4ce4a2abaf6650ba653556c9f96cf8822d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Ja=C5=BAwa?= Date: Wed, 5 Aug 2026 11:33:14 +0200 Subject: [PATCH 05/18] Clear the shared touch marker only from its owning gesture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A directive destroyed as a bystander (e.g. virtualization destroying node or port components during a touch pan) no longer resets the shared TouchEventsStateService.currentEvent, so gesture exclusivity survives. Only the directive whose gesture set the marker clears it — on pointerup, on cancelActiveInteraction, or when destroyed mid-gesture. --- CHANGELOG.md | 1 + .../linking/linking.directive.spec.ts | 52 +++++++++ .../input-events/linking/linking.directive.ts | 16 +-- .../pointer-move-selection.directive.spec.ts | 107 ++++++++++++++++++ .../pointer-move-selection.directive.ts | 9 +- .../resize/resize.directive.spec.ts | 99 ++++++++++++++++ .../input-events/resize/resize.directive.ts | 16 +-- .../rotate/rotate.directive.spec.ts | 74 ++++++++++++ .../input-events/rotate/rotate.directive.ts | 17 +-- 9 files changed, 368 insertions(+), 23 deletions(-) create mode 100644 packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/linking/linking.directive.spec.ts create mode 100644 packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/pointer-move-selection/pointer-move-selection.directive.spec.ts create mode 100644 packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/resize/resize.directive.spec.ts create mode 100644 packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/rotate/rotate.directive.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c0d6e8a3..89327af64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ 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! 🙏 +- **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/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..7c5b57b7f --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/linking/linking.directive.spec.ts @@ -0,0 +1,52 @@ +import { TestBed } from '@angular/core/testing'; +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 } from '../../../types'; +import { LinkingInputDirective } from './linking.directive'; + +describe('LinkingInputDirective (shared touch marker ownership)', () => { + let directive: LinkingInputDirective; + let touchState: TouchEventsStateService; + let clearLinking: ReturnType; + + beforeEach(() => { + clearLinking = vi.fn(); + + const mockLinkingEventService = { + emitStart: vi.fn(), + emitContinue: vi.fn(), + emitEnd: vi.fn(), + }; + const mockFlowCoreProvider = { + isInitialized: () => true, + provide: () => ({ + actionStateManager: { clearLinking, isLinking: () => false }, + registerInteractionCleanup: vi.fn().mockReturnValue(vi.fn()), + }), + }; + + TestBed.configureTestingModule({ + providers: [ + LinkingInputDirective, + { provide: LinkingEventService, useValue: mockLinkingEventService }, + { provide: FlowCoreProviderService, useValue: mockFlowCoreProvider }, + TouchEventsStateService, + ], + }); + + directive = TestBed.inject(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); + + directive.ngOnDestroy(); + + expect(touchState.currentEvent()).toBe(DiagramEventName.Panning); + expect(clearLinking).not.toHaveBeenCalled(); + }); +}); 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 ed0345d05..2f941a4e4 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 @@ -26,16 +26,14 @@ export class LinkingInputDirective implements OnDestroy { portId = input.required(); ngOnDestroy(): void { + const wasMidGesture = this.gestureActive; this.cleanup(); // 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(); } } @@ -93,7 +91,6 @@ export class LinkingInputDirective implements OnDestroy { }; onPointerUp = ($event: PointerInputEvent) => { - this.gestureActive = false; this.linkingEventService.emitEnd($event, this.target(), this.portId()); this.cleanup(); }; @@ -114,7 +111,12 @@ export class LinkingInputDirective implements OnDestroy { private cleanup() { this.unregisterInteractionCleanup?.(); this.unregisterInteractionCleanup = null; - this.touchEventsStateService.clearCurrentEvent(); + // The shared touch marker belongs to whichever gesture set it — a bystander + // destroyed mid-gesture (virtualization during touch panning) must leave it alone. + 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/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..5fcd25cf1 --- /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,107 @@ +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; + + beforeEach(() => { + 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 }, + registerInteractionCleanup: vi.fn().mockReturnValue(vi.fn()), + }), + }; + 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 when destroyed mid-gesture', () => { + directive.onPointerDown(makePointerEvent()); + + fixture.destroy(); + + expect(touchState.currentEvent()).toBeNull(); + }); +}); 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 d55d1a084..e8937d91e 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 @@ -25,6 +25,7 @@ export class PointerMoveSelectionDirective implements OnDestroy { private edgePanningInterval: number | null = null; private cachedDiagramRect: DOMRect | null = null; private unregisterInteractionCleanup: (() => void) | null = null; + private gestureActive = false; ngOnDestroy() { this.removeListeners(); @@ -44,6 +45,7 @@ export class PointerMoveSelectionDirective implements OnDestroy { return; } + this.gestureActive = true; this.touchEventsStateService.currentEvent.set(DiagramEventName.Move); this.cachedDiagramRect = this.diagramComponent.getBoundingClientRect(); event.moveSelectionHandled = true; @@ -136,7 +138,12 @@ export class PointerMoveSelectionDirective implements OnDestroy { document.removeEventListener('pointerup', this.onPointerUp); this.stopEdgePanning(); this.cachedDiagramRect = null; - this.touchEventsStateService.clearCurrentEvent(); + // The shared touch marker belongs to whichever gesture set it — a bystander + // destroyed mid-gesture (virtualization during touch panning) must leave it alone. + if (this.gestureActive) { + this.gestureActive = false; + this.touchEventsStateService.clearCurrentEvent(); + } } private finishDragging(event: PointerInputEvent): void { 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..15143e1a0 --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/resize/resize.directive.spec.ts @@ -0,0 +1,99 @@ +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; + + beforeEach(() => { + clearResize = vi.fn(); + + 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: vi.fn().mockReturnValue(vi.fn()), + }), + }; + + 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(); + }); +}); 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 c581ce04f..33f7dd182 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 @@ -24,15 +24,13 @@ export class ResizeDirective implements OnDestroy { private unregisterInteractionCleanup: (() => void) | null = null; ngOnDestroy() { + 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; - if (this.flowCoreProvider.isInitialized()) { - this.flowCoreProvider.provide().actionStateManager.clearResize(); - } + if (wasMidGesture && this.flowCoreProvider.isInitialized()) { + this.flowCoreProvider.provide().actionStateManager.clearResize(); } } onPointerDown(event: PointerInputEvent): void { @@ -72,11 +70,15 @@ export class ResizeDirective implements OnDestroy { this.unregisterInteractionCleanup = null; document.removeEventListener('pointermove', this.onPointerMove); document.removeEventListener('pointerup', this.onPointerUp); - this.touchEventsStateService.clearCurrentEvent(); + // The shared touch marker belongs to whichever gesture set it — a bystander + // destroyed mid-gesture (virtualization during touch panning) must leave it alone. + if (this.gestureActive) { + this.gestureActive = false; + this.touchEventsStateService.clearCurrentEvent(); + } } onPointerUp = (event: PointerEvent) => { - this.gestureActive = false; this.removeListeners(); const baseEvent = this.inputEventsRouter.getBaseEvent(event); 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..53cf66245 --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/rotate/rotate.directive.spec.ts @@ -0,0 +1,74 @@ +import { TestBed } from '@angular/core/testing'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +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'; + +function makePointerEvent(overrides: Partial = {}): PointerInputEvent { + return { + clientX: 10, + clientY: 10, + boxSelectionHandled: false, + ...overrides, + } as unknown as PointerInputEvent; +} + +describe('RotateHandleDirective (shared touch marker ownership)', () => { + let directive: RotateHandleDirective; + let touchState: TouchEventsStateService; + let clearRotation: ReturnType; + + beforeEach(() => { + clearRotation = vi.fn(); + + 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: vi.fn().mockReturnValue(vi.fn()), + }), + }; + + TestBed.configureTestingModule({ + providers: [ + RotateHandleDirective, + { provide: InputEventsRouterService, useValue: mockRouter }, + { provide: FlowCoreProviderService, useValue: mockFlowCoreProvider }, + TouchEventsStateService, + ], + }); + + directive = TestBed.inject(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); + + directive.ngOnDestroy(); + + expect(touchState.currentEvent()).toBe(DiagramEventName.Panning); + expect(clearRotation).not.toHaveBeenCalled(); + }); + + it('clears its own marker and the rotation state when destroyed mid-gesture', () => { + directive.onPointerDown(makePointerEvent()); + expect(touchState.currentEvent()).toBe(DiagramEventName.Rotate); + + directive.ngOnDestroy(); + + expect(touchState.currentEvent()).toBeNull(); + expect(clearRotation).toHaveBeenCalled(); + }); +}); 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 7a3d277e6..fe164a094 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 @@ -23,14 +23,12 @@ export class RotateHandleDirective implements OnDestroy { private unregisterInteractionCleanup: (() => void) | null = null; ngOnDestroy() { + const wasMidGesture = this.gestureActive; this.cleanup(); // 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(); } } @@ -97,7 +95,6 @@ export class RotateHandleDirective implements OnDestroy { }; onPointerUp = ($event: PointerInputEvent) => { - this.gestureActive = false; const targetData = this.targetData(); if (!targetData) { return; @@ -119,7 +116,6 @@ export class RotateHandleDirective implements OnDestroy { }; onPointerCancel = ($event: PointerInputEvent) => { - this.gestureActive = false; const targetData = this.targetData(); if (!targetData) { return; @@ -151,7 +147,12 @@ export class RotateHandleDirective implements OnDestroy { private cleanup() { this.unregisterInteractionCleanup?.(); this.unregisterInteractionCleanup = null; - this.touchEventsStateService.clearCurrentEvent(); + // The shared touch marker belongs to whichever gesture set it — a bystander + // destroyed mid-gesture (virtualization during touch panning) must leave it alone. + if (this.gestureActive) { + this.gestureActive = false; + this.touchEventsStateService.clearCurrentEvent(); + } document.removeEventListener('pointermove', this.onPointerMove); document.removeEventListener('pointerup', this.onPointerUp); document.removeEventListener('pointercancel', this.onPointerCancel); From ac8bcb873616be0743fc20e2a11e28fd8b5ad18f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Ja=C5=BAwa?= Date: Wed, 5 Aug 2026 11:33:48 +0200 Subject: [PATCH 06/18] Add e2e coverage for cancelActiveInteraction and the Escape binding Covers every gesture (drag, linking, resize, rotate, pan): state rollback, immediate listener teardown, the cancelled reason on the ended events, the kept viewport after a cancelled pan, the programmatic no-op, and Escape staying non-intrusive when nothing is cancellable. --- apps/e2e/tests/cancel-interaction.spec.ts | 300 ++++++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 apps/e2e/tests/cancel-interaction.spec.ts diff --git a/apps/e2e/tests/cancel-interaction.spec.ts b/apps/e2e/tests/cancel-interaction.spec.ts new file mode 100644 index 000000000..35559bf98 --- /dev/null +++ b/apps/e2e/tests/cancel-interaction.spec.ts @@ -0,0 +1,300 @@ +import type { Model, Point } 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. + */ + +const positionOf = async (diagram: Diagram, id: string) => { + const node = await diagram.model.getNodeById(id); + if (!node) throw new Error(`node "${id}" not in model`); + return { x: node.position.x, y: node.position.y }; +}; + +const centerOf = async (locator: import('@playwright/test').Locator, label: string): Promise => { + const box = await locator.boundingBox(); + if (!box) throw new Error(`${label} has no bounding box`); + return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; +}; + +/** Pointer-down plus moves WITHOUT the release — the gesture stays in flight. */ +const beginPointerGesture = async (diagram: Diagram, from: Point, to: Point) => { + const mid = { x: (from.x + to.x) / 2, y: (from.y + to.y) / 2 }; + await diagram.page.mouse.move(from.x, from.y); + await diagram.page.mouse.down(); + await diagram.page.mouse.move(mid.x, mid.y, { steps: 6 }); + await diagram.page.mouse.move(to.x, to.y, { steps: 6 }); + // pointermove delivery is frame-aligned in Chromium — let the last + // coalesced move land before the test continues + await diagram.page.evaluate(() => new Promise(requestAnimationFrame)); +}; + +const nextFrame = (diagram: Diagram) => diagram.page.evaluate(() => new Promise(requestAnimationFrame)); + +/** 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 positionOf(diagram, 'node-a'); + + const start = await centerOf(diagram.node('node-a'), 'node "node-a"'); + await beginPointerGesture(diagram, start, { x: start.x + 90, y: start.y + 60 }); + await expect.poll(() => positionOf(diagram, 'node-a')).not.toEqual(before); + + await diagram.page.keyboard.press('Escape'); + + await expect.poll(() => positionOf(diagram, 'node-a')).toEqual(before); + await expect.poll(() => endedEvents(diagram)).toEqual(['drag:cancelled']); + await expect.poll(async () => (await diagram.diagram.actionState()).dragging).toBeUndefined(); + + // 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 nextFrame(diagram); + expect(await positionOf(diagram, 'node-a')).toEqual(before); + await diagram.page.mouse.up(); + await nextFrame(diagram); + 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 centerOf(diagram.port('node-a', 'port-right'), 'port node-a/port-right'); + await beginPointerGesture(diagram, 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 nextFrame(diagram); + 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 centerOf(diagram.node('box').locator('.resize-handle--top-left'), 'top-left resize handle'); + await beginPointerGesture(diagram, 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 nextFrame(diagram); + expect(await endedEvents(diagram)).toEqual(['resize:cancelled']); + }); + + 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 centerOf( + diagram.node('auto').locator('.resize-handle--bottom-right'), + 'bottom-right resize handle' + ); + await beginPointerGesture(diagram, 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 centerOf(diagram.node('spin').locator('.ng-diagram-rotate-handle'), 'rotate handle'); + await beginPointerGesture(diagram, 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 nextFrame(diagram); + 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 beginPointerGesture(diagram, 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 nextFrame(diagram); + expect(await diagram.viewport.viewport()).toEqual(frozen); + await diagram.page.mouse.up(); + await nextFrame(diagram); + 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 positionOf(diagram, 'node-a'); + + const start = await centerOf(diagram.node('node-a'), 'node "node-a"'); + await beginPointerGesture(diagram, start, { x: start.x + 80, y: start.y + 50 }); + await expect.poll(() => positionOf(diagram, 'node-a')).not.toEqual(before); + + expect(await diagram.diagram.cancelActiveInteraction()).toBe(true); + + await expect.poll(() => positionOf(diagram, 'node-a')).toEqual(before); + await diagram.page.mouse.up(); + }); + + 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 nextFrame(diagram); + 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 centerOf(diagram.node('node-a'), 'node "node-a"'); + await beginPointerGesture(diagram, start, { x: start.x + 60, y: start.y + 40 }); + await diagram.page.keyboard.press('Escape'); // mid-drag → swallowed + await diagram.page.mouse.up(); + + await expect + .poll(() => diagram.page.evaluate(() => (window as unknown as { __escSwallowed?: boolean[] }).__escSwallowed)) + .toEqual([false, true]); + }); +}); From 6329c2d8a40aee5318a8d3be35163d1290a9a9b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Ja=C5=BAwa?= Date: Wed, 5 Aug 2026 11:53:22 +0200 Subject: [PATCH 07/18] Simplify the cancel-interaction additions after review - move the drag rollback snapshot (initialPositions) off the public DraggingActionState onto the handler-private gesture state, matching the documented rule that gesture bookkeeping stays out of the public action state; regenerate api-report and docs - cancelActiveInteraction: derive the return value from hasActiveInteraction and collect cancel errors in a plain list - panning handlers: no-op cancel() early when no pan is active - pointer-move-selection cancel(): guard clause instead of one big if - tests: reuse macrotask/mockEnvironment from test-utils, collapse the repeated router.emit literals behind an emitGesture helper, share the dragging-state fixture in flow-core tests - e2e fixture: promote nodePosition/centerOf/beginDrag/nextFrame onto the Diagram page object and reuse them in the cancel-interaction spec --- .../docs/api/Internals/DraggingActionState.md | 9 -- apps/e2e/tests/cancel-interaction.spec.ts | 91 +++++++------------ apps/e2e/tests/fixtures/diagram.ts | 40 +++++--- .../ng-diagram/api-report/ng-diagram.api.md | 9 +- .../ng-diagram/src/core/src/flow-core.test.ts | 29 +++--- .../ng-diagram/src/core/src/flow-core.ts | 16 ++-- .../cancel-interaction.integration.test.ts | 84 ++++++----------- .../handlers/panning/panning.handler.ts | 3 + .../panning/virtualized-panning.handler.ts | 3 + .../pointer-move-selection.handler.ts | 86 +++++++++--------- .../core/src/types/action-state.interface.ts | 5 - 11 files changed, 160 insertions(+), 215 deletions(-) diff --git a/apps/docs/src/content/docs/api/Internals/DraggingActionState.md b/apps/docs/src/content/docs/api/Internals/DraggingActionState.md index ea4b17b5f..7337bc156 100644 --- a/apps/docs/src/content/docs/api/Internals/DraggingActionState.md +++ b/apps/docs/src/content/docs/api/Internals/DraggingActionState.md @@ -27,15 +27,6 @@ Set when the drag is aborted; carried into `nodeDragEnded`. *** -### initialPositions? - -> `optional` **initialPositions**: `Map`\<`string`, [`Point`](/docs/api/types/geometry/point/)\> - -Positions of the dragged nodes captured when the move threshold was crossed, -used to restore them when the drag is cancelled. - -*** - ### modifiers > **modifiers**: [`InputModifiers`](/docs/api/types/configuration/shortcuts/inputmodifiers/) diff --git a/apps/e2e/tests/cancel-interaction.spec.ts b/apps/e2e/tests/cancel-interaction.spec.ts index 35559bf98..ffc422ac8 100644 --- a/apps/e2e/tests/cancel-interaction.spec.ts +++ b/apps/e2e/tests/cancel-interaction.spec.ts @@ -1,4 +1,4 @@ -import type { Model, Point } from 'ng-diagram'; +import type { Model } from 'ng-diagram'; import { expect, test } from './fixtures/diagram'; import type { Diagram } from './fixtures/diagram'; import { pair } from './fixtures/models'; @@ -11,32 +11,6 @@ import { pair } from './fixtures/models'; * non-intrusive when there is nothing to cancel. */ -const positionOf = async (diagram: Diagram, id: string) => { - const node = await diagram.model.getNodeById(id); - if (!node) throw new Error(`node "${id}" not in model`); - return { x: node.position.x, y: node.position.y }; -}; - -const centerOf = async (locator: import('@playwright/test').Locator, label: string): Promise => { - const box = await locator.boundingBox(); - if (!box) throw new Error(`${label} has no bounding box`); - return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; -}; - -/** Pointer-down plus moves WITHOUT the release — the gesture stays in flight. */ -const beginPointerGesture = async (diagram: Diagram, from: Point, to: Point) => { - const mid = { x: (from.x + to.x) / 2, y: (from.y + to.y) / 2 }; - await diagram.page.mouse.move(from.x, from.y); - await diagram.page.mouse.down(); - await diagram.page.mouse.move(mid.x, mid.y, { steps: 6 }); - await diagram.page.mouse.move(to.x, to.y, { steps: 6 }); - // pointermove delivery is frame-aligned in Chromium — let the last - // coalesced move land before the test continues - await diagram.page.evaluate(() => new Promise(requestAnimationFrame)); -}; - -const nextFrame = (diagram: Diagram) => diagram.page.evaluate(() => new Promise(requestAnimationFrame)); - /** Record every gesture-ended event as `kind:reason`, in emission order. */ const recordEndedEvents = (diagram: Diagram) => diagram.page.evaluate(() => { @@ -109,24 +83,24 @@ 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 positionOf(diagram, 'node-a'); + const before = await diagram.nodePosition('node-a'); - const start = await centerOf(diagram.node('node-a'), 'node "node-a"'); - await beginPointerGesture(diagram, start, { x: start.x + 90, y: start.y + 60 }); - await expect.poll(() => positionOf(diagram, 'node-a')).not.toEqual(before); + 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(() => positionOf(diagram, 'node-a')).toEqual(before); + 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(); // 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 nextFrame(diagram); - expect(await positionOf(diagram, 'node-a')).toEqual(before); + await diagram.nextFrame(); + expect(await diagram.nodePosition('node-a')).toEqual(before); await diagram.page.mouse.up(); - await nextFrame(diagram); + await diagram.nextFrame(); expect(await endedEvents(diagram)).toEqual(['drag:cancelled']); }); @@ -134,8 +108,8 @@ test.describe('Escape cancels the in-flight gesture', () => { await diagram.load({ model: pair }); await recordEndedEvents(diagram); - const from = await centerOf(diagram.port('node-a', 'port-right'), 'port node-a/port-right'); - await beginPointerGesture(diagram, from, { x: from.x + 120, y: from.y + 90 }); + 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'); @@ -145,7 +119,7 @@ test.describe('Escape cancels the in-flight gesture', () => { await expect.poll(async () => (await diagram.diagram.actionState()).linking).toBeUndefined(); await diagram.page.mouse.up(); - await nextFrame(diagram); + await diagram.nextFrame(); expect(await diagram.model.edges()).toEqual([]); expect(await endedEvents(diagram)).toEqual(['link:cancelled']); }); @@ -156,8 +130,11 @@ test.describe('Escape cancels the in-flight gesture', () => { await diagram.node('box').click(); // Top-left handle changes both size and position — rollback must restore both - const handle = await centerOf(diagram.node('box').locator('.resize-handle--top-left'), 'top-left resize handle'); - await beginPointerGesture(diagram, handle, { x: handle.x + 30, y: handle.y + 20 }); + 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({ @@ -172,7 +149,7 @@ test.describe('Escape cancels the in-flight gesture', () => { await expect.poll(() => endedEvents(diagram)).toEqual(['resize:cancelled']); await diagram.page.mouse.up(); - await nextFrame(diagram); + await diagram.nextFrame(); expect(await endedEvents(diagram)).toEqual(['resize:cancelled']); }); @@ -183,11 +160,11 @@ test.describe('Escape cancels the in-flight gesture', () => { await expect.poll(async () => (await diagram.model.getNodeById('auto'))?.size).toBeDefined(); const measured = (await diagram.model.getNodeById('auto'))!.size; - const handle = await centerOf( + const handle = await diagram.centerOf( diagram.node('auto').locator('.resize-handle--bottom-right'), 'bottom-right resize handle' ); - await beginPointerGesture(diagram, handle, { x: handle.x + 40, y: handle.y + 30 }); + 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'); @@ -202,8 +179,8 @@ test.describe('Escape cancels the in-flight gesture', () => { await recordEndedEvents(diagram); await diagram.node('spin').click(); - const handle = await centerOf(diagram.node('spin').locator('.ng-diagram-rotate-handle'), 'rotate handle'); - await beginPointerGesture(diagram, handle, { x: handle.x + 120, y: handle.y + 120 }); + 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'); @@ -212,7 +189,7 @@ test.describe('Escape cancels the in-flight gesture', () => { await expect.poll(() => endedEvents(diagram)).toEqual(['rotate:cancelled']); await diagram.page.mouse.up(); - await nextFrame(diagram); + await diagram.nextFrame(); expect(await endedEvents(diagram)).toEqual(['rotate:cancelled']); }); @@ -226,7 +203,7 @@ test.describe('Escape cancels the in-flight gesture', () => { x: containerBox.x + containerBox.width - 30, y: containerBox.y + containerBox.height - 30, }; - await beginPointerGesture(diagram, corner, { x: corner.x - 60, y: corner.y - 40 }); + 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'); @@ -237,10 +214,10 @@ test.describe('Escape cancels the in-flight gesture', () => { // 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 nextFrame(diagram); + await diagram.nextFrame(); expect(await diagram.viewport.viewport()).toEqual(frozen); await diagram.page.mouse.up(); - await nextFrame(diagram); + await diagram.nextFrame(); expect(await diagram.viewport.viewport()).toEqual(frozen); }); }); @@ -248,15 +225,15 @@ test.describe('Escape cancels the in-flight gesture', () => { test.describe('cancelActiveInteraction()', () => { test('aborts a pointer drag programmatically and resolves true', async ({ diagram }) => { await diagram.load({ model: pair }); - const before = await positionOf(diagram, 'node-a'); + const before = await diagram.nodePosition('node-a'); - const start = await centerOf(diagram.node('node-a'), 'node "node-a"'); - await beginPointerGesture(diagram, start, { x: start.x + 80, y: start.y + 50 }); - await expect.poll(() => positionOf(diagram, 'node-a')).not.toEqual(before); + 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(() => positionOf(diagram, 'node-a')).toEqual(before); + await expect.poll(() => diagram.nodePosition('node-a')).toEqual(before); await diagram.page.mouse.up(); }); @@ -266,7 +243,7 @@ test.describe('cancelActiveInteraction()', () => { expect(await diagram.diagram.cancelActiveInteraction()).toBe(false); - await nextFrame(diagram); + await diagram.nextFrame(); expect(await endedEvents(diagram)).toEqual([]); }); }); @@ -288,8 +265,8 @@ test.describe('Escape stays non-intrusive', () => { await diagram.clickCanvas(); await diagram.page.keyboard.press('Escape'); // …nothing to cancel → not swallowed - const start = await centerOf(diagram.node('node-a'), 'node "node-a"'); - await beginPointerGesture(diagram, start, { x: start.x + 60, y: start.y + 40 }); + 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(); 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 67f03d43b..ba3287ce9 100644 --- a/packages/ng-diagram/api-report/ng-diagram.api.md +++ b/packages/ng-diagram/api-report/ng-diagram.api.md @@ -185,7 +185,7 @@ export interface DiagramInitEvent { // @public (undocumented) export class DiagramSelectionDirective extends ObjectSelectionDirective { // (undocumented) - readonly targetData: InputSignal | Node_2 | undefined>; + readonly targetData: InputSignal | undefined>; // (undocumented) targetType: BasePointerInputEvent['targetType']; // (undocumented) @@ -198,7 +198,6 @@ export class DiagramSelectionDirective extends ObjectSelectionDirective { export interface DraggingActionState { accumulatedDeltas: Map; cancelReason?: GestureCancelReason; - initialPositions?: Map; modifiers: InputModifiers; movementStarted: boolean; nodeIds: string[]; @@ -320,7 +319,7 @@ export type EdgeRoutingName = LooseAutocomplete; // @public (undocumented) export class EdgeSelectionDirective extends ObjectSelectionDirective { // (undocumented) - readonly targetData: InputSignal | Node_2 | undefined>; + readonly targetData: InputSignal | undefined>; // (undocumented) targetType: BasePointerInputEvent['targetType']; // (undocumented) @@ -1374,7 +1373,7 @@ export interface NodeRotationConfig { // @public (undocumented) export class NodeSelectionDirective extends ObjectSelectionDirective { // (undocumented) - readonly targetData: InputSignal | Node_2 | undefined>; + readonly targetData: InputSignal | undefined>; // (undocumented) targetType: BasePointerInputEvent['targetType']; // (undocumented) @@ -1704,7 +1703,7 @@ export interface ZIndexConfig { // @public (undocumented) export class ZIndexDirective { // (undocumented) - data: InputSignal | Node_2>; + data: InputSignal>; // (undocumented) zIndex: Signal; // (undocumented) 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 492581116..2f3ebc257 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) => { @@ -317,12 +325,7 @@ describe('FlowCore', () => { }); it('should cancel every active gesture', async () => { - flowCore.actionStateManager.dragging = { - nodeIds: [], - modifiers: { primary: false, secondary: false, shift: false, meta: false }, - accumulatedDeltas: new Map(), - movementStarted: true, - }; + flowCore.actionStateManager.dragging = draggingState(); flowCore.actionStateManager.panning = { active: true }; await flowCore.cancelActiveInteraction(); @@ -334,12 +337,7 @@ describe('FlowCore', () => { }); it('should still cancel the remaining gestures and rethrow when one cancel fails', async () => { - flowCore.actionStateManager.dragging = { - nodeIds: [], - modifiers: { primary: false, secondary: false, shift: false, meta: false }, - accumulatedDeltas: new Map(), - movementStarted: true, - }; + flowCore.actionStateManager.dragging = draggingState(); flowCore.actionStateManager.panning = { active: true }; const error = new Error('cancel failed'); (mockEventRouter.cancel as Mock).mockImplementation((name: string) => @@ -362,12 +360,7 @@ describe('FlowCore', () => { expect(flowCore.hasActiveInteraction()).toBe(true); flowCore.actionStateManager.clearLinking(); - flowCore.actionStateManager.dragging = { - nodeIds: [], - modifiers: { primary: false, secondary: false, shift: false, meta: false }, - accumulatedDeltas: new Map(), - movementStarted: false, - }; + flowCore.actionStateManager.dragging = draggingState(false); expect(flowCore.hasActiveInteraction()).toBe(true); flowCore.actionStateManager.clearDragging(); 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 916e4ab2c..55480599b 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 @@ -526,6 +526,8 @@ export class FlowCore { * @returns Whether any gesture or registered listener cleanup was torn down */ async cancelActiveInteraction(): Promise { + const hadInteraction = this.hasActiveInteraction(); + const activeGestures: InputEventName[] = []; if (this.actionStateManager.isLinking()) activeGestures.push('linking'); if (this.actionStateManager.isDragging()) activeGestures.push('pointerMoveSelection'); @@ -543,23 +545,19 @@ export class FlowCore { // One failing cancel must not leave the remaining gestures active — cancel // them all, then rethrow the first failure. - let firstError: unknown; - let failed = false; + const errors: unknown[] = []; for (const gesture of activeGestures) { try { await this.inputEventsRouter.cancel(gesture); } catch (error) { - if (!failed) { - failed = true; - firstError = error; - } + errors.push(error); } } - if (failed) { - throw firstError; + if (errors.length > 0) { + throw errors[0]; } - return activeGestures.length > 0 || cleanups.length > 0; + return hadInteraction; } /** 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 index fe39be43e..ebb3f4d3f 100644 --- 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 @@ -11,8 +11,9 @@ import type { Node, Renderer, } from '../../../types'; +import { macrotask, mockEnvironment } from '../../../test-utils'; import { InputEventsRouter } from '../../input-events.router'; -import type { InputModifiers } from '../../input-events.interface'; +import type { InputEventName, InputModifiers } from '../../input-events.interface'; /** * Integration tests for cancelActiveInteraction() using a real FlowCore — @@ -26,9 +27,7 @@ import type { InputModifiers } from '../../input-events.interface'; class TestInputEventsRouter extends InputEventsRouter {} const environment: EnvironmentInfo = { - os: 'MacOS', - browser: 'Chrome', - runtime: 'web', + ...mockEnvironment, now: () => 0, generateId: (() => { let i = 0; @@ -38,6 +37,11 @@ const environment: EnvironmentInfo = { 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>(); @@ -89,9 +93,6 @@ function createFlowCore(nodes: Node[], edges: Edge[] = []) { return { flowCore, router, observedActionTypes }; } -/** Flushes the fire-and-forget async work the input handlers schedule. */ -const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); - const draggableNode = (overrides: Partial = {}): Node => ({ id: 'n1', type: 'node', @@ -118,29 +119,23 @@ describe('cancelActiveInteraction (integration)', () => { describe('drag', () => { const startDrag = async (flowCore: FlowCore, router: InputEventsRouter, node: Node) => { - router.emit({ + emitGesture(router, { name: 'pointerMoveSelection', phase: 'start', - id: 'e1', - timestamp: 0, - modifiers, target: node, targetType: 'node', lastInputPoint: { x: 100, y: 100 }, panningForce: null, }); - await router.emit({ + await emitGesture(router, { name: 'pointerMoveSelection', phase: 'continue', - id: 'e2', - timestamp: 0, - modifiers, target: node, targetType: 'node', lastInputPoint: { x: 150, y: 180 }, panningForce: null, }); - await settle(); + await macrotask(); }; it('rolls the dragged node back and emits nodeDragEnded with the cancelled reason', async () => { @@ -180,18 +175,15 @@ describe('cancelActiveInteraction (integration)', () => { flowCore.eventManager.on('nodeDragEnded', dragEnded); await startDrag(flowCore, router, node); - await router.emit({ + await emitGesture(router, { name: 'pointerMoveSelection', phase: 'end', - id: 'e3', - timestamp: 0, - modifiers, target: node, targetType: 'node', lastInputPoint: { x: 150, y: 180 }, panningForce: null, }); - await settle(); + await macrotask(); expect(dragEnded).toHaveBeenCalledTimes(1); expect(dragEnded.mock.calls[0][0].cancelReason).toBeUndefined(); @@ -219,29 +211,23 @@ describe('cancelActiveInteraction (integration)', () => { const resizeEnded = vi.fn(); flowCore.eventManager.on('nodeResizeEnded', resizeEnded); - await router.emit({ + await emitGesture(router, { name: 'resize', phase: 'start', - id: 'e1', - timestamp: 0, - modifiers, target: node, targetType: 'node', direction: 'bottom-right', lastInputPoint: { x: 100, y: 100 }, }); - await router.emit({ + await emitGesture(router, { name: 'resize', phase: 'continue', - id: 'e2', - timestamp: 0, - modifiers, target: node, targetType: 'node', direction: 'bottom-right', lastInputPoint: { x: 150, y: 140 }, }); - await settle(); + await macrotask(); const resized = flowCore.getNodeById('n1'); expect(resized?.size).toEqual({ width: 250, height: 140 }); @@ -278,29 +264,23 @@ describe('cancelActiveInteraction (integration)', () => { const rotateEnded = vi.fn(); flowCore.eventManager.on('nodeRotateEnded', rotateEnded); - await router.emit({ + await emitGesture(router, { name: 'rotate', phase: 'start', - id: 'e1', - timestamp: 0, - modifiers, target: node, targetType: 'node', center: { x: 60, y: 45 }, lastInputPoint: { x: 200, y: 45 }, }); - await router.emit({ + await emitGesture(router, { name: 'rotate', phase: 'continue', - id: 'e2', - timestamp: 0, - modifiers, target: node, targetType: 'node', center: { x: 60, y: 45 }, lastInputPoint: { x: 60, y: 200 }, }); - await settle(); + await macrotask(); expect(flowCore.getNodeById('n1')?.angle).not.toBe(30); expect(flowCore.actionStateManager.isRotating()).toBe(true); @@ -328,30 +308,24 @@ describe('cancelActiveInteraction (integration)', () => { const edgeDrawEnded = vi.fn(); flowCore.eventManager.on('edgeDrawEnded', edgeDrawEnded); - router.emit({ + emitGesture(router, { name: 'linking', phase: 'start', - id: 'e1', - timestamp: 0, - modifiers, target: node, targetType: 'node', portId: undefined, lastInputPoint: { x: 10, y: 20 }, }); - await settle(); - router.emit({ + await macrotask(); + emitGesture(router, { name: 'linking', phase: 'continue', - id: 'e2', - timestamp: 0, - modifiers, target: node, targetType: 'node', portId: undefined, lastInputPoint: { x: 120, y: 90 }, }); - await settle(); + await macrotask(); expect(flowCore.actionStateManager.isLinking()).toBe(true); @@ -372,27 +346,21 @@ describe('cancelActiveInteraction (integration)', () => { it('stops panning without rolling back the viewport', async () => { const { flowCore, router } = createFlowCore([draggableNode({ selected: false })]); - router.emit({ + emitGesture(router, { name: 'panning', phase: 'start', - id: 'e1', - timestamp: 0, - modifiers, target: undefined, targetType: 'diagram', lastInputPoint: { x: 100, y: 100 }, }); - await router.emit({ + await emitGesture(router, { name: 'panning', phase: 'continue', - id: 'e2', - timestamp: 0, - modifiers, target: undefined, targetType: 'diagram', lastInputPoint: { x: 130, y: 110 }, }); - await settle(); + await macrotask(); const viewportAfterPan = { ...flowCore.getState().metadata.viewport }; expect(flowCore.actionStateManager.isPanning()).toBe(true); 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 065859f0f..ee530d449 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 @@ -37,6 +37,9 @@ export class PanningEventHandler extends EventHandler { } override cancel(): void { + if (!this.flow.actionStateManager.isPanning()) { + return; + } this.lastPoint = undefined; this.flow.actionStateManager.clearPanning(); } 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 f5f7a00b2..a76eb0f41 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 @@ -49,6 +49,9 @@ export class VirtualizedPanningEventHandler extends EventHandler { } override cancel(): void { + if (!this.flow.actionStateManager.isPanning()) { + return; + } this.accumulatedDelta = { x: 0, y: 0 }; this.lastPoint = undefined; this.rafScheduled = false; 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 a091b8314..d76601a7d 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 @@ -19,6 +19,8 @@ interface DragGesture { lastPointerPosition: Point; hasMoved: boolean; ended: boolean; + /** Pre-drag node positions captured at threshold crossing — cancel() restores them. */ + initialPositions?: Map; } export class PointerMoveSelectionEventHandler extends EventHandler { @@ -67,14 +69,14 @@ export class PointerMoveSelectionEventHandler extends EventHandler n.id); if (crossedThreshold) { gesture.hasMoved = true; + // Snapshot positions before the first delta is applied so an aborted + // drag (cancelActiveInteraction) can restore them. + gesture.initialPositions = new Map(selectedNodesWithChildren.map((n) => [n.id, { ...n.position }])); this.flow.actionStateManager.dragging = { nodeIds: draggedNodeIds, modifiers: { ...event.modifiers }, movementStarted: true, accumulatedDeltas: new Map(), - // Snapshot positions before the first delta is applied so an aborted - // drag (cancelActiveInteraction) can restore them. - initialPositions: new Map(selectedNodesWithChildren.map((n) => [n.id, { ...n.position }])), }; } @@ -155,51 +157,51 @@ export class PointerMoveSelectionEventHandler extends EventHandler { const gesture = this.gesture; - if (gesture || this.flow.actionStateManager.isDragging()) { - const dragging = this.flow.actionStateManager.dragging; - const needsStop = gesture?.hasMoved ?? false; - const needsHighlightClear = !!this.flow.actionStateManager.highlightGroup; - const draggedNodeIds = dragging?.nodeIds ?? []; + const dragging = this.flow.actionStateManager.dragging; + if (!gesture && !dragging) { + return; + } - // Marks the gesture dead for any suspended 'continue' passes, mirroring - // the 'end' phase — a delta must not be applied after the abort. - if (gesture) { - gesture.ended = true; - } + const needsStop = !!dragging && (gesture?.hasMoved ?? false); + const needsHighlightClear = !!this.flow.actionStateManager.highlightGroup; - if (needsStop && dragging) { - // Must be set BEFORE the transaction commits — the NodeDragEndedEmitter - // reads it from the action state during middleware execution. - dragging.cancelReason = 'cancelled'; - } + // Marks the gesture dead for any suspended 'continue' passes, mirroring + // the 'end' phase — a delta must not be applied after the abort. + if (gesture) { + gesture.ended = true; + } - if (needsStop || needsHighlightClear) { - // Unlike the 'end' phase, skip the drop handling — an aborted drag must - // not change group membership. - await this.flow.transaction('cancelDrag', async (tx) => { - if (needsStop) { - // Snap the dragged nodes back to where they were before the drag. - const initialPositions = dragging?.initialPositions; - if (initialPositions?.size) { - await tx.emit('updateNodes', { - nodes: [...initialPositions].map(([id, position]) => ({ id, position })), - }); - } - await tx.emit('moveNodesStop', { nodeIds: draggedNodeIds }); - } - if (needsHighlightClear) { - await tx.emit('highlightGroupClear'); - } - }); - } + if (needsStop && dragging) { + // Must be set BEFORE the transaction commits — the NodeDragEndedEmitter + // reads it from the action state during middleware execution. + dragging.cancelReason = 'cancelled'; + } - // A new drag may have started while the transaction above was suspended — - // the identity guards (mirroring the 'end' phase) keep its fresh state intact. - if (this.flow.actionStateManager.dragging === dragging) { - this.flow.actionStateManager.clearDragging(); - } + if (needsStop || needsHighlightClear) { + // Unlike the 'end' phase, skip the drop handling — an aborted drag must + // not change group membership. + await this.flow.transaction('cancelDrag', async (tx) => { + if (needsStop) { + // Snap the dragged nodes back to where they were before the drag. + 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 — + // the identity guards (mirroring the 'end' phase) keep its fresh state intact. + if (dragging && this.flow.actionStateManager.dragging === dragging) { + this.flow.actionStateManager.clearDragging(); + } if (this.gesture === gesture) { this.gesture = null; } 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 00689509f..dc038204b 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 @@ -131,11 +131,6 @@ export interface DraggingActionState { * `false` when the drag state is first created (on pointer down), `true` once movement exceeds the threshold. */ movementStarted: boolean; - /** - * Positions of the dragged nodes captured when the move threshold was crossed, - * used to restore them when the drag is cancelled. - */ - initialPositions?: Map; /** Set when the drag is aborted; carried into `nodeDragEnded`. */ cancelReason?: GestureCancelReason; } From e82018ea94045a77d276776feb560e59e3a52b75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Ja=C5=BAwa?= Date: Wed, 5 Aug 2026 13:33:46 +0200 Subject: [PATCH 08/18] Fix adversarial-review findings in gesture cancellation - a cancel arriving while the gesture's normal end phase is in flight is refused: the drag handler reads gesture.ended, resize/rotate track the finishing state identity, linking uses a _finishing marker shared by finishLinking and cancelLinking (also prevents duplicate edgeDrawEnded and double-cancel re-entry); handler cancel() now returns whether it tore anything down and cancelActiveInteraction reports that truthfully - cancelActiveInteraction refuses to run while a transaction is active (the rollback would merge into it and could be discarded with it) and ignores concurrent re-entrant calls - gesture directives and ManualLinkingService guard against a second start mid-gesture, which orphaned interaction-cleanup registrations and made hasActiveInteraction() stick to true - minimap navigation joins the cancel machinery: drag registers an interaction cleanup, cancel tears down its listeners and pointer capture, destroy mid-drag clears the panning state it set - tests: cancel-vs-in-flight-end at unit and integration level, transaction guard, concurrent double cancel, group-children rollback, virtualized panning cancel, cancelLinking finishing/identity guards, re-entry and unregister-on-pointerup specs, ManualLinkingService and minimap specs, e2e: selection intact after cancel, Escape free after a completed gesture, programmatic cancel of manual linking --- apps/e2e/tests/cancel-interaction.spec.ts | 28 +++- .../ng-diagram/api-report/ng-diagram.api.md | 8 +- .../linking/__tests__/cancel-linking.test.ts | 40 ++++++ .../commands/linking/cancel-linking.ts | 4 +- .../commands/linking/finish-linking.ts | 3 + .../ng-diagram/src/core/src/flow-core.test.ts | 2 +- .../ng-diagram/src/core/src/flow-core.ts | 63 ++++++--- .../cancel-interaction.integration.test.ts | 129 ++++++++++++++++++ .../input-events/handlers/event-handler.ts | 7 +- .../handlers/linking/linking.handler.ts | 10 +- .../handlers/panning/panning.handler.ts | 5 +- .../panning/virtualized-panning.handler.ts | 5 +- .../panning/virtualized-panning.test.ts | 20 +++ .../pointer-move-selection.handler.ts | 11 +- .../pointer-move-selection.test.ts | 65 +++++++++ .../handlers/resize/resize.handler.ts | 22 ++- .../handlers/resize/resize.test.ts | 23 ++++ .../handlers/rotate/rotate.handler.ts | 22 ++- .../handlers/rotate/rotate.test.ts | 23 ++++ .../src/input-events/input-events.router.ts | 6 +- .../core/src/types/action-state.interface.ts | 2 + ...agram-minimap-navigation.directive.spec.ts | 99 ++++++++++++++ ...ng-diagram-minimap-navigation.directive.ts | 48 +++++-- .../linking/linking.directive.spec.ts | 71 ++++++++-- .../input-events/linking/linking.directive.ts | 4 +- .../input-events/panning/panning.directive.ts | 8 ++ .../pointer-move-selection.directive.spec.ts | 24 +++- .../pointer-move-selection.directive.ts | 7 + .../resize/resize.directive.spec.ts | 24 +++- .../input-events/resize/resize.directive.ts | 5 +- .../input-events/rotate/rotate.directive.ts | 4 +- .../lib/public-services/ng-diagram.service.ts | 6 +- .../manual-linking.service.spec.ts | 77 +++++++++++ .../input-events/manual-linking.service.ts | 5 + 34 files changed, 801 insertions(+), 79 deletions(-) create mode 100644 packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/ng-diagram-minimap-navigation.directive.spec.ts create mode 100644 packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/manual-linking.service.spec.ts diff --git a/apps/e2e/tests/cancel-interaction.spec.ts b/apps/e2e/tests/cancel-interaction.spec.ts index ffc422ac8..3123581f8 100644 --- a/apps/e2e/tests/cancel-interaction.spec.ts +++ b/apps/e2e/tests/cancel-interaction.spec.ts @@ -94,6 +94,8 @@ test.describe('Escape cancels the in-flight gesture', () => { 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 }); @@ -237,6 +239,25 @@ test.describe('cancelActiveInteraction()', () => { 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); @@ -270,8 +291,13 @@ test.describe('Escape stays non-intrusive', () => { 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]); + .toEqual([false, true, false]); }); }); diff --git a/packages/ng-diagram/api-report/ng-diagram.api.md b/packages/ng-diagram/api-report/ng-diagram.api.md index ba3287ce9..73711cf70 100644 --- a/packages/ng-diagram/api-report/ng-diagram.api.md +++ b/packages/ng-diagram/api-report/ng-diagram.api.md @@ -185,7 +185,7 @@ export interface DiagramInitEvent { // @public (undocumented) export class DiagramSelectionDirective extends ObjectSelectionDirective { // (undocumented) - readonly targetData: InputSignal | undefined>; + readonly targetData: InputSignal | Node_2 | undefined>; // (undocumented) targetType: BasePointerInputEvent['targetType']; // (undocumented) @@ -319,7 +319,7 @@ export type EdgeRoutingName = LooseAutocomplete; // @public (undocumented) export class EdgeSelectionDirective extends ObjectSelectionDirective { // (undocumented) - readonly targetData: InputSignal | undefined>; + readonly targetData: InputSignal | Node_2 | undefined>; // (undocumented) targetType: BasePointerInputEvent['targetType']; // (undocumented) @@ -1373,7 +1373,7 @@ export interface NodeRotationConfig { // @public (undocumented) export class NodeSelectionDirective extends ObjectSelectionDirective { // (undocumented) - readonly targetData: InputSignal | undefined>; + readonly targetData: InputSignal | Node_2 | undefined>; // (undocumented) targetType: BasePointerInputEvent['targetType']; // (undocumented) @@ -1703,7 +1703,7 @@ export interface ZIndexConfig { // @public (undocumented) export class ZIndexDirective { // (undocumented) - data: InputSignal>; + data: InputSignal | Node_2>; // (undocumented) zIndex: Signal; // (undocumented) 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 index feaebb557..671cbe8f8 100644 --- 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 @@ -1,6 +1,7 @@ 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', () => { @@ -74,6 +75,45 @@ describe('cancelLinking', () => { 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', 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 index 5ec48ab86..ccd779884 100644 --- 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 @@ -17,10 +17,12 @@ export interface CancelLinkingCommand { export const cancelLinking = async (commandHandler: CommandHandler): Promise => { const linking = commandHandler.flowCore.actionStateManager.linking as InternalLinkingActionState | undefined; - if (!linking) { + // 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 }; 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 301dd1bf8..5885ed37b 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 @@ -55,6 +55,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/flow-core.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-core.test.ts index 2f3ebc257..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 @@ -123,7 +123,7 @@ describe('FlowCore', () => { emit: vi.fn(), register: vi.fn(), registerDefaultCallbacks: vi.fn(), - cancel: vi.fn().mockResolvedValue(undefined), + cancel: vi.fn().mockResolvedValue(true), } as unknown as InputEventsRouter; // Reset all mocks 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 55480599b..73d44decb 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 @@ -73,6 +73,7 @@ export class FlowCore { readonly measurementTracker: MeasurementTracker; private readonly interactionCleanups = new Set<() => void>(); + private cancellingInteraction = false; private readonly directRenderStrategy: DirectRenderStrategy; private readonly virtualizedRenderStrategy: VirtualizedRenderStrategy; @@ -521,12 +522,24 @@ export class FlowCore { * Runs the registered listener cleanups, restores the diagram state the * gesture modified (node positions/size/angle, temporary edge), clears the * gesture's action state and lets the corresponding "ended" event fire with - * the `cancelled` reason. No-op when nothing is active. + * the `cancelled` reason. No-op when nothing is active, when a cancel is + * already in flight, when the gesture's normal end is already being + * processed, or when a transaction is active — the rollback would merge into + * the transaction and could be silently discarded, so it is refused with a + * console warning instead. * * @returns Whether any gesture or registered listener cleanup was torn down */ async cancelActiveInteraction(): Promise { - const hadInteraction = this.hasActiveInteraction(); + if (this.cancellingInteraction) { + return false; + } + if (this.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: InputEventName[] = []; if (this.actionStateManager.isLinking()) activeGestures.push('linking'); @@ -535,29 +548,35 @@ export class FlowCore { if (this.actionStateManager.isRotating()) activeGestures.push('rotate'); if (this.actionStateManager.isPanning()) activeGestures.push('panning'); - // 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.interactionCleanups]; - this.interactionCleanups.clear(); - for (const cleanup of cleanups) { - cleanup(); - } + this.cancellingInteraction = 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.interactionCleanups]; + this.interactionCleanups.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. - const errors: unknown[] = []; - for (const gesture of activeGestures) { - try { - await this.inputEventsRouter.cancel(gesture); - } catch (error) { - errors.push(error); + // 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.inputEventsRouter.cancel(gesture)) || cancelledAny; + } catch (error) { + errors.push(error); + } + } + if (errors.length > 0) { + throw errors[0]; } - } - if (errors.length > 0) { - throw errors[0]; - } - return hadInteraction; + return cancelledAny || cleanups.length > 0; + } finally { + this.cancellingInteraction = false; + } } /** 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 index ebb3f4d3f..4f1c37fec 100644 --- 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 @@ -373,4 +373,133 @@ describe('cancelActiveInteraction (integration)', () => { expect(flowCore.getState().metadata.viewport).toEqual(viewportAfterPan); }); }); + + describe('cancel vs concurrent work', () => { + const dragStartEvent = (node: Node) => ({ + name: 'pointerMoveSelection' as const, + phase: 'start' as const, + target: node, + targetType: 'node' as const, + lastInputPoint: { x: 100, y: 100 }, + panningForce: null, + }); + + 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 startDragOn(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 startDragOn(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 startDragOn(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('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 startDragOn(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 }); + }); + + // Mirrors the drag suite's startDrag but reusable for any node fixture + async function startDragOn(flowCore: FlowCore, router: InputEventsRouter, node: Node) { + emitGesture(router, dragStartEvent(node)); + await emitGesture(router, { + name: 'pointerMoveSelection', + phase: 'continue', + target: node, + targetType: 'node', + lastInputPoint: { x: 150, y: 180 }, + panningForce: null, + }); + await macrotask(); + } + }); }); 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 3ee79dc51..fc9c311b4 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 @@ -13,8 +13,11 @@ export abstract class EventHandler { * Gesture handlers override this to clear their action state, reset internal * tracking and let the corresponding "ended" event fire with a cancel reason. * The default is a no-op for handlers without an in-progress gesture concept. + * + * @returns Whether anything was actually torn down — `false` when there is no + * gesture, or when its normal end (or another cancel) is already in flight. */ - cancel(): void | Promise { - // No-op by default. + 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 91a0d5e9f..f87b71c3f 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'; @@ -66,11 +67,14 @@ export class LinkingEventHandler extends EventHandler { } } - override async cancel(): Promise { - if (!this.flow.actionStateManager.isLinking()) { - return; + 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/panning/panning.handler.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/panning.handler.ts index ee530d449..2613def97 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 @@ -36,11 +36,12 @@ export class PanningEventHandler extends EventHandler { } } - override cancel(): void { + override cancel(): boolean { if (!this.flow.actionStateManager.isPanning()) { - return; + 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/virtualized-panning.handler.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/panning/virtualized-panning.handler.ts index a76eb0f41..627112aa6 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 @@ -48,14 +48,15 @@ export class VirtualizedPanningEventHandler extends EventHandler { } } - override cancel(): void { + override cancel(): boolean { if (!this.flow.actionStateManager.isPanning()) { - return; + return false; } this.accumulatedDelta = { x: 0, y: 0 }; this.lastPoint = undefined; this.rafScheduled = false; this.flow.actionStateManager.clearPanning(); + return true; } /** 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..79f610f95 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 @@ -213,4 +213,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 d76601a7d..2854965aa 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 @@ -155,11 +155,17 @@ export class PointerMoveSelectionEventHandler extends EventHandler { + override async cancel(): Promise { const gesture = this.gesture; const dragging = this.flow.actionStateManager.dragging; if (!gesture && !dragging) { - return; + 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); @@ -205,6 +211,7 @@ export class PointerMoveSelectionEventHandler extends EventHandler { 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') { 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 fcf1adf74..6a488226c 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 @@ -1,4 +1,5 @@ import { Point, Size } from '../../..'; +import type { ResizeActionState } from '../../../types/action-state.interface'; import { EventHandler } from '../event-handler'; import { ResizeEvent } from './resize.event'; @@ -16,6 +17,9 @@ This indicates a programming error. Resize events must have a target node. Documentation: https://www.ngdiagram.dev/docs/guides/nodes/resizing/`; export class ResizeEventHandler extends EventHandler { + /** The resize state whose end or cancel is currently in flight. */ + private finishingState: ResizeActionState | null = null; + async handle(event: ResizeEvent): Promise { if (!event.target) { throw new Error(RESIZE_MISSING_TARGET_ERROR(event)); @@ -121,6 +125,9 @@ export class ResizeEventHandler extends EventHandler { } case 'end': { const resizeState = this.flow.actionStateManager.resize; + // Marks this state as being finished — cancel() must not roll back a + // resize whose normal end is already in flight. + this.finishingState = resizeState ?? null; try { await this.flow.commandHandler.emit('resizeNodeStop', { nodeId: resizeState?.resizingNode.id }); } finally { @@ -130,17 +137,22 @@ export class ResizeEventHandler extends EventHandler { if (this.flow.actionStateManager.resize === resizeState) { this.flow.actionStateManager.clearResize(); } + if (this.finishingState === resizeState) { + this.finishingState = null; + } } break; } } } - override async cancel(): Promise { + override async cancel(): Promise { const resize = this.flow.actionStateManager.resize; - if (!resize) { - return; + // No resize, or its normal end (or another cancel) already owns the teardown. + if (!resize || resize === this.finishingState) { + return false; } + this.finishingState = resize; resize.cancelReason = 'cancelled'; @@ -165,5 +177,9 @@ export class ResizeEventHandler extends EventHandler { if (this.flow.actionStateManager.resize === resize) { this.flow.actionStateManager.clearResize(); } + if (this.finishingState === resize) { + this.finishingState = null; + } + 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 0056a54d4..005656f69 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 @@ -226,6 +226,29 @@ describe('ResizeEventHandler', () => { 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') { 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 3f4e0b9ef..a4cdc4bfc 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 @@ -1,4 +1,5 @@ import { NgDiagramMath } from '../../../math'; +import type { RotationActionState } from '../../../types/action-state.interface'; import { EventHandler } from '../event-handler'; import { RotateInputEvent } from './rotate.event'; @@ -18,6 +19,9 @@ This indicates a programming error. Rotation events must have a target node. Documentation: https://www.ngdiagram.dev/docs/guides/nodes/rotation/`; export class RotateEventHandler extends EventHandler { + /** The rotation state whose end or cancel is currently in flight. */ + private finishingState: RotationActionState | null = null; + async handle(event: RotateInputEvent): Promise { const { center, lastInputPoint, target, phase } = event; if (!target) { @@ -77,6 +81,9 @@ export class RotateEventHandler extends EventHandler { case 'end': { const rotationState = this.flow.actionStateManager.rotation; + // Marks this state as being finished — cancel() must not roll back a + // rotation whose normal end is already in flight. + this.finishingState = rotationState ?? null; try { await this.flow.commandHandler.emit('rotateNodeStop', { nodeId: rotationState?.nodeId }); } finally { @@ -86,17 +93,22 @@ export class RotateEventHandler extends EventHandler { if (this.flow.actionStateManager.rotation === rotationState) { this.flow.actionStateManager.clearRotation(); } + if (this.finishingState === rotationState) { + this.finishingState = null; + } } break; } } } - override async cancel(): Promise { + override async cancel(): Promise { const rotation = this.flow.actionStateManager.rotation; - if (!rotation) { - return; + // No rotation, or its normal end (or another cancel) already owns the teardown. + if (!rotation || rotation === this.finishingState) { + return false; } + this.finishingState = rotation; rotation.cancelReason = 'cancelled'; @@ -115,5 +127,9 @@ export class RotateEventHandler extends EventHandler { if (this.flow.actionStateManager.rotation === rotation) { this.flow.actionStateManager.clearRotation(); } + if (this.finishingState === rotation) { + this.finishingState = null; + } + 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 05834dec6..7cd4def4b 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 @@ -264,6 +264,29 @@ describe('RotateEventHandler', () => { 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) => { 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 98b38cd18..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 @@ -69,8 +69,10 @@ export abstract class InputEventsRouter { /** * 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 { - await this.handlers[eventName]?.cancel(); + async cancel(eventName: InputEventName): Promise { + return (await this.handlers[eventName]?.cancel()) ?? false; } } 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 dc038204b..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 @@ -63,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; } /** 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..e131a5e02 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,7 @@ interface DragState { isDragging: boolean; lastPosition: Point; pointerId: number | null; + captureElement: Element | null; } /** @@ -44,14 +45,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(); + const wasDragging = this.dragState.isDragging; + this.stopDragging(); + // Destroyed mid-drag: the pointerup will never come, so the panning state + // this directive set must be cleared here. + if (wasDragging && this.flowCoreProvider.isInitialized()) { + this.flowCoreProvider.provide().actionStateManager.clearPanning(); + } } onPointerDown(event: PointerEvent): void { - if (event.button !== 0) { + if (event.button !== 0 || this.dragState.isDragging) { return; } @@ -62,6 +72,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.stopDragging()); } private onPointerMove = (event: PointerEvent): void => { @@ -76,29 +92,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.stopDragging(); }; + private stopDragging(): 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/linking/linking.directive.spec.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/linking/linking.directive.spec.ts index 7c5b57b7f..14434a9f8 100644 --- 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 @@ -1,18 +1,41 @@ -import { TestBed } from '@angular/core/testing'; +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 } from '../../../types'; +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(), @@ -23,20 +46,20 @@ describe('LinkingInputDirective (shared touch marker ownership)', () => { isInitialized: () => true, provide: () => ({ actionStateManager: { clearLinking, isLinking: () => false }, - registerInteractionCleanup: vi.fn().mockReturnValue(vi.fn()), + registerInteractionCleanup, }), }; TestBed.configureTestingModule({ - providers: [ - LinkingInputDirective, - { provide: LinkingEventService, useValue: mockLinkingEventService }, - { provide: FlowCoreProviderService, useValue: mockFlowCoreProvider }, - TouchEventsStateService, - ], + imports: [HostComponent], + providers: [{ provide: FlowCoreProviderService, useValue: mockFlowCoreProvider }, TouchEventsStateService], }); + // LinkingEventService is a directive-level provider — override it there + TestBed.overrideProvider(LinkingEventService, { useValue: mockLinkingEventService }); - directive = TestBed.inject(LinkingInputDirective); + fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + directive = fixture.debugElement.query(By.directive(LinkingInputDirective)).injector.get(LinkingInputDirective); touchState = TestBed.inject(TouchEventsStateService); }); @@ -44,9 +67,35 @@ describe('LinkingInputDirective (shared touch marker ownership)', () => { // Simulates virtualization destroying this port's component during a touch pan touchState.currentEvent.set(DiagramEventName.Panning); - directive.ngOnDestroy(); + 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 2f941a4e4..58e8ebc85 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 @@ -42,7 +42,9 @@ export class LinkingInputDirective implements OnDestroy { } onPointerDown($event: PointerInputEvent) { - if (!this.shouldHandle($event)) { + // A second pointerdown mid-gesture must not restart the gesture — + // re-registering the interaction cleanup would orphan the previous entry. + if (this.gestureActive || !this.shouldHandle($event)) { return; } 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 f6fa72ce7..078f1867a 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 @@ -20,6 +20,7 @@ export class PanningDirective implements OnDestroy { private readonly flowCoreProvider = inject(FlowCoreProviderService); private unregisterInteractionCleanup: (() => void) | null = null; + private gestureActive = false; ngOnDestroy(): void { this.removeListeners(); @@ -30,6 +31,11 @@ export class PanningDirective implements OnDestroy { if (event.pointerType === 'touch') { return; } + // A second pointerdown mid-pan must not restart the gesture — + // re-registering the interaction cleanup would orphan the previous entry. + if (this.gestureActive) { + return; + } if (!this.inputEventsRouter.eventGuards.withPrimaryButton(event) || !this.shouldHandle(event)) { return; } @@ -51,6 +57,7 @@ export class PanningDirective implements OnDestroy { }, }); + this.gestureActive = true; document.addEventListener('pointermove', this.onMouseMove); document.addEventListener('pointerup', this.onPointerUp); this.unregisterInteractionCleanup = this.flowCoreProvider @@ -117,6 +124,7 @@ export class PanningDirective implements OnDestroy { }; private removeListeners(): void { + this.gestureActive = false; this.unregisterInteractionCleanup?.(); this.unregisterInteractionCleanup = null; document.removeEventListener('pointermove', this.onMouseMove); 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 index 5fcd25cf1..d29c1f8c6 100644 --- 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 @@ -37,8 +37,12 @@ describe('PointerMoveSelectionDirective (shared touch marker ownership)', () => let fixture: ComponentFixture; let directive: PointerMoveSelectionDirective; let touchState: TouchEventsStateService; + let registerInteractionCleanup: ReturnType; + let unregister: ReturnType; beforeEach(() => { + unregister = vi.fn(); + registerInteractionCleanup = vi.fn().mockReturnValue(unregister); const mockRouter = { getBaseEvent: () => ({ id: 'id', @@ -54,7 +58,7 @@ describe('PointerMoveSelectionDirective (shared touch marker ownership)', () => isInitialized: () => true, provide: () => ({ config: { nodeDraggingEnabled: true }, - registerInteractionCleanup: vi.fn().mockReturnValue(vi.fn()), + registerInteractionCleanup, }), }; const mockDiagramComponent = { @@ -104,4 +108,22 @@ describe('PointerMoveSelectionDirective (shared touch marker ownership)', () => expect(touchState.currentEvent()).toBeNull(); }); + + 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 e8937d91e..c2ffc2576 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 @@ -32,6 +32,13 @@ export class PointerMoveSelectionDirective implements OnDestroy { } onPointerDown(event: PointerInputEvent): void { + // A second pointerdown mid-gesture (second touch contact) must not restart + // the gesture — re-registering the interaction cleanup would orphan the + // previous entry in FlowCore's registry. + if (this.gestureActive) { + return; + } + if (!this.shouldHandle(event)) { return; } 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 index 15143e1a0..008e9c3f5 100644 --- 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 @@ -34,9 +34,13 @@ describe('ResizeDirective (shared touch marker ownership)', () => { 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: () => ({ @@ -50,7 +54,7 @@ describe('ResizeDirective (shared touch marker ownership)', () => { isInitialized: () => true, provide: () => ({ actionStateManager: { clearResize }, - registerInteractionCleanup: vi.fn().mockReturnValue(vi.fn()), + registerInteractionCleanup, }), }; @@ -96,4 +100,22 @@ describe('ResizeDirective (shared touch marker ownership)', () => { 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 33f7dd182..a439c1d6b 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 @@ -34,7 +34,10 @@ export class ResizeDirective implements OnDestroy { } } onPointerDown(event: PointerInputEvent): void { - if (!this.shouldHandle(event)) { + // A second pointerdown mid-gesture (second touch contact, other mouse + // button) must not restart the gesture — re-registering the interaction + // cleanup would orphan the previous entry in FlowCore's registry. + if (this.gestureActive || !this.shouldHandle(event)) { return; } 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 fe164a094..6d7bd0423 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 @@ -33,7 +33,9 @@ export class RotateHandleDirective implements OnDestroy { } onPointerDown($event: PointerInputEvent) { - if (!this.shouldHandle($event)) { + // A second pointerdown mid-gesture must not restart the gesture — + // re-registering the interaction cleanup would orphan the previous entry. + if (this.gestureActive || !this.shouldHandle($event)) { return; } 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 5a9d29681..fe49d4ad9 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 @@ -232,7 +232,11 @@ export class NgDiagramService extends NgDiagramBaseService { * its original size/position/autoSize back, a rotated node its original * angle, and the temporary edge of a linking gesture is discarded. Panning * only stops — the viewport is navigation state and is not rolled back. - * No-op when nothing is active. + * No-op when nothing is active, when the gesture's normal end is already + * completing (a finished gesture is never rolled back), or when a + * transaction is active — the rollback would merge into the transaction and + * could be discarded with it, so the call is refused with a console warning; + * await the transaction and cancel afterwards. * * Bound to the Escape key by default via the `cancelInteraction` shortcut * action; rebind or disable it with {@link configureShortcuts}. 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 20865dc0f..df1133dfd 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 @@ -16,6 +16,11 @@ export class ManualLinkingService { /** 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. + if (this.unregisterInteractionCleanup) { + this.cleanup(); + } this.node = node; this.portId = portId; const position = this.cursorPositionTrackerService.getLastPosition(); From 08a72362fe2e200d17228268d2a790622de46667 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Ja=C5=BAwa?= Date: Wed, 5 Aug 2026 13:50:02 +0200 Subject: [PATCH 09/18] Simplify the adversarial-fix commit after review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - finishingState in the resize/rotate handlers is set-only: every gesture starts with a fresh state object, so a stale reference can never match a live gesture — the four conditional resets were pure bookkeeping - integration test: hoist the shared startDrag helper instead of a second copy (startDragOn/dragStartEvent removed) - minimap ngOnDestroy uses the existing setPanningState helper - ManualLinkingService.startLinking calls the idempotent cleanup() unconditionally - one canonical re-entry-guard comment across the five gesture directives --- .../cancel-interaction.integration.test.ts | 72 +++++++------------ .../handlers/resize/resize.handler.ts | 20 +++--- .../handlers/rotate/rotate.handler.ts | 20 +++--- ...ng-diagram-minimap-navigation.directive.ts | 7 +- .../input-events/linking/linking.directive.ts | 3 +- .../input-events/panning/panning.directive.ts | 3 +- .../pointer-move-selection.directive.ts | 4 +- .../input-events/resize/resize.directive.ts | 4 +- .../input-events/rotate/rotate.directive.ts | 3 +- .../input-events/manual-linking.service.ts | 4 +- 10 files changed, 52 insertions(+), 88 deletions(-) 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 index 4f1c37fec..92f3330e5 100644 --- 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 @@ -102,6 +102,27 @@ const draggableNode = (overrides: Partial = {}): Node => ({ ...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(); @@ -118,26 +139,6 @@ describe('cancelActiveInteraction (integration)', () => { }); describe('drag', () => { - 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(); - }; - it('rolls the dragged node back and emits nodeDragEnded with the cancelled reason', async () => { const node = draggableNode(); const { flowCore, router, observedActionTypes } = createFlowCore([node]); @@ -375,15 +376,6 @@ describe('cancelActiveInteraction (integration)', () => { }); describe('cancel vs concurrent work', () => { - const dragStartEvent = (node: Node) => ({ - name: 'pointerMoveSelection' as const, - phase: 'start' as const, - target: node, - targetType: 'node' as const, - lastInputPoint: { x: 100, y: 100 }, - panningForce: null, - }); - it('refuses to cancel a drag whose pointerup end is already in flight', async () => { const node = draggableNode(); const { flowCore, router } = createFlowCore([node]); @@ -404,7 +396,7 @@ describe('cancelActiveInteraction (integration)', () => { }, }); - await startDragOn(flowCore, router, node); + await startDrag(flowCore, router, node); const endPromise = emitGesture(router, { name: 'pointerMoveSelection', phase: 'end', @@ -432,7 +424,7 @@ describe('cancelActiveInteraction (integration)', () => { const { flowCore, router } = createFlowCore([node]); const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - await startDragOn(flowCore, router, node); + await startDrag(flowCore, router, node); let releaseTx: () => void = () => undefined; const txPromise = flowCore.transaction('appTransaction', async () => { @@ -461,7 +453,7 @@ describe('cancelActiveInteraction (integration)', () => { const dragEnded = vi.fn(); flowCore.eventManager.on('nodeDragEnded', dragEnded); - await startDragOn(flowCore, router, node); + await startDrag(flowCore, router, node); const [first, second] = await Promise.all([ flowCore.cancelActiveInteraction(), flowCore.cancelActiveInteraction(), @@ -478,7 +470,7 @@ describe('cancelActiveInteraction (integration)', () => { const child = draggableNode({ id: 'child', selected: false, groupId: 'grp', position: { x: 120, y: 130 } }); const { flowCore, router } = createFlowCore([group, child]); - await startDragOn(flowCore, router, group); + 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 }); @@ -487,19 +479,5 @@ describe('cancelActiveInteraction (integration)', () => { expect(flowCore.getNodeById('grp')?.position).toEqual({ x: 100, y: 100 }); expect(flowCore.getNodeById('child')?.position).toEqual({ x: 120, y: 130 }); }); - - // Mirrors the drag suite's startDrag but reusable for any node fixture - async function startDragOn(flowCore: FlowCore, router: InputEventsRouter, node: Node) { - emitGesture(router, dragStartEvent(node)); - await emitGesture(router, { - name: 'pointerMoveSelection', - phase: 'continue', - target: node, - targetType: 'node', - lastInputPoint: { x: 150, y: 180 }, - panningForce: null, - }); - await macrotask(); - } }); }); 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 6a488226c..d1ee9487b 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,8 +17,12 @@ This indicates a programming error. Resize events must have a target node. Documentation: https://www.ngdiagram.dev/docs/guides/nodes/resizing/`; export class ResizeEventHandler extends EventHandler { - /** The resize state whose end or cancel is currently in flight. */ - private finishingState: ResizeActionState | null = null; + /** + * The resize state whose end or cancel claimed the teardown. Never reset — + * every gesture starts with a fresh state object, so a stale reference can + * never match a live gesture. + */ + private finishingState: ResizeActionState | undefined; async handle(event: ResizeEvent): Promise { if (!event.target) { @@ -125,9 +129,9 @@ export class ResizeEventHandler extends EventHandler { } case 'end': { const resizeState = this.flow.actionStateManager.resize; - // Marks this state as being finished — cancel() must not roll back a - // resize whose normal end is already in flight. - this.finishingState = resizeState ?? null; + // Claims the teardown — cancel() must not roll back a resize whose + // normal end is already in flight. + this.finishingState = resizeState; try { await this.flow.commandHandler.emit('resizeNodeStop', { nodeId: resizeState?.resizingNode.id }); } finally { @@ -137,9 +141,6 @@ export class ResizeEventHandler extends EventHandler { if (this.flow.actionStateManager.resize === resizeState) { this.flow.actionStateManager.clearResize(); } - if (this.finishingState === resizeState) { - this.finishingState = null; - } } break; } @@ -177,9 +178,6 @@ export class ResizeEventHandler extends EventHandler { if (this.flow.actionStateManager.resize === resize) { this.flow.actionStateManager.clearResize(); } - if (this.finishingState === resize) { - this.finishingState = null; - } return true; } } 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 a4cdc4bfc..6fe380aaf 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,8 +19,12 @@ This indicates a programming error. Rotation events must have a target node. Documentation: https://www.ngdiagram.dev/docs/guides/nodes/rotation/`; export class RotateEventHandler extends EventHandler { - /** The rotation state whose end or cancel is currently in flight. */ - private finishingState: RotationActionState | null = null; + /** + * The rotation state whose end or cancel claimed the teardown. Never reset — + * every gesture starts with a fresh state object, so a stale reference can + * never match a live gesture. + */ + private finishingState: RotationActionState | undefined; async handle(event: RotateInputEvent): Promise { const { center, lastInputPoint, target, phase } = event; @@ -81,9 +85,9 @@ export class RotateEventHandler extends EventHandler { case 'end': { const rotationState = this.flow.actionStateManager.rotation; - // Marks this state as being finished — cancel() must not roll back a - // rotation whose normal end is already in flight. - this.finishingState = rotationState ?? null; + // Claims the teardown — cancel() must not roll back a rotation whose + // normal end is already in flight. + this.finishingState = rotationState; try { await this.flow.commandHandler.emit('rotateNodeStop', { nodeId: rotationState?.nodeId }); } finally { @@ -93,9 +97,6 @@ export class RotateEventHandler extends EventHandler { if (this.flow.actionStateManager.rotation === rotationState) { this.flow.actionStateManager.clearRotation(); } - if (this.finishingState === rotationState) { - this.finishingState = null; - } } break; } @@ -127,9 +128,6 @@ export class RotateEventHandler extends EventHandler { if (this.flow.actionStateManager.rotation === rotation) { this.flow.actionStateManager.clearRotation(); } - if (this.finishingState === rotation) { - this.finishingState = null; - } return true; } } 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 e131a5e02..7ad01c72c 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 @@ -51,13 +51,12 @@ export class NgDiagramMinimapNavigationDirective implements OnDestroy { private unregisterInteractionCleanup: (() => void) | null = null; ngOnDestroy(): void { - const wasDragging = this.dragState.isDragging; - this.stopDragging(); // Destroyed mid-drag: the pointerup will never come, so the panning state // this directive set must be cleared here. - if (wasDragging && this.flowCoreProvider.isInitialized()) { - this.flowCoreProvider.provide().actionStateManager.clearPanning(); + if (this.dragState.isDragging && this.flowCoreProvider.isInitialized()) { + this.setPanningState(false); } + this.stopDragging(); } onPointerDown(event: PointerEvent): void { 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 58e8ebc85..9409f50bc 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 @@ -42,8 +42,7 @@ export class LinkingInputDirective implements OnDestroy { } onPointerDown($event: PointerInputEvent) { - // A second pointerdown mid-gesture must not restart the gesture — - // re-registering the interaction cleanup would orphan the previous entry. + // Re-entry guard: a second pointerdown mid-gesture would orphan the previous interaction-cleanup registration. if (this.gestureActive || !this.shouldHandle($event)) { return; } 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 078f1867a..303bb9f0f 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 @@ -31,8 +31,7 @@ export class PanningDirective implements OnDestroy { if (event.pointerType === 'touch') { return; } - // A second pointerdown mid-pan must not restart the gesture — - // re-registering the interaction cleanup would orphan the previous entry. + // Re-entry guard: a second pointerdown mid-gesture would orphan the previous interaction-cleanup registration. if (this.gestureActive) { return; } 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 c2ffc2576..ad28620a2 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 @@ -32,9 +32,7 @@ export class PointerMoveSelectionDirective implements OnDestroy { } onPointerDown(event: PointerInputEvent): void { - // A second pointerdown mid-gesture (second touch contact) must not restart - // the gesture — re-registering the interaction cleanup would orphan the - // previous entry in FlowCore's registry. + // Re-entry guard: a second pointerdown mid-gesture would orphan the previous interaction-cleanup registration. if (this.gestureActive) { return; } 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 a439c1d6b..0429c4317 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 @@ -34,9 +34,7 @@ export class ResizeDirective implements OnDestroy { } } onPointerDown(event: PointerInputEvent): void { - // A second pointerdown mid-gesture (second touch contact, other mouse - // button) must not restart the gesture — re-registering the interaction - // cleanup would orphan the previous entry in FlowCore's registry. + // Re-entry guard: a second pointerdown mid-gesture would orphan the previous interaction-cleanup registration. if (this.gestureActive || !this.shouldHandle(event)) { return; } 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 6d7bd0423..bd9630d3e 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 @@ -33,8 +33,7 @@ export class RotateHandleDirective implements OnDestroy { } onPointerDown($event: PointerInputEvent) { - // A second pointerdown mid-gesture must not restart the gesture — - // re-registering the interaction cleanup would orphan the previous entry. + // Re-entry guard: a second pointerdown mid-gesture would orphan the previous interaction-cleanup registration. if (this.gestureActive || !this.shouldHandle($event)) { return; } 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 df1133dfd..b45e02487 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 @@ -18,9 +18,7 @@ export class ManualLinkingService { 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. - if (this.unregisterInteractionCleanup) { - this.cleanup(); - } + this.cleanup(); this.node = node; this.portId = portId; const position = this.cursorPositionTrackerService.getLastPosition(); From 594bbab4821908344e9715ae96d4b48c1c38e849 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Ja=C5=BAwa?= Date: Wed, 5 Aug 2026 14:16:09 +0200 Subject: [PATCH 10/18] Make the cancel machinery self-guiding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FlowCore keeps one cancellableGestures registry (action-state probe paired with the handler's input-event name) that both hasActiveInteraction() and cancelActiveInteraction() derive from — a new cancellable gesture is one entry, and the dragging/pointerMoveSelection naming seam is an explicit pair instead of a surprise - the teardown-claim concept moves into EventHandler as claimTeardown()/isTeardownClaimed(); resize and rotate drop their twin finishingState fields, and the base-class doc is the single place that explains why drag (gesture.ended) and linking (_finishing) differ - every gesture participant names its teardown removeListeners() (rotate, linking, ManualLinkingService, minimap renamed) - rotate.directive follows the sibling shape: validate targetData before committing any state, tear down before emitting the end phase, pointercancel delegates to pointerup - pointer-move-selection and panning directives clear their core action state when destroyed mid-gesture, like resize/rotate/linking/minimap already did --- .../ng-diagram/src/core/src/flow-core.ts | 32 +++++++------ .../input-events/handlers/event-handler.ts | 26 ++++++++++ .../handlers/resize/resize.handler.ts | 17 ++----- .../handlers/rotate/rotate.handler.ts | 17 ++----- ...ng-diagram-minimap-navigation.directive.ts | 8 ++-- .../input-events/linking/linking.directive.ts | 8 ++-- .../input-events/panning/panning.directive.ts | 7 +++ .../pointer-move-selection.directive.spec.ts | 12 ++++- .../pointer-move-selection.directive.ts | 7 +++ .../rotate/rotate.directive.spec.ts | 48 ++++++++++++++++--- .../input-events/rotate/rotate.directive.ts | 36 ++++---------- .../input-events/manual-linking.service.ts | 10 ++-- 12 files changed, 140 insertions(+), 88 deletions(-) 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 73d44decb..636c394e4 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 @@ -75,6 +75,20 @@ export class FlowCore { private readonly interactionCleanups = new Set<() => void>(); private cancellingInteraction = 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.actionStateManager.isLinking() }, + { event: 'pointerMoveSelection', isActive: () => this.actionStateManager.isDragging() }, + { event: 'resize', isActive: () => this.actionStateManager.isResizing() }, + { event: 'rotate', isActive: () => this.actionStateManager.isRotating() }, + { event: 'panning', isActive: () => this.actionStateManager.isPanning() }, + ]; + private readonly directRenderStrategy: DirectRenderStrategy; private readonly virtualizedRenderStrategy: VirtualizedRenderStrategy; @@ -505,14 +519,7 @@ export class FlowCore { * still registered. */ hasActiveInteraction(): boolean { - return ( - this.actionStateManager.isLinking() || - this.actionStateManager.isDragging() || - this.actionStateManager.isResizing() || - this.actionStateManager.isRotating() || - this.actionStateManager.isPanning() || - this.interactionCleanups.size > 0 - ); + return this.cancellableGestures.some((gesture) => gesture.isActive()) || this.interactionCleanups.size > 0; } /** @@ -541,12 +548,9 @@ export class FlowCore { return false; } - const activeGestures: InputEventName[] = []; - if (this.actionStateManager.isLinking()) activeGestures.push('linking'); - if (this.actionStateManager.isDragging()) activeGestures.push('pointerMoveSelection'); - if (this.actionStateManager.isResizing()) activeGestures.push('resize'); - if (this.actionStateManager.isRotating()) activeGestures.push('rotate'); - if (this.actionStateManager.isPanning()) activeGestures.push('panning'); + const activeGestures = this.cancellableGestures + .filter((gesture) => gesture.isActive()) + .map((gesture) => gesture.event); this.cancellingInteraction = true; try { 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 fc9c311b4..f97ac2c1f 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,10 +2,36 @@ import { FlowCore } from '../../flow-core'; import { BaseInputEvent } from '../input-events.interface'; export abstract class EventHandler { + /** + * The gesture state whose end or cancel claimed the teardown — see + * {@link claimTeardown}. Never reset: every gesture starts with a fresh + * state object, so a stale claim can never match a live gesture. + */ + private claimedTeardownState: unknown; + constructor(protected readonly flow: FlowCore) {} abstract handle(event: TEvent): void | Promise; + /** + * Claims the teardown of the gesture owning `state`: the end phase claims it + * so a racing cancel() no-ops instead of rolling back a completing gesture, + * and cancel() claims it so a second cancel no-ops. + * + * Only usable when the gesture keeps ONE state object for its whole lifetime. + * Linking replaces its state object mid-gesture, so it stamps the state + * instead (see `InternalLinkingActionState`); drag folds the claim into its + * private `DragGesture.ended` flag, which also kills suspended continues. + */ + 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 gesture this handler is currently tracking, without the side * effects of a normal `end` phase (no edge creation, no group drop, …). 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 d1ee9487b..23e1201ca 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 @@ -1,5 +1,4 @@ import { Point, Size } from '../../..'; -import type { ResizeActionState } from '../../../types/action-state.interface'; import { EventHandler } from '../event-handler'; import { ResizeEvent } from './resize.event'; @@ -17,13 +16,6 @@ This indicates a programming error. Resize events must have a target node. Documentation: https://www.ngdiagram.dev/docs/guides/nodes/resizing/`; export class ResizeEventHandler extends EventHandler { - /** - * The resize state whose end or cancel claimed the teardown. Never reset — - * every gesture starts with a fresh state object, so a stale reference can - * never match a live gesture. - */ - private finishingState: ResizeActionState | undefined; - async handle(event: ResizeEvent): Promise { if (!event.target) { throw new Error(RESIZE_MISSING_TARGET_ERROR(event)); @@ -129,9 +121,7 @@ export class ResizeEventHandler extends EventHandler { } case 'end': { const resizeState = this.flow.actionStateManager.resize; - // Claims the teardown — cancel() must not roll back a resize whose - // normal end is already in flight. - this.finishingState = resizeState; + this.claimTeardown(resizeState); try { await this.flow.commandHandler.emit('resizeNodeStop', { nodeId: resizeState?.resizingNode.id }); } finally { @@ -149,11 +139,10 @@ export class ResizeEventHandler extends EventHandler { override async cancel(): Promise { const resize = this.flow.actionStateManager.resize; - // No resize, or its normal end (or another cancel) already owns the teardown. - if (!resize || resize === this.finishingState) { + if (!resize || this.isTeardownClaimed(resize)) { return false; } - this.finishingState = resize; + this.claimTeardown(resize); resize.cancelReason = 'cancelled'; 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 6fe380aaf..9402765ed 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 @@ -1,5 +1,4 @@ import { NgDiagramMath } from '../../../math'; -import type { RotationActionState } from '../../../types/action-state.interface'; import { EventHandler } from '../event-handler'; import { RotateInputEvent } from './rotate.event'; @@ -19,13 +18,6 @@ This indicates a programming error. Rotation events must have a target node. Documentation: https://www.ngdiagram.dev/docs/guides/nodes/rotation/`; export class RotateEventHandler extends EventHandler { - /** - * The rotation state whose end or cancel claimed the teardown. Never reset — - * every gesture starts with a fresh state object, so a stale reference can - * never match a live gesture. - */ - private finishingState: RotationActionState | undefined; - async handle(event: RotateInputEvent): Promise { const { center, lastInputPoint, target, phase } = event; if (!target) { @@ -85,9 +77,7 @@ export class RotateEventHandler extends EventHandler { case 'end': { const rotationState = this.flow.actionStateManager.rotation; - // Claims the teardown — cancel() must not roll back a rotation whose - // normal end is already in flight. - this.finishingState = rotationState; + this.claimTeardown(rotationState); try { await this.flow.commandHandler.emit('rotateNodeStop', { nodeId: rotationState?.nodeId }); } finally { @@ -105,11 +95,10 @@ export class RotateEventHandler extends EventHandler { override async cancel(): Promise { const rotation = this.flow.actionStateManager.rotation; - // No rotation, or its normal end (or another cancel) already owns the teardown. - if (!rotation || rotation === this.finishingState) { + if (!rotation || this.isTeardownClaimed(rotation)) { return false; } - this.finishingState = rotation; + this.claimTeardown(rotation); rotation.cancelReason = 'cancelled'; 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 7ad01c72c..a455afb6f 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 @@ -56,7 +56,7 @@ export class NgDiagramMinimapNavigationDirective implements OnDestroy { if (this.dragState.isDragging && this.flowCoreProvider.isInitialized()) { this.setPanningState(false); } - this.stopDragging(); + this.removeListeners(); } onPointerDown(event: PointerEvent): void { @@ -76,7 +76,7 @@ export class NgDiagramMinimapNavigationDirective implements OnDestroy { // listeners and the pointer capture. this.unregisterInteractionCleanup = this.flowCoreProvider .provide() - .registerInteractionCleanup(() => this.stopDragging()); + .registerInteractionCleanup(() => this.removeListeners()); } private onPointerMove = (event: PointerEvent): void => { @@ -93,10 +93,10 @@ export class NgDiagramMinimapNavigationDirective implements OnDestroy { private onPointerUp = (): void => { this.setPanningState(false); - this.stopDragging(); + this.removeListeners(); }; - private stopDragging(): void { + private removeListeners(): void { this.unregisterInteractionCleanup?.(); this.unregisterInteractionCleanup = null; this.dragState.isDragging = false; 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 9409f50bc..d1f92d7b9 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 @@ -27,7 +27,7 @@ export class LinkingInputDirective implements OnDestroy { ngOnDestroy(): void { const wasMidGesture = this.gestureActive; - this.cleanup(); + 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, @@ -55,7 +55,7 @@ export class LinkingInputDirective implements OnDestroy { document.addEventListener('pointerup', this.onPointerUp); this.unregisterInteractionCleanup = this.flowCoreProviderService .provide() - .registerInteractionCleanup(() => this.cleanup()); + .registerInteractionCleanup(() => this.removeListeners()); this.linkingEventService.emitStart($event, this.target(), this.portId()); } @@ -93,7 +93,7 @@ export class LinkingInputDirective implements OnDestroy { onPointerUp = ($event: PointerInputEvent) => { this.linkingEventService.emitEnd($event, this.target(), this.portId()); - this.cleanup(); + this.removeListeners(); }; private shouldHandle(event: PointerInputEvent) { @@ -109,7 +109,7 @@ export class LinkingInputDirective implements OnDestroy { ); } - private cleanup() { + private removeListeners() { this.unregisterInteractionCleanup?.(); this.unregisterInteractionCleanup = null; // The shared touch marker belongs to whichever gesture set it — a bystander 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 303bb9f0f..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 @@ -23,7 +23,14 @@ export class PanningDirective implements OnDestroy { private gestureActive = false; ngOnDestroy(): void { + 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 { 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 index d29c1f8c6..7fb4baea9 100644 --- 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 @@ -39,10 +39,12 @@ describe('PointerMoveSelectionDirective (shared touch marker ownership)', () => 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', @@ -58,6 +60,7 @@ describe('PointerMoveSelectionDirective (shared touch marker ownership)', () => isInitialized: () => true, provide: () => ({ config: { nodeDraggingEnabled: true }, + actionStateManager: { clearDragging }, registerInteractionCleanup, }), }; @@ -101,12 +104,19 @@ describe('PointerMoveSelectionDirective (shared touch marker ownership)', () => expect(touchState.currentEvent()).toBe(DiagramEventName.Panning); }); - it('clears its own marker when destroyed mid-gesture', () => { + 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', () => { 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 ad28620a2..4c04cc1c3 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 @@ -28,7 +28,14 @@ export class PointerMoveSelectionDirective implements OnDestroy { private gestureActive = false; ngOnDestroy() { + 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 { 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 index 53cf66245..b36de3d00 100644 --- 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 @@ -1,11 +1,23 @@ -import { TestBed } from '@angular/core/testing'; +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, @@ -16,12 +28,17 @@ function makePointerEvent(overrides: Partial = {}): PointerIn } 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: () => ({ @@ -35,20 +52,22 @@ describe('RotateHandleDirective (shared touch marker ownership)', () => { isInitialized: () => true, provide: () => ({ actionStateManager: { clearRotation }, - registerInteractionCleanup: vi.fn().mockReturnValue(vi.fn()), + registerInteractionCleanup, }), }; TestBed.configureTestingModule({ + imports: [HostComponent], providers: [ - RotateHandleDirective, { provide: InputEventsRouterService, useValue: mockRouter }, { provide: FlowCoreProviderService, useValue: mockFlowCoreProvider }, TouchEventsStateService, ], }); - directive = TestBed.inject(RotateHandleDirective); + fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + directive = fixture.debugElement.query(By.directive(RotateHandleDirective)).injector.get(RotateHandleDirective); touchState = TestBed.inject(TouchEventsStateService); }); @@ -56,19 +75,36 @@ describe('RotateHandleDirective (shared touch marker ownership)', () => { // Simulates virtualization destroying this handle's component during a touch pan touchState.currentEvent.set(DiagramEventName.Panning); - directive.ngOnDestroy(); + 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); - directive.ngOnDestroy(); + 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 bd9630d3e..98ad954e3 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 @@ -24,7 +24,7 @@ export class RotateHandleDirective implements OnDestroy { ngOnDestroy() { const wasMidGesture = this.gestureActive; - this.cleanup(); + 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 (wasMidGesture && this.flowCoreProvider.isInitialized()) { @@ -38,15 +38,15 @@ export class RotateHandleDirective implements OnDestroy { 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, @@ -65,7 +65,7 @@ export class RotateHandleDirective implements OnDestroy { document.addEventListener('pointercancel', this.onPointerCancel); this.unregisterInteractionCleanup = this.flowCoreProvider .provide() - .registerInteractionCleanup(() => this.cleanup()); + .registerInteractionCleanup(() => this.removeListeners()); } onPointerMove = ($event: PointerInputEvent) => { @@ -96,6 +96,8 @@ export class RotateHandleDirective implements OnDestroy { }; onPointerUp = ($event: PointerInputEvent) => { + this.removeListeners(); + const targetData = this.targetData(); if (!targetData) { return; @@ -113,28 +115,10 @@ export class RotateHandleDirective implements OnDestroy { }, center: this.getNodeCenter(targetData), }); - this.cleanup(); }; onPointerCancel = ($event: PointerInputEvent) => { - 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) { @@ -145,7 +129,7 @@ export class RotateHandleDirective implements OnDestroy { ); } - private cleanup() { + private removeListeners() { this.unregisterInteractionCleanup?.(); this.unregisterInteractionCleanup = null; // The shared touch marker belongs to whichever gesture set it — a bystander 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 b45e02487..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 @@ -18,7 +18,7 @@ export class ManualLinkingService { 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.cleanup(); + this.removeListeners(); this.node = node; this.portId = portId; const position = this.cursorPositionTrackerService.getLastPosition(); @@ -36,7 +36,7 @@ export class ManualLinkingService { document.addEventListener('touchend', this.onTouchEnd, { passive: false }); this.unregisterInteractionCleanup = this.flowCoreProvider .provide() - .registerInteractionCleanup(() => this.cleanup()); + .registerInteractionCleanup(() => this.removeListeners()); } private onPointerMove = (event: PointerEvent) => { @@ -69,16 +69,16 @@ 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); From 4ea02065fbcb3b84bab2d45875e7881a7cabecb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Ja=C5=BAwa?= Date: Wed, 5 Aug 2026 15:48:02 +0200 Subject: [PATCH 11/18] Drop gesture input while a cancel rollback is still committing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A gesture started right after Escape could capture geometry from the model before the cancel's rollback transaction committed: the second resize's start phase read the pre-rollback size as its baseline, so the first pointer move snapped the node back to the size from just before Escape. The same window existed for rotate (initial angle), drag (position snapshot) and linking (stale port geometry). FlowCore exposes isCancellingInteraction() and every gesture handler drops incoming input events while it is true — the window closes when the rollback commit resolves, which the awaitable-emit contract guarantees. Reproduced and pinned by an integration test that parks the cancelResize pass on a slow middleware and races a second resize into the window. --- .../ng-diagram/src/core/src/flow-core.ts | 10 ++++ .../cancel-interaction.integration.test.ts | 55 +++++++++++++++++++ .../handlers/linking/linking.handler.ts | 3 + .../handlers/linking/linking.test.ts | 2 + .../handlers/panning/panning.handler.ts | 3 + .../handlers/panning/panning.test.ts | 1 + .../panning/virtualized-panning.handler.ts | 3 + .../panning/virtualized-panning.test.ts | 1 + .../pointer-move-selection.handler.ts | 3 + .../pointer-move-selection.test.ts | 1 + .../handlers/resize/resize.handler.ts | 3 + .../handlers/resize/resize.test.ts | 2 + .../handlers/rotate/rotate.handler.ts | 3 + .../handlers/rotate/rotate.test.ts | 1 + 14 files changed, 91 insertions(+) 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 636c394e4..116bde73d 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 @@ -513,6 +513,16 @@ export class FlowCore { }; } + /** + * Whether {@link cancelActiveInteraction} is mid-flight — its rollback has + * not committed yet. Gesture handlers drop input events while this is true, + * so a new gesture cannot capture geometry the pending rollback is about to + * rewrite. + */ + isCancellingInteraction(): boolean { + return this.cancellingInteraction; + } + /** * Whether an interactive gesture (linking, dragging, resizing, rotating, * panning) is currently in progress, or a gesture's listener cleanup is 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 index 92f3330e5..c6c99325e 100644 --- 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 @@ -465,6 +465,61 @@ describe('cancelActiveInteraction (integration)', () => { 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 } }); 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 f87b71c3f..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 @@ -20,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; 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 2613def97..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; 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 627112aa6..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; 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 79f610f95..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; 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 2854965aa..f862731c7 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 @@ -27,6 +27,9 @@ export class PointerMoveSelectionEventHandler extends EventHandler { mockFlowCore = { getState: mockGetState, commandHandler: { emit: mockEmit }, + isCancellingInteraction: () => false, environment: mockEnvironment, clientToFlowPosition: vi.fn(({ x, y }) => ({ x, y })), modelLookup: mockModelLookup, 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 23e1201ca..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)); } 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 005656f69..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 @@ -55,6 +55,7 @@ describe('ResizeEventHandler', () => { const mockFlowCore = { commandHandler: { emit: mockEmit }, + isCancellingInteraction: () => false, clientToFlowPosition: vi.fn(({ x, y }) => ({ x, y })), getNodeById: vi.fn().mockReturnValue(nodeWithSize), actionStateManager: mockActionStateManager, @@ -130,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, 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 9402765ed..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)); 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 7cd4def4b..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,6 +52,7 @@ describe('RotateEventHandler', () => { }; flowCore = { commandHandler: mockCommandHandler, + isCancellingInteraction: () => false, actionStateManager: mockActionStateManager, clientToFlowPosition: vi.fn().mockImplementation((point) => point), getNodeById: vi.fn().mockReturnValue(node), From 4ac6c5b2f8db7baaa8f738be786278c1492f6415 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Ja=C5=BAwa?= Date: Wed, 5 Aug 2026 16:19:26 +0200 Subject: [PATCH 12/18] Grab diagram focus in the capture phase of pointerdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resize and rotate handles stopPropagation() on pointerdown, so the keyboard directive's bubble-phase focus grab never fired for them. A gesture started while focus was outside the diagram (e.g. right after clicking a toolbar button) left every shortcut dead — Escape could not cancel the resize. The focus grab now runs as a capture-phase listener, which fires before any handler can stop propagation. E2e reproduces the exact flow: focus a button outside the diagram, start a resize on the handle, press Escape — the size must roll back. --- apps/e2e/tests/cancel-interaction.spec.ts | 30 +++++++++++++++++++ .../keyboard-inputs.directive.ts | 20 +++++++++---- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/apps/e2e/tests/cancel-interaction.spec.ts b/apps/e2e/tests/cancel-interaction.spec.ts index 3123581f8..bdf07ca1b 100644 --- a/apps/e2e/tests/cancel-interaction.spec.ts +++ b/apps/e2e/tests/cancel-interaction.spec.ts @@ -155,6 +155,36 @@ test.describe('Escape cancels the in-flight gesture', () => { 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(); 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 9db79ba3e..4a71334df 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,11 +29,22 @@ export class KeyboardInputsDirective { ]; private readonly elementRef = inject>(ElementRef); - onPointerDown(): void { + constructor() { + // Capture phase: the resize/rotate handles stopPropagation() on pointerdown, + // so a bubble-phase focus grab never fires for them — a gesture started with + // focus outside the diagram would leave every shortcut (incl. Escape) dead. + this.elementRef.nativeElement.addEventListener('pointerdown', this.onPointerDown, true); + } + + ngOnDestroy(): void { + this.elementRef.nativeElement.removeEventListener('pointerdown', this.onPointerDown, true); + } + + onPointerDown = (): void => { if (!this.elementRef.nativeElement.contains(document.activeElement)) { this.elementRef.nativeElement.focus(); } - } + }; onKeyDown(event: KeyboardEvent): void { if (!this.elementRef.nativeElement.contains(document.activeElement)) { From c3fa227f636e8537b3e3a6282121a46ce76e669a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Ja=C5=BAwa?= Date: Wed, 5 Aug 2026 21:08:03 +0200 Subject: [PATCH 13/18] Keep KeyboardInputsDirective's public shape after the focus-grab change onPointerDown stays a plain method (the capture listener wraps it), so the api-report only gains the additive OnDestroy members. Adds the missing CHANGELOG entry for the focus fix and regenerates the api docs. --- CHANGELOG.md | 1 + .../src/content/docs/api/Services/NgDiagramService.md | 6 +++++- packages/ng-diagram/api-report/ng-diagram.api.md | 5 ++++- .../keyboard-inputs/keyboard-inputs.directive.ts | 10 ++++++---- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89327af64..828131903 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ 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)) diff --git a/apps/docs/src/content/docs/api/Services/NgDiagramService.md b/apps/docs/src/content/docs/api/Services/NgDiagramService.md index 8fbc36611..c83af67a3 100644 --- a/apps/docs/src/content/docs/api/Services/NgDiagramService.md +++ b/apps/docs/src/content/docs/api/Services/NgDiagramService.md @@ -177,7 +177,11 @@ dragged nodes snap back to their pre-drag positions, a resized node gets its original size/position/autoSize back, a rotated node its original angle, and the temporary edge of a linking gesture is discarded. Panning only stops — the viewport is navigation state and is not rolled back. -No-op when nothing is active. +No-op when nothing is active, when the gesture's normal end is already +completing (a finished gesture is never rolled back), or when a +transaction is active — the rollback would merge into the transaction and +could be discarded with it, so the call is refused with a console warning; +await the transaction and cancel afterwards. Bound to the Escape key by default via the `cancelInteraction` shortcut action; rebind or disable it with [configureShortcuts](/docs/api/utilities/configureshortcuts/). diff --git a/packages/ng-diagram/api-report/ng-diagram.api.md b/packages/ng-diagram/api-report/ng-diagram.api.md index 73711cf70..31637a45c 100644 --- a/packages/ng-diagram/api-report/ng-diagram.api.md +++ b/packages/ng-diagram/api-report/ng-diagram.api.md @@ -455,7 +455,10 @@ export interface InvalidateMeasurementsOptions { 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) 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 4a71334df..c63595262 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 @@ -29,22 +29,24 @@ export class KeyboardInputsDirective implements OnDestroy { ]; private readonly elementRef = inject>(ElementRef); + private readonly grabFocus = (): void => this.onPointerDown(); + constructor() { // Capture phase: the resize/rotate handles stopPropagation() on pointerdown, // so a bubble-phase focus grab never fires for them — a gesture started with // focus outside the diagram would leave every shortcut (incl. Escape) dead. - this.elementRef.nativeElement.addEventListener('pointerdown', this.onPointerDown, true); + this.elementRef.nativeElement.addEventListener('pointerdown', this.grabFocus, true); } ngOnDestroy(): void { - this.elementRef.nativeElement.removeEventListener('pointerdown', this.onPointerDown, true); + this.elementRef.nativeElement.removeEventListener('pointerdown', this.grabFocus, true); } - onPointerDown = (): void => { + onPointerDown(): void { if (!this.elementRef.nativeElement.contains(document.activeElement)) { this.elementRef.nativeElement.focus(); } - }; + } onKeyDown(event: KeyboardEvent): void { if (!this.elementRef.nativeElement.contains(document.activeElement)) { From de389a739dd947588d67a61f80b71e26065dd1ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Ja=C5=BAwa?= Date: Wed, 5 Aug 2026 21:51:22 +0200 Subject: [PATCH 14/18] Sort the shortcut actions table alphabetically --- .../content/docs/guides/shortcut-manager.mdx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/docs/src/content/docs/guides/shortcut-manager.mdx b/apps/docs/src/content/docs/guides/shortcut-manager.mdx index 591045ce3..05e99e8c8 100644 --- a/apps/docs/src/content/docs/guides/shortcut-manager.mdx +++ b/apps/docs/src/content/docs/guides/shortcut-manager.mdx @@ -15,27 +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 | -| `cancelInteraction` | Escape | Cancel the in-progress interaction (linking, drag, resize, rotate, pan) | -| `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 From 6099b292ac1a5e91112691b21fdbce342d2c7801 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Ja=C5=BAwa?= Date: Wed, 5 Aug 2026 21:51:37 +0200 Subject: [PATCH 15/18] Tighten the cancelActiveInteraction TSDoc --- .../docs/api/Services/NgDiagramService.md | 36 +++++++------------ .../lib/public-services/ng-diagram.service.ts | 32 ++++++----------- 2 files changed, 24 insertions(+), 44 deletions(-) diff --git a/apps/docs/src/content/docs/api/Services/NgDiagramService.md b/apps/docs/src/content/docs/api/Services/NgDiagramService.md index c83af67a3..2ad9a4bae 100644 --- a/apps/docs/src/content/docs/api/Services/NgDiagramService.md +++ b/apps/docs/src/content/docs/api/Services/NgDiagramService.md @@ -165,38 +165,28 @@ True if events are enabled. > **cancelActiveInteraction**(): `Promise`\<`boolean`\> -Aborts the interactive gesture currently in progress — linking, dragging, -resizing, rotating or panning. - -The gesture is torn down immediately: its action state is cleared, its -document-level pointer listeners are removed (no need to wait for pointer -release) and the corresponding "ended" event (`edgeDrawEnded`, -`nodeDragEnded`, `nodeResizeEnded`, `nodeRotateEnded`) fires with the -`cancelled` reason. Diagram state modified by the gesture is restored: -dragged nodes snap back to their pre-drag positions, a resized node gets -its original size/position/autoSize back, a rotated node its original -angle, and the temporary edge of a linking gesture is discarded. Panning -only stops — the viewport is navigation state and is not rolled back. -No-op when nothing is active, when the gesture's normal end is already -completing (a finished gesture is never rolled back), or when a -transaction is active — the rollback would merge into the transaction and -could be discarded with it, so the call is refused with a console warning; -await the transaction and cancel afterwards. - -Bound to the Escape key by default via the `cancelInteraction` shortcut -action; rebind or disable it with [configureShortcuts](/docs/api/utilities/configureshortcuts/). +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 any gesture or registered listener -cleanup was torn down. +Promise resolving to whether anything was torn down #### Example ```typescript -// Abort the temporary edge / drag preview from custom logic ngDiagramService.cancelActiveInteraction(); ``` 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 fe49d4ad9..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 @@ -220,35 +220,25 @@ export class NgDiagramService extends NgDiagramBaseService { // ============================== /** - * Aborts the interactive gesture currently in progress — linking, dragging, - * resizing, rotating or panning. + * 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. * - * The gesture is torn down immediately: its action state is cleared, its - * document-level pointer listeners are removed (no need to wait for pointer - * release) and the corresponding "ended" event (`edgeDrawEnded`, - * `nodeDragEnded`, `nodeResizeEnded`, `nodeRotateEnded`) fires with the - * `cancelled` reason. Diagram state modified by the gesture is restored: - * dragged nodes snap back to their pre-drag positions, a resized node gets - * its original size/position/autoSize back, a rotated node its original - * angle, and the temporary edge of a linking gesture is discarded. Panning - * only stops — the viewport is navigation state and is not rolled back. - * No-op when nothing is active, when the gesture's normal end is already - * completing (a finished gesture is never rolled back), or when a - * transaction is active — the rollback would merge into the transaction and - * could be discarded with it, so the call is refused with a console warning; - * await the transaction and cancel afterwards. + * 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 the Escape key by default via the `cancelInteraction` shortcut - * action; rebind or disable it with {@link configureShortcuts}. + * Bound to Escape by default via the `cancelInteraction` shortcut action — + * see {@link configureShortcuts}. * * @example * ```typescript - * // Abort the temporary edge / drag preview from custom logic * ngDiagramService.cancelActiveInteraction(); * ``` * - * @returns Promise resolving to whether any gesture or registered listener - * cleanup was torn down. + * @returns Promise resolving to whether anything was torn down * * @since 1.3.0 */ From e5eddc77d3c64d523d7497e5fd7988f2b4f86c70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Ja=C5=BAwa?= Date: Wed, 5 Aug 2026 21:51:51 +0200 Subject: [PATCH 16/18] Extract the cancel machinery into InteractionCoordinator FlowCore keeps the public facade (unchanged signatures and TSDoc, thin delegations); the cleanup registry, the re-entrancy latch, the cancellable gestures registry and the cancel orchestration move into a dedicated collaborator, following the TransactionManager/MeasurementTracker pattern. No caller changes. --- .../ng-diagram/src/core/src/flow-core.ts | 77 ++------------- .../interaction-coordinator.ts | 95 +++++++++++++++++++ 2 files changed, 104 insertions(+), 68 deletions(-) create mode 100644 packages/ng-diagram/projects/ng-diagram/src/core/src/interaction-coordinator/interaction-coordinator.ts 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 116bde73d..8be028435 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 @@ -3,7 +3,8 @@ import { CommandHandler } from './command-handler/command-handler'; import { EdgeRoutingManager } from './edge-routing-manager'; import { EventManager } from './event-manager'; import { createFlowConfig } from './flow-config/default-flow-config'; -import { InputEventsRouter, type InputEventName } from './input-events'; +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,23 +73,7 @@ export class FlowCore { readonly shortcutManager: ShortcutManager; readonly measurementTracker: MeasurementTracker; - private readonly interactionCleanups = new Set<() => void>(); - private cancellingInteraction = 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.actionStateManager.isLinking() }, - { event: 'pointerMoveSelection', isActive: () => this.actionStateManager.isDragging() }, - { event: 'resize', isActive: () => this.actionStateManager.isResizing() }, - { event: 'rotate', isActive: () => this.actionStateManager.isRotating() }, - { event: 'panning', isActive: () => this.actionStateManager.isPanning() }, - ]; - + private readonly interactionCoordinator: InteractionCoordinator; private readonly directRenderStrategy: DirectRenderStrategy; private readonly virtualizedRenderStrategy: VirtualizedRenderStrategy; @@ -119,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(); @@ -507,10 +493,7 @@ export class FlowCore { * @returns Function that unregisters the callback */ registerInteractionCleanup(cleanup: () => void): () => void { - this.interactionCleanups.add(cleanup); - return () => { - this.interactionCleanups.delete(cleanup); - }; + return this.interactionCoordinator.registerInteractionCleanup(cleanup); } /** @@ -520,7 +503,7 @@ export class FlowCore { * rewrite. */ isCancellingInteraction(): boolean { - return this.cancellingInteraction; + return this.interactionCoordinator.isCancellingInteraction(); } /** @@ -529,7 +512,7 @@ export class FlowCore { * still registered. */ hasActiveInteraction(): boolean { - return this.cancellableGestures.some((gesture) => gesture.isActive()) || this.interactionCleanups.size > 0; + return this.interactionCoordinator.hasActiveInteraction(); } /** @@ -547,50 +530,8 @@ export class FlowCore { * * @returns Whether any gesture or registered listener cleanup was torn down */ - async cancelActiveInteraction(): Promise { - if (this.cancellingInteraction) { - return false; - } - if (this.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.cancellingInteraction = 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.interactionCleanups]; - this.interactionCleanups.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.inputEventsRouter.cancel(gesture)) || cancelledAny; - } catch (error) { - errors.push(error); - } - } - if (errors.length > 0) { - throw errors[0]; - } - - return cancelledAny || cleanups.length > 0; - } finally { - this.cancellingInteraction = false; - } + cancelActiveInteraction(): Promise { + return this.interactionCoordinator.cancelActiveInteraction(); } /** 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; + } + } +} From 81d9ff7201ed46a4c894d08e82e36681cfb57412 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Ja=C5=BAwa?= Date: Wed, 5 Aug 2026 22:46:08 +0200 Subject: [PATCH 17/18] Make the cancel-path comments say what, not how it went Comment-quality pass over the branch's additions: constraints and mechanisms stay, in-progress narration goes. - the four directives' marker guard now states why it exists (the marker keeps concurrent gestures out) and what gestureActive marks (the marker's writer) - EventHandler docs trimmed to contracts; handle() documents the un-awaited invocation interleaving contract the drag handler's comment already pointed at - FlowCore facade docs shortened to match the service canon; the registerInteractionCleanup and isCancellingInteraction docs drop consumer narration - cancel-linking/finish-linking lose the "mirror" narration; the cancelReason write in the drag handler explains it is not a dead write - keyboard-inputs capture comment generalized beyond resize/rotate; minimap gains the canonical re-entry comment and the captureElement rationale --- .../commands/linking/cancel-linking.ts | 6 ++-- .../commands/linking/finish-linking.ts | 12 ++----- .../ng-diagram/src/core/src/flow-core.ts | 33 +++++++------------ .../cancel-interaction.handler.ts | 4 --- .../input-events/handlers/event-handler.ts | 29 ++++++---------- .../pointer-move-selection.handler.ts | 31 ++++++++--------- ...ng-diagram-minimap-navigation.directive.ts | 2 ++ .../keyboard-inputs.directive.ts | 6 ++-- .../input-events/linking/linking.directive.ts | 5 +-- .../pointer-move-selection.directive.ts | 5 +-- .../input-events/resize/resize.directive.ts | 5 +-- .../input-events/rotate/rotate.directive.ts | 5 +-- 12 files changed, 57 insertions(+), 86 deletions(-) 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 index ccd779884..5d88c4a2c 100644 --- 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 @@ -27,9 +27,9 @@ export const cancelLinking = async (commandHandler: CommandHandler): Promise => { await commandHandler.flowCore.applyUpdate({}, 'finishLinking'); }; 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 8be028435..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 @@ -482,14 +482,10 @@ export class FlowCore { } /** - * Registers a cleanup callback for the gesture that is starting — typically - * the removal of document-level pointer listeners owned by the view layer. + * 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. * - * The callback runs when {@link cancelActiveInteraction} aborts the gesture. - * The caller must invoke the returned unregister function in its own normal - * teardown (pointer release) so stale callbacks don't accumulate. - * - * @param cleanup Callback tearing down the gesture's listeners * @returns Function that unregisters the callback */ registerInteractionCleanup(cleanup: () => void): () => void { @@ -498,9 +494,8 @@ export class FlowCore { /** * Whether {@link cancelActiveInteraction} is mid-flight — its rollback has - * not committed yet. Gesture handlers drop input events while this is true, - * so a new gesture cannot capture geometry the pending rollback is about to - * rewrite. + * not committed yet, so input read now could capture geometry it is about + * to rewrite. */ isCancellingInteraction(): boolean { return this.interactionCoordinator.isCancellingInteraction(); @@ -516,19 +511,13 @@ export class FlowCore { } /** - * Aborts the interactive gesture currently in progress (linking, dragging, - * resizing, rotating or panning). - * - * Runs the registered listener cleanups, restores the diagram state the - * gesture modified (node positions/size/angle, temporary edge), clears the - * gesture's action state and lets the corresponding "ended" event fire with - * the `cancelled` reason. No-op when nothing is active, when a cancel is - * already in flight, when the gesture's normal end is already being - * processed, or when a transaction is active — the rollback would merge into - * the transaction and could be silently discarded, so it is refused with a - * console warning instead. + * 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 listener cleanup was torn down + * @returns Whether any gesture or registered cleanup was torn down */ cancelActiveInteraction(): Promise { return this.interactionCoordinator.cancelActiveInteraction(); 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 index 98f26cd54..f602f5eb7 100644 --- 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 @@ -4,10 +4,6 @@ import { EventHandler } from '../event-handler'; /** * Aborts whatever interactive gesture is currently in progress * (linking, dragging, resizing, rotating, panning). - * - * Bound to the `cancelInteraction` shortcut action (Escape by default) and - * reachable programmatically via `NgDiagramService.cancelActiveInteraction()`. - * No-op when nothing is active. */ export class CancelInteractionEventHandler extends EventHandler { async handle(): Promise { 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 f97ac2c1f..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 @@ -3,25 +3,20 @@ import { BaseInputEvent } from '../input-events.interface'; export abstract class EventHandler { /** - * The gesture state whose end or cancel claimed the teardown — see - * {@link claimTeardown}. Never reset: every gesture starts with a fresh - * state object, so a stale claim can never match a live gesture. + * 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`: the end phase claims it - * so a racing cancel() no-ops instead of rolling back a completing gesture, - * and cancel() claims it so a second cancel no-ops. - * - * Only usable when the gesture keeps ONE state object for its whole lifetime. - * Linking replaces its state object mid-gesture, so it stamps the state - * instead (see `InternalLinkingActionState`); drag folds the claim into its - * private `DragGesture.ended` flag, which also kills suspended continues. + * 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; @@ -33,15 +28,11 @@ export abstract class EventHandler { } /** - * Aborts the gesture this handler is currently tracking, without the side - * effects of a normal `end` phase (no edge creation, no group drop, …). - * - * Gesture handlers override this to clear their action state, reset internal - * tracking and let the corresponding "ended" event fire with a cancel reason. - * The default is a no-op for handlers without an in-progress gesture concept. + * 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 actually torn down — `false` when there is no - * gesture, or when its normal end (or another cancel) is already in flight. + * @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/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 f862731c7..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,14 +5,12 @@ 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; @@ -72,8 +70,7 @@ export class PointerMoveSelectionEventHandler extends EventHandler n.id); if (crossedThreshold) { gesture.hasMoved = true; - // Snapshot positions before the first delta is applied so an aborted - // drag (cancelActiveInteraction) can restore them. + // Snapshot before the first delta is applied. gesture.initialPositions = new Map(selectedNodesWithChildren.map((n) => [n.id, { ...n.position }])); this.flow.actionStateManager.dragging = { nodeIds: draggedNodeIds, @@ -142,8 +139,8 @@ export class PointerMoveSelectionEventHandler extends EventHandler { if (needsStop) { - // Snap the dragged nodes back to where they were before the drag. const initialPositions = gesture?.initialPositions; if (initialPositions?.size) { await tx.emit('updateNodes', { @@ -207,7 +202,7 @@ export class PointerMoveSelectionEventHandler extends EventHandler this.onPointerDown(); constructor() { - // Capture phase: the resize/rotate handles stopPropagation() on pointerdown, - // so a bubble-phase focus grab never fires for them — a gesture started with - // focus outside the diagram would leave every shortcut (incl. Escape) dead. + // 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); } 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 d1f92d7b9..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 @@ -112,8 +112,9 @@ export class LinkingInputDirective implements OnDestroy { private removeListeners() { this.unregisterInteractionCleanup?.(); this.unregisterInteractionCleanup = null; - // The shared touch marker belongs to whichever gesture set it — a bystander - // destroyed mid-gesture (virtualization during touch panning) must leave it alone. + // 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(); 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 4c04cc1c3..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 @@ -150,8 +150,9 @@ export class PointerMoveSelectionDirective implements OnDestroy { document.removeEventListener('pointerup', this.onPointerUp); this.stopEdgePanning(); this.cachedDiagramRect = null; - // The shared touch marker belongs to whichever gesture set it — a bystander - // destroyed mid-gesture (virtualization during touch panning) must leave it alone. + // 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(); 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 0429c4317..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 @@ -71,8 +71,9 @@ export class ResizeDirective implements OnDestroy { this.unregisterInteractionCleanup = null; document.removeEventListener('pointermove', this.onPointerMove); document.removeEventListener('pointerup', this.onPointerUp); - // The shared touch marker belongs to whichever gesture set it — a bystander - // destroyed mid-gesture (virtualization during touch panning) must leave it alone. + // 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(); 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 98ad954e3..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 @@ -132,8 +132,9 @@ export class RotateHandleDirective implements OnDestroy { private removeListeners() { this.unregisterInteractionCleanup?.(); this.unregisterInteractionCleanup = null; - // The shared touch marker belongs to whichever gesture set it — a bystander - // destroyed mid-gesture (virtualization during touch panning) must leave it alone. + // 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(); From 326447e00da104e0eeb1a698035d8b5e70b75c92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Ja=C5=BAwa?= Date: Wed, 5 Aug 2026 22:51:53 +0200 Subject: [PATCH 18/18] Pin InteractionCoordinator behavior with dedicated unit tests Covers what the FlowCore facade tests cannot see: cleanups run before handler cancels, the cancelling latch is up only while a cancel is in flight and is released on rejection, the transaction guard refuses without touching cleanups or the router, and a re-entrant cancel is a side-effect-free no-op. --- .../interaction-coordinator.test.ts | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 packages/ng-diagram/projects/ng-diagram/src/core/src/interaction-coordinator/interaction-coordinator.test.ts 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); + }); +});