From e60bc56c8ef510380b5911382a861e1b289f046e Mon Sep 17 00:00:00 2001 From: Jacek Debek Date: Mon, 14 Sep 2026 07:31:30 +0200 Subject: [PATCH 01/10] Edge relinking --- apps/angular-demo/src/app/app.component.html | 2 + apps/angular-demo/src/app/app.component.ts | 37 +- .../src/app/data/default-model.ts | 22 ++ .../src/app/toolbar/toolbar.component.html | 3 +- .../src/app/toolbar/toolbar.component.ts | 12 + .../Components/NgDiagramBaseEdgeComponent.md | 19 + .../docs/api/Components/NgDiagramComponent.md | 28 ++ .../docs/api/Internals/LinkingActionState.md | 23 ++ .../api/Internals/LinkingRelinkContext.md | 33 ++ .../docs/api/Other/RelinkHandleDirective.md | 11 + .../api/Services/NgDiagramModelService.md | 138 +++++++ .../docs/api/Services/NgDiagramService.md | 29 ++ .../Features/DanglingEdgesConfig.md | 116 ++++++ .../Features/EdgeRelinkingConfig.md | 66 ++++ .../api/Types/Configuration/FlowConfig.md | 24 ++ .../docs/api/Types/Events/DiagramEventMap.md | 28 ++ .../api/Types/Events/EdgeDrawEndedEvent.md | 8 +- .../Types/Events/EdgeRelinkCancelReason.md | 17 + .../api/Types/Events/EdgeRelinkEndedEvent.md | 93 +++++ .../Types/Events/EdgeRelinkStartedEvent.md | 26 ++ .../api/Types/Events/SelectionRemovedEvent.md | 14 + .../api/Types/Middleware/ModelActionType.md | 2 +- .../docs/api/Types/Model/DanglingEndpoint.md | 33 ++ .../content/docs/api/Types/Model/EdgeEnd.md | 11 + .../api/Utilities/getDanglingEndpoints.md | 23 ++ .../getNearestDanglingEndpointInRange.md | 31 ++ .../docs/api/Utilities/hasFreeEndpoint.md | 29 ++ .../docs/api/Utilities/isDanglingEdge.md | 22 ++ apps/docs/src/content/docs/api/_readme.md | 13 + apps/e2e/tests/dangling-relinking.spec.ts | 350 ++++++++++++++++++ .../ng-diagram/api-report/ng-diagram.api.md | 106 +++++- .../commands/__tests__/copy-paste.test.ts | 116 ++++++ .../__tests__/detach-on-node-delete.test.ts | 199 ++++++++++ .../commands/add-update-delete.ts | 11 +- .../command-handler/commands/copy-paste.ts | 70 ++-- .../commands/delete-selection.ts | 42 ++- .../commands/detach-on-node-delete.ts | 92 +++++ .../src/command-handler/commands/index.ts | 4 + .../linking/__tests__/cancel-linking.test.ts | 45 +++ .../linking/__tests__/finish-linking.test.ts | 104 ++++++ .../__tests__/finish-relinking.test.ts | 344 +++++++++++++++++ .../linking/__tests__/linking-utils.test.ts | 181 +++++++++ .../__tests__/move-temporary-edge.test.ts | 13 +- .../start-linking-from-position.test.ts | 15 + .../linking/__tests__/start-relinking.test.ts | 219 +++++++++++ .../commands/linking/cancel-linking.ts | 22 +- .../commands/linking/finish-linking.ts | 43 ++- .../commands/linking/finish-relinking.ts | 134 +++++++ .../command-handler/commands/linking/index.ts | 2 + .../commands/linking/move-temporary-edge.ts | 158 +++++--- .../linking/start-linking-from-position.ts | 6 + .../commands/linking/start-relinking.ts | 77 ++++ .../command-handler/commands/linking/utils.ts | 76 +++- .../src/core/src/event-manager/event-types.ts | 98 ++++- .../src/core/src/event-manager/index.ts | 3 + .../src/flow-config/default-flow-config.ts | 13 + .../box-selection/box-selection.handler.ts | 18 +- .../input-events/handlers/relinking/index.ts | 1 + .../handlers/relinking/relinking.event.ts | 13 + .../handlers/relinking/relinking.handler.ts | 84 +++++ .../input-events/input-events.interface.ts | 1 + .../src/input-events/input-events.router.ts | 2 + .../get-source-target-positions.test.ts | 61 +++ .../get-source-target-positions.ts | 14 +- .../__tests__/edge-draw-ended.emitter.test.ts | 48 +++ .../__tests__/edge-relink.emitter.test.ts | 255 +++++++++++++ .../selection-removed.emitter.test.ts | 89 +++++ .../emitters/edge-draw-ended.emitter.ts | 13 +- .../emitters/edge-relink.emitter.ts | 67 ++++ .../event-emitter/emitters/index.ts | 1 + .../emitters/selection-removed.emitter.ts | 11 +- .../event-emitter/event-emitter.middleware.ts | 4 + .../z-index-assignment/z-index-assignment.ts | 19 +- .../base-render-strategy.test.ts | 35 +- .../render-strategy/base-render-strategy.ts | 11 +- .../virtualized-render-strategy.ts | 2 +- .../virtualized/visible-elements-resolver.ts | 71 +++- .../core/src/types/action-state.interface.ts | 31 +- .../src/types/command-handler.interface.ts | 4 + .../src/core/src/types/edge.interface.ts | 9 + .../core/src/types/flow-config.interface.ts | 92 ++++- .../core/src/types/middleware.interface.ts | 3 + .../utils/__tests__/dangling-edges.test.ts | 130 +++++++ .../src/core/src/utils/dangling-edges.ts | 153 ++++++++ .../ng-diagram/src/core/src/utils/index.ts | 1 + .../diagram/ng-diagram.component.ts | 22 ++ .../base-edge/base-edge-component.spec.ts | 2 + .../edge/base-edge/base-edge.component.html | 23 ++ .../edge/base-edge/base-edge.component.scss | 14 + .../edge/base-edge/base-edge.component.ts | 37 +- .../edge/ng-diagram-edge.component.ts | 12 +- .../object-selection.directive.ts | 6 + .../input-events/panning/panning.directive.ts | 1 + .../relinking/relinking.directive.ts | 39 ++ .../lib/model/signal-model-adapter.spec.ts | 18 + .../src/lib/providers/ng-diagram.providers.ts | 4 + .../ng-diagram-model.service.ts | 120 +++++- .../lib/public-services/ng-diagram.service.ts | 16 + .../input-events/manual-linking.service.ts | 25 +- .../input-events/relinking-event.service.ts | 52 +++ .../input-events/relinking-gesture.service.ts | 135 +++++++ .../ng-diagram/src/lib/types/pointer-event.ts | 1 + .../projects/ng-diagram/src/public-api.ts | 12 + 103 files changed, 5086 insertions(+), 152 deletions(-) create mode 100644 apps/docs/src/content/docs/api/Internals/LinkingRelinkContext.md create mode 100644 apps/docs/src/content/docs/api/Other/RelinkHandleDirective.md create mode 100644 apps/docs/src/content/docs/api/Types/Configuration/Features/DanglingEdgesConfig.md create mode 100644 apps/docs/src/content/docs/api/Types/Configuration/Features/EdgeRelinkingConfig.md create mode 100644 apps/docs/src/content/docs/api/Types/Events/EdgeRelinkCancelReason.md create mode 100644 apps/docs/src/content/docs/api/Types/Events/EdgeRelinkEndedEvent.md create mode 100644 apps/docs/src/content/docs/api/Types/Events/EdgeRelinkStartedEvent.md create mode 100644 apps/docs/src/content/docs/api/Types/Model/DanglingEndpoint.md create mode 100644 apps/docs/src/content/docs/api/Types/Model/EdgeEnd.md create mode 100644 apps/docs/src/content/docs/api/Utilities/getDanglingEndpoints.md create mode 100644 apps/docs/src/content/docs/api/Utilities/getNearestDanglingEndpointInRange.md create mode 100644 apps/docs/src/content/docs/api/Utilities/hasFreeEndpoint.md create mode 100644 apps/docs/src/content/docs/api/Utilities/isDanglingEdge.md create mode 100644 apps/e2e/tests/dangling-relinking.spec.ts create mode 100644 packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/detach-on-node-delete.test.ts create mode 100644 packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/detach-on-node-delete.ts create mode 100644 packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/finish-relinking.test.ts create mode 100644 packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/linking-utils.test.ts create mode 100644 packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/start-relinking.test.ts create mode 100644 packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/finish-relinking.ts create mode 100644 packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/start-relinking.ts create mode 100644 packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/relinking/index.ts create mode 100644 packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/relinking/relinking.event.ts create mode 100644 packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/relinking/relinking.handler.ts create mode 100644 packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/__tests__/edge-relink.emitter.test.ts create mode 100644 packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/edge-relink.emitter.ts create mode 100644 packages/ng-diagram/projects/ng-diagram/src/core/src/utils/__tests__/dangling-edges.test.ts create mode 100644 packages/ng-diagram/projects/ng-diagram/src/core/src/utils/dangling-edges.ts create mode 100644 packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/relinking/relinking.directive.ts create mode 100644 packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/relinking-event.service.ts create mode 100644 packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/relinking-gesture.service.ts diff --git a/apps/angular-demo/src/app/app.component.html b/apps/angular-demo/src/app/app.component.html index 8e4fae78e..6ba2c571e 100644 --- a/apps/angular-demo/src/app/app.component.html +++ b/apps/angular-demo/src/app/app.component.html @@ -14,6 +14,8 @@ (nodeRotateEnded)="onNodeRotateEnded($event)" (edgeDrawn)="onEdgeDrawn($event)" (edgeDrawEnded)="onEdgeDrawEnded($event)" + (edgeRelinkStarted)="onEdgeRelinkStarted($event)" + (edgeRelinkEnded)="onEdgeRelinkEnded($event)" (clipboardPasted)="onClipboardPasted($event)" (nodeResized)="onNodeResized($event)" (nodeResizeStarted)="onNodeResizeStarted($event)" diff --git a/apps/angular-demo/src/app/app.component.ts b/apps/angular-demo/src/app/app.component.ts index ade837a51..427ec3cae 100644 --- a/apps/angular-demo/src/app/app.component.ts +++ b/apps/angular-demo/src/app/app.component.ts @@ -5,6 +5,8 @@ import { DiagramInitEvent, EdgeDrawEndedEvent, EdgeDrawnEvent, + EdgeRelinkEndedEvent, + EdgeRelinkStartedEvent, GroupMembershipChangedEvent, initializeModel, MinimapNodeStyle, @@ -125,6 +127,18 @@ export class AppComponent { linking: { selectNodeOnPortPress: false, }, + // Dangling edges: a link drawn onto empty canvas is kept (with a free + // endpoint), and deleting a node detaches its edges instead of deleting + // them. Delete an edge explicitly by selecting it. + danglingEdges: { + enabled: true, + detachOnNodeDelete: true, + }, + // Every selected edge shows grabbable endpoint handles — drag one to + // reconnect it to another port or drop it on empty canvas to detach it. + edgeRelinking: { + enabled: true, + }, shortcuts: configureShortcuts([ { actionName: 'keyboardMoveSelectionUp', @@ -274,18 +288,37 @@ export class AppComponent { }); } + onEdgeRelinkStarted(event: EdgeRelinkStartedEvent): void { + console.log('Edge Relink Started:', { edge: event.edge.id, end: event.end }); + } + + onEdgeRelinkEnded(event: EdgeRelinkEndedEvent): void { + console.log('Edge Relink Ended:', { + edge: event.edge.id, + end: event.end, + success: event.success, + previousNode: event.previousNode?.id, + previousPort: event.previousPort, + target: event.target?.id, + targetPort: event.targetPort, + reason: event.reason, + dropPosition: event.dropPosition, + }); + } + onEdgeDrawEnded(event: EdgeDrawEndedEvent): void { if (event.success) { console.log('Edge Draw Ended (success):', { edge: event.edge!.id, - source: event.source.id, + // source is undefined for draws started from empty canvas + source: event.source?.id, target: event.target?.id, sourcePort: event.sourcePort, targetPort: event.targetPort, }); } else { console.log('Edge Draw Ended (cancelled):', { - source: event.source.id, + source: event.source?.id, sourcePort: event.sourcePort, reason: event.reason, dropPosition: event.dropPosition, diff --git a/apps/angular-demo/src/app/data/default-model.ts b/apps/angular-demo/src/app/data/default-model.ts index 293e3161c..bbddf6748 100644 --- a/apps/angular-demo/src/app/data/default-model.ts +++ b/apps/angular-demo/src/app/data/default-model.ts @@ -201,6 +201,28 @@ export const defaultModel: DiagramModel = { targetPort: 'port-left', routing: 'orthogonal', }, + // Dangling edge: connected at the source, free at the target. Grab the + // endpoint handle (select the edge first) to reconnect it to a port. + { + id: 'dangling-single', + source: '9', + sourcePort: 'port-right', + target: '', + targetPosition: { x: 420, y: 620 }, + data: {}, + routing: 'orthogonal', + }, + // Dual dangling edge: connected to nothing on both ends. It routes + // between its two free endpoints, survives copy/paste and persistence. + { + id: 'dangling-dual', + source: '', + sourcePosition: { x: -250, y: 850 }, + target: '', + targetPosition: { x: -50, y: 950 }, + data: {}, + routing: 'orthogonal', + }, { id: '2', source: '2', diff --git a/apps/angular-demo/src/app/toolbar/toolbar.component.html b/apps/angular-demo/src/app/toolbar/toolbar.component.html index 465edc2bc..6010d427d 100644 --- a/apps/angular-demo/src/app/toolbar/toolbar.component.html +++ b/apps/angular-demo/src/app/toolbar/toolbar.component.html @@ -1,6 +1,7 @@
- + + diff --git a/apps/angular-demo/src/app/toolbar/toolbar.component.ts b/apps/angular-demo/src/app/toolbar/toolbar.component.ts index f679da116..3ac94408e 100644 --- a/apps/angular-demo/src/app/toolbar/toolbar.component.ts +++ b/apps/angular-demo/src/app/toolbar/toolbar.component.ts @@ -109,6 +109,18 @@ export class ToolbarComponent { } } + onLinkFromPositionClick() { + // Start drawing an edge from the middle of the screen — no source node. + // Finishing on empty canvas keeps it as a dual dangling edge (the + // danglingEdges feature is enabled in this demo); finishing on a port + // connects it. + const center = this.ngDiagramViewportService.clientToFlowPosition({ + x: window.innerWidth / 2, + y: window.innerHeight / 2, + }); + this.ngDiagramService.startLinkingFromPosition(center); + } + onCenterOnClick() { const node = this.ngDiagramSelectionService.selection().nodes[0]; diff --git a/apps/docs/src/content/docs/api/Components/NgDiagramBaseEdgeComponent.md b/apps/docs/src/content/docs/api/Components/NgDiagramBaseEdgeComponent.md index e5fd6090a..09d7e8f89 100644 --- a/apps/docs/src/content/docs/api/Components/NgDiagramBaseEdgeComponent.md +++ b/apps/docs/src/content/docs/api/Components/NgDiagramBaseEdgeComponent.md @@ -11,6 +11,16 @@ It can be extended or used directly to render edges in the diagram. ## Properties +### dangling + +> `readonly` **dangling**: `Signal`\<`boolean`\> + +Whether the edge has at least one free (unconnected) endpoint. Temporary +edges are excluded — a draw preview always has a free end and must not +pick up dangling styling. + +*** + ### edge > **edge**: `InputSignal`\<[`Edge`](/docs/api/types/model/edge/)\<`object`\>\> @@ -19,6 +29,15 @@ Edge data model *** +### relinkHandlesVisible + +> `readonly` **relinkHandlesVisible**: `Signal`\<`boolean`\> + +Endpoint handles for the relinking gesture — rendered on selected, +committed edges when `edgeRelinking.enabled` is true. + +*** + ### routing > **routing**: `InputSignal`\<`undefined` \| `string`\> diff --git a/apps/docs/src/content/docs/api/Components/NgDiagramComponent.md b/apps/docs/src/content/docs/api/Components/NgDiagramComponent.md index 60c52733a..29d707bed 100644 --- a/apps/docs/src/content/docs/api/Components/NgDiagramComponent.md +++ b/apps/docs/src/content/docs/api/Components/NgDiagramComponent.md @@ -72,6 +72,34 @@ Use `edgeDrawEnded` instead, which fires for both successful and cancelled draws *** +### edgeRelinkEnded + +> **edgeRelinkEnded**: `EventEmitter`\<[`EdgeRelinkEndedEvent`](/docs/api/types/events/edgerelinkendedevent/)\> + +Event emitted when an edge relink gesture ends, regardless of outcome. + +Fires when the dragged endpoint is dropped — reconnected to a port, left +dangling on empty canvas, or reverted (invalid drop or cancelled gesture). + +#### Since + +1.4.0 + +*** + +### edgeRelinkStarted + +> **edgeRelinkStarted**: `EventEmitter`\<[`EdgeRelinkStartedEvent`](/docs/api/types/events/edgerelinkstartedevent/)\> + +Event emitted when the user starts dragging an endpoint of an existing +edge (the relinking gesture, see `edgeRelinking` config). + +#### Since + +1.4.0 + +*** + ### edgeTemplateMap > **edgeTemplateMap**: `InputSignal`\<[`NgDiagramEdgeTemplateMap`](/docs/api/types/templates/ngdiagramedgetemplatemap/)\> diff --git a/apps/docs/src/content/docs/api/Internals/LinkingActionState.md b/apps/docs/src/content/docs/api/Internals/LinkingActionState.md index 5dd5738d4..0b7401025 100644 --- a/apps/docs/src/content/docs/api/Internals/LinkingActionState.md +++ b/apps/docs/src/content/docs/api/Internals/LinkingActionState.md @@ -26,6 +26,29 @@ Position where the pointer was released. *** +### relink? + +> `optional` **relink**: [`LinkingRelinkContext`](/docs/api/internals/linkingrelinkcontext/) + +Present while an existing edge's endpoint is being relinked. The original +edge is hidden and represented by the temporary edge until the gesture +ends; `relink.end` is the end of the temporary edge that follows the +pointer (a normal draw always drags the target end). + +#### Since + +1.4.0 + +*** + +### relinkCancelReason? + +> `optional` **relinkCancelReason**: [`EdgeRelinkCancelReason`](/docs/api/types/events/edgerelinkcancelreason/) + +Reason the relink gesture ended without changing the edge (set by finishRelinking on failure paths). + +*** + ### sourceNodeId > **sourceNodeId**: `string` diff --git a/apps/docs/src/content/docs/api/Internals/LinkingRelinkContext.md b/apps/docs/src/content/docs/api/Internals/LinkingRelinkContext.md new file mode 100644 index 000000000..f663332e5 --- /dev/null +++ b/apps/docs/src/content/docs/api/Internals/LinkingRelinkContext.md @@ -0,0 +1,33 @@ +--- +version: "since v1.4.0" +editUrl: false +next: false +prev: false +title: "LinkingRelinkContext" +--- + +Context of an edge relink gesture carried inside the linking action state. + +## Properties + +### edgeId + +> **edgeId**: `string` + +ID of the edge whose endpoint is being dragged. + +*** + +### end + +> **end**: [`EdgeEnd`](/docs/api/types/model/edgeend/) + +Which endpoint of the edge is being dragged. + +*** + +### originalEdge + +> **originalEdge**: [`Edge`](/docs/api/types/model/edge/) + +Snapshot of the edge as it was when the gesture started. diff --git a/apps/docs/src/content/docs/api/Other/RelinkHandleDirective.md b/apps/docs/src/content/docs/api/Other/RelinkHandleDirective.md new file mode 100644 index 000000000..768c6cf5a --- /dev/null +++ b/apps/docs/src/content/docs/api/Other/RelinkHandleDirective.md @@ -0,0 +1,11 @@ +--- +editUrl: false +next: false +prev: false +title: "RelinkHandleDirective" +--- + +Turns its host element into a grabbable edge-endpoint handle: a pointerdown +starts the relink gesture for the given end of the given edge. The gesture +itself is driven by RelinkingGestureService at document level — the +host element unmounts when the edge is hidden during the drag. diff --git a/apps/docs/src/content/docs/api/Services/NgDiagramModelService.md b/apps/docs/src/content/docs/api/Services/NgDiagramModelService.md index 4466e3b6c..eef3cebcb 100644 --- a/apps/docs/src/content/docs/api/Services/NgDiagramModelService.md +++ b/apps/docs/src/content/docs/api/Services/NgDiagramModelService.md @@ -113,6 +113,52 @@ A promise that resolves once the change has been applied to the model. Inside a *** +### attachEdge() + +> **attachEdge**(`edgeId`, `end`, `nodeId`, `portId?`): `Promise`\<`boolean`\> + +Attaches one endpoint of an edge to a node (and optionally a port), +running `linking.validateConnection` with the edge's endpoints in their +proper roles — the symmetric counterpart of [detachEdge](/docs/api/services/ngdiagrammodelservice/#detachedge). + +#### Parameters + +##### edgeId + +`string` + +The edge to attach. + +##### end + +[`EdgeEnd`](/docs/api/types/model/edgeend/) + +Which endpoint to attach. + +##### nodeId + +`string` + +The node to attach to. + +##### portId? + +`string` + +The port to attach to. + +#### Returns + +`Promise`\<`boolean`\> + +Whether the connection was valid and applied. + +#### Since + +1.4.0 + +*** + ### computePartsBounds() > **computePartsBounds**(`nodes`, `edges`): [`Rect`](/docs/api/types/geometry/rect/) @@ -202,6 +248,46 @@ A promise that resolves once the change has been applied to the model. Inside a *** +### detachEdge() + +> **detachEdge**(`edgeId`, `end`, `position?`): `Promise`\<`void`\> + +Detaches one endpoint of an edge into a free (dangling) endpoint. + +When `position` is omitted, the endpoint stays anchored where it is now: +at the port's current position when the edge was connected to a port, at +the edge's routed endpoint otherwise. + +#### Parameters + +##### edgeId + +`string` + +The edge to detach. + +##### end + +[`EdgeEnd`](/docs/api/types/model/edgeend/) + +Which endpoint to detach. + +##### position? + +[`Point`](/docs/api/types/geometry/point/) + +Optional anchor position for the freed endpoint. + +#### Returns + +`Promise`\<`void`\> + +#### Since + +1.4.0 + +*** + ### getChildren() > **getChildren**\<`T`\>(`groupId`): [`Node`](/docs/api/types/model/node/)\<`T`\>[] @@ -322,6 +408,25 @@ Array of nodes connected to the given node *** +### getDanglingEndpoints() + +> **getDanglingEndpoints**(): [`DanglingEndpoint`](/docs/api/types/model/danglingendpoint/)[] + +Collects the free (unconnected) endpoints of all committed edges — a dual +dangling edge yields two entries. + +#### Returns + +[`DanglingEndpoint`](/docs/api/types/model/danglingendpoint/)[] + +Dangling endpoints with their edge, end and anchor position. + +#### Since + +1.4.0 + +*** + ### getEdgeById() > **getEdgeById**\<`T`\>(`edgeId`): `null` \| [`Edge`](/docs/api/types/model/edge/)\<`T`\> @@ -365,6 +470,39 @@ Returns null if flowCore is not initialized. *** +### getNearestDanglingEndpointInRange() + +> **getNearestDanglingEndpointInRange**(`point`, `range`): `null` \| [`DanglingEndpoint`](/docs/api/types/model/danglingendpoint/) + +Gets the free edge endpoint nearest to a point within a range — the +dangling-edges sibling of [getNearestPortInRange](/docs/api/services/ngdiagrammodelservice/#getnearestportinrange). + +#### Parameters + +##### point + +[`Point`](/docs/api/types/geometry/point/) + +Point to check from. + +##### range + +`number` + +Range to check in. + +#### Returns + +`null` \| [`DanglingEndpoint`](/docs/api/types/model/danglingendpoint/) + +Nearest dangling endpoint in range or null. + +#### Since + +1.4.0 + +*** + ### getNearestNodeInRange() > **getNearestNodeInRange**\<`T`\>(`point`, `range`): `null` \| [`Node`](/docs/api/types/model/node/)\<`T`\> diff --git a/apps/docs/src/content/docs/api/Services/NgDiagramService.md b/apps/docs/src/content/docs/api/Services/NgDiagramService.md index 1f7713c40..9dc08ff6e 100644 --- a/apps/docs/src/content/docs/api/Services/NgDiagramService.md +++ b/apps/docs/src/content/docs/api/Services/NgDiagramService.md @@ -521,6 +521,35 @@ The port ID from which the linking starts. Creates a floating edge when undefine *** +### startLinkingFromPosition() + +> **startLinkingFromPosition**(`position`): `void` + +Call this method to start drawing an edge from a position on the canvas +(no source node) from your custom logic. The free end of the edge follows +the pointer until a click finishes the draw — on a port the edge connects +to it; on empty canvas the edge is kept as a dual dangling edge when +`danglingEdges.enabled` is true (and `shouldKeepOnDrop` allows it), +discarded otherwise. + +#### Parameters + +##### position + +[`Point`](/docs/api/types/geometry/point/) + +The position in flow (diagram) coordinates where the edge starts. + +#### Returns + +`void` + +#### Since + +1.4.0 + +*** + ### transaction() #### Call Signature diff --git a/apps/docs/src/content/docs/api/Types/Configuration/Features/DanglingEdgesConfig.md b/apps/docs/src/content/docs/api/Types/Configuration/Features/DanglingEdgesConfig.md new file mode 100644 index 000000000..cb7978ea8 --- /dev/null +++ b/apps/docs/src/content/docs/api/Types/Configuration/Features/DanglingEdgesConfig.md @@ -0,0 +1,116 @@ +--- +version: "since v1.4.0" +editUrl: false +next: false +prev: false +title: "DanglingEdgesConfig" +--- + +Configuration for dangling edges — edges with one or both endpoints not +connected to any node (an empty `source`/`target` with the free end anchored +at `sourcePosition`/`targetPosition`). + +Everything here is opt-in; with the defaults the diagram behaves exactly as +before: a link drop on empty canvas discards the edge and deleting a node +deletes its edges. + +## Properties + +### detachOnNodeDelete + +> **detachOnNodeDelete**: `boolean` + +When true, edges connected to a deleted node are detached into dangling +edges — anchored where their port was — instead of being deleted. +Requires `enabled` to be true. An edge that is itself part of the deleted +selection is always deleted. An edge losing both endpoints in one delete +becomes a dual dangling edge. + +#### Default + +```ts +false +``` + +*** + +### enabled + +> **enabled**: `boolean` + +Master switch for dangling edges. When true, an edge draw that ends on +empty canvas keeps the edge as a dangling edge instead of discarding it, +and an edge relink dropped on empty canvas detaches that endpoint. + +#### Default + +```ts +false +``` + +*** + +### shouldDetachOnNodeDelete()? + +> `optional` **shouldDetachOnNodeDelete**: (`edge`, `deletedNode`, `end`) => `boolean` + +Per-edge decision whether a given endpoint is detached (kept dangling) or +deleted along with the node. Called only when `enabled` and +`detachOnNodeDelete` are true, once per endpoint losing its node. +Returning false deletes the edge. + +#### Parameters + +##### edge + +[`Edge`](/docs/api/types/model/edge/) + +##### deletedNode + +[`Node`](/docs/api/types/model/node/) + +##### end + +[`EdgeEnd`](/docs/api/types/model/edgeend/) + +#### Returns + +`boolean` + +#### Default + +```ts +undefined (detach every edge) +``` + +*** + +### shouldKeepOnDrop()? + +> `optional` **shouldKeepOnDrop**: (`edge`, `dropPosition`) => `boolean` + +Per-edge decision whether a link dropped on empty canvas is kept as a +dangling edge. Called only when `enabled` is true. The edge passed in is +the fully-built final edge (after `linking.finalEdgeDataBuilder`). +Returning false discards the edge (the default behavior when the feature +is off). + +#### Parameters + +##### edge + +[`Edge`](/docs/api/types/model/edge/) + +##### dropPosition + +[`Point`](/docs/api/types/geometry/point/) + +#### Returns + +`boolean` + +#### Default + +```ts +undefined (keep every edge) +``` diff --git a/apps/docs/src/content/docs/api/Types/Configuration/Features/EdgeRelinkingConfig.md b/apps/docs/src/content/docs/api/Types/Configuration/Features/EdgeRelinkingConfig.md new file mode 100644 index 000000000..d671a52d9 --- /dev/null +++ b/apps/docs/src/content/docs/api/Types/Configuration/Features/EdgeRelinkingConfig.md @@ -0,0 +1,66 @@ +--- +version: "since v1.4.0" +editUrl: false +next: false +prev: false +title: "EdgeRelinkingConfig" +--- + +Configuration for interactive edge relinking — dragging an endpoint of an +existing edge to reconnect it to another port or leave it dangling. + +## Properties + +### enabled + +> **enabled**: `boolean` + +Enables the relinking gesture. When true, a selected edge shows grabbable +endpoint handles; dragging one previews the reconnection live and commits +it on drop. Dropping on empty canvas leaves the endpoint dangling when +`danglingEdges.enabled` is true, otherwise the relink is reverted. + +#### Default + +```ts +false +``` + +*** + +### validateRelink()? + +> `optional` **validateRelink**: (`edge`, `end`, `targetNode`, `targetPort`) => `boolean` + +Validates a relink drop. Receives the edge being relinked, which endpoint +is dragged, and the candidate node/port under the pointer. +When not provided, `linking.validateConnection` is used with the edge's +endpoints in their proper roles. + +#### Parameters + +##### edge + +[`Edge`](/docs/api/types/model/edge/) + +##### end + +[`EdgeEnd`](/docs/api/types/model/edgeend/) + +##### targetNode + +`null` | [`Node`](/docs/api/types/model/node/) + +##### targetPort + +`null` | [`Port`](/docs/api/types/model/port/) + +#### Returns + +`boolean` + +#### Default + +```ts +undefined (falls back to linking.validateConnection) +``` diff --git a/apps/docs/src/content/docs/api/Types/Configuration/FlowConfig.md b/apps/docs/src/content/docs/api/Types/Configuration/FlowConfig.md index 747b48896..317110564 100644 --- a/apps/docs/src/content/docs/api/Types/Configuration/FlowConfig.md +++ b/apps/docs/src/content/docs/api/Types/Configuration/FlowConfig.md @@ -58,6 +58,18 @@ The node's unique ID. *** +### danglingEdges + +> **danglingEdges**: [`DanglingEdgesConfig`](/docs/api/types/configuration/features/danglingedgesconfig/) + +Configuration for dangling edges (edges with unconnected endpoints). + +#### Since + +1.4.0 + +*** + ### debugMode > **debugMode**: `boolean` @@ -84,6 +96,18 @@ Configuration options for the default Node Templates *** +### edgeRelinking + +> **edgeRelinking**: [`EdgeRelinkingConfig`](/docs/api/types/configuration/features/edgerelinkingconfig/) + +Configuration for interactive edge relinking. + +#### Since + +1.4.0 + +*** + ### edgeRouting > **edgeRouting**: [`EdgeRoutingConfig`](/docs/api/types/configuration/features/edgeroutingconfig/) diff --git a/apps/docs/src/content/docs/api/Types/Events/DiagramEventMap.md b/apps/docs/src/content/docs/api/Types/Events/DiagramEventMap.md index 0cd8d98b4..025874cdc 100644 --- a/apps/docs/src/content/docs/api/Types/Events/DiagramEventMap.md +++ b/apps/docs/src/content/docs/api/Types/Events/DiagramEventMap.md @@ -63,6 +63,34 @@ Use `edgeDrawEnded` instead, which fires for both successful and cancelled draws *** +### edgeRelinkEnded + +> **edgeRelinkEnded**: [`EdgeRelinkEndedEvent`](/docs/api/types/events/edgerelinkendedevent/) + +Event emitted when an edge relink gesture ends, regardless of outcome. + +Fires when the dragged endpoint is dropped — reconnected to a port, left +dangling on empty canvas, or reverted (invalid drop or cancelled gesture). + +#### Since + +1.4.0 + +*** + +### edgeRelinkStarted + +> **edgeRelinkStarted**: [`EdgeRelinkStartedEvent`](/docs/api/types/events/edgerelinkstartedevent/) + +Event emitted when the user starts dragging an endpoint of an existing +edge (the relinking gesture). + +#### Since + +1.4.0 + +*** + ### groupMembershipChanged > **groupMembershipChanged**: [`GroupMembershipChangedEvent`](/docs/api/types/events/groupmembershipchangedevent/) diff --git a/apps/docs/src/content/docs/api/Types/Events/EdgeDrawEndedEvent.md b/apps/docs/src/content/docs/api/Types/Events/EdgeDrawEndedEvent.md index 4c76fb43c..ff9d88f40 100644 --- a/apps/docs/src/content/docs/api/Types/Events/EdgeDrawEndedEvent.md +++ b/apps/docs/src/content/docs/api/Types/Events/EdgeDrawEndedEvent.md @@ -38,11 +38,13 @@ The reason the draw was cancelled (only present on cancel) *** -### source +### source? -> **source**: [`Node`](/docs/api/types/model/node/) +> `optional` **source**: [`Node`](/docs/api/types/model/node/) -The source node from which the edge was drawn +The source node from which the edge was drawn. +Undefined for draws started from empty canvas +(see [NgDiagramService.startLinkingFromPosition](/docs/api/services/ngdiagramservice/#startlinkingfromposition)). *** diff --git a/apps/docs/src/content/docs/api/Types/Events/EdgeRelinkCancelReason.md b/apps/docs/src/content/docs/api/Types/Events/EdgeRelinkCancelReason.md new file mode 100644 index 000000000..fc5c039d1 --- /dev/null +++ b/apps/docs/src/content/docs/api/Types/Events/EdgeRelinkCancelReason.md @@ -0,0 +1,17 @@ +--- +version: "since v1.4.0" +editUrl: false +next: false +prev: false +title: "EdgeRelinkCancelReason" +--- + +> **EdgeRelinkCancelReason** = `"noTarget"` \| `"invalidConnection"` \| `"cancelled"` + +Reason an edge relink gesture ended without changing the edge. + +- `noTarget` — dropped on empty canvas while dangling edges are disabled +- `invalidConnection` — the candidate connection failed validation +- `cancelled` — the gesture was aborted (Esc key, + [NgDiagramService.cancelActiveInteraction](/docs/api/services/ngdiagramservice/#cancelactiveinteraction), or another gesture + claimed the pointer) diff --git a/apps/docs/src/content/docs/api/Types/Events/EdgeRelinkEndedEvent.md b/apps/docs/src/content/docs/api/Types/Events/EdgeRelinkEndedEvent.md new file mode 100644 index 000000000..3e1017bb4 --- /dev/null +++ b/apps/docs/src/content/docs/api/Types/Events/EdgeRelinkEndedEvent.md @@ -0,0 +1,93 @@ +--- +version: "since v1.4.0" +editUrl: false +next: false +prev: false +title: "EdgeRelinkEndedEvent" +--- + +Event payload emitted when an edge relink gesture ends, regardless of outcome. + +On success the edge was either reconnected (`target`/`targetPort` populated) +or left dangling (`edge` has an empty endpoint anchored at `dropPosition`). +On failure the edge is unchanged and `reason` explains why. + +## Properties + +### dropPosition + +> **dropPosition**: [`Point`](/docs/api/types/geometry/point/) + +The position where the pointer was released. + +*** + +### edge + +> **edge**: [`Edge`](/docs/api/types/model/edge/) + +The edge after the relink (unchanged snapshot when `success` is false). + +*** + +### end + +> **end**: [`EdgeEnd`](/docs/api/types/model/edgeend/) + +Which endpoint was dragged. + +*** + +### previousNode? + +> `optional` **previousNode**: [`Node`](/docs/api/types/model/node/) + +The node the endpoint was connected to before the relink, if any. + +*** + +### previousPort? + +> `optional` **previousPort**: `string` + +The port the endpoint was connected to before the relink, if any. + +*** + +### previousPosition? + +> `optional` **previousPosition**: [`Point`](/docs/api/types/geometry/point/) + +The dangling position the endpoint had before the relink, if it was dangling. + +*** + +### reason? + +> `optional` **reason**: [`EdgeRelinkCancelReason`](/docs/api/types/events/edgerelinkcancelreason/) + +The reason the relink was reverted (only present on failure). + +*** + +### success + +> **success**: `boolean` + +Whether the edge was changed (reconnected or left dangling). + +*** + +### target? + +> `optional` **target**: [`Node`](/docs/api/types/model/node/) + +The node the endpoint was reconnected to (only present on reconnect). + +*** + +### targetPort? + +> `optional` **targetPort**: `string` + +The port the endpoint was reconnected to (only present on reconnect). diff --git a/apps/docs/src/content/docs/api/Types/Events/EdgeRelinkStartedEvent.md b/apps/docs/src/content/docs/api/Types/Events/EdgeRelinkStartedEvent.md new file mode 100644 index 000000000..983b7e3f8 --- /dev/null +++ b/apps/docs/src/content/docs/api/Types/Events/EdgeRelinkStartedEvent.md @@ -0,0 +1,26 @@ +--- +version: "since v1.4.0" +editUrl: false +next: false +prev: false +title: "EdgeRelinkStartedEvent" +--- + +Event payload emitted when the user starts dragging an endpoint of an +existing edge. + +## Properties + +### edge + +> **edge**: [`Edge`](/docs/api/types/model/edge/) + +The edge being relinked (snapshot at gesture start). + +*** + +### end + +> **end**: [`EdgeEnd`](/docs/api/types/model/edgeend/) + +Which endpoint is being dragged. diff --git a/apps/docs/src/content/docs/api/Types/Events/SelectionRemovedEvent.md b/apps/docs/src/content/docs/api/Types/Events/SelectionRemovedEvent.md index be7f74cc6..e5720b7d5 100644 --- a/apps/docs/src/content/docs/api/Types/Events/SelectionRemovedEvent.md +++ b/apps/docs/src/content/docs/api/Types/Events/SelectionRemovedEvent.md @@ -26,3 +26,17 @@ Edges that were deleted from the diagram > **deletedNodes**: [`Node`](/docs/api/types/model/node/)[] Nodes that were deleted from the diagram + +*** + +### detachedEdges + +> **detachedEdges**: [`Edge`](/docs/api/types/model/edge/)\<`object`\>[] + +Edges that were detached into dangling edges instead of being deleted +(see `danglingEdges.detachOnNodeDelete`). Snapshots taken after the +detach — the freed endpoints are already empty with their positions set. + +#### Since + +1.4.0 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 68ba4dfc4..873444251 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"` \| `"cancelResize"` \| `"startLinking"` \| `"moveTemporaryEdge"` \| `"finishLinking"` \| `"zoom"` \| `"changeZOrder"` \| `"rotateNodeTo"` \| `"rotateNodeStart"` \| `"rotateNodeStop"` \| `"cancelRotate"` \| `"highlightGroup"` \| `"highlightGroupClear"` \| `"moveNodes"` \| `"moveNodesStart"` \| `"moveNodesStop"` \| `"cancelDrag"` \| `"selectEnd"` \| `"templateVisibilityChange"` +> **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"` \| `"startLinkingFromPosition"` \| `"moveTemporaryEdge"` \| `"finishLinking"` \| `"startRelinking"` \| `"finishRelinking"` \| `"zoom"` \| `"changeZOrder"` \| `"rotateNodeTo"` \| `"rotateNodeStart"` \| `"rotateNodeStop"` \| `"cancelRotate"` \| `"highlightGroup"` \| `"highlightGroupClear"` \| `"moveNodes"` \| `"moveNodesStart"` \| `"moveNodesStop"` \| `"cancelDrag"` \| `"selectEnd"` \| `"templateVisibilityChange"` 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/Types/Model/DanglingEndpoint.md b/apps/docs/src/content/docs/api/Types/Model/DanglingEndpoint.md new file mode 100644 index 000000000..cad35d8a1 --- /dev/null +++ b/apps/docs/src/content/docs/api/Types/Model/DanglingEndpoint.md @@ -0,0 +1,33 @@ +--- +version: "since v1.4.0" +editUrl: false +next: false +prev: false +title: "DanglingEndpoint" +--- + +One free (unconnected) endpoint of a dangling edge. + +## Properties + +### edge + +> **edge**: [`Edge`](/docs/api/types/model/edge/) + +The dangling edge. + +*** + +### end + +> **end**: [`EdgeEnd`](/docs/api/types/model/edgeend/) + +Which endpoint of the edge is free. + +*** + +### position + +> **position**: [`Point`](/docs/api/types/geometry/point/) + +The position the free endpoint is anchored at. diff --git a/apps/docs/src/content/docs/api/Types/Model/EdgeEnd.md b/apps/docs/src/content/docs/api/Types/Model/EdgeEnd.md new file mode 100644 index 000000000..fd0d4b27a --- /dev/null +++ b/apps/docs/src/content/docs/api/Types/Model/EdgeEnd.md @@ -0,0 +1,11 @@ +--- +version: "since v1.4.0" +editUrl: false +next: false +prev: false +title: "EdgeEnd" +--- + +> **EdgeEnd** = `"source"` \| `"target"` + +Identifies one endpoint of an edge. diff --git a/apps/docs/src/content/docs/api/Utilities/getDanglingEndpoints.md b/apps/docs/src/content/docs/api/Utilities/getDanglingEndpoints.md new file mode 100644 index 000000000..756d1c0db --- /dev/null +++ b/apps/docs/src/content/docs/api/Utilities/getDanglingEndpoints.md @@ -0,0 +1,23 @@ +--- +version: "since v1.4.0" +editUrl: false +next: false +prev: false +title: "getDanglingEndpoints" +--- + +> **getDanglingEndpoints**(`edges`): [`DanglingEndpoint`](/docs/api/types/model/danglingendpoint/)[] + +Collects the free endpoints of the given edges. A dual dangling edge yields +two entries. Endpoints whose anchor position is missing are skipped — +they cannot be rendered or snapped to. + +## Parameters + +### edges + +readonly [`Edge`](/docs/api/types/model/edge/)\<`object`\>[] + +## Returns + +[`DanglingEndpoint`](/docs/api/types/model/danglingendpoint/)[] diff --git a/apps/docs/src/content/docs/api/Utilities/getNearestDanglingEndpointInRange.md b/apps/docs/src/content/docs/api/Utilities/getNearestDanglingEndpointInRange.md new file mode 100644 index 000000000..e029a227b --- /dev/null +++ b/apps/docs/src/content/docs/api/Utilities/getNearestDanglingEndpointInRange.md @@ -0,0 +1,31 @@ +--- +version: "since v1.4.0" +editUrl: false +next: false +prev: false +title: "getNearestDanglingEndpointInRange" +--- + +> **getNearestDanglingEndpointInRange**(`edges`, `point`, `range`): `null` \| [`DanglingEndpoint`](/docs/api/types/model/danglingendpoint/) + +Finds the free edge endpoint nearest to `point` within `range`, or null when +none is close enough. Sibling of `getNearestPortInRange` for snapping to +dangling ends. + +## Parameters + +### edges + +readonly [`Edge`](/docs/api/types/model/edge/)\<`object`\>[] + +### point + +[`Point`](/docs/api/types/geometry/point/) + +### range + +`number` + +## Returns + +`null` \| [`DanglingEndpoint`](/docs/api/types/model/danglingendpoint/) diff --git a/apps/docs/src/content/docs/api/Utilities/hasFreeEndpoint.md b/apps/docs/src/content/docs/api/Utilities/hasFreeEndpoint.md new file mode 100644 index 000000000..bb512b3db --- /dev/null +++ b/apps/docs/src/content/docs/api/Utilities/hasFreeEndpoint.md @@ -0,0 +1,29 @@ +--- +version: "since v1.4.0" +editUrl: false +next: false +prev: false +title: "hasFreeEndpoint" +--- + +> **hasFreeEndpoint**(`edge`, `end?`): `boolean` + +Checks whether the given endpoint of an edge is free (not connected to a +node). A free endpoint is represented by an empty `source`/`target` with the +position stored in `sourcePosition`/`targetPosition`. + +When `end` is omitted, checks whether either endpoint is free. + +## Parameters + +### edge + +[`Edge`](/docs/api/types/model/edge/) + +### end? + +[`EdgeEnd`](/docs/api/types/model/edgeend/) + +## Returns + +`boolean` diff --git a/apps/docs/src/content/docs/api/Utilities/isDanglingEdge.md b/apps/docs/src/content/docs/api/Utilities/isDanglingEdge.md new file mode 100644 index 000000000..41ae873ba --- /dev/null +++ b/apps/docs/src/content/docs/api/Utilities/isDanglingEdge.md @@ -0,0 +1,22 @@ +--- +version: "since v1.4.0" +editUrl: false +next: false +prev: false +title: "isDanglingEdge" +--- + +> **isDanglingEdge**(`edge`): `boolean` + +Checks whether an edge is dangling — has at least one endpoint not connected +to a node. An edge with both endpoints free is a dual dangling edge. + +## Parameters + +### edge + +[`Edge`](/docs/api/types/model/edge/) + +## Returns + +`boolean` diff --git a/apps/docs/src/content/docs/api/_readme.md b/apps/docs/src/content/docs/api/_readme.md index d7e10f91e..6acaf299d 100644 --- a/apps/docs/src/content/docs/api/_readme.md +++ b/apps/docs/src/content/docs/api/_readme.md @@ -38,6 +38,7 @@ title: "ng-diagram" - [EnvironmentInfo](/docs/api/internals/environmentinfo/) - [HighlightGroupActionState](/docs/api/internals/highlightgroupactionstate/) - [LinkingActionState](/docs/api/internals/linkingactionstate/) +- [LinkingRelinkContext](/docs/api/internals/linkingrelinkcontext/) - [PanningActionState](/docs/api/internals/panningactionstate/) - [ResizeActionState](/docs/api/internals/resizeactionstate/) - [RotationActionState](/docs/api/internals/rotationactionstate/) @@ -45,6 +46,7 @@ title: "ng-diagram" ## Other +- [RelinkHandleDirective](/docs/api/other/relinkhandledirective/) - [InvalidateMeasurementsOptions](/docs/api/other/invalidatemeasurementsoptions/) - [~~BaseEdgeLabelComponent~~](/docs/api/other/baseedgelabelcomponent/) @@ -71,7 +73,9 @@ title: "ng-diagram" - [BackgroundConfig](/docs/api/types/configuration/features/backgroundconfig/) - [BoxSelectionConfig](/docs/api/types/configuration/features/boxselectionconfig/) +- [DanglingEdgesConfig](/docs/api/types/configuration/features/danglingedgesconfig/) - [DefaultNodeTemplateConfig](/docs/api/types/configuration/features/defaultnodetemplateconfig/) +- [EdgeRelinkingConfig](/docs/api/types/configuration/features/edgerelinkingconfig/) - [EdgeRoutingConfig](/docs/api/types/configuration/features/edgeroutingconfig/) - [GroupingConfig](/docs/api/types/configuration/features/groupingconfig/) - [LinkingConfig](/docs/api/types/configuration/features/linkingconfig/) @@ -107,6 +111,8 @@ title: "ng-diagram" - [DiagramInitEvent](/docs/api/types/events/diagraminitevent/) - [EdgeDrawEndedEvent](/docs/api/types/events/edgedrawendedevent/) - [~~EdgeDrawnEvent~~](/docs/api/types/events/edgedrawnevent/) +- [EdgeRelinkEndedEvent](/docs/api/types/events/edgerelinkendedevent/) +- [EdgeRelinkStartedEvent](/docs/api/types/events/edgerelinkstartedevent/) - [GroupMembershipChangedEvent](/docs/api/types/events/groupmembershipchangedevent/) - [NodeDragEndedEvent](/docs/api/types/events/nodedragendedevent/) - [NodeDragStartedEvent](/docs/api/types/events/nodedragstartedevent/) @@ -123,6 +129,7 @@ title: "ng-diagram" - [SelectionRotatedEvent](/docs/api/types/events/selectionrotatedevent/) - [ViewportChangedEvent](/docs/api/types/events/viewportchangedevent/) - [EdgeDrawCancelReason](/docs/api/types/events/edgedrawcancelreason/) +- [EdgeRelinkCancelReason](/docs/api/types/events/edgerelinkcancelreason/) - [GestureCancelReason](/docs/api/types/events/gesturecancelreason/) ## Types/Geometry @@ -154,6 +161,7 @@ title: "ng-diagram" ## Types/Model +- [DanglingEndpoint](/docs/api/types/model/danglingendpoint/) - [Edge](/docs/api/types/model/edge/) - [EdgeLabel](/docs/api/types/model/edgelabel/) - [FlowState](/docs/api/types/model/flowstate/) @@ -167,6 +175,7 @@ title: "ng-diagram" - [SimpleNode](/docs/api/types/model/simplenode/) - [Viewport](/docs/api/types/model/viewport/) - [AbsoluteEdgeLabelPosition](/docs/api/types/model/absoluteedgelabelposition/) +- [EdgeEnd](/docs/api/types/model/edgeend/) - [EdgeLabelPosition](/docs/api/types/model/edgelabelposition/) - [Node](/docs/api/types/model/node/) - [OriginPoint](/docs/api/types/model/originpoint/) @@ -202,8 +211,12 @@ title: "ng-diagram" - [configureShortcuts](/docs/api/utilities/configureshortcuts/) - [createMiddlewares](/docs/api/utilities/createmiddlewares/) +- [getDanglingEndpoints](/docs/api/utilities/getdanglingendpoints/) +- [getNearestDanglingEndpointInRange](/docs/api/utilities/getnearestdanglingendpointinrange/) +- [hasFreeEndpoint](/docs/api/utilities/hasfreeendpoint/) - [initializeModel](/docs/api/utilities/initializemodel/) - [initializeModelAdapter](/docs/api/utilities/initializemodeladapter/) +- [isDanglingEdge](/docs/api/utilities/isdanglingedge/) - [provideNgDiagram](/docs/api/utilities/providengdiagram/) - [stripEdgeRuntimeProperties](/docs/api/utilities/stripedgeruntimeproperties/) - [stripNodeRuntimeProperties](/docs/api/utilities/stripnoderuntimeproperties/) diff --git a/apps/e2e/tests/dangling-relinking.spec.ts b/apps/e2e/tests/dangling-relinking.spec.ts new file mode 100644 index 000000000..079fd2539 --- /dev/null +++ b/apps/e2e/tests/dangling-relinking.spec.ts @@ -0,0 +1,350 @@ +import type { Model } from 'ng-diagram'; +import { expect, test, type Diagram } from './fixtures/diagram'; +import { pair, trio } from './fixtures/models'; + +/** + * Dangling edges (config `danglingEdges`) and edge relinking + * (config `edgeRelinking`) — both opt-in, default off. + */ + +/** Both free endpoints set — a dual dangling edge plus one anchored node. */ +const danglingArena: Partial = { + nodes: [{ id: 'node-a', position: { x: 80, y: 120 }, data: { label: 'A' } }], + edges: [ + { + id: 'dual', + source: '', + sourcePosition: { x: 400, y: 300 }, + target: '', + targetPosition: { x: 550, y: 400 }, + data: {}, + }, + ], +}; + +/** Record every edgeRelinkEnded payload on the page for later assertions. */ +async function recordRelinkEnded(diagram: Diagram): Promise { + await diagram.page.evaluate(() => { + const events: unknown[] = []; + (window as unknown as Record).__relinkEnded = events; + window.__diagram!.diagram.addEventListener('edgeRelinkEnded', (event) => { + events.push({ + edge: event.edge.id, + end: event.end, + success: event.success, + reason: event.reason ?? null, + previousNode: event.previousNode?.id ?? null, + target: event.target?.id ?? null, + targetPort: event.targetPort ?? null, + }); + }); + }); +} + +function relinkEnded(diagram: Diagram): Promise { + return diagram.page.evaluate(() => (window as unknown as Record).__relinkEnded as unknown[]); +} + +/** Drag from a port onto empty canvas and release. */ +async function dragFromPortToCanvas(diagram: Diagram, node: string, port: string): Promise<{ x: number; y: number }> { + const from = await diagram.centerOf(diagram.port(node, port), `port ${node}/${port}`); + const to = { x: from.x + 200, y: from.y + 140 }; + await diagram.beginDrag(from, to); + await diagram.page.mouse.up(); + return to; +} + +test.describe('dangling edges', () => { + test('link drop on empty canvas is discarded when the feature is off (default)', async ({ diagram }) => { + await diagram.load({ model: pair }); + + await dragFromPortToCanvas(diagram, 'node-a', 'port-right'); + + await expect(diagram.allEdges).toHaveCount(0); + expect(await diagram.model.edges()).toEqual([]); + }); + + test('link drop on empty canvas keeps a dangling edge when enabled', async ({ diagram }) => { + await diagram.load({ model: pair, config: { danglingEdges: { enabled: true } } }); + + const drop = await dragFromPortToCanvas(diagram, 'node-a', 'port-right'); + const dropFlow = await diagram.viewport.clientToFlowPosition(drop); + + await expect(diagram.allEdges).toHaveCount(1); + await expect.poll(async () => (await diagram.model.edges()).length).toBe(1); + + const [edge] = await diagram.model.edges(); + expect(edge).toMatchObject({ source: 'node-a', target: '' }); + expect(edge.targetPosition).toEqual(dropFlow); + // The free end routes toward the drop point. + expect(edge.points!.at(-1)).toEqual(dropFlow); + // Styling hook for dangling edges. + await expect(diagram.edge(edge.id)).toHaveClass(/ng-diagram-edge--dangling/); + }); + + test('deleting a node deletes its edges by default', async ({ diagram }) => { + await diagram.load({ model: trio }); + + await diagram.selection.select(['node-b']); + await diagram.selection.deleteSelection(); + + await expect.poll(async () => (await diagram.model.edges()).length).toBe(0); + expect(await diagram.model.getNodeById('node-b')).toBeNull(); + }); + + test('deleting a node detaches its edges when detachOnNodeDelete is on', async ({ diagram }) => { + await diagram.load({ + model: trio, + config: { danglingEdges: { enabled: true, detachOnNodeDelete: true } }, + }); + + await diagram.selection.select(['node-b']); + await diagram.selection.deleteSelection(); + + await expect.poll(async () => (await diagram.model.getNodeById('node-b')) === null).toBe(true); + + const edges = await diagram.model.edges(); + expect(edges).toHaveLength(1); + expect(edges[0]).toMatchObject({ id: 'edge-ab', source: 'node-a', target: '' }); + // Anchored where the deleted node was. + expect(edges[0].targetPosition).toBeTruthy(); + await expect(diagram.edge('edge-ab')).toHaveClass(/ng-diagram-edge--dangling/); + }); + + test('an explicitly selected edge is deleted, not detached', async ({ diagram }) => { + await diagram.load({ + model: trio, + config: { danglingEdges: { enabled: true, detachOnNodeDelete: true } }, + }); + + await diagram.selection.select(['node-b'], ['edge-ab']); + await diagram.selection.deleteSelection(); + + await expect.poll(async () => (await diagram.model.edges()).length).toBe(0); + }); + + test('a dual dangling edge renders and routes between its endpoints', async ({ diagram }) => { + await diagram.load({ model: danglingArena }); + + await expect(diagram.edge('dual')).toBeAttached(); + const edge = (await diagram.model.edges()).find((candidate) => candidate.id === 'dual')!; + expect(edge.points![0]).toEqual({ x: 400, y: 300 }); + expect(edge.points!.at(-1)).toEqual({ x: 550, y: 400 }); + }); + + test('copy/paste offsets the free endpoint of a dangling edge', async ({ diagram }) => { + await diagram.load({ model: danglingArena }); + + await diagram.selection.select([], ['dual']); + await diagram.clipboard.copy(); + // The anchors' center is (475, 350); pasting at (675, 550) shifts everything by (200, 200). + await diagram.clipboard.paste({ x: 675, y: 550 }); + + await expect.poll(async () => (await diagram.model.edges()).length).toBe(2); + const pasted = (await diagram.model.edges()).find((candidate) => candidate.id !== 'dual')!; + expect(pasted.sourcePosition).toEqual({ x: 600, y: 500 }); + expect(pasted.targetPosition).toEqual({ x: 750, y: 600 }); + }); +}); + +test.describe('edge relinking', () => { + const relinkOn = { edgeRelinking: { enabled: true } }; + + test('handles render only on selected edges and only when enabled', async ({ diagram }) => { + await diagram.load({ model: trio }); + await diagram.selection.select([], ['edge-ab']); + await expect(diagram.page.locator('[data-relink-handle]')).toHaveCount(0); + + await diagram.load({ model: trio, config: relinkOn }); + await expect(diagram.page.locator('[data-relink-handle]')).toHaveCount(0); + await diagram.selection.select([], ['edge-ab']); + await expect(diagram.page.locator('[data-relink-handle]')).toHaveCount(2); + }); + + test('dragging the target handle onto another port reconnects the edge', async ({ diagram }) => { + await diagram.load({ model: trio, config: relinkOn }); + await recordRelinkEnded(diagram); + await diagram.selection.select([], ['edge-ab']); + + const handle = await diagram.centerOf( + diagram.edge('edge-ab').locator('[data-relink-handle="target"]'), + 'target handle of edge-ab' + ); + const dst = await diagram.centerOf(diagram.port('node-c', 'port-left'), 'port node-c/port-left'); + await diagram.beginDrag(handle, dst); + // The original edge is hidden during the drag and replaced by the preview. + await expect(diagram.edge('TEMPORARY_EDGE')).toBeAttached(); + await expect(diagram.edge('edge-ab')).toHaveCount(0); + await diagram.page.mouse.up(); + + await expect.poll(async () => (await diagram.model.getEdgeById('edge-ab'))?.target).toBe('node-c'); + const edge = await diagram.model.getEdgeById('edge-ab'); + expect(edge).toMatchObject({ source: 'node-a', target: 'node-c', targetPort: 'port-left' }); + await expect(diagram.edge('edge-ab')).toBeAttached(); + await expect(diagram.edge('TEMPORARY_EDGE')).toHaveCount(0); + + await expect + .poll(() => relinkEnded(diagram)) + .toEqual([ + { + edge: 'edge-ab', + end: 'target', + success: true, + reason: null, + previousNode: 'node-b', + target: 'node-c', + targetPort: 'port-left', + }, + ]); + }); + + test('dropping an endpoint on empty canvas reverts when dangling edges are off', async ({ diagram }) => { + await diagram.load({ model: trio, config: relinkOn }); + await recordRelinkEnded(diagram); + await diagram.selection.select([], ['edge-ab']); + + const handle = await diagram.centerOf( + diagram.edge('edge-ab').locator('[data-relink-handle="target"]'), + 'target handle of edge-ab' + ); + await diagram.beginDrag(handle, { x: handle.x + 150, y: handle.y + 160 }); + await diagram.page.mouse.up(); + + const edge = await diagram.model.getEdgeById('edge-ab'); + expect(edge).toMatchObject({ source: 'node-a', target: 'node-b' }); + await expect + .poll(() => relinkEnded(diagram)) + .toEqual([ + { + edge: 'edge-ab', + end: 'target', + success: false, + reason: 'noTarget', + previousNode: 'node-b', + target: null, + targetPort: null, + }, + ]); + }); + + test('dropping an endpoint on empty canvas detaches it when dangling edges are on', async ({ diagram }) => { + await diagram.load({ + model: trio, + config: { ...relinkOn, danglingEdges: { enabled: true } }, + }); + await diagram.selection.select([], ['edge-ab']); + + const handle = await diagram.centerOf( + diagram.edge('edge-ab').locator('[data-relink-handle="target"]'), + 'target handle of edge-ab' + ); + const drop = { x: handle.x + 150, y: handle.y + 160 }; + await diagram.beginDrag(handle, drop); + await diagram.page.mouse.up(); + + await expect.poll(async () => (await diagram.model.getEdgeById('edge-ab'))?.target).toBe(''); + const edge = await diagram.model.getEdgeById('edge-ab'); + const dropFlow = await diagram.viewport.clientToFlowPosition(drop); + expect(edge?.targetPosition).toEqual(dropFlow); + expect(edge?.source).toBe('node-a'); + }); + + test('relinking the source endpoint works too', async ({ diagram }) => { + await diagram.load({ model: trio, config: relinkOn }); + await diagram.selection.select([], ['edge-ab']); + + const handle = await diagram.centerOf( + diagram.edge('edge-ab').locator('[data-relink-handle="source"]'), + 'source handle of edge-ab' + ); + const dst = await diagram.centerOf(diagram.port('node-c', 'port-right'), 'port node-c/port-right'); + await diagram.beginDrag(handle, dst); + await diagram.page.mouse.up(); + + await expect.poll(async () => (await diagram.model.getEdgeById('edge-ab'))?.source).toBe('node-c'); + const edge = await diagram.model.getEdgeById('edge-ab'); + expect(edge).toMatchObject({ source: 'node-c', sourcePort: 'port-right', target: 'node-b' }); + }); + + test('Escape cancels a relink and restores the edge', async ({ diagram }) => { + await diagram.load({ model: trio, config: relinkOn }); + await recordRelinkEnded(diagram); + await diagram.selection.select([], ['edge-ab']); + + const handle = await diagram.centerOf( + diagram.edge('edge-ab').locator('[data-relink-handle="target"]'), + 'target handle of edge-ab' + ); + await diagram.beginDrag(handle, { x: handle.x + 120, y: handle.y + 100 }); + await expect(diagram.edge('TEMPORARY_EDGE')).toBeAttached(); + + await diagram.page.keyboard.press('Escape'); + + await expect(diagram.edge('edge-ab')).toBeAttached(); + const edge = await diagram.model.getEdgeById('edge-ab'); + expect(edge).toMatchObject({ source: 'node-a', target: 'node-b' }); + await expect.poll(async () => (await relinkEnded(diagram)).length).toBe(1); + expect((await relinkEnded(diagram))[0]).toMatchObject({ success: false, reason: 'cancelled' }); + + // Listeners were removed on cancel: the release is inert. + await diagram.page.mouse.up(); + await diagram.nextFrame(); + expect((await relinkEnded(diagram)).length).toBe(1); + }); +}); + +test.describe('startLinkingFromPosition', () => { + test('a draw started from a position connects to a port on click', async ({ diagram }) => { + await diagram.load({ model: pair }); + + const portCenter = await diagram.centerOf(diagram.port('node-b', 'port-left'), 'port node-b/port-left'); + const startFlow = await diagram.viewport.clientToFlowPosition({ x: portCenter.x - 200, y: portCenter.y + 120 }); + + await diagram.diagram.startLinkingFromPosition(startFlow); + await diagram.page.mouse.move(portCenter.x - 100, portCenter.y + 60, { steps: 4 }); + await expect(diagram.edge('TEMPORARY_EDGE')).toBeAttached(); + await diagram.page.mouse.move(portCenter.x, portCenter.y, { steps: 4 }); + await diagram.page.mouse.click(portCenter.x, portCenter.y); + + await expect.poll(async () => (await diagram.model.edges()).length).toBe(1); + const [edge] = await diagram.model.edges(); + expect(edge).toMatchObject({ source: '', target: 'node-b', targetPort: 'port-left' }); + expect(edge.sourcePosition).toEqual(startFlow); + }); + + test('a draw started from a position kept on empty canvas becomes a dual dangling edge', async ({ diagram }) => { + await diagram.load({ model: pair, config: { danglingEdges: { enabled: true } } }); + + const box = await diagram.container.boundingBox(); + const start = { x: box!.x + 500, y: box!.y + 400 }; + const end = { x: start.x + 140, y: start.y + 80 }; + const startFlow = await diagram.viewport.clientToFlowPosition(start); + const endFlow = await diagram.viewport.clientToFlowPosition(end); + + await diagram.diagram.startLinkingFromPosition(startFlow); + await diagram.page.mouse.move(end.x, end.y, { steps: 4 }); + await diagram.page.mouse.click(end.x, end.y); + + await expect.poll(async () => (await diagram.model.edges()).length).toBe(1); + const [edge] = await diagram.model.edges(); + expect(edge).toMatchObject({ source: '', target: '' }); + expect(edge.sourcePosition).toEqual(startFlow); + expect(edge.targetPosition).toEqual(endFlow); + }); + + test('a draw started from a position dropped on empty canvas is discarded when dangling edges are off', async ({ + diagram, + }) => { + await diagram.load({ model: pair }); + + const box = await diagram.container.boundingBox(); + const start = { x: box!.x + 500, y: box!.y + 400 }; + const startFlow = await diagram.viewport.clientToFlowPosition(start); + + await diagram.diagram.startLinkingFromPosition(startFlow); + await diagram.page.mouse.move(start.x + 120, start.y + 60, { steps: 4 }); + await diagram.page.mouse.click(start.x + 120, start.y + 60); + + await expect.poll(async () => (await diagram.model.edges()).length).toBe(0); + }); +}); diff --git a/packages/ng-diagram/api-report/ng-diagram.api.md b/packages/ng-diagram/api-report/ng-diagram.api.md index e11c8953c..d36de493b 100644 --- a/packages/ng-diagram/api-report/ng-diagram.api.md +++ b/packages/ng-diagram/api-report/ng-diagram.api.md @@ -144,6 +144,21 @@ export class CursorPositionTrackerDirective { static ɵfac: i0.ɵɵFactoryDeclaration; } +// @public +export interface DanglingEdgesConfig { + detachOnNodeDelete: boolean; + enabled: boolean; + shouldDetachOnNodeDelete?: (edge: Edge, deletedNode: Node_2, end: EdgeEnd) => boolean; + shouldKeepOnDrop?: (edge: Edge, dropPosition: Point) => boolean; +} + +// @public +export interface DanglingEndpoint { + edge: Edge; + end: EdgeEnd; + position: Point; +} + // @public (undocumented) export type DataObject = object; @@ -159,6 +174,8 @@ export interface DiagramEventMap { edgeDrawEnded: EdgeDrawEndedEvent; // @deprecated edgeDrawn: EdgeDrawnEvent; + edgeRelinkEnded: EdgeRelinkEndedEvent; + edgeRelinkStarted: EdgeRelinkStartedEvent; groupMembershipChanged: GroupMembershipChangedEvent; nodeDragEnded: NodeDragEndedEvent; nodeDragStarted: NodeDragStartedEvent; @@ -240,7 +257,7 @@ export interface EdgeDrawEndedEvent { dropPosition: Point; edge?: Edge; reason?: EdgeDrawCancelReason; - source: Node_2; + source?: Node_2; sourcePort?: string; success: boolean; target?: Node_2; @@ -256,6 +273,9 @@ export interface EdgeDrawnEvent { targetPort?: string; } +// @public +export type EdgeEnd = 'source' | 'target'; + // @public export interface EdgeLabel { id: string; @@ -267,6 +287,35 @@ export interface EdgeLabel { // @public export type EdgeLabelPosition = number | AbsoluteEdgeLabelPosition; +// @public +export type EdgeRelinkCancelReason = 'noTarget' | 'invalidConnection' | 'cancelled'; + +// @public +export interface EdgeRelinkEndedEvent { + dropPosition: Point; + edge: Edge; + end: EdgeEnd; + previousNode?: Node_2; + previousPort?: string; + previousPosition?: Point; + reason?: EdgeRelinkCancelReason; + success: boolean; + target?: Node_2; + targetPort?: string; +} + +// @public +export interface EdgeRelinkingConfig { + enabled: boolean; + validateRelink?: (edge: Edge, end: EdgeEnd, targetNode: Node_2 | null, targetPort: Port | null) => boolean; +} + +// @public +export interface EdgeRelinkStartedEvent { + edge: Edge; + end: EdgeEnd; +} + // @public export interface EdgeRouting { computePointAtDistance?(points: Point[], distancePx: number): Point; @@ -347,8 +396,10 @@ export interface FlowConfig { boxSelection: BoxSelectionConfig; computeEdgeId: () => string; computeNodeId: () => string; + danglingEdges: DanglingEdgesConfig; debugMode: boolean; defaultNode?: DefaultNodeTemplateConfig; + edgeRelinking: EdgeRelinkingConfig; edgeRouting: EdgeRoutingConfig; grouping: GroupingConfig; hideWatermark?: boolean; @@ -397,6 +448,12 @@ export interface FlowStateUpdate { // @public export type GestureCancelReason = 'cancelled'; +// @public +export const getDanglingEndpoints: (edges: readonly Edge[]) => DanglingEndpoint[]; + +// @public +export const getNearestDanglingEndpointInRange: (edges: readonly Edge[], point: Point, range: number) => DanglingEndpoint | null; + // @public export interface GroupingConfig { canGroup: (node: Node_2, group: Node_2) => boolean; @@ -420,6 +477,9 @@ export interface GroupNode extends SimpleNode // @public export type GroupNodeData = SimpleNodeData & Pick; +// @public +export const hasFreeEndpoint: (edge: Edge, end?: EdgeEnd) => boolean; + // @public export interface HighlightGroupActionState { highlightedGroupId: string | null; @@ -455,6 +515,9 @@ export interface InvalidateMeasurementsOptions { }[]; } +// @public +export const isDanglingEdge: (edge: Edge) => boolean; + // @public export type KeyboardActionName = KeyboardMoveSelectionAction | KeyboardPanAction | KeyboardZoomAction | Extract; @@ -498,6 +561,8 @@ export type KeyboardZoomAction = 'keyboardZoomIn' | 'keyboardZoomOut'; export interface LinkingActionState { cancelReason?: EdgeDrawCancelReason; dropPosition?: Point; + relink?: LinkingRelinkContext; + relinkCancelReason?: EdgeRelinkCancelReason; sourceNodeId: string; sourcePortId: string; temporaryEdge: Edge | null; @@ -535,6 +600,13 @@ export class LinkingInputDirective implements OnDestroy { static ɵfac: i0.ɵɵFactoryDeclaration; } +// @public +export interface LinkingRelinkContext { + edgeId: string; + end: EdgeEnd; + originalEdge: Edge; +} + // Warning: (ae-internal-missing-underscore) The name "loggerMiddleware" should be prefixed with an underscore because the declaration is marked as @internal // // @internal @@ -672,7 +744,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' | 'cancelResize' | 'startLinking' | 'moveTemporaryEdge' | 'finishLinking' | 'zoom' | 'changeZOrder' | 'rotateNodeTo' | 'rotateNodeStart' | 'rotateNodeStop' | 'cancelRotate' | 'highlightGroup' | 'highlightGroupClear' | 'moveNodes' | 'moveNodesStart' | 'moveNodesStop' | 'cancelDrag' | 'selectEnd' | 'templateVisibilityChange'; +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' | 'startLinkingFromPosition' | 'moveTemporaryEdge' | 'finishLinking' | 'startRelinking' | 'finishRelinking' | 'zoom' | 'changeZOrder' | 'rotateNodeTo' | 'rotateNodeStart' | 'rotateNodeStop' | 'cancelRotate' | 'highlightGroup' | 'highlightGroupClear' | 'moveNodes' | 'moveNodesStart' | 'moveNodesStop' | 'cancelDrag' | 'selectEnd' | 'templateVisibilityChange'; // @public export type ModelActionTypes = LooseAutocomplete[]; @@ -732,6 +804,7 @@ export class NgDiagramBaseEdgeComponent { constructor(); // (undocumented) readonly class: Signal; + readonly dangling: Signal; edge: InputSignal>; // (undocumented) readonly labels: Signal; @@ -743,6 +816,11 @@ export class NgDiagramBaseEdgeComponent { readonly path: Signal; // (undocumented) readonly points: Signal; + readonly relinkHandlesVisible: Signal; + // (undocumented) + readonly relinkSourceHandle: Signal; + // (undocumented) + readonly relinkTargetHandle: Signal; routing: InputSignal; // (undocumented) readonly selected: Signal; @@ -836,6 +914,8 @@ export class NgDiagramComponent implements OnInit, OnDestroy { edgeDrawEnded: EventEmitter; // @deprecated edgeDrawn: EventEmitter; + edgeRelinkEnded: EventEmitter; + edgeRelinkStarted: EventEmitter; // (undocumented) readonly edges: WritableSignal[]>; edgeTemplateMap: InputSignal; @@ -879,7 +959,7 @@ export class NgDiagramComponent implements OnInit, OnDestroy { viewportChanged: EventEmitter; readonly viewportPannable: WritableSignal; // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; } @@ -1048,16 +1128,20 @@ export class NgDiagramModelService extends NgDiagramBaseService implements OnDes addNodes(nodes: Node_2[], options?: { waitForMeasurements?: boolean; }): Promise; + attachEdge(edgeId: string, end: EdgeEnd, nodeId: string, portId?: string): Promise; computePartsBounds(nodes: Node_2[], edges: Edge[]): Rect; deleteEdges(ids: string[]): Promise; deleteNodes(ids: string[]): Promise; + detachEdge(edgeId: string, end: EdgeEnd, position?: Point): Promise; readonly edges: Signal[]>; getChildren(groupId: string): Node_2[]; getChildrenNested(groupId: string): Node_2[]; getConnectedEdges(nodeId: string): Edge[]; getConnectedNodes(nodeId: string): Node_2[]; + getDanglingEndpoints(): DanglingEndpoint[]; getEdgeById(edgeId: string): Edge | null; getModel(): ModelAdapter; + getNearestDanglingEndpointInRange(point: Point, range: number): DanglingEndpoint | null; getNearestNodeInRange(point: Point, range: number): Node_2 | null; getNearestPortInRange(point: Point, range: number): Port | null; getNodeById(nodeId: string): Node_2 | null; @@ -1299,6 +1383,7 @@ export class NgDiagramService extends NgDiagramBaseService { setDefaultRouting(name: string): void; setEventsEnabled(enabled: boolean): void; startLinking(node: Node_2, portId?: string): void; + startLinkingFromPosition(position: Point): void; transaction(callback: () => Promise): Promise; transaction(callback: () => Promise, options: TransactionOptions): Promise; transaction(callback: () => void): Promise; @@ -1540,6 +1625,20 @@ export interface Rect { y: number; } +// @public +export class RelinkHandleDirective { + // (undocumented) + edge: InputSignal>; + // (undocumented) + end: InputSignal; + // (undocumented) + onPointerDown($event: PointerInputEvent): void; + // (undocumented) + static ɵdir: i0.ɵɵDirectiveDeclaration; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; +} + // @public export interface ResizeActionState { cancelReason?: GestureCancelReason; @@ -1605,6 +1704,7 @@ export interface SelectionMovingConfig { export interface SelectionRemovedEvent { deletedEdges: Edge[]; deletedNodes: Node_2[]; + detachedEdges: Edge[]; } // @public diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/copy-paste.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/copy-paste.test.ts index ef6658cb4..b1b0c17f1 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/copy-paste.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/copy-paste.test.ts @@ -724,6 +724,122 @@ describe('Copy-Paste Commands', () => { }); }); + describe('dangling edges', () => { + it('should offset the free endpoint position and the points of a pasted dangling edge', async () => { + commandHandler.flowCore.getState = () => ({ + nodes: [{ ...mockNode, id: 'node1', position: { x: 10, y: 20 }, selected: true }], + edges: [ + { + ...mockEdge, + id: 'edge1', + source: 'node1', + target: '', + targetPosition: { x: 300, y: 300 }, + points: [ + { x: 110, y: 120 }, + { x: 300, y: 300 }, + ], + selected: true, + }, + ], + metadata: mockMetadata, + }); + + await copy(commandHandler); + await paste(commandHandler, { name: 'paste' }); + + const updateCall = commandHandler.flowCore.applyUpdate as unknown as ReturnType; + const [update] = updateCall.mock.calls[0]; + + expect(update.edgesToAdd).toHaveLength(1); + const pastedEdge = update.edgesToAdd[0]; + // The free end travels with the default (20, 20) paste offset, and the + // stored path travels along so it stays aligned. + expect(pastedEdge.target).toBe(''); + expect(pastedEdge.targetPosition).toEqual({ x: 320, y: 320 }); + expect(pastedEdge.points).toEqual([ + { x: 130, y: 140 }, + { x: 320, y: 320 }, + ]); + // The connected end is remapped to the pasted node. + expect(pastedEdge.source).toBe(update.nodesToAdd[0].id); + }); + + it('should anchor a paste of only a dangling edge at its free endpoint center', async () => { + commandHandler.flowCore.getState = () => ({ + nodes: [], + edges: [ + { + ...mockEdge, + id: 'edge1', + source: '', + target: '', + sourcePosition: { x: 0, y: 0 }, + targetPosition: { x: 100, y: 100 }, + points: [ + { x: 0, y: 0 }, + { x: 100, y: 100 }, + ], + selected: true, + }, + ], + metadata: mockMetadata, + }); + + await copy(commandHandler); + await paste(commandHandler, { name: 'paste', position: { x: 200, y: 200 } }); + + const updateCall = commandHandler.flowCore.applyUpdate as unknown as ReturnType; + const [update] = updateCall.mock.calls[0]; + + expect(update.nodesToAdd).toHaveLength(0); + expect(update.edgesToAdd).toHaveLength(1); + const pastedEdge = update.edgesToAdd[0]; + // Free endpoint center is (50, 50), cursor at (200, 200) → offset (150, 150). + expect(pastedEdge.sourcePosition).toEqual({ x: 150, y: 150 }); + expect(pastedEdge.targetPosition).toEqual({ x: 250, y: 250 }); + expect(pastedEdge.points).toEqual([ + { x: 150, y: 150 }, + { x: 250, y: 250 }, + ]); + }); + + it('should not offset the points of fully-connected pasted edges', async () => { + const originalPoints = [ + { x: 5, y: 5 }, + { x: 45, y: 45 }, + ]; + commandHandler.flowCore.getState = () => ({ + nodes: [ + { ...mockNode, id: 'node1', position: { x: 0, y: 0 }, selected: true }, + { ...mockNode, id: 'node2', position: { x: 50, y: 50 }, selected: true }, + ], + edges: [ + { + ...mockEdge, + id: 'edge1', + source: 'node1', + target: 'node2', + points: originalPoints, + selected: true, + }, + ], + metadata: mockMetadata, + }); + + await copy(commandHandler); + await paste(commandHandler, { name: 'paste' }); + + const updateCall = commandHandler.flowCore.applyUpdate as unknown as ReturnType; + const [update] = updateCall.mock.calls[0]; + + // Fully-connected edges are re-routed from their new nodes instead. + expect(update.edgesToAdd[0].points).toEqual(originalPoints); + expect(update.edgesToAdd[0].sourcePosition).toBeUndefined(); + expect(update.edgesToAdd[0].targetPosition).toBeUndefined(); + }); + }); + describe('hidden elements', () => { const setState = (nodes: Partial[], edges: object[] = [], descendants: Record = {}) => { const flowCore = commandHandler.flowCore as unknown as { diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/detach-on-node-delete.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/detach-on-node-delete.test.ts new file mode 100644 index 000000000..7730c3926 --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/detach-on-node-delete.test.ts @@ -0,0 +1,199 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { FlowCore } from '../../../flow-core'; +import { mockEdge, mockNode, mockPort } from '../../../test-utils'; +import type { Edge, Node } from '../../../types'; +import type { DanglingEdgesConfig } from '../../../types/flow-config.interface'; +import { partitionIncidentEdges } from '../detach-on-node-delete'; + +describe('partitionIncidentEdges', () => { + // Port on the right side: flow position = node.position + port.position + // + {width, height / 2} = (100 + 90 + 10, 200 + 45 + 5) = (200, 250). + const nodeWithPort: Node = { + ...mockNode, + id: 'node-a', + position: { x: 100, y: 200 }, + size: { width: 80, height: 40 }, + measuredPorts: [ + { + ...mockPort, + id: 'port-a', + type: 'source', + side: 'right', + position: { x: 90, y: 45 }, + size: { width: 10, height: 10 }, + nodeId: 'node-a', + }, + ], + }; + + // No ports: the detach anchor falls back to the node center = (330, 410). + const nodeWithoutPorts: Node = { + ...mockNode, + id: 'node-b', + position: { x: 300, y: 400 }, + size: { width: 60, height: 20 }, + }; + + const portAnchor = { x: 200, y: 250 }; + const centerAnchor = { x: 330, y: 410 }; + + // No routed points — the fallback chain (port → routed endpoint → node + // center) is exercised per test; the routed-endpoint case has its own test. + const incidentEdge: Edge = { + ...mockEdge, + id: 'edge-ab', + source: 'node-a', + sourcePort: 'port-a', + target: 'node-b', + points: undefined, + }; + + const nonIncidentEdge: Edge = { + ...mockEdge, + id: 'edge-xy', + source: 'node-x', + target: 'node-y', + }; + + let mockFlowCore: { config: { danglingEdges?: Partial }; getNodeById: ReturnType }; + + const flowCore = () => mockFlowCore as unknown as FlowCore; + + beforeEach(() => { + mockFlowCore = { + config: {}, + getNodeById: vi.fn((id: string) => + id === 'node-a' ? nodeWithPort : id === 'node-b' ? nodeWithoutPorts : undefined + ), + }; + }); + + it('should remove every incident edge and update none when the feature is off', () => { + const result = partitionIncidentEdges(flowCore(), [incidentEdge, nonIncidentEdge], new Set(['node-a'])); + + expect(result.edgesToRemove).toEqual(['edge-ab']); + expect(result.edgesToUpdate).toEqual([]); + }); + + it('should remove incident edges when enabled but detachOnNodeDelete is off', () => { + mockFlowCore.config.danglingEdges = { enabled: true, detachOnNodeDelete: false }; + + const result = partitionIncidentEdges(flowCore(), [incidentEdge], new Set(['node-a'])); + + expect(result.edgesToRemove).toEqual(['edge-ab']); + expect(result.edgesToUpdate).toEqual([]); + }); + + describe('with enabled + detachOnNodeDelete', () => { + beforeEach(() => { + mockFlowCore.config.danglingEdges = { enabled: true, detachOnNodeDelete: true }; + }); + + it('should detach the lost source endpoint anchored at its port flow position', () => { + const result = partitionIncidentEdges(flowCore(), [incidentEdge], new Set(['node-a'])); + + expect(result.edgesToRemove).toEqual([]); + expect(result.edgesToUpdate).toEqual([ + { id: 'edge-ab', source: '', sourcePort: undefined, sourcePosition: portAnchor }, + ]); + }); + + it('should fall back to the node center when the endpoint has no port', () => { + const result = partitionIncidentEdges(flowCore(), [incidentEdge], new Set(['node-b'])); + + expect(result.edgesToRemove).toEqual([]); + expect(result.edgesToUpdate).toEqual([ + { id: 'edge-ab', target: '', targetPort: undefined, targetPosition: centerAnchor }, + ]); + }); + + it('should anchor at the node position when the node has no ports and no size', () => { + mockFlowCore.getNodeById.mockReturnValue({ ...nodeWithoutPorts, size: undefined }); + + const result = partitionIncidentEdges(flowCore(), [incidentEdge], new Set(['node-b'])); + + expect(result.edgesToUpdate).toEqual([ + { id: 'edge-ab', target: '', targetPort: undefined, targetPosition: { x: 300, y: 400 } }, + ]); + }); + + it('should anchor at the routed endpoint when the endpoint has no port but the edge has points', () => { + const routedEdge: Edge = { + ...incidentEdge, + points: [ + { x: 10, y: 20 }, + { x: 350, y: 420 }, + ], + }; + + const result = partitionIncidentEdges(flowCore(), [routedEdge], new Set(['node-b'])); + + expect(result.edgesToUpdate).toEqual([ + { id: 'edge-ab', target: '', targetPort: undefined, targetPosition: { x: 350, y: 420 } }, + ]); + }); + + it('should always remove explicitly deleted edges', () => { + const result = partitionIncidentEdges(flowCore(), [incidentEdge], new Set(['node-a']), new Set(['edge-ab'])); + + expect(result.edgesToRemove).toEqual(['edge-ab']); + expect(result.edgesToUpdate).toEqual([]); + }); + + it('should remove the edge when shouldDetachOnNodeDelete denies the lost end', () => { + const shouldDetachOnNodeDelete = vi.fn().mockReturnValue(false); + mockFlowCore.config.danglingEdges!.shouldDetachOnNodeDelete = shouldDetachOnNodeDelete; + + const result = partitionIncidentEdges(flowCore(), [incidentEdge], new Set(['node-a'])); + + expect(shouldDetachOnNodeDelete).toHaveBeenCalledWith(incidentEdge, nodeWithPort, 'source'); + expect(result.edgesToRemove).toEqual(['edge-ab']); + expect(result.edgesToUpdate).toEqual([]); + }); + + it('should produce a dual dangling patch when the edge loses both ends', () => { + const result = partitionIncidentEdges(flowCore(), [incidentEdge], new Set(['node-a', 'node-b'])); + + expect(result.edgesToRemove).toEqual([]); + expect(result.edgesToUpdate).toEqual([ + { + id: 'edge-ab', + source: '', + sourcePort: undefined, + sourcePosition: portAnchor, + target: '', + targetPort: undefined, + targetPosition: centerAnchor, + }, + ]); + }); + + it('should keep the dual dangling patch when the callback allows both ends', () => { + const shouldDetachOnNodeDelete = vi.fn().mockReturnValue(true); + mockFlowCore.config.danglingEdges!.shouldDetachOnNodeDelete = shouldDetachOnNodeDelete; + + const result = partitionIncidentEdges(flowCore(), [incidentEdge], new Set(['node-a', 'node-b'])); + + expect(shouldDetachOnNodeDelete).toHaveBeenCalledWith(incidentEdge, nodeWithPort, 'source'); + expect(shouldDetachOnNodeDelete).toHaveBeenCalledWith(incidentEdge, nodeWithoutPorts, 'target'); + expect(result.edgesToRemove).toEqual([]); + expect(result.edgesToUpdate).toHaveLength(1); + }); + + it('should remove the edge losing both ends when the callback denies either end', () => { + mockFlowCore.config.danglingEdges!.shouldDetachOnNodeDelete = (_edge, _node, end) => end !== 'target'; + + const result = partitionIncidentEdges(flowCore(), [incidentEdge], new Set(['node-a', 'node-b'])); + + expect(result.edgesToRemove).toEqual(['edge-ab']); + expect(result.edgesToUpdate).toEqual([]); + }); + + it('should leave non-incident edges untouched', () => { + const result = partitionIncidentEdges(flowCore(), [incidentEdge, nonIncidentEdge], new Set(['node-a'])); + + expect(result.edgesToRemove).not.toContain('edge-xy'); + expect(result.edgesToUpdate.map((update) => update.id)).not.toContain('edge-xy'); + }); + }); +}); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/add-update-delete.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/add-update-delete.ts index 6fd8d7242..eb30ea086 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/add-update-delete.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/add-update-delete.ts @@ -1,6 +1,7 @@ import { resolveLabelPosition } from '../../edge-routing-manager'; import type { CommandHandler, Edge, EdgeLabel, EdgeLabelPosition, Node, Point, Port } from '../../types'; import { snapNodePosition } from '../../utils'; +import { partitionIncidentEdges } from './detach-on-node-delete'; const computeAddedPorts = (node: Node, ports: Port[]): Port[] => { const newPortIds = new Set(ports.map((port) => port.id)); @@ -78,17 +79,13 @@ export interface DeleteNodesCommand { export const deleteNodes = async (commandHandler: CommandHandler, command: DeleteNodesCommand) => { const { edges } = commandHandler.flowCore.getState(); const { ids } = command; - const edgesToDeleteIds = new Set(); const nodesToDeleteIds = new Set(ids); - edges.forEach((edge) => { - if (nodesToDeleteIds.has(edge.source) || nodesToDeleteIds.has(edge.target)) { - edgesToDeleteIds.add(edge.id); - } - }); + const { edgesToRemove, edgesToUpdate } = partitionIncidentEdges(commandHandler.flowCore, edges, nodesToDeleteIds); await commandHandler.flowCore.applyUpdate( { nodesToRemove: Array.from(nodesToDeleteIds), - edgesToRemove: edgesToDeleteIds.size > 0 ? Array.from(edgesToDeleteIds) : [], + edgesToRemove, + ...(edgesToUpdate.length > 0 ? { edgesToUpdate } : {}), }, 'deleteNodes' ); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/copy-paste.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/copy-paste.ts index bcdf962f2..0a16fe187 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/copy-paste.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/copy-paste.ts @@ -1,6 +1,6 @@ import { NgDiagramMath } from '../../math'; import type { CommandHandler, Edge, FlowConfig, FlowStateUpdate, Node, Point } from '../../types'; -import { snapNodePosition } from '../../utils'; +import { getDanglingEndpoints, hasFreeEndpoint, snapNodePosition } from '../../utils'; import { computeHiddenNodeIds, isEdgeEffectivelyHidden } from '../../visibility/effective-visibility'; const OFFSET = 20; @@ -15,25 +15,17 @@ export interface PasteCommand { } /** - * Calculate the center point of a collection of nodes + * Collect the positions of free (dangling) endpoints among the copied edges. + * They anchor pasted content the same way node positions do — without them a + * copied selection of just a dangling edge would have no reference point. */ -const calculateNodeCenter = (nodes: Node[]): Point => { - if (nodes.length === 0) { - return { x: 0, y: 0 }; - } - - const centerX = nodes.reduce((sum, node) => sum + node.position.x, 0) / nodes.length; - const centerY = nodes.reduce((sum, node) => sum + node.position.y, 0) / nodes.length; - - return { x: centerX, y: centerY }; -}; +const collectFreeEndpointPositions = (edges: Edge[]): Point[] => + getDanglingEndpoints(edges).map((endpoint) => endpoint.position); /** * Calculate the paste position and offset based on command parameters */ -const calculatePasteOffset = (copiedNodes: Node[], command: PasteCommand): Point => { - const center = calculateNodeCenter(copiedNodes); - +const calculatePasteOffset = (copiedNodes: Node[], copiedEdges: Edge[], command: PasteCommand): Point => { if (!command.position) { // Default behavior: offset from original center return { @@ -42,7 +34,9 @@ const calculatePasteOffset = (copiedNodes: Node[], command: PasteCommand): Point }; } - if (copiedNodes.length === 1) { + const freeEndpointPositions = collectFreeEndpointPositions(copiedEdges); + + if (copiedNodes.length === 1 && freeEndpointPositions.length === 0) { // Single node: center the node at cursor position, accounting for node size const singleNode = copiedNodes[0]; const nodeWidth = singleNode.size?.width ?? 0; @@ -57,7 +51,16 @@ const calculatePasteOffset = (copiedNodes: Node[], command: PasteCommand): Point return NgDiagramMath.subtractPoints(target, singleNode.position); } - // Multiple nodes: maintain relative positioning with center at cursor + // Multiple anchors (node positions and free edge endpoints): maintain + // relative positioning with their center at cursor + const anchors = [...copiedNodes.map((node) => node.position), ...freeEndpointPositions]; + if (anchors.length === 0) { + return { x: OFFSET, y: OFFSET }; + } + const center = { + x: anchors.reduce((sum, point) => sum + point.x, 0) / anchors.length, + y: anchors.reduce((sum, point) => sum + point.y, 0) / anchors.length, + }; return NgDiagramMath.subtractPoints(command.position, center); }; @@ -125,16 +128,31 @@ const createPastedNodes = ( const createPastedEdges = ( config: FlowConfig, copiedEdges: Edge[], + offset: Point, nodeIdMap: Map, hiddenCopiedNodeIds: Set ): Edge[] => { return copiedEdges.map((edge) => { const newEdgeId = config.computeEdgeId(); + + // Free (dangling) endpoints move with the paste offset like node positions + // do — otherwise the pasted copy would pin its free end exactly on the + // original. Manual points travel along so the stored path stays aligned; + // fully-connected edges get re-routed from their new nodes instead. const newEdge: Edge = { ...edge, id: newEdgeId, source: nodeIdMap.get(edge.source) || edge.source, target: nodeIdMap.get(edge.target) || edge.target, + ...(!edge.source && edge.sourcePosition + ? { sourcePosition: { x: edge.sourcePosition.x + offset.x, y: edge.sourcePosition.y + offset.y } } + : {}), + ...(!edge.target && edge.targetPosition + ? { targetPosition: { x: edge.targetPosition.x + offset.x, y: edge.targetPosition.y + offset.y } } + : {}), + ...(hasFreeEndpoint(edge) && edge.points + ? { points: edge.points.map((point) => ({ x: point.x + offset.x, y: point.y + offset.y })) } + : {}), // See createPastedNodes — hidden pasted edges stay deselected. selected: !isEdgeEffectivelyHidden(edge, hiddenCopiedNodeIds), }; @@ -192,10 +210,18 @@ export const copy = async (commandHandler: CommandHandler) => { // Edges: explicitly selected visible edges, plus every edge fully inside the // copied node set (the internal wiring of copied groups, hidden or not). - const copiedEdges = edges.filter( - (edge) => - (edge.selected && !edge.computedHidden) || (copiedNodeIds.has(edge.source) && copiedNodeIds.has(edge.target)) - ); + // With dangling edges enabled, "fully inside" counts only the connected + // endpoints — a dangling edge travels with its one node (a dual dangling + // edge still only copies when selected). + const danglingEnabled = commandHandler.flowCore.config.danglingEdges?.enabled; + const isInsideCopiedSet = (edge: Edge): boolean => { + if (danglingEnabled) { + const connectedEndpoints = [edge.source, edge.target].filter(Boolean); + return connectedEndpoints.length > 0 && connectedEndpoints.every((nodeId) => copiedNodeIds.has(nodeId)); + } + return copiedNodeIds.has(edge.source) && copiedNodeIds.has(edge.target); + }; + const copiedEdges = edges.filter((edge) => (edge.selected && !edge.computedHidden) || isInsideCopiedSet(edge)); commandHandler.flowCore.actionStateManager.copyPaste = { copiedNodes, @@ -229,6 +255,7 @@ export const paste = async (commandHandler: CommandHandler, command: PasteComman const visibleCopiedNodes = copyPasteState.copiedNodes.filter((node) => !hiddenCopiedNodeIds.has(node.id)); const offset = calculatePasteOffset( visibleCopiedNodes.length > 0 ? visibleCopiedNodes : copyPasteState.copiedNodes, + copyPasteState.copiedEdges, command ); @@ -244,6 +271,7 @@ export const paste = async (commandHandler: CommandHandler, command: PasteComman const newEdges = createPastedEdges( commandHandler.flowCore.config, copyPasteState.copiedEdges, + offset, nodeIdMap, hiddenCopiedNodeIds ); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/delete-selection.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/delete-selection.ts index adf7420e8..3bffc40d2 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/delete-selection.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/delete-selection.ts @@ -1,26 +1,10 @@ -import type { CommandHandler, Edge } from '../../types'; +import type { CommandHandler } from '../../types'; +import { partitionIncidentEdges } from './detach-on-node-delete'; export interface DeleteSelectionCommand { name: 'deleteSelection'; } -interface GetEdgesToRemoveParams { - edges: Edge[]; - nodesToDeleteIds: string[]; -} - -const getEdgesToRemove = ({ edges, nodesToDeleteIds }: GetEdgesToRemoveParams): string[] => { - const nodeIdsSet = new Set(nodesToDeleteIds); - // A hidden selected edge is not deleted through its own selection, but an - // edge whose endpoint is being deleted always dies — hidden or not — - // otherwise it would dangle. - return edges - .filter( - (edge) => (edge.selected && !edge.computedHidden) || nodeIdsSet.has(edge.source) || nodeIdsSet.has(edge.target) - ) - .map((edge) => edge.id); -}; - export const deleteSelection = async (commandHandler: CommandHandler) => { const { nodes, edges } = commandHandler.flowCore.getState(); const { modelLookup } = commandHandler.flowCore; @@ -35,15 +19,33 @@ export const deleteSelection = async (commandHandler: CommandHandler) => { const nodesToDeleteIds = [ ...new Set(selectedVisibleNodeIds.flatMap((id) => [id, ...modelLookup.getAllDescendantIds(id)])), ]; + const nodesToDeleteIdsSet = new Set(nodesToDeleteIds); + + // Explicitly selected visible edges are always deleted — detach-on-delete + // never demotes them. A hidden selected edge is not deleted through its own + // selection, but an edge whose endpoint is being deleted dies (or detaches, + // when the dangling-edges feature allows) — hidden or not. + const selectedEdgeIds = new Set(edges.filter((edge) => edge.selected && !edge.computedHidden).map((edge) => edge.id)); + + const { edgesToRemove, edgesToUpdate } = partitionIncidentEdges( + commandHandler.flowCore, + edges, + nodesToDeleteIdsSet, + selectedEdgeIds + ); - const edgesToDeleteIds = getEdgesToRemove({ edges, nodesToDeleteIds }); + const edgesToDeleteIds = [...new Set([...selectedEdgeIds, ...edgesToRemove])]; if (nodesToDeleteIds.length === 0 && edgesToDeleteIds.length === 0) { return; } await commandHandler.flowCore.applyUpdate( - { nodesToRemove: nodesToDeleteIds, edgesToRemove: edgesToDeleteIds }, + { + nodesToRemove: nodesToDeleteIds, + edgesToRemove: edgesToDeleteIds, + ...(edgesToUpdate.length > 0 ? { edgesToUpdate } : {}), + }, 'deleteSelection' ); }; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/detach-on-node-delete.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/detach-on-node-delete.ts new file mode 100644 index 000000000..5f6e47942 --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/detach-on-node-delete.ts @@ -0,0 +1,92 @@ +import type { FlowCore } from '../../flow-core'; +import type { Edge, EdgeEnd } from '../../types'; +import { computeDetachAnchor } from '../../utils'; + +/** + * How the edges incident to a set of deleted nodes are handled: removed along + * with the nodes (the default), or detached into dangling edges. + */ +export interface IncidentEdgesPartition { + edgesToRemove: string[]; + edgesToUpdate: (Partial & { id: Edge['id'] })[]; +} + +/** + * Partitions the edges incident to the nodes being deleted into edges to + * delete and edges to detach into dangling edges. + * + * With `danglingEdges.enabled` + `danglingEdges.detachOnNodeDelete` off, every + * incident edge is deleted — the pre-existing cascade behavior. With them on, + * an incident edge is detached (its lost endpoint becomes free, anchored where + * its port was) unless: + * - it is part of `explicitlyDeletedEdgeIds` (e.g. selected in a + * deleteSelection) — an explicit delete always wins, or + * - `shouldDetachOnNodeDelete` returns false for any endpoint being lost — + * then the whole edge is deleted. + * + * An edge losing both endpoints becomes a dual dangling edge when both + * per-endpoint decisions allow it. + */ +export const partitionIncidentEdges = ( + flowCore: FlowCore, + edges: Edge[], + nodesToDeleteIds: Set, + explicitlyDeletedEdgeIds?: Set +): IncidentEdgesPartition => { + const danglingEdges = flowCore.config?.danglingEdges; + const detachEnabled = !!danglingEdges?.enabled && !!danglingEdges.detachOnNodeDelete; + + const edgesToRemove: string[] = []; + const edgesToUpdate: (Partial & { id: Edge['id'] })[] = []; + + const mayDetach = (edge: Edge, end: EdgeEnd, nodeId: string): boolean => { + if (!danglingEdges?.shouldDetachOnNodeDelete) { + return true; + } + const node = flowCore.getNodeById(nodeId); + return !!node && danglingEdges.shouldDetachOnNodeDelete(edge, node, end); + }; + + for (const edge of edges) { + const sourceLost = nodesToDeleteIds.has(edge.source); + const targetLost = nodesToDeleteIds.has(edge.target); + if (!sourceLost && !targetLost) { + continue; + } + + if ( + !detachEnabled || + explicitlyDeletedEdgeIds?.has(edge.id) || + (sourceLost && !mayDetach(edge, 'source', edge.source)) || + (targetLost && !mayDetach(edge, 'target', edge.target)) + ) { + edgesToRemove.push(edge.id); + continue; + } + + const update: Partial & { id: Edge['id'] } = { id: edge.id }; + if (sourceLost) { + const anchor = computeDetachAnchor(edge, 'source', flowCore.getNodeById(edge.source)); + if (!anchor) { + edgesToRemove.push(edge.id); + continue; + } + update.source = ''; + update.sourcePort = undefined; + update.sourcePosition = anchor; + } + if (targetLost) { + const anchor = computeDetachAnchor(edge, 'target', flowCore.getNodeById(edge.target)); + if (!anchor) { + edgesToRemove.push(edge.id); + continue; + } + update.target = ''; + update.targetPort = undefined; + update.targetPosition = anchor; + } + edgesToUpdate.push(update); + } + + return { edgesToRemove, edgesToUpdate }; +}; 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 869edd553..a31aa7671 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 @@ -28,9 +28,11 @@ import { cancelLinking, finishLinking, finishLinkingToPosition, + finishRelinking, moveTemporaryEdge, startLinking, startLinkingFromPosition, + startRelinking, } from './linking/'; import { moveNodesBy } from './move'; import { moveNodesStart, moveNodesStop } from './move-nodes-lifecycle'; @@ -85,6 +87,8 @@ export const commands: CommandMap = { cancelLinking, finishLinkingToPosition, startLinkingFromPosition, + startRelinking, + finishRelinking, resizeNode, resizeNodeStart, resizeNodeStop, 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 671cbe8f8..3c0ac40d5 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 @@ -114,6 +114,51 @@ describe('cancelLinking', () => { expect(mockFlowCore.actionStateManager.clearLinking).not.toHaveBeenCalled(); }); + describe('during a relink gesture', () => { + const originalEdge: Edge = { + id: 'edge-1', + source: 'source-node', + sourcePort: 'source-port', + target: 'target-node', + targetPort: 'target-port', + data: {}, + }; + + it('should run a finishRelinking pass with the cancelled relink reason', async () => { + const linking: LinkingActionState = { + sourceNodeId: 'source-node', + sourcePortId: 'source-port', + temporaryEdge: mockTemporaryEdge, + relink: { edgeId: 'edge-1', end: 'target', originalEdge }, + }; + mockFlowCore.actionStateManager.linking = linking; + + await cancelLinking(mockCommandHandler); + + expect(linking.relinkCancelReason).toBe('cancelled'); + expect(linking.cancelReason).toBeUndefined(); + expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith({}, 'finishRelinking'); + expect(mockFlowCore.applyUpdate).not.toHaveBeenCalledWith({}, 'finishLinking'); + expect(mockFlowCore.actionStateManager.clearLinking).toHaveBeenCalled(); + }); + + it('should keep the finishLinking pass for a plain draw gesture', 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(linking.relinkCancelReason).toBeUndefined(); + expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith({}, 'finishLinking'); + expect(mockFlowCore.applyUpdate).not.toHaveBeenCalledWith({}, 'finishRelinking'); + }); + }); + 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/__tests__/finish-linking.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/finish-linking.test.ts index bebb0bf28..fce5c9e75 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/finish-linking.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/finish-linking.test.ts @@ -434,6 +434,110 @@ describe('finishLinking', () => { expect(mockFlowCore.actionStateManager.clearLinking).toHaveBeenCalled(); }); + describe('dangling edges: keep on drop', () => { + const temporaryEdgeNoTarget: Edge = { + ...mockTemporaryEdge, + target: '', + targetPort: '', + }; + const keptEdge: Edge = { + id: 'kept-edge', + source: 'source-node', + sourcePort: 'source-port', + target: '', + targetPort: '', + targetPosition: { x: 50, y: 60 }, + temporary: false, + data: {}, + }; + + beforeEach(() => { + mockFlowCore.config = { danglingEdges: { enabled: true, detachOnNodeDelete: false } }; + mockFlowCore.actionStateManager.linking = { + sourceNodeId: 'source-node', + sourcePortId: 'source-port', + temporaryEdge: temporaryEdgeNoTarget, + }; + mockCreateFinalEdge.mockReturnValue(keptEdge); + }); + + it('should keep the built dangling edge on a drop over empty canvas', async () => { + await finishLinking(mockCommandHandler, { name: 'finishLinking', position: { x: 50, y: 60 } }); + + expect(mockCreateFinalEdge).toHaveBeenCalledWith(mockFlowCore.config, temporaryEdgeNoTarget, { + target: '', + targetPort: '', + targetPosition: { x: 50, y: 60 }, + }); + expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith({ edgesToAdd: [keptEdge] }, 'finishLinking'); + expect(mockFlowCore.actionStateManager.linking!.cancelReason).toBeUndefined(); + expect(mockFlowCore.actionStateManager.clearLinking).toHaveBeenCalled(); + }); + + it('should build the final edge before shouldKeepOnDrop and pass the built edge and drop position', async () => { + const callOrder: string[] = []; + mockCreateFinalEdge.mockImplementation(() => { + callOrder.push('createFinalEdge'); + return keptEdge; + }); + const shouldKeepOnDrop = vi.fn(() => { + callOrder.push('shouldKeepOnDrop'); + return true; + }); + mockFlowCore.config = { danglingEdges: { enabled: true, shouldKeepOnDrop } }; + + await finishLinking(mockCommandHandler, { name: 'finishLinking', position: { x: 50, y: 60 } }); + + // The callback must see what would actually be committed — the edge + // after finalEdgeDataBuilder ran. + expect(callOrder).toEqual(['createFinalEdge', 'shouldKeepOnDrop']); + expect(shouldKeepOnDrop).toHaveBeenCalledWith(keptEdge, { x: 50, y: 60 }); + expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith({ edgesToAdd: [keptEdge] }, 'finishLinking'); + }); + + it('should fall back to the cancelled pass when shouldKeepOnDrop declines', async () => { + mockFlowCore.config = { danglingEdges: { enabled: true, shouldKeepOnDrop: () => false } }; + + await finishLinking(mockCommandHandler, { name: 'finishLinking', position: { x: 50, y: 60 } }); + + expect(mockFlowCore.actionStateManager.linking!.cancelReason).toBe('noTarget'); + expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith({}, 'finishLinking'); + expect(mockFlowCore.applyUpdate).not.toHaveBeenCalledWith( + expect.objectContaining({ edgesToAdd: expect.anything() }), + 'finishLinking' + ); + expect(mockFlowCore.actionStateManager.clearLinking).toHaveBeenCalled(); + }); + + it('should discard the edge when dangling edges are disabled', async () => { + mockFlowCore.config = { danglingEdges: { enabled: false } }; + + await finishLinking(mockCommandHandler, { name: 'finishLinking', position: { x: 50, y: 60 } }); + + expect(mockCreateFinalEdge).not.toHaveBeenCalled(); + expect(mockFlowCore.actionStateManager.linking!.cancelReason).toBe('noTarget'); + expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith({}, 'finishLinking'); + expect(mockFlowCore.actionStateManager.clearLinking).toHaveBeenCalled(); + }); + + it('should not keep a dangling edge from an effectively hidden source', async () => { + mockFlowCore.getNodeById.mockImplementation((id: string) => + id === 'source-node' ? { ...mockNode, id: 'source-node', computedHidden: true } : null + ); + + await finishLinking(mockCommandHandler, { name: 'finishLinking', position: { x: 50, y: 60 } }); + + expect(mockCreateFinalEdge).not.toHaveBeenCalled(); + expect(mockFlowCore.actionStateManager.linking!.cancelReason).toBe('noTarget'); + expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith({}, 'finishLinking'); + expect(mockFlowCore.applyUpdate).not.toHaveBeenCalledWith( + expect.objectContaining({ edgesToAdd: expect.anything() }), + 'finishLinking' + ); + expect(mockFlowCore.actionStateManager.clearLinking).toHaveBeenCalled(); + }); + }); + it('should not clear a new linking gesture that replaced the state while finishLinking was suspended', async () => { const ownGesture: InternalLinkingActionState = { sourceNodeId: 'source-node', diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/finish-relinking.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/finish-relinking.test.ts new file mode 100644 index 000000000..e320b4cb4 --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/finish-relinking.test.ts @@ -0,0 +1,344 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { FlowCore } from '../../../../flow-core'; +import { mockEdge, mockNode, mockPort } from '../../../../test-utils'; +import type { CommandHandler, Edge, LinkingActionState, Node } from '../../../../types'; +import type { InternalLinkingActionState } from '../../../../types/action-state.interface'; +import { finishRelinking } from '../finish-relinking'; + +describe('finishRelinking', () => { + let mockCommandHandler: CommandHandler; + let mockFlowCore: { + getEdgeById: ReturnType; + getNodeById: ReturnType; + applyUpdate: ReturnType; + config: { + danglingEdges: { + enabled: boolean; + shouldKeepOnDrop?: ReturnType; + }; + edgeRelinking: { + enabled: boolean; + validateRelink?: ReturnType; + }; + linking: { validateConnection: ReturnType }; + }; + actionStateManager: { + linking: LinkingActionState | null; + clearLinking: ReturnType; + }; + }; + + const originalEdge: Edge = { + ...mockEdge, + id: 'edge-1', + source: 'node-a', + sourcePort: 'out', + target: 'node-b', + targetPort: 'in', + }; + + const sourceNode: Node = { + ...mockNode, + id: 'node-a', + measuredPorts: [{ ...mockPort, id: 'out', type: 'source', side: 'right', nodeId: 'node-a' }], + }; + const targetNode: Node = { + ...mockNode, + id: 'node-b', + measuredPorts: [{ ...mockPort, id: 'in', type: 'target', side: 'left', nodeId: 'node-b' }], + }; + const candidateNode: Node = { + ...mockNode, + id: 'node-c', + measuredPorts: [ + { ...mockPort, id: 'in-c', type: 'target', side: 'left', nodeId: 'node-c' }, + { ...mockPort, id: 'out-c', type: 'source', side: 'right', nodeId: 'node-c' }, + ], + }; + const nodes: Record = { 'node-a': sourceNode, 'node-b': targetNode, 'node-c': candidateNode }; + + const setLinking = (end: 'source' | 'target', temporaryEdge: Partial): InternalLinkingActionState => { + const linking: InternalLinkingActionState = { + sourceNodeId: originalEdge.source, + sourcePortId: originalEdge.sourcePort ?? '', + temporaryEdge: { ...mockEdge, id: 'TEMPORARY_EDGE', temporary: true, ...temporaryEdge }, + relink: { edgeId: 'edge-1', end, originalEdge }, + }; + mockFlowCore.actionStateManager.linking = linking; + return linking; + }; + + beforeEach(() => { + vi.clearAllMocks(); + + mockFlowCore = { + getEdgeById: vi.fn().mockReturnValue(originalEdge), + getNodeById: vi.fn((id: string) => nodes[id]), + applyUpdate: vi.fn().mockResolvedValue(undefined), + config: { + danglingEdges: { enabled: false }, + edgeRelinking: { enabled: true }, + linking: { validateConnection: vi.fn().mockReturnValue(true) }, + }, + actionStateManager: { + linking: null, + clearLinking: vi.fn(), + }, + }; + + mockCommandHandler = { + flowCore: mockFlowCore as unknown as FlowCore, + emit: vi.fn(), + register: vi.fn(), + } as unknown as CommandHandler; + }); + + it('should do nothing when no relink is in progress', async () => { + mockFlowCore.actionStateManager.linking = { + sourceNodeId: 'node-a', + sourcePortId: 'out', + temporaryEdge: null, + }; + + await finishRelinking(mockCommandHandler, { name: 'finishRelinking', position: { x: 0, y: 0 } }); + + expect(mockFlowCore.applyUpdate).not.toHaveBeenCalled(); + expect(mockFlowCore.actionStateManager.clearLinking).not.toHaveBeenCalled(); + }); + + it('should commit the target end to the candidate port on drop', async () => { + setLinking('target', { source: 'node-a', sourcePort: 'out', target: 'node-c', targetPort: 'in-c' }); + + await finishRelinking(mockCommandHandler, { name: 'finishRelinking', position: { x: 5, y: 6 } }); + + expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith( + { edgesToUpdate: [{ id: 'edge-1', target: 'node-c', targetPort: 'in-c', targetPosition: undefined }] }, + 'finishRelinking' + ); + expect(mockFlowCore.actionStateManager.clearLinking).toHaveBeenCalled(); + }); + + it('should commit the source end to the candidate port on drop', async () => { + setLinking('source', { source: 'node-c', sourcePort: 'out-c', target: 'node-b', targetPort: 'in' }); + + await finishRelinking(mockCommandHandler, { name: 'finishRelinking', position: { x: 5, y: 6 } }); + + expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith( + { edgesToUpdate: [{ id: 'edge-1', source: 'node-c', sourcePort: 'out-c', sourcePosition: undefined }] }, + 'finishRelinking' + ); + expect(mockFlowCore.actionStateManager.clearLinking).toHaveBeenCalled(); + }); + + describe('empty-canvas drop', () => { + it('should detach the dragged end at the drop position when dangling edges are enabled', async () => { + mockFlowCore.config.danglingEdges.enabled = true; + setLinking('target', { source: 'node-a', sourcePort: 'out', target: '', targetPort: '' }); + + await finishRelinking(mockCommandHandler, { name: 'finishRelinking', position: { x: 400, y: 500 } }); + + expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith( + { edgesToUpdate: [{ id: 'edge-1', target: '', targetPort: undefined, targetPosition: { x: 400, y: 500 } }] }, + 'finishRelinking' + ); + expect(mockFlowCore.actionStateManager.clearLinking).toHaveBeenCalled(); + }); + + it('should detach the source end at the drop position when dragging the source', async () => { + mockFlowCore.config.danglingEdges.enabled = true; + setLinking('source', { source: '', sourcePort: '', target: 'node-b', targetPort: 'in' }); + + await finishRelinking(mockCommandHandler, { name: 'finishRelinking', position: { x: 40, y: 50 } }); + + expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith( + { edgesToUpdate: [{ id: 'edge-1', source: '', sourcePort: undefined, sourcePosition: { x: 40, y: 50 } }] }, + 'finishRelinking' + ); + }); + + it('should revert with noTarget when dangling edges are disabled', async () => { + const linking = setLinking('target', { source: 'node-a', sourcePort: 'out', target: '', targetPort: '' }); + + await finishRelinking(mockCommandHandler, { name: 'finishRelinking', position: { x: 400, y: 500 } }); + + expect(linking.relinkCancelReason).toBe('noTarget'); + expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith({}, 'finishRelinking'); + expect(mockFlowCore.actionStateManager.clearLinking).toHaveBeenCalled(); + }); + + it('should revert with noTarget when shouldKeepOnDrop declines the detached edge', async () => { + const shouldKeepOnDrop = vi.fn().mockReturnValue(false); + mockFlowCore.config.danglingEdges = { enabled: true, shouldKeepOnDrop }; + const linking = setLinking('target', { source: 'node-a', sourcePort: 'out', target: '', targetPort: '' }); + + await finishRelinking(mockCommandHandler, { name: 'finishRelinking', position: { x: 400, y: 500 } }); + + // The callback sees the edge as it would be committed (detached end). + expect(shouldKeepOnDrop).toHaveBeenCalledWith( + expect.objectContaining({ id: 'edge-1', target: '', targetPosition: { x: 400, y: 500 } }), + { x: 400, y: 500 } + ); + expect(linking.relinkCancelReason).toBe('noTarget'); + expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith({}, 'finishRelinking'); + }); + }); + + describe('validation', () => { + it('should use validateRelink when provided and revert on false', async () => { + const validateRelink = vi.fn().mockReturnValue(false); + mockFlowCore.config.edgeRelinking.validateRelink = validateRelink; + const linking = setLinking('target', { + source: 'node-a', + sourcePort: 'out', + target: 'node-c', + targetPort: 'in-c', + }); + + await finishRelinking(mockCommandHandler, { name: 'finishRelinking', position: { x: 0, y: 0 } }); + + expect(validateRelink).toHaveBeenCalledWith( + originalEdge, + 'target', + candidateNode, + candidateNode.measuredPorts![0] + ); + expect(mockFlowCore.config.linking.validateConnection).not.toHaveBeenCalled(); + expect(linking.relinkCancelReason).toBe('invalidConnection'); + expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith({}, 'finishRelinking'); + expect(mockFlowCore.applyUpdate).not.toHaveBeenCalledWith( + expect.objectContaining({ edgesToUpdate: expect.anything() }), + 'finishRelinking' + ); + }); + + it('should commit when validateRelink allows the drop', async () => { + mockFlowCore.config.edgeRelinking.validateRelink = vi.fn().mockReturnValue(true); + setLinking('target', { source: 'node-a', sourcePort: 'out', target: 'node-c', targetPort: 'in-c' }); + + await finishRelinking(mockCommandHandler, { name: 'finishRelinking', position: { x: 0, y: 0 } }); + + expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith( + { edgesToUpdate: [{ id: 'edge-1', target: 'node-c', targetPort: 'in-c', targetPosition: undefined }] }, + 'finishRelinking' + ); + }); + + it('should fall back to linking.validateConnection with the endpoints in their proper roles (target end)', async () => { + setLinking('target', { source: 'node-a', sourcePort: 'out', target: 'node-c', targetPort: 'in-c' }); + + await finishRelinking(mockCommandHandler, { name: 'finishRelinking', position: { x: 0, y: 0 } }); + + expect(mockFlowCore.config.linking.validateConnection).toHaveBeenCalledWith( + sourceNode, + sourceNode.measuredPorts![0], + candidateNode, + candidateNode.measuredPorts![0] + ); + }); + + it('should fall back to linking.validateConnection with the endpoints in their proper roles (source end)', async () => { + setLinking('source', { source: 'node-c', sourcePort: 'out-c', target: 'node-b', targetPort: 'in' }); + + await finishRelinking(mockCommandHandler, { name: 'finishRelinking', position: { x: 0, y: 0 } }); + + expect(mockFlowCore.config.linking.validateConnection).toHaveBeenCalledWith( + candidateNode, + candidateNode.measuredPorts![1], + targetNode, + targetNode.measuredPorts![0] + ); + }); + + it('should revert when validateConnection rejects the candidate', async () => { + mockFlowCore.config.linking.validateConnection.mockReturnValue(false); + const linking = setLinking('target', { + source: 'node-a', + sourcePort: 'out', + target: 'node-c', + targetPort: 'in-c', + }); + + await finishRelinking(mockCommandHandler, { name: 'finishRelinking', position: { x: 0, y: 0 } }); + + expect(linking.relinkCancelReason).toBe('invalidConnection'); + expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith({}, 'finishRelinking'); + }); + + it('should revert when the candidate node is effectively hidden', async () => { + mockFlowCore.getNodeById.mockImplementation((id: string) => + id === 'node-c' ? { ...candidateNode, computedHidden: true } : nodes[id] + ); + const linking = setLinking('target', { + source: 'node-a', + sourcePort: 'out', + target: 'node-c', + targetPort: 'in-c', + }); + + await finishRelinking(mockCommandHandler, { name: 'finishRelinking', position: { x: 0, y: 0 } }); + + expect(linking.relinkCancelReason).toBe('invalidConnection'); + expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith({}, 'finishRelinking'); + }); + + it('should revert a target-end drop on a source-typed port', async () => { + const linking = setLinking('target', { + source: 'node-a', + sourcePort: 'out', + target: 'node-c', + targetPort: 'out-c', + }); + + await finishRelinking(mockCommandHandler, { name: 'finishRelinking', position: { x: 0, y: 0 } }); + + expect(linking.relinkCancelReason).toBe('invalidConnection'); + expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith({}, 'finishRelinking'); + expect(mockFlowCore.config.linking.validateConnection).not.toHaveBeenCalled(); + }); + + it('should revert a source-end drop on a target-typed port', async () => { + const linking = setLinking('source', { + source: 'node-c', + sourcePort: 'in-c', + target: 'node-b', + targetPort: 'in', + }); + + await finishRelinking(mockCommandHandler, { name: 'finishRelinking', position: { x: 0, y: 0 } }); + + expect(linking.relinkCancelReason).toBe('invalidConnection'); + expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith({}, 'finishRelinking'); + }); + }); + + it('should revert with cancelled when the relinked edge no longer exists', async () => { + mockFlowCore.getEdgeById.mockReturnValue(undefined); + const linking = setLinking('target', { source: 'node-a', sourcePort: 'out', target: 'node-c', targetPort: 'in-c' }); + + await finishRelinking(mockCommandHandler, { name: 'finishRelinking', position: { x: 0, y: 0 } }); + + expect(linking.relinkCancelReason).toBe('cancelled'); + expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith({}, 'finishRelinking'); + expect(mockFlowCore.actionStateManager.clearLinking).toHaveBeenCalled(); + }); + + it('should revert with cancelled when the fixed end became hidden mid-gesture', async () => { + mockFlowCore.getNodeById.mockImplementation((id: string) => + id === 'node-a' ? { ...sourceNode, computedHidden: true } : nodes[id] + ); + const linking = setLinking('target', { source: 'node-a', sourcePort: 'out', target: 'node-c', targetPort: 'in-c' }); + + await finishRelinking(mockCommandHandler, { name: 'finishRelinking', position: { x: 0, y: 0 } }); + + expect(linking.relinkCancelReason).toBe('cancelled'); + expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith({}, 'finishRelinking'); + }); + + it('should record the drop position on the linking state', async () => { + const linking = setLinking('target', { source: 'node-a', sourcePort: 'out', target: 'node-c', targetPort: 'in-c' }); + + await finishRelinking(mockCommandHandler, { name: 'finishRelinking', position: { x: 12, y: 34 } }); + + expect(linking.dropPosition).toEqual({ x: 12, y: 34 }); + }); +}); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/linking-utils.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/linking-utils.test.ts new file mode 100644 index 000000000..42cf71417 --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/linking-utils.test.ts @@ -0,0 +1,181 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { FlowCore } from '../../../../flow-core'; +import { mockEdge, mockNode, mockPort } from '../../../../test-utils'; +import { createTemporaryEdge, isProperSourcePort, isProperTargetPort, validateRelinkOrConnection } from '../utils'; + +describe('linking utils', () => { + describe('isProperTargetPort', () => { + const targetPort = { ...mockPort, id: 'in', type: 'target' as const, nodeId: 'node-b' }; + + it('should reject source-typed ports', () => { + expect(isProperTargetPort({ ...targetPort, type: 'source' }, 'node-a', 'out')).toBe(false); + }); + + it('should accept any target-capable port when there is no source at all', () => { + // A draw started from empty canvas, or relinking an edge whose other end + // is dangling, has neither a source node nor a source port. + expect(isProperTargetPort(targetPort, undefined, undefined)).toBe(true); + }); + + it('should accept a port on another node', () => { + expect(isProperTargetPort(targetPort, 'node-a', 'out')).toBe(true); + }); + + it('should accept a different port on the same node', () => { + expect(isProperTargetPort({ ...targetPort, nodeId: 'node-a' }, 'node-a', 'out')).toBe(true); + }); + + it('should reject the source port itself', () => { + expect(isProperTargetPort({ ...targetPort, id: 'out', nodeId: 'node-a' }, 'node-a', 'out')).toBe(false); + }); + }); + + describe('isProperSourcePort', () => { + const sourcePort = { ...mockPort, id: 'out', type: 'source' as const, nodeId: 'node-a' }; + + it('should reject target-typed ports', () => { + expect(isProperSourcePort({ ...sourcePort, type: 'target' }, 'node-b', 'in')).toBe(false); + }); + + it('should accept any source-capable port when there is no fixed target', () => { + expect(isProperSourcePort(sourcePort, undefined, undefined)).toBe(true); + }); + + it('should accept a port on another node', () => { + expect(isProperSourcePort(sourcePort, 'node-b', 'in')).toBe(true); + }); + + it('should reject the fixed target port itself', () => { + expect(isProperSourcePort({ ...sourcePort, id: 'in', nodeId: 'node-b' }, 'node-b', 'in')).toBe(false); + }); + }); + + describe('createTemporaryEdge', () => { + const config = { + linking: { + temporaryEdgeDataBuilder: (edge: unknown) => edge, + }, + } as unknown as Parameters[0]; + + it('should apply the default arrowhead when the caller does not mention the property', () => { + const edge = createTemporaryEdge(config, { source: 'node-a' }); + + expect(edge.targetArrowhead).toBe('ng-diagram-arrow'); + expect(edge).toMatchObject({ id: 'TEMPORARY_EDGE', temporary: true, source: 'node-a', target: '' }); + }); + + it('should honor an explicit undefined arrowhead key from the caller', () => { + // A relink preview passes the original edge's arrowheads through, + // including explicit undefined for "no arrowhead". + const edge = createTemporaryEdge(config, { source: 'node-a', targetArrowhead: undefined }); + + expect(edge.targetArrowhead).toBeUndefined(); + }); + + it('should default empty source and target to empty strings', () => { + const edge = createTemporaryEdge(config, {}); + + expect(edge.source).toBe(''); + expect(edge.target).toBe(''); + }); + }); + + describe('validateRelinkOrConnection', () => { + const candidateNode = { + ...mockNode, + id: 'node-c', + measuredPorts: [{ ...mockPort, id: 'in-c', type: 'target' as const, nodeId: 'node-c' }], + }; + let core: { + getEdgeById: ReturnType; + getNodeById: ReturnType; + config: { + edgeRelinking: { validateRelink?: ReturnType }; + linking: { validateConnection: ReturnType }; + }; + }; + + beforeEach(() => { + core = { + getEdgeById: vi.fn().mockReturnValue(mockEdge), + getNodeById: vi.fn().mockReturnValue(candidateNode), + config: { + edgeRelinking: {}, + linking: { validateConnection: vi.fn().mockReturnValue(true) }, + }, + }; + }); + + it('should call validateRelink with the edge, end and candidate during a relink', () => { + const validateRelink = vi.fn().mockReturnValue(false); + core.config.edgeRelinking.validateRelink = validateRelink; + + const result = validateRelinkOrConnection( + core as unknown as FlowCore, + { edgeId: 'edge-1', end: 'target', originalEdge: mockEdge }, + 'node-a', + 'out', + 'node-c', + 'in-c', + true + ); + + expect(result).toBe(false); + expect(validateRelink).toHaveBeenCalledWith(mockEdge, 'target', candidateNode, candidateNode.measuredPorts[0]); + expect(core.config.linking.validateConnection).not.toHaveBeenCalled(); + }); + + it('should pass the original edge snapshot when the edge is gone from the model', () => { + const validateRelink = vi.fn().mockReturnValue(true); + core.config.edgeRelinking.validateRelink = validateRelink; + core.getEdgeById.mockReturnValue(undefined); + const originalEdge = { ...mockEdge, id: 'edge-1' }; + + validateRelinkOrConnection( + core as unknown as FlowCore, + { edgeId: 'edge-1', end: 'source', originalEdge }, + 'node-c', + 'in-c', + 'node-b', + 'in', + true + ); + + expect(validateRelink).toHaveBeenCalledWith( + originalEdge, + 'source', + candidateNode, + candidateNode.measuredPorts[0] + ); + }); + + it('should fall back to validateConnection when validateRelink is not configured', () => { + const result = validateRelinkOrConnection( + core as unknown as FlowCore, + { edgeId: 'edge-1', end: 'target', originalEdge: mockEdge }, + undefined, + undefined, + 'node-c', + 'in-c', + true + ); + + expect(result).toBe(true); + expect(core.config.linking.validateConnection).toHaveBeenCalledWith( + null, + null, + candidateNode, + candidateNode.measuredPorts[0] + ); + }); + + it('should behave like validateConnection outside a relink', () => { + core.config.edgeRelinking.validateRelink = vi.fn(); + + validateRelinkOrConnection(core as unknown as FlowCore, undefined, undefined, undefined, 'node-c', 'in-c', true); + + expect(core.config.edgeRelinking.validateRelink).not.toHaveBeenCalled(); + expect(core.config.linking.validateConnection).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/move-temporary-edge.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/move-temporary-edge.test.ts index 4ca7e7aeb..f1133af9a 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/move-temporary-edge.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/move-temporary-edge.test.ts @@ -8,13 +8,15 @@ import { moveTemporaryEdge, MoveTemporaryEdgeCommand } from '../move-temporary-e vi.mock('../utils', () => ({ createTemporaryEdge: vi.fn(), isProperTargetPort: vi.fn(), + isProperSourcePort: vi.fn(), validateConnection: vi.fn(), + validateRelinkOrConnection: vi.fn(), })); -import { createTemporaryEdge, isProperTargetPort, validateConnection } from '../utils'; +import { createTemporaryEdge, isProperTargetPort, validateRelinkOrConnection } from '../utils'; const mockCreateTemporaryEdge = vi.mocked(createTemporaryEdge); const mockIsProperTargetPort = vi.mocked(isProperTargetPort); -const mockValidateConnection = vi.mocked(validateConnection); +const mockValidateRelinkOrConnection = vi.mocked(validateRelinkOrConnection); describe('moveTemporaryEdge', () => { let mockCommandHandler: CommandHandler; @@ -152,7 +154,7 @@ describe('moveTemporaryEdge', () => { mockFlowCore.getNearestPortInRange.mockReturnValue(mockTargetPort); mockIsProperTargetPort.mockReturnValue(true); mockFlowCore.getNodeById.mockReturnValue(mockTargetNode); - mockValidateConnection.mockReturnValue(true); + mockValidateRelinkOrConnection.mockReturnValue(true); const connectedEdge = { ...mockTemporaryEdge, @@ -193,7 +195,7 @@ describe('moveTemporaryEdge', () => { mockFlowCore.getNearestPortInRange.mockReturnValue(mockTargetPort); mockIsProperTargetPort.mockReturnValue(true); mockFlowCore.getNodeById.mockReturnValue(mockTargetNode); - mockValidateConnection.mockReturnValue(false); // Connection invalid + mockValidateRelinkOrConnection.mockReturnValue(false); // Connection invalid const floatingEdge = { ...mockTemporaryEdge, target: '', targetPort: '' }; mockCreateTemporaryEdge.mockReturnValue(floatingEdge); @@ -205,8 +207,9 @@ describe('moveTemporaryEdge', () => { await moveTemporaryEdge(mockCommandHandler, command); - expect(mockValidateConnection).toHaveBeenCalledWith( + expect(mockValidateRelinkOrConnection).toHaveBeenCalledWith( mockFlowCore, + undefined, mockTemporaryEdge.source, mockTemporaryEdge.sourcePort, 'target-node', diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/start-linking-from-position.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/start-linking-from-position.test.ts index b11e4e5cb..ae3c91918 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/start-linking-from-position.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/start-linking-from-position.test.ts @@ -21,9 +21,23 @@ describe('startLinkingFromPosition', () => { }; actionStateManager: { linking: LinkingActionState | null; + isLinking: ReturnType; }; }; + it('should refuse to clobber an in-flight linking gesture', async () => { + mockFlowCore.actionStateManager.isLinking.mockReturnValue(true); + const previousLinking = mockFlowCore.actionStateManager.linking; + + await startLinkingFromPosition(mockCommandHandler, { + name: 'startLinkingFromPosition', + position: { x: 10, y: 20 }, + }); + + expect(mockFlowCore.actionStateManager.linking).toBe(previousLinking); + expect(mockFlowCore.applyUpdate).not.toHaveBeenCalled(); + }); + beforeEach(() => { vi.clearAllMocks(); @@ -36,6 +50,7 @@ describe('startLinkingFromPosition', () => { }, actionStateManager: { linking: null, + isLinking: vi.fn().mockReturnValue(false), }, }; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/start-relinking.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/start-relinking.test.ts new file mode 100644 index 000000000..654de18a2 --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/start-relinking.test.ts @@ -0,0 +1,219 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { FlowCore } from '../../../../flow-core'; +import { mockEdge } from '../../../../test-utils'; +import type { CommandHandler, Edge, LinkingActionState } from '../../../../types'; +import type { InternalLinkingActionState } from '../../../../types/action-state.interface'; +import { startRelinking } from '../start-relinking'; + +describe('startRelinking', () => { + let mockCommandHandler: CommandHandler; + let mockFlowCore: { + getEdgeById: ReturnType; + applyUpdate: ReturnType; + config: { + edgeRelinking: { enabled: boolean }; + linking: { temporaryEdgeDataBuilder: ReturnType }; + computeEdgeId: ReturnType; + }; + actionStateManager: { + linking: LinkingActionState | null; + isLinking: ReturnType; + clearLinking: ReturnType; + }; + }; + + const edge: Edge = { + ...mockEdge, + id: 'edge-1', + type: 'custom-edge', + source: 'node-a', + sourcePort: 'out', + target: 'node-b', + targetPort: 'in', + data: { label: 'relinked' }, + routing: 'orthogonal', + sourceArrowhead: undefined, + targetArrowhead: undefined, + points: [ + { x: 10, y: 20 }, + { x: 50, y: 60 }, + { x: 90, y: 100 }, + ], + }; + + const linkingState = () => mockFlowCore.actionStateManager.linking as InternalLinkingActionState; + + beforeEach(() => { + vi.clearAllMocks(); + + const actionStateManager = { + linking: null as LinkingActionState | null, + isLinking: vi.fn(() => actionStateManager.linking !== null), + clearLinking: vi.fn(() => { + actionStateManager.linking = null; + }), + }; + + mockFlowCore = { + getEdgeById: vi.fn().mockReturnValue(edge), + applyUpdate: vi.fn().mockResolvedValue(undefined), + config: { + edgeRelinking: { enabled: true }, + linking: { + // Passthrough builder — mirrors the default config's identity builder. + temporaryEdgeDataBuilder: vi.fn((temporaryEdge: Edge) => temporaryEdge), + }, + computeEdgeId: vi.fn(() => 'generated-edge-id'), + }, + actionStateManager, + }; + + mockCommandHandler = { + flowCore: mockFlowCore as unknown as FlowCore, + emit: vi.fn(), + register: vi.fn(), + } as unknown as CommandHandler; + }); + + describe('refusals', () => { + it('should do nothing when edgeRelinking is disabled', async () => { + mockFlowCore.config.edgeRelinking.enabled = false; + + await startRelinking(mockCommandHandler, { name: 'startRelinking', edgeId: 'edge-1', end: 'target' }); + + expect(mockFlowCore.actionStateManager.linking).toBeNull(); + expect(mockFlowCore.applyUpdate).not.toHaveBeenCalled(); + }); + + it('should do nothing when a linking gesture is already in progress', async () => { + const existingLinking: LinkingActionState = { + sourceNodeId: 'other-node', + sourcePortId: '', + temporaryEdge: null, + }; + mockFlowCore.actionStateManager.linking = existingLinking; + + await startRelinking(mockCommandHandler, { name: 'startRelinking', edgeId: 'edge-1', end: 'target' }); + + expect(mockFlowCore.actionStateManager.linking).toBe(existingLinking); + expect(mockFlowCore.applyUpdate).not.toHaveBeenCalled(); + }); + + it('should do nothing when the edge does not exist', async () => { + mockFlowCore.getEdgeById.mockReturnValue(undefined); + + await startRelinking(mockCommandHandler, { name: 'startRelinking', edgeId: 'missing', end: 'target' }); + + expect(mockFlowCore.actionStateManager.linking).toBeNull(); + expect(mockFlowCore.applyUpdate).not.toHaveBeenCalled(); + }); + + it('should do nothing when the edge is temporary', async () => { + mockFlowCore.getEdgeById.mockReturnValue({ ...edge, temporary: true }); + + await startRelinking(mockCommandHandler, { name: 'startRelinking', edgeId: 'edge-1', end: 'target' }); + + expect(mockFlowCore.actionStateManager.linking).toBeNull(); + expect(mockFlowCore.applyUpdate).not.toHaveBeenCalled(); + }); + + it('should do nothing when the edge is effectively hidden', async () => { + mockFlowCore.getEdgeById.mockReturnValue({ ...edge, computedHidden: true }); + + await startRelinking(mockCommandHandler, { name: 'startRelinking', edgeId: 'edge-1', end: 'target' }); + + expect(mockFlowCore.actionStateManager.linking).toBeNull(); + expect(mockFlowCore.applyUpdate).not.toHaveBeenCalled(); + }); + + it('should do nothing when no dragged position can be resolved', async () => { + mockFlowCore.getEdgeById.mockReturnValue({ ...edge, points: undefined, targetPosition: undefined }); + + await startRelinking(mockCommandHandler, { name: 'startRelinking', edgeId: 'edge-1', end: 'target' }); + + expect(mockFlowCore.actionStateManager.linking).toBeNull(); + expect(mockFlowCore.applyUpdate).not.toHaveBeenCalled(); + }); + }); + + describe('dragging the target end', () => { + it('should set the linking state with a temporary edge mirroring the edge', async () => { + await startRelinking(mockCommandHandler, { name: 'startRelinking', edgeId: 'edge-1', end: 'target' }); + + const linking = linkingState(); + expect(linking.sourceNodeId).toBe('node-a'); + expect(linking.sourcePortId).toBe('out'); + expect(linking.relink?.end).toBe('target'); + expect(linking.relink).toEqual({ edgeId: 'edge-1', end: 'target', originalEdge: edge }); + expect(linking._gestureId).toEqual(expect.any(Number)); + // The dragged end starts at the edge's last routed point; the fixed + // source end carries the edge's identity into the preview. + expect(linking.temporaryEdge).toMatchObject({ + id: 'TEMPORARY_EDGE', + temporary: true, + type: 'custom-edge', + data: { label: 'relinked' }, + routing: 'orthogonal', + source: 'node-a', + sourcePort: 'out', + sourcePosition: { x: 10, y: 20 }, + target: '', + targetPort: '', + targetPosition: { x: 90, y: 100 }, + }); + expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith({}, 'startRelinking'); + }); + + it('should not inject the default arrowhead into the preview of an arrowless edge', async () => { + await startRelinking(mockCommandHandler, { name: 'startRelinking', edgeId: 'edge-1', end: 'target' }); + + // The edge has explicit undefined arrowheads — createTemporaryEdge's + // 'ng-diagram-arrow' default must not leak into the preview. + expect(linkingState().temporaryEdge!.targetArrowhead).toBeUndefined(); + expect(linkingState().temporaryEdge!.sourceArrowhead).toBeUndefined(); + }); + + it('should carry the edge arrowheads into the preview when present', async () => { + mockFlowCore.getEdgeById.mockReturnValue({ ...edge, sourceArrowhead: 'circle', targetArrowhead: 'diamond' }); + + await startRelinking(mockCommandHandler, { name: 'startRelinking', edgeId: 'edge-1', end: 'target' }); + + expect(linkingState().temporaryEdge!.sourceArrowhead).toBe('circle'); + expect(linkingState().temporaryEdge!.targetArrowhead).toBe('diamond'); + }); + }); + + describe('dragging the source end', () => { + it('should keep the target end and float the source at the first routed point', async () => { + await startRelinking(mockCommandHandler, { name: 'startRelinking', edgeId: 'edge-1', end: 'source' }); + + const linking = linkingState(); + expect(linking.relink?.end).toBe('source'); + expect(linking.relink).toEqual({ edgeId: 'edge-1', end: 'source', originalEdge: edge }); + expect(linking.temporaryEdge).toMatchObject({ + id: 'TEMPORARY_EDGE', + temporary: true, + source: '', + sourcePort: '', + sourcePosition: { x: 10, y: 20 }, + target: 'node-b', + targetPort: 'in', + targetPosition: { x: 90, y: 100 }, + }); + expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith({}, 'startRelinking'); + }); + + it('should fall back to the stored dangling position when the edge has no points', async () => { + mockFlowCore.getEdgeById.mockReturnValue({ + ...edge, + points: undefined, + sourcePosition: { x: 7, y: 8 }, + targetPosition: { x: 70, y: 80 }, + }); + + await startRelinking(mockCommandHandler, { name: 'startRelinking', edgeId: 'edge-1', end: 'source' }); + + expect(linkingState().temporaryEdge!.sourcePosition).toEqual({ x: 7, y: 8 }); + }); + }); +}); 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 5d88c4a2c..bbb14a235 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 @@ -24,14 +24,24 @@ export const cancelLinking = async (commandHandler: CommandHandler): Promise
+
+

Edge Relinking

+

Reconnect existing edges to other nodes and ports

+ +

Assembly Line Template

Demo app for assembly line diagrams

@@ -84,13 +89,7 @@ ngDiagram is stable and actively developed. Here's what we're working on right n - -
-

Edge Relinking

-

Reconnect existing edges to other nodes and ports

- -
- +
diff --git a/apps/docs/src/content/docs/intro/services.mdx b/apps/docs/src/content/docs/intro/services.mdx index b2b5feefa..f86278fb8 100644 --- a/apps/docs/src/content/docs/intro/services.mdx +++ b/apps/docs/src/content/docs/intro/services.mdx @@ -28,6 +28,7 @@ The main orchestration service that provides access to the action state and midd - Access action state and environment information - Manage edges routing - Manage event listeners +- Start linking programmatically — [`startLinking()`](/docs/api/services/ngdiagramservice#startlinking) from a node or port, and [`startLinkingFromPosition()`](/docs/api/services/ngdiagramservice#startlinkingfromposition) from a point on the canvas (requires [`danglingEdges.enabled`](/docs/api/types/configuration/features/danglingedgesconfig/#enabled)) - [Invalidate element measurements](/docs/guides/nodes/ports#port-measurement) after CSS-driven position changes **Usage Example:** @@ -62,6 +63,7 @@ Handles all model-related operations including node and edge updates, spatial qu - Perform spatial queries to find nearby elements - Access nodes, edges, and metadata as reactive signals - Find elements by ID +- Detach and attach edge endpoints — [`detachEdge()`](/docs/api/services/ngdiagrammodelservice#detachedge) (requires [`danglingEdges.enabled`](/docs/api/types/configuration/features/danglingedgesconfig/#enabled)) and [`attachEdge()`](/docs/api/services/ngdiagrammodelservice#attachedge) — and query free endpoints with [`getDanglingEndpoints()`](/docs/api/services/ngdiagrammodelservice#getdanglingendpoints) and [`getNearestDanglingEndpointInRange()`](/docs/api/services/ngdiagrammodelservice#getnearestdanglingendpointinrange) — see [Dangling Edges](/docs/guides/edges/dangling-edges) Every mutating method returns a promise that resolves once the change has been applied to the model, so operations can be sequenced with `await` — see diff --git a/apps/e2e/tests/dangling-relinking.spec.ts b/apps/e2e/tests/dangling-relinking.spec.ts index 079fd2539..5cf8d24b5 100644 --- a/apps/e2e/tests/dangling-relinking.spec.ts +++ b/apps/e2e/tests/dangling-relinking.spec.ts @@ -98,6 +98,20 @@ test.describe('dangling edges', () => { config: { danglingEdges: { enabled: true, detachOnNodeDelete: true } }, }); + // The selectionRemoved payload reports the demoted edges. + await diagram.page.evaluate(() => { + (window as unknown as Record).__detached = []; + window.__diagram!.diagram.addEventListener('selectionRemoved', (event) => { + ((window as unknown as Record).__detached as unknown[]).push( + event.detachedEdges.map((edge) => edge.id) + ); + }); + }); + + // The freed endpoint stays anchored exactly where the edge ended before the delete. + const before = await diagram.model.getEdgeById('edge-ab'); + const expectedAnchor = before!.points!.at(-1); + await diagram.selection.select(['node-b']); await diagram.selection.deleteSelection(); @@ -106,9 +120,12 @@ test.describe('dangling edges', () => { const edges = await diagram.model.edges(); expect(edges).toHaveLength(1); expect(edges[0]).toMatchObject({ id: 'edge-ab', source: 'node-a', target: '' }); - // Anchored where the deleted node was. - expect(edges[0].targetPosition).toBeTruthy(); + expect(edges[0].targetPosition).toEqual(expectedAnchor); await expect(diagram.edge('edge-ab')).toHaveClass(/ng-diagram-edge--dangling/); + + await expect + .poll(() => diagram.page.evaluate(() => (window as unknown as Record).__detached)) + .toEqual([['edge-ab']]); }); test('an explicitly selected edge is deleted, not detached', async ({ diagram }) => { @@ -210,8 +227,6 @@ test.describe('edge relinking', () => { await diagram.beginDrag(handle, { x: handle.x + 150, y: handle.y + 160 }); await diagram.page.mouse.up(); - const edge = await diagram.model.getEdgeById('edge-ab'); - expect(edge).toMatchObject({ source: 'node-a', target: 'node-b' }); await expect .poll(() => relinkEnded(diagram)) .toEqual([ @@ -225,6 +240,8 @@ test.describe('edge relinking', () => { targetPort: null, }, ]); + const edge = await diagram.model.getEdgeById('edge-ab'); + expect(edge).toMatchObject({ source: 'node-a', target: 'node-b' }); }); test('dropping an endpoint on empty canvas detaches it when dangling edges are on', async ({ diagram }) => { @@ -291,11 +308,216 @@ test.describe('edge relinking', () => { await diagram.nextFrame(); expect((await relinkEnded(diagram)).length).toBe(1); }); + test('a click on a handle without dragging changes nothing and emits nothing', async ({ diagram }) => { + await diagram.load({ + model: trio, + config: { ...relinkOn, danglingEdges: { enabled: true } }, + }); + await recordRelinkEnded(diagram); + await diagram.selection.select([], ['edge-ab']); + + const handle = await diagram.centerOf( + diagram.edge('edge-ab').locator('[data-relink-handle="target"]'), + 'target handle of edge-ab' + ); + await diagram.page.mouse.move(handle.x, handle.y); + await diagram.page.mouse.down(); + await diagram.page.mouse.up(); + await diagram.nextFrame(); + + const edge = await diagram.model.getEdgeById('edge-ab'); + expect(edge).toMatchObject({ source: 'node-a', target: 'node-b' }); + expect(await relinkEnded(diagram)).toEqual([]); + // The edge stayed rendered the whole time — no gesture, no hidden original. + await expect(diagram.edge('edge-ab')).toBeAttached(); + }); + + test('a drop back on the original endpoint reverts without changing the model', async ({ diagram }) => { + // The edge must be port-connected: "the original endpoint" means the same + // node AND port (a port-less endpoint dropped onto a port is a real change). + const trioWithPorts: Partial = { + nodes: trio.nodes, + edges: [ + { + id: 'edge-ab', + source: 'node-a', + sourcePort: 'port-right', + target: 'node-b', + targetPort: 'port-left', + data: {}, + }, + ], + }; + await diagram.load({ model: trioWithPorts, config: relinkOn }); + await recordRelinkEnded(diagram); + await diagram.selection.select([], ['edge-ab']); + + const handle = await diagram.centerOf( + diagram.edge('edge-ab').locator('[data-relink-handle="target"]'), + 'target handle of edge-ab' + ); + // Drag away (past the threshold) and come back to the original endpoint. + await diagram.beginDrag(handle, { x: handle.x + 120, y: handle.y + 90 }); + await diagram.page.mouse.move(handle.x, handle.y, { steps: 4 }); + await diagram.page.mouse.up(); + + await expect.poll(async () => (await relinkEnded(diagram)).length).toBe(1); + expect((await relinkEnded(diagram))[0]).toMatchObject({ success: false, reason: 'cancelled' }); + const edge = await diagram.model.getEdgeById('edge-ab'); + expect(edge).toMatchObject({ source: 'node-a', target: 'node-b', targetPort: 'port-left' }); + }); + + test('shift+drag on a handle box-selects instead of relinking', async ({ diagram }) => { + await diagram.load({ model: trio, config: relinkOn }); + await recordRelinkEnded(diagram); + await diagram.selection.select([], ['edge-ab']); + + const handle = await diagram.centerOf( + diagram.edge('edge-ab').locator('[data-relink-handle="target"]'), + 'target handle of edge-ab' + ); + await diagram.page.keyboard.down('Shift'); + await diagram.beginDrag(handle, { x: handle.x + 120, y: handle.y + 100 }); + await diagram.page.mouse.up(); + await diagram.page.keyboard.up('Shift'); + + const edge = await diagram.model.getEdgeById('edge-ab'); + expect(edge).toMatchObject({ source: 'node-a', target: 'node-b' }); + expect(await relinkEnded(diagram)).toEqual([]); + }); + + test('the diagram host carries the relinking class only while dragging', async ({ diagram }) => { + await diagram.load({ model: trio, config: relinkOn }); + await diagram.selection.select([], ['edge-ab']); + const host = diagram.page.locator('ng-diagram'); + + const handle = await diagram.centerOf( + diagram.edge('edge-ab').locator('[data-relink-handle="target"]'), + 'target handle of edge-ab' + ); + await expect(host).not.toHaveClass(/relinking/); + await diagram.beginDrag(handle, { x: handle.x + 100, y: handle.y + 80 }); + await expect(host).toHaveClass(/relinking/); + await diagram.page.mouse.up(); + await expect(host).not.toHaveClass(/relinking/); + + // Escape clears it too. + await diagram.selection.select([], ['edge-ab']); + const handleAgain = await diagram.centerOf( + diagram.edge('edge-ab').locator('[data-relink-handle="target"]'), + 'target handle of edge-ab' + ); + await diagram.beginDrag(handleAgain, { x: handleAgain.x + 100, y: handleAgain.y + 80 }); + await expect(host).toHaveClass(/relinking/); + await diagram.page.keyboard.press('Escape'); + await expect(host).not.toHaveClass(/relinking/); + await diagram.page.mouse.up(); + }); + + test('validateConnection sees reason "relink" and can reject the drop', async ({ diagram }) => { + await diagram.load({ model: trio, config: relinkOn }); + await recordRelinkEnded(diagram); + await diagram.page.evaluate(() => { + window.__diagram!.diagram.updateConfig({ + linking: { + validateConnection: ( + _source: unknown, + _sourcePort: unknown, + _target: unknown, + _targetPort: unknown, + context?: { reason?: string } + ) => context?.reason !== 'relink', + }, + }); + }); + await diagram.selection.select([], ['edge-ab']); + + const handle = await diagram.centerOf( + diagram.edge('edge-ab').locator('[data-relink-handle="target"]'), + 'target handle of edge-ab' + ); + const dst = await diagram.centerOf(diagram.port('node-c', 'port-left'), 'port node-c/port-left'); + await diagram.beginDrag(handle, dst); + await diagram.page.mouse.up(); + + await expect.poll(async () => (await relinkEnded(diagram)).length).toBe(1); + expect((await relinkEnded(diagram))[0]).toMatchObject({ success: false, reason: 'invalidConnection' }); + const edge = await diagram.model.getEdgeById('edge-ab'); + expect(edge).toMatchObject({ source: 'node-a', target: 'node-b' }); + + // The same validator still allows drawing new edges from a port. + await diagram.selection.deselectAll(); + await diagram.linkPorts({ node: 'node-a', port: 'port-right' }, { node: 'node-c', port: 'port-left' }); + await expect.poll(async () => (await diagram.model.edges()).length).toBe(2); + }); +}); + +test.describe('edge relinking on touch', () => { + test.use({ hasTouch: true }); + + const relinkOn = { edgeRelinking: { enabled: true } }; + + /** Dispatch a raw CDP touch sequence (Playwright's touchscreen has no drag). */ + async function touchSequence( + diagram: Diagram, + points: { x: number; y: number }[], + last: 'touchEnd' | 'touchCancel' + ): Promise { + const cdp = await diagram.page.context().newCDPSession(diagram.page); + const [start, ...moves] = points; + await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ ...start, id: 1 }] }); + for (const move of moves) { + await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ ...move, id: 1 }] }); + await diagram.nextFrame(); + } + await cdp.send('Input.dispatchTouchEvent', { type: last, touchPoints: [] }); + await cdp.detach(); + } + + test('a touch drag of the target handle reconnects the edge', async ({ diagram }) => { + await diagram.load({ model: trio, config: relinkOn }); + await diagram.selection.select([], ['edge-ab']); + + const handle = await diagram.centerOf( + diagram.edge('edge-ab').locator('[data-relink-handle="target"]'), + 'target handle of edge-ab' + ); + const dst = await diagram.centerOf(diagram.port('node-c', 'port-left'), 'port node-c/port-left'); + await touchSequence( + diagram, + [handle, { x: (handle.x + dst.x) / 2, y: (handle.y + dst.y) / 2 }, dst, dst], + 'touchEnd' + ); + + await expect.poll(async () => (await diagram.model.getEdgeById('edge-ab'))?.target).toBe('node-c'); + }); + + test('a cancelled touch reverts the relink', async ({ diagram }) => { + await diagram.load({ model: trio, config: relinkOn }); + await recordRelinkEnded(diagram); + await diagram.selection.select([], ['edge-ab']); + + const handle = await diagram.centerOf( + diagram.edge('edge-ab').locator('[data-relink-handle="target"]'), + 'target handle of edge-ab' + ); + await touchSequence(diagram, [handle, { x: handle.x + 90, y: handle.y + 70 }], 'touchCancel'); + + await expect.poll(async () => (await relinkEnded(diagram)).length).toBe(1); + expect((await relinkEnded(diagram))[0]).toMatchObject({ success: false, reason: 'cancelled' }); + const edge = await diagram.model.getEdgeById('edge-ab'); + expect(edge).toMatchObject({ source: 'node-a', target: 'node-b' }); + // The gesture released its claim: a fresh port draw works right away. + await diagram.selection.deselectAll(); + await expect.poll(async () => (await diagram.diagram.actionState()).linking).toBeFalsy(); + }); }); test.describe('startLinkingFromPosition', () => { test('a draw started from a position connects to a port on click', async ({ diagram }) => { - await diagram.load({ model: pair }); + // startLinkingFromPosition requires the dangling-edges feature: the drawn + // edge has an empty source by construction. + await diagram.load({ model: pair, config: { danglingEdges: { enabled: true } } }); const portCenter = await diagram.centerOf(diagram.port('node-b', 'port-left'), 'port node-b/port-left'); const startFlow = await diagram.viewport.clientToFlowPosition({ x: portCenter.x - 200, y: portCenter.y + 120 }); @@ -332,17 +554,17 @@ test.describe('startLinkingFromPosition', () => { expect(edge.targetPosition).toEqual(endFlow); }); - test('a draw started from a position dropped on empty canvas is discarded when dangling edges are off', async ({ - diagram, - }) => { + test('startLinkingFromPosition is refused when dangling edges are off', async ({ diagram }) => { await diagram.load({ model: pair }); const box = await diagram.container.boundingBox(); const start = { x: box!.x + 500, y: box!.y + 400 }; const startFlow = await diagram.viewport.clientToFlowPosition(start); + // The call is ignored with a console warning — no draw starts at all. await diagram.diagram.startLinkingFromPosition(startFlow); await diagram.page.mouse.move(start.x + 120, start.y + 60, { steps: 4 }); + await expect(diagram.edge('TEMPORARY_EDGE')).toHaveCount(0); await diagram.page.mouse.click(start.x + 120, start.y + 60); await expect.poll(async () => (await diagram.model.edges()).length).toBe(0); diff --git a/packages/ng-diagram/api-report/ng-diagram.api.md b/packages/ng-diagram/api-report/ng-diagram.api.md index d36de493b..cdea21978 100644 --- a/packages/ng-diagram/api-report/ng-diagram.api.md +++ b/packages/ng-diagram/api-report/ng-diagram.api.md @@ -120,9 +120,19 @@ export interface ClipboardPastedEvent { nodes: Node_2[]; } +// @public +export const computeDetachAnchor: (edge: Edge, end: EdgeEnd, node: Node_2 | null | undefined) => Point | null; + // @public export function configureShortcuts(userShortcuts: ShortcutDefinition[], baseShortcuts?: ShortcutDefinition[]): ShortcutDefinition[]; +// @public +export interface ConnectionValidationContext { + edge?: Edge; + end?: EdgeEnd; + reason: 'draw' | 'relink' | 'attach'; +} + // @public export interface CopyPasteActionState { copiedEdges: Edge[]; @@ -307,7 +317,6 @@ export interface EdgeRelinkEndedEvent { // @public export interface EdgeRelinkingConfig { enabled: boolean; - validateRelink?: (edge: Edge, end: EdgeEnd, targetNode: Node_2 | null, targetPort: Port | null) => boolean; } // @public @@ -577,7 +586,7 @@ export interface LinkingConfig { portSnapDistance: number; selectNodeOnPortPress: boolean; temporaryEdgeDataBuilder: (defaultTemporaryEdgeData: Edge) => Edge; - validateConnection: (source: Node_2 | null, sourcePort: Port | null, target: Node_2 | null, targetPort: Port | null) => boolean; + validateConnection: (source: Node_2 | null, sourcePort: Port | null, target: Node_2 | null, targetPort: Port | null, context?: ConnectionValidationContext) => boolean; } // @public (undocumented) @@ -816,10 +825,9 @@ export class NgDiagramBaseEdgeComponent { readonly path: Signal; // (undocumented) readonly points: Signal; + readonly relinkHandleHitRadius: Signal; readonly relinkHandlesVisible: Signal; - // (undocumented) readonly relinkSourceHandle: Signal; - // (undocumented) readonly relinkTargetHandle: Signal; routing: InputSignal; // (undocumented) @@ -944,6 +952,7 @@ export class NgDiagramComponent implements OnInit, OnDestroy { readonly nodes: WritableSignal; nodeTemplateMap: InputSignal; paletteItemDropped: EventEmitter; + protected readonly relinkingActive: Signal; selectionChanged: EventEmitter; selectionGestureEnded: EventEmitter; selectionMoved: EventEmitter; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/add-update-delete.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/add-update-delete.test.ts index d1ae33cbf..04071e94f 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/add-update-delete.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/add-update-delete.test.ts @@ -161,6 +161,80 @@ describe('Add Update Delete Command', () => { ); }); + it('should detach the edges of a deleted subtree when detach-on-delete is enabled', () => { + (flowCore.config as unknown as { danglingEdges: object }).danglingEdges = { + enabled: true, + detachOnNodeDelete: true, + }; + const group = { ...mockNode, id: 'group', isGroup: true }; + const child = { ...mockNode, id: 'child', groupId: 'group', position: { x: 5, y: 6 } }; + const standalone = { ...mockNode, id: 'standalone' }; + const childEdge = { + ...mockEdge, + id: 'childEdge', + source: 'child', + target: 'standalone', + points: [ + { x: 5, y: 6 }, + { x: 100, y: 100 }, + ], + }; + (flowCore.getState as ReturnType).mockReturnValue({ + nodes: [group, child, standalone], + edges: [childEdge], + metadata: {}, + }); + getNodeByIdMock.mockImplementation((id: string) => [group, child, standalone].find((node) => node.id === id)); + (flowCore.modelLookup.getAllDescendantIds as ReturnType).mockImplementation((id: string) => + id === 'group' ? ['child'] : [] + ); + + commandHandler.emit('deleteNodes', { ids: ['group'] }); + + expect(flowCore.applyUpdate).toHaveBeenCalledWith( + { + nodesToRemove: ['group', 'child'], + edgesToRemove: [], + edgesToUpdate: [{ id: 'childEdge', source: '', sourcePort: undefined, sourcePosition: { x: 5, y: 6 } }], + }, + 'deleteNodes' + ); + }); + + it('should delete, not detach, the edges of hidden children of a deleted group', () => { + (flowCore.config as unknown as { danglingEdges: object }).danglingEdges = { + enabled: true, + detachOnNodeDelete: true, + }; + const group = { ...mockNode, id: 'group', isGroup: true }; + const hiddenChild = { ...mockNode, id: 'child', groupId: 'group', computedHidden: true }; + const standalone = { ...mockNode, id: 'standalone' }; + const hiddenChildEdge = { + ...mockEdge, + id: 'childEdge', + source: 'child', + target: 'standalone', + computedHidden: true, + }; + (flowCore.getState as ReturnType).mockReturnValue({ + nodes: [group, hiddenChild, standalone], + edges: [hiddenChildEdge], + metadata: {}, + }); + getNodeByIdMock.mockImplementation((id: string) => [group, hiddenChild, standalone].find((node) => node.id === id)); + (flowCore.modelLookup.getAllDescendantIds as ReturnType).mockImplementation((id: string) => + id === 'group' ? ['child'] : [] + ); + + commandHandler.emit('deleteNodes', { ids: ['group'] }); + + // Invisible wiring must never be materialized as visible dangling edges. + expect(flowCore.applyUpdate).toHaveBeenCalledWith( + { nodesToRemove: ['group', 'child'], edgesToRemove: ['childEdge'] }, + 'deleteNodes' + ); + }); + it('should not duplicate ids when a deleted group and its child are both passed explicitly', () => { (flowCore.getState as ReturnType).mockReturnValue({ nodes: [ diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/copy-paste.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/copy-paste.test.ts index 3d08bc704..1cf3c7a13 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/copy-paste.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/copy-paste.test.ts @@ -75,6 +75,96 @@ describe('Copy-Paste Commands', () => { expect(update.edgesToAdd[0].selected).toBe(true); }); + describe('dangling edges travelling with copied nodes', () => { + const setDanglingEnabled = (enabled: boolean) => { + (commandHandler.flowCore.config as unknown as { danglingEdges: { enabled: boolean } }).danglingEdges = { + enabled, + }; + }; + + const copyPasteState = () => + ( + commandHandler.flowCore.actionStateManager as unknown as { + copyPaste: { copiedNodes: Node[]; copiedEdges: Edge[] }; + } + ).copyPaste; + + const singleDanglingEdge: Edge = { + ...mockEdge, + id: 'dangling-edge', + source: 'node1', + sourcePort: 'out', + target: '', + targetPort: undefined, + targetPosition: { x: 300, y: 400 }, + selected: false, + }; + + const dualDanglingEdge: Edge = { + ...mockEdge, + id: 'dual-dangling-edge', + source: '', + sourcePort: undefined, + sourcePosition: { x: 10, y: 20 }, + target: '', + targetPort: undefined, + targetPosition: { x: 300, y: 400 }, + selected: false, + }; + + const stateWith = (edges: Edge[]) => () => ({ + nodes: [ + { ...mockNode, id: 'node1', position: { x: 10, y: 20 }, selected: true }, + { ...mockNode, id: 'node2', position: { x: 30, y: 40 }, selected: false }, + ], + edges, + metadata: mockMetadata, + }); + + it('should copy an unselected single-dangling edge together with its copied node when enabled', async () => { + setDanglingEnabled(true); + commandHandler.flowCore.getState = stateWith([singleDanglingEdge]); + + await copy(commandHandler); + + expect(copyPasteState().copiedEdges.map((edge) => edge.id)).toEqual(['dangling-edge']); + }); + + it('should not copy an unselected single-dangling edge when the feature is disabled', async () => { + setDanglingEnabled(false); + commandHandler.flowCore.getState = stateWith([singleDanglingEdge]); + + await copy(commandHandler); + + expect(copyPasteState().copiedEdges).toEqual([]); + }); + + it('should not copy a dangling edge whose connected node was not copied', async () => { + setDanglingEnabled(true); + commandHandler.flowCore.getState = stateWith([{ ...singleDanglingEdge, source: 'node2' }]); + + await copy(commandHandler); + + expect(copyPasteState().copiedEdges).toEqual([]); + }); + + it('should copy a dual dangling edge only when it is selected', async () => { + setDanglingEnabled(true); + commandHandler.flowCore.getState = stateWith([dualDanglingEdge]); + + await copy(commandHandler); + + // Unselected: no connected endpoint inside the copied set — not copied. + expect(copyPasteState().copiedEdges).toEqual([]); + + commandHandler.flowCore.getState = stateWith([{ ...dualDanglingEdge, selected: true }]); + + await copy(commandHandler); + + expect(copyPasteState().copiedEdges.map((edge) => edge.id)).toEqual(['dual-dangling-edge']); + }); + }); + it('should not copy anything if nothing is selected', async () => { commandHandler.flowCore.getState = () => ({ nodes: [ diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/delete-selection.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/delete-selection.test.ts index 994e1b085..13df24687 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/delete-selection.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/delete-selection.test.ts @@ -15,6 +15,8 @@ describe('Delete Selection Command', () => { getState: vi.fn(), applyUpdate: vi.fn(), modelLookup: mockModelLookup, + config: {}, + getNodeById: vi.fn(), transactionManager: { isActive: vi.fn().mockReturnValue(false), getCurrentTransaction: vi.fn(), @@ -111,6 +113,85 @@ describe('Delete Selection Command', () => { ); }); + describe('dangling edges (detach on delete)', () => { + beforeEach(() => { + (flowCore.config as { danglingEdges?: { enabled: boolean; detachOnNodeDelete: boolean } }).danglingEdges = { + enabled: true, + detachOnNodeDelete: true, + }; + }); + + it('should detach incident edges into dangling and still remove selected edges', () => { + const node1 = { id: 'node1', selected: true, position: { x: 100, y: 100 }, size: { width: 50, height: 50 } }; + const other = { id: 'other', selected: false, position: { x: 500, y: 500 } }; + const nodes = [node1, other]; + const edges = [ + // Explicitly selected — an explicit delete always wins over detach. + { id: 'selectedEdge', selected: true, source: 'node1', target: 'other' }, + // Incident, unselected — detached into a dangling edge anchored at the + // deleted node's center (no port, no routed points). + { id: 'incidentEdge', selected: false, source: 'node1', target: 'other' }, + ]; + + (flowCore.getState as ReturnType).mockReturnValue({ nodes, edges, metadata: {} }); + (flowCore.getNodeById as ReturnType).mockImplementation((id: string) => + nodes.find((node) => node.id === id) + ); + + commandHandler.emit('deleteSelection'); + + expect(flowCore.applyUpdate).toHaveBeenCalledWith( + { + nodesToRemove: ['node1'], + edgesToRemove: ['selectedEdge'], + edgesToUpdate: [ + { id: 'incidentEdge', source: '', sourcePort: undefined, sourcePosition: { x: 125, y: 125 } }, + ], + }, + 'deleteSelection' + ); + }); + + it('should delete the hidden edges of a deleted collapsed group instead of detaching them', () => { + // A3: the group is visible and selected; its children (and their wiring) + // are hidden. The hidden wiring must be deleted, never materialized as + // visible dangling edges — while the group's own visible edge detaches. + const group = { id: 'group', selected: true, position: { x: 0, y: 0 }, size: { width: 100, height: 100 } }; + const child = { + id: 'child', + selected: false, + computedHidden: true, + groupId: 'group', + position: { x: 10, y: 10 }, + }; + const outside = { id: 'outside', selected: false, position: { x: 500, y: 500 } }; + const nodes = [group, child, outside]; + const edges = [ + { id: 'hiddenChildEdge', selected: false, computedHidden: true, source: 'child', target: 'outside' }, + { id: 'groupEdge', selected: false, source: 'group', target: 'outside' }, + ]; + + (flowCore.getState as ReturnType).mockReturnValue({ nodes, edges, metadata: {} }); + (flowCore.getNodeById as ReturnType).mockImplementation((id: string) => + nodes.find((node) => node.id === id) + ); + (flowCore.modelLookup.getAllDescendantIds as ReturnType).mockImplementation((id: string) => + id === 'group' ? ['child'] : [] + ); + + commandHandler.emit('deleteSelection'); + + expect(flowCore.applyUpdate).toHaveBeenCalledWith( + { + nodesToRemove: ['group', 'child'], + edgesToRemove: ['hiddenChildEdge'], + edgesToUpdate: [{ id: 'groupEdge', source: '', sourcePort: undefined, sourcePosition: { x: 50, y: 50 } }], + }, + 'deleteSelection' + ); + }); + }); + describe('hidden elements', () => { it('should not delete effectively hidden selected nodes', () => { const nodes = [ diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/detach-on-node-delete.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/detach-on-node-delete.test.ts index 7730c3926..ac537c3c6 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/detach-on-node-delete.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/detach-on-node-delete.test.ts @@ -151,9 +151,23 @@ describe('partitionIncidentEdges', () => { expect(result.edgesToUpdate).toEqual([]); }); - it('should produce a dual dangling patch when the edge loses both ends', () => { + it('should remove an edge losing both ends when no shouldDetachOnNodeDelete callback is set', () => { + // Dual dangling is opt-in: detaching both ends by default would leave + // dual-dangling debris at the deleted nodes' old positions. const result = partitionIncidentEdges(flowCore(), [incidentEdge], new Set(['node-a', 'node-b'])); + expect(result.edgesToRemove).toEqual(['edge-ab']); + expect(result.edgesToUpdate).toEqual([]); + }); + + it('should produce a dual dangling patch when the callback allows both ends', () => { + const shouldDetachOnNodeDelete = vi.fn().mockReturnValue(true); + mockFlowCore.config.danglingEdges!.shouldDetachOnNodeDelete = shouldDetachOnNodeDelete; + + const result = partitionIncidentEdges(flowCore(), [incidentEdge], new Set(['node-a', 'node-b'])); + + expect(shouldDetachOnNodeDelete).toHaveBeenCalledWith(incidentEdge, nodeWithPort, 'source'); + expect(shouldDetachOnNodeDelete).toHaveBeenCalledWith(incidentEdge, nodeWithoutPorts, 'target'); expect(result.edgesToRemove).toEqual([]); expect(result.edgesToUpdate).toEqual([ { @@ -168,27 +182,84 @@ describe('partitionIncidentEdges', () => { ]); }); - it('should keep the dual dangling patch when the callback allows both ends', () => { - const shouldDetachOnNodeDelete = vi.fn().mockReturnValue(true); - mockFlowCore.config.danglingEdges!.shouldDetachOnNodeDelete = shouldDetachOnNodeDelete; + it('should remove the edge losing both ends when the callback denies either end', () => { + mockFlowCore.config.danglingEdges!.shouldDetachOnNodeDelete = (_edge, _node, end) => end !== 'target'; const result = partitionIncidentEdges(flowCore(), [incidentEdge], new Set(['node-a', 'node-b'])); - expect(shouldDetachOnNodeDelete).toHaveBeenCalledWith(incidentEdge, nodeWithPort, 'source'); - expect(shouldDetachOnNodeDelete).toHaveBeenCalledWith(incidentEdge, nodeWithoutPorts, 'target'); - expect(result.edgesToRemove).toEqual([]); - expect(result.edgesToUpdate).toHaveLength(1); + expect(result.edgesToRemove).toEqual(['edge-ab']); + expect(result.edgesToUpdate).toEqual([]); }); - it('should remove the edge losing both ends when the callback denies either end', () => { - mockFlowCore.config.danglingEdges!.shouldDetachOnNodeDelete = (_edge, _node, end) => end !== 'target'; + it('should remove the edge when the lost endpoint node is effectively hidden', () => { + mockFlowCore.getNodeById.mockImplementation((id: string) => + id === 'node-a' ? { ...nodeWithPort, computedHidden: true } : id === 'node-b' ? nodeWithoutPorts : undefined + ); - const result = partitionIncidentEdges(flowCore(), [incidentEdge], new Set(['node-a', 'node-b'])); + const result = partitionIncidentEdges(flowCore(), [incidentEdge], new Set(['node-a'])); + + expect(result.edgesToRemove).toEqual(['edge-ab']); + expect(result.edgesToUpdate).toEqual([]); + }); + + it('should remove an effectively hidden edge instead of detaching it', () => { + const hiddenEdge: Edge = { ...incidentEdge, computedHidden: true }; + + const result = partitionIncidentEdges(flowCore(), [hiddenEdge], new Set(['node-a'])); expect(result.edgesToRemove).toEqual(['edge-ab']); expect(result.edgesToUpdate).toEqual([]); }); + it('should delete the edges of hidden children when a collapsed group cascade is deleted', () => { + // A3: deleting a group cascades to its (collapsed, hidden) children — + // their invisible wiring must be deleted, never materialized as visible + // dangling edges; a visible sibling's edge still detaches. + const hiddenChildA: Node = { ...mockNode, id: 'child-a', computedHidden: true, position: { x: 0, y: 0 } }; + const hiddenChildB: Node = { ...mockNode, id: 'child-b', computedHidden: true, position: { x: 50, y: 0 } }; + const nodes: Record = { + 'child-a': hiddenChildA, + 'child-b': hiddenChildB, + 'node-b': nodeWithoutPorts, + }; + mockFlowCore.getNodeById.mockImplementation((id: string) => nodes[id]); + + const childEdge: Edge = { + ...mockEdge, + id: 'edge-children', + source: 'child-a', + target: 'child-b', + computedHidden: true, + points: undefined, + }; + const outgoingHiddenEdge: Edge = { + ...mockEdge, + id: 'edge-child-out', + source: 'child-a', + target: 'node-b', + computedHidden: true, + points: undefined, + }; + const visibleEdgeToDeleted: Edge = { + ...mockEdge, + id: 'edge-visible', + source: 'outside', + target: 'node-b', + points: undefined, + }; + + const result = partitionIncidentEdges( + flowCore(), + [childEdge, outgoingHiddenEdge, visibleEdgeToDeleted], + new Set(['child-a', 'child-b', 'node-b']) + ); + + expect(result.edgesToRemove).toEqual(['edge-children', 'edge-child-out']); + expect(result.edgesToUpdate).toEqual([ + { id: 'edge-visible', target: '', targetPort: undefined, targetPosition: centerAnchor }, + ]); + }); + it('should leave non-incident edges untouched', () => { const result = partitionIncidentEdges(flowCore(), [incidentEdge, nonIncidentEdge], new Set(['node-a'])); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/detach-on-node-delete.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/detach-on-node-delete.ts index 5f6e47942..d08dc93c2 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/detach-on-node-delete.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/detach-on-node-delete.ts @@ -21,11 +21,15 @@ export interface IncidentEdgesPartition { * its port was) unless: * - it is part of `explicitlyDeletedEdgeIds` (e.g. selected in a * deleteSelection) — an explicit delete always wins, or + * - the edge itself or the lost endpoint's node is effectively hidden — + * detaching would materialize invisible wiring (e.g. the collapsed children + * of a deleted group) as visible dangling edges, or * - `shouldDetachOnNodeDelete` returns false for any endpoint being lost — - * then the whole edge is deleted. - * - * An edge losing both endpoints becomes a dual dangling edge when both - * per-endpoint decisions allow it. + * then the whole edge is deleted, or + * - the edge loses BOTH endpoints in the same cascade — such an edge is + * deleted by default (detaching would leave dual-dangling debris at the + * deleted nodes' old positions); it becomes a dual dangling edge only when + * `shouldDetachOnNodeDelete` is defined and returns true for both ends. */ export const partitionIncidentEdges = ( flowCore: FlowCore, @@ -33,18 +37,23 @@ export const partitionIncidentEdges = ( nodesToDeleteIds: Set, explicitlyDeletedEdgeIds?: Set ): IncidentEdgesPartition => { - const danglingEdges = flowCore.config?.danglingEdges; + const danglingEdges = flowCore.config.danglingEdges; const detachEnabled = !!danglingEdges?.enabled && !!danglingEdges.detachOnNodeDelete; const edgesToRemove: string[] = []; const edgesToUpdate: (Partial & { id: Edge['id'] })[] = []; const mayDetach = (edge: Edge, end: EdgeEnd, nodeId: string): boolean => { + const node = flowCore.getNodeById(nodeId); + // A hidden lost endpoint must not demote its (hidden) edge into a visible + // dangling edge. + if (!node || node.computedHidden) { + return false; + } if (!danglingEdges?.shouldDetachOnNodeDelete) { return true; } - const node = flowCore.getNodeById(nodeId); - return !!node && danglingEdges.shouldDetachOnNodeDelete(edge, node, end); + return danglingEdges.shouldDetachOnNodeDelete(edge, node, end); }; for (const edge of edges) { @@ -54,9 +63,15 @@ export const partitionIncidentEdges = ( continue; } + // An edge losing both ends is deleted unless the app opted into dual + // dangling explicitly through the callback. + const dualWithoutOptIn = sourceLost && targetLost && !danglingEdges?.shouldDetachOnNodeDelete; + if ( !detachEnabled || explicitlyDeletedEdgeIds?.has(edge.id) || + edge.computedHidden || + dualWithoutOptIn || (sourceLost && !mayDetach(edge, 'source', edge.source)) || (targetLost && !mayDetach(edge, 'target', edge.target)) ) { diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/finish-linking-to-position.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/finish-linking-to-position.test.ts index 9773adfef..077ee78c0 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/finish-linking-to-position.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/finish-linking-to-position.test.ts @@ -108,7 +108,7 @@ describe('finishLinkingToPosition', () => { expect(mockCreateFinalEdge).toHaveBeenCalledWith(mockFlowCore.config, mockTemporaryEdge, { target: '', - targetPort: '', + targetPort: undefined, targetPosition: position, }); @@ -142,7 +142,47 @@ describe('finishLinkingToPosition', () => { expect(mockFlowCore.actionStateManager.linking!.dropPosition).toEqual(position); }); - it('should always create edge with empty target and targetPort', async () => { + it('should return immediately when a relink owns the linking state', async () => { + mockFlowCore.actionStateManager.linking = { + sourceNodeId: 'source-node', + sourcePortId: 'source-port', + temporaryEdge: mockTemporaryEdge, + relink: { + edgeId: 'edge-1', + end: 'target', + originalEdge: { id: 'edge-1', source: 'source-node', target: 'other-node', data: {} }, + }, + }; + + await finishLinkingToPosition(mockCommandHandler, { + name: 'finishLinkingToPosition', + position: { x: 1, y: 2 }, + }); + + // finishRelinking is the only legal finish for a relink — committing here + // would ADD a new edge instead of updating the relinked one. + expect(mockCreateFinalEdge).not.toHaveBeenCalled(); + expect(mockFlowCore.applyUpdate).not.toHaveBeenCalled(); + expect(mockFlowCore.actionStateManager.clearLinking).not.toHaveBeenCalled(); + }); + + it('should return immediately when a teardown is already in progress', async () => { + mockFlowCore.actionStateManager.linking = { + sourceNodeId: 'source-node', + sourcePortId: 'source-port', + temporaryEdge: mockTemporaryEdge, + _finishing: true, + } as InternalLinkingActionState; + + await finishLinkingToPosition(mockCommandHandler, { + name: 'finishLinkingToPosition', + position: { x: 1, y: 2 }, + }); + + expect(mockFlowCore.applyUpdate).not.toHaveBeenCalled(); + }); + + it('should always create edge with empty target and undefined targetPort', async () => { const position = { x: 300, y: 400 }; const finalEdge = { id: 'final-edge', source: 'source-node', target: '', data: {} }; @@ -160,10 +200,10 @@ describe('finishLinkingToPosition', () => { await finishLinkingToPosition(mockCommandHandler, command); - // Verify that target and targetPort are always empty strings + // A free end always has an empty target and an undefined port (never ''). const createFinalEdgeCall = mockCreateFinalEdge.mock.calls[0][2]; expect(createFinalEdgeCall.target).toBe(''); - expect(createFinalEdgeCall.targetPort).toBe(''); + expect(createFinalEdgeCall.targetPort).toBeUndefined(); expect(createFinalEdgeCall.targetPosition).toEqual(position); }); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/finish-linking.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/finish-linking.test.ts index fce5c9e75..c00fbc75b 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/finish-linking.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/finish-linking.test.ts @@ -101,6 +101,43 @@ describe('finishLinking', () => { expect(mockFlowCore.actionStateManager.clearLinking).toHaveBeenCalled(); }); + it('should return immediately when a relink owns the linking state', async () => { + mockFlowCore.actionStateManager.linking = { + sourceNodeId: 'source-node', + sourcePortId: 'source-port', + temporaryEdge: mockTemporaryEdge, + relink: { + edgeId: 'edge-1', + end: 'target', + originalEdge: { id: 'edge-1', source: 'source-node', target: 'other-node', data: {} }, + }, + }; + mockValidateConnection.mockReturnValue(true); + + await finishLinking(mockCommandHandler, { name: 'finishLinking', position: { x: 0, y: 0 } }); + + // Finishing a relink as a draw would ADD a duplicate edge instead of + // updating the relinked one — finishRelinking is the only legal finish. + expect(mockFlowCore.applyUpdate).not.toHaveBeenCalled(); + expect(mockCreateFinalEdge).not.toHaveBeenCalled(); + expect(mockFlowCore.actionStateManager.clearLinking).not.toHaveBeenCalled(); + }); + + it('should return immediately when a teardown is already in progress', async () => { + mockFlowCore.actionStateManager.linking = { + sourceNodeId: 'source-node', + sourcePortId: 'source-port', + temporaryEdge: mockTemporaryEdge, + _finishing: true, + } as InternalLinkingActionState; + mockValidateConnection.mockReturnValue(true); + + await finishLinking(mockCommandHandler, { name: 'finishLinking', position: { x: 0, y: 0 } }); + + expect(mockFlowCore.applyUpdate).not.toHaveBeenCalled(); + expect(mockFlowCore.actionStateManager.clearLinking).not.toHaveBeenCalled(); + }); + it('should clear the linking state even when connection validation throws', async () => { mockFlowCore.actionStateManager.linking = { sourceNodeId: 'source-node', @@ -464,9 +501,10 @@ describe('finishLinking', () => { it('should keep the built dangling edge on a drop over empty canvas', async () => { await finishLinking(mockCommandHandler, { name: 'finishLinking', position: { x: 50, y: 60 } }); + // A free end carries no port — targetPort is undefined, never ''. expect(mockCreateFinalEdge).toHaveBeenCalledWith(mockFlowCore.config, temporaryEdgeNoTarget, { target: '', - targetPort: '', + targetPort: undefined, targetPosition: { x: 50, y: 60 }, }); expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith({ edgesToAdd: [keptEdge] }, 'finishLinking'); @@ -474,6 +512,39 @@ describe('finishLinking', () => { expect(mockFlowCore.actionStateManager.clearLinking).toHaveBeenCalled(); }); + it('should commit the real kept-edge shape (real createFinalEdge, passthrough builders)', async () => { + const actualUtils = await vi.importActual('../utils'); + mockCreateFinalEdge.mockImplementation(actualUtils.createFinalEdge); + mockFlowCore.config = { + danglingEdges: { enabled: true }, + linking: { finalEdgeDataBuilder: (edge: Edge) => edge }, + computeEdgeId: () => 'fresh-id', + }; + + await finishLinking(mockCommandHandler, { name: 'finishLinking', position: { x: 50, y: 60 } }); + + expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith( + { + edgesToAdd: [ + { + ...temporaryEdgeNoTarget, + id: 'fresh-id', + temporary: false, + target: '', + targetPort: undefined, + targetPosition: { x: 50, y: 60 }, + }, + ], + }, + 'finishLinking' + ); + const [added] = mockFlowCore.applyUpdate.mock.calls[0][0].edgesToAdd as Edge[]; + expect(added.id).toBe('fresh-id'); + expect(added.temporary).toBe(false); + expect(added.target).toBe(''); + expect(added.targetPort).toBeUndefined(); + }); + it('should build the final edge before shouldKeepOnDrop and pass the built edge and drop position', async () => { const callOrder: string[] = []; mockCreateFinalEdge.mockImplementation(() => { diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/finish-relinking.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/finish-relinking.test.ts index e320b4cb4..e7d93b379 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/finish-relinking.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/finish-relinking.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { FlowCore } from '../../../../flow-core'; import { mockEdge, mockNode, mockPort } from '../../../../test-utils'; -import type { CommandHandler, Edge, LinkingActionState, Node } from '../../../../types'; +import type { CommandHandler, Edge, LinkingActionState, Node, Port } from '../../../../types'; import type { InternalLinkingActionState } from '../../../../types/action-state.interface'; import { finishRelinking } from '../finish-relinking'; @@ -10,17 +10,16 @@ describe('finishRelinking', () => { let mockFlowCore: { getEdgeById: ReturnType; getNodeById: ReturnType; + getNearestPortInRange: ReturnType; applyUpdate: ReturnType; + templateVisibilityRegistry?: { isPortHidden: (nodeId: string, portId: string) => boolean }; config: { danglingEdges: { enabled: boolean; shouldKeepOnDrop?: ReturnType; }; - edgeRelinking: { - enabled: boolean; - validateRelink?: ReturnType; - }; - linking: { validateConnection: ReturnType }; + edgeRelinking: { enabled: boolean }; + linking: { portSnapDistance: number; validateConnection: ReturnType }; }; actionStateManager: { linking: LinkingActionState | null; @@ -50,13 +49,23 @@ describe('finishRelinking', () => { const candidateNode: Node = { ...mockNode, id: 'node-c', + position: { x: 200, y: 100 }, measuredPorts: [ - { ...mockPort, id: 'in-c', type: 'target', side: 'left', nodeId: 'node-c' }, - { ...mockPort, id: 'out-c', type: 'source', side: 'right', nodeId: 'node-c' }, + { ...mockPort, id: 'in-c', type: 'target', side: 'left', position: { x: 0, y: 20 }, nodeId: 'node-c' }, + { ...mockPort, id: 'out-c', type: 'source', side: 'right', position: { x: 40, y: 20 }, nodeId: 'node-c' }, ], }; const nodes: Record = { 'node-a': sourceNode, 'node-b': targetNode, 'node-c': candidateNode }; + // Ports as the spatial hit test reports them (what getNearestPortInRange + // returns for the drop position). + const hitPort = (id: string, nodeId: string, type: Port['type']): Port => ({ + ...mockPort, + id, + nodeId, + type, + }); + const setLinking = (end: 'source' | 'target', temporaryEdge: Partial): InternalLinkingActionState => { const linking: InternalLinkingActionState = { sourceNodeId: originalEdge.source, @@ -74,11 +83,12 @@ describe('finishRelinking', () => { mockFlowCore = { getEdgeById: vi.fn().mockReturnValue(originalEdge), getNodeById: vi.fn((id: string) => nodes[id]), + getNearestPortInRange: vi.fn().mockReturnValue(null), applyUpdate: vi.fn().mockResolvedValue(undefined), config: { danglingEdges: { enabled: false }, edgeRelinking: { enabled: true }, - linking: { validateConnection: vi.fn().mockReturnValue(true) }, + linking: { portSnapDistance: 12, validateConnection: vi.fn().mockReturnValue(true) }, }, actionStateManager: { linking: null, @@ -106,11 +116,37 @@ describe('finishRelinking', () => { expect(mockFlowCore.actionStateManager.clearLinking).not.toHaveBeenCalled(); }); - it('should commit the target end to the candidate port on drop', async () => { - setLinking('target', { source: 'node-a', sourcePort: 'out', target: 'node-c', targetPort: 'in-c' }); + it('should do nothing when a teardown is already in progress', async () => { + const linking = setLinking('target', { source: 'node-a', sourcePort: 'out' }); + linking._finishing = true; + + await finishRelinking(mockCommandHandler, { name: 'finishRelinking', position: { x: 0, y: 0 } }); + + expect(mockFlowCore.applyUpdate).not.toHaveBeenCalled(); + expect(mockFlowCore.actionStateManager.clearLinking).not.toHaveBeenCalled(); + }); + + it('should run only one teardown for two overlapping finishRelinking calls', async () => { + mockFlowCore.getNearestPortInRange.mockReturnValue(hitPort('in-c', 'node-c', 'target')); + setLinking('target', { source: 'node-a', sourcePort: 'out' }); + + // Both start before either resolves — the second must see _finishing and + // bail without committing a second update. + await Promise.all([ + finishRelinking(mockCommandHandler, { name: 'finishRelinking', position: { x: 5, y: 6 } }), + finishRelinking(mockCommandHandler, { name: 'finishRelinking', position: { x: 5, y: 6 } }), + ]); + + expect(mockFlowCore.applyUpdate).toHaveBeenCalledTimes(1); + }); + + it('should commit the target end to the port under the drop position', async () => { + mockFlowCore.getNearestPortInRange.mockReturnValue(hitPort('in-c', 'node-c', 'target')); + setLinking('target', { source: 'node-a', sourcePort: 'out' }); await finishRelinking(mockCommandHandler, { name: 'finishRelinking', position: { x: 5, y: 6 } }); + expect(mockFlowCore.getNearestPortInRange).toHaveBeenCalledWith({ x: 5, y: 6 }, 12); expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith( { edgesToUpdate: [{ id: 'edge-1', target: 'node-c', targetPort: 'in-c', targetPosition: undefined }] }, 'finishRelinking' @@ -118,8 +154,9 @@ describe('finishRelinking', () => { expect(mockFlowCore.actionStateManager.clearLinking).toHaveBeenCalled(); }); - it('should commit the source end to the candidate port on drop', async () => { - setLinking('source', { source: 'node-c', sourcePort: 'out-c', target: 'node-b', targetPort: 'in' }); + it('should commit the source end to the port under the drop position', async () => { + mockFlowCore.getNearestPortInRange.mockReturnValue(hitPort('out-c', 'node-c', 'source')); + setLinking('source', { target: 'node-b', targetPort: 'in' }); await finishRelinking(mockCommandHandler, { name: 'finishRelinking', position: { x: 5, y: 6 } }); @@ -130,10 +167,25 @@ describe('finishRelinking', () => { expect(mockFlowCore.actionStateManager.clearLinking).toHaveBeenCalled(); }); + it('should revert with cancelled when dropped back on the original node and port', async () => { + mockFlowCore.getNearestPortInRange.mockReturnValue(hitPort('in', 'node-b', 'target')); + const linking = setLinking('target', { source: 'node-a', sourcePort: 'out' }); + + await finishRelinking(mockCommandHandler, { name: 'finishRelinking', position: { x: 5, y: 6 } }); + + expect(linking.relinkCancelReason).toBe('cancelled'); + expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith({}, 'finishRelinking'); + expect(mockFlowCore.applyUpdate).not.toHaveBeenCalledWith( + expect.objectContaining({ edgesToUpdate: expect.anything() }), + 'finishRelinking' + ); + expect(mockFlowCore.actionStateManager.clearLinking).toHaveBeenCalled(); + }); + describe('empty-canvas drop', () => { it('should detach the dragged end at the drop position when dangling edges are enabled', async () => { mockFlowCore.config.danglingEdges.enabled = true; - setLinking('target', { source: 'node-a', sourcePort: 'out', target: '', targetPort: '' }); + setLinking('target', { source: 'node-a', sourcePort: 'out' }); await finishRelinking(mockCommandHandler, { name: 'finishRelinking', position: { x: 400, y: 500 } }); @@ -146,7 +198,7 @@ describe('finishRelinking', () => { it('should detach the source end at the drop position when dragging the source', async () => { mockFlowCore.config.danglingEdges.enabled = true; - setLinking('source', { source: '', sourcePort: '', target: 'node-b', targetPort: 'in' }); + setLinking('source', { target: 'node-b', targetPort: 'in' }); await finishRelinking(mockCommandHandler, { name: 'finishRelinking', position: { x: 40, y: 50 } }); @@ -156,8 +208,55 @@ describe('finishRelinking', () => { ); }); + it('should not consult the connection validator on a canvas drop', async () => { + mockFlowCore.config.danglingEdges.enabled = true; + setLinking('target', { source: 'node-a', sourcePort: 'out' }); + + await finishRelinking(mockCommandHandler, { name: 'finishRelinking', position: { x: 400, y: 500 } }); + + // A detach is not a connection — only danglingEdges.enabled and + // shouldKeepOnDrop decide it. + expect(mockFlowCore.config.linking.validateConnection).not.toHaveBeenCalled(); + }); + + it('should align the manual points of a detached manual-routing edge to the drop position', async () => { + mockFlowCore.config.danglingEdges.enabled = true; + const manualEdge: Edge = { + ...originalEdge, + routingMode: 'manual', + points: [ + { x: 10, y: 20 }, + { x: 60, y: 70 }, + { x: 110, y: 120 }, + ], + }; + mockFlowCore.getEdgeById.mockReturnValue(manualEdge); + setLinking('target', { source: 'node-a', sourcePort: 'out' }); + + await finishRelinking(mockCommandHandler, { name: 'finishRelinking', position: { x: 400, y: 500 } }); + + expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith( + { + edgesToUpdate: [ + { + id: 'edge-1', + target: '', + targetPort: undefined, + targetPosition: { x: 400, y: 500 }, + points: [ + { x: 10, y: 20 }, + { x: 60, y: 70 }, + { x: 400, y: 500 }, + ], + }, + ], + }, + 'finishRelinking' + ); + }); + it('should revert with noTarget when dangling edges are disabled', async () => { - const linking = setLinking('target', { source: 'node-a', sourcePort: 'out', target: '', targetPort: '' }); + const linking = setLinking('target', { source: 'node-a', sourcePort: 'out' }); await finishRelinking(mockCommandHandler, { name: 'finishRelinking', position: { x: 400, y: 500 } }); @@ -169,7 +268,7 @@ describe('finishRelinking', () => { it('should revert with noTarget when shouldKeepOnDrop declines the detached edge', async () => { const shouldKeepOnDrop = vi.fn().mockReturnValue(false); mockFlowCore.config.danglingEdges = { enabled: true, shouldKeepOnDrop }; - const linking = setLinking('target', { source: 'node-a', sourcePort: 'out', target: '', targetPort: '' }); + const linking = setLinking('target', { source: 'node-a', sourcePort: 'out' }); await finishRelinking(mockCommandHandler, { name: 'finishRelinking', position: { x: 400, y: 500 } }); @@ -184,47 +283,9 @@ describe('finishRelinking', () => { }); describe('validation', () => { - it('should use validateRelink when provided and revert on false', async () => { - const validateRelink = vi.fn().mockReturnValue(false); - mockFlowCore.config.edgeRelinking.validateRelink = validateRelink; - const linking = setLinking('target', { - source: 'node-a', - sourcePort: 'out', - target: 'node-c', - targetPort: 'in-c', - }); - - await finishRelinking(mockCommandHandler, { name: 'finishRelinking', position: { x: 0, y: 0 } }); - - expect(validateRelink).toHaveBeenCalledWith( - originalEdge, - 'target', - candidateNode, - candidateNode.measuredPorts![0] - ); - expect(mockFlowCore.config.linking.validateConnection).not.toHaveBeenCalled(); - expect(linking.relinkCancelReason).toBe('invalidConnection'); - expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith({}, 'finishRelinking'); - expect(mockFlowCore.applyUpdate).not.toHaveBeenCalledWith( - expect.objectContaining({ edgesToUpdate: expect.anything() }), - 'finishRelinking' - ); - }); - - it('should commit when validateRelink allows the drop', async () => { - mockFlowCore.config.edgeRelinking.validateRelink = vi.fn().mockReturnValue(true); - setLinking('target', { source: 'node-a', sourcePort: 'out', target: 'node-c', targetPort: 'in-c' }); - - await finishRelinking(mockCommandHandler, { name: 'finishRelinking', position: { x: 0, y: 0 } }); - - expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith( - { edgesToUpdate: [{ id: 'edge-1', target: 'node-c', targetPort: 'in-c', targetPosition: undefined }] }, - 'finishRelinking' - ); - }); - - it('should fall back to linking.validateConnection with the endpoints in their proper roles (target end)', async () => { - setLinking('target', { source: 'node-a', sourcePort: 'out', target: 'node-c', targetPort: 'in-c' }); + it('should pass reason relink and the endpoints in their proper roles (target end)', async () => { + mockFlowCore.getNearestPortInRange.mockReturnValue(hitPort('in-c', 'node-c', 'target')); + setLinking('target', { source: 'node-a', sourcePort: 'out' }); await finishRelinking(mockCommandHandler, { name: 'finishRelinking', position: { x: 0, y: 0 } }); @@ -232,12 +293,14 @@ describe('finishRelinking', () => { sourceNode, sourceNode.measuredPorts![0], candidateNode, - candidateNode.measuredPorts![0] + candidateNode.measuredPorts![0], + { reason: 'relink', edge: originalEdge, end: 'target' } ); }); - it('should fall back to linking.validateConnection with the endpoints in their proper roles (source end)', async () => { - setLinking('source', { source: 'node-c', sourcePort: 'out-c', target: 'node-b', targetPort: 'in' }); + it('should pass reason relink and the endpoints in their proper roles (source end)', async () => { + mockFlowCore.getNearestPortInRange.mockReturnValue(hitPort('out-c', 'node-c', 'source')); + setLinking('source', { target: 'node-b', targetPort: 'in' }); await finishRelinking(mockCommandHandler, { name: 'finishRelinking', position: { x: 0, y: 0 } }); @@ -245,35 +308,58 @@ describe('finishRelinking', () => { candidateNode, candidateNode.measuredPorts![1], targetNode, - targetNode.measuredPorts![0] + targetNode.measuredPorts![0], + { reason: 'relink', edge: originalEdge, end: 'source' } ); }); it('should revert when validateConnection rejects the candidate', async () => { mockFlowCore.config.linking.validateConnection.mockReturnValue(false); - const linking = setLinking('target', { - source: 'node-a', - sourcePort: 'out', - target: 'node-c', - targetPort: 'in-c', - }); + mockFlowCore.getNearestPortInRange.mockReturnValue(hitPort('in-c', 'node-c', 'target')); + const linking = setLinking('target', { source: 'node-a', sourcePort: 'out' }); await finishRelinking(mockCommandHandler, { name: 'finishRelinking', position: { x: 0, y: 0 } }); expect(linking.relinkCancelReason).toBe('invalidConnection'); expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith({}, 'finishRelinking'); + expect(mockFlowCore.applyUpdate).not.toHaveBeenCalledWith( + expect.objectContaining({ edgesToUpdate: expect.anything() }), + 'finishRelinking' + ); }); it('should revert when the candidate node is effectively hidden', async () => { mockFlowCore.getNodeById.mockImplementation((id: string) => id === 'node-c' ? { ...candidateNode, computedHidden: true } : nodes[id] ); - const linking = setLinking('target', { - source: 'node-a', - sourcePort: 'out', - target: 'node-c', - targetPort: 'in-c', - }); + mockFlowCore.getNearestPortInRange.mockReturnValue(hitPort('in-c', 'node-c', 'target')); + const linking = setLinking('target', { source: 'node-a', sourcePort: 'out' }); + + await finishRelinking(mockCommandHandler, { name: 'finishRelinking', position: { x: 0, y: 0 } }); + + expect(linking.relinkCancelReason).toBe('invalidConnection'); + expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith({}, 'finishRelinking'); + // Structural rejection — the app validator is never consulted. + expect(mockFlowCore.config.linking.validateConnection).not.toHaveBeenCalled(); + }); + + it('should revert a target-end drop on a port measured as source-typed', async () => { + // The spatial hit still reports the port as connectable, but the node's + // measuredPorts say it is source-typed — a stale index must not let a + // target end commit to it. + mockFlowCore.getNearestPortInRange.mockReturnValue(hitPort('out-c', 'node-c', 'both')); + const linking = setLinking('target', { source: 'node-a', sourcePort: 'out' }); + + await finishRelinking(mockCommandHandler, { name: 'finishRelinking', position: { x: 0, y: 0 } }); + + expect(linking.relinkCancelReason).toBe('invalidConnection'); + expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith({}, 'finishRelinking'); + expect(mockFlowCore.config.linking.validateConnection).not.toHaveBeenCalled(); + }); + + it('should revert a source-end drop on a port measured as target-typed', async () => { + mockFlowCore.getNearestPortInRange.mockReturnValue(hitPort('in-c', 'node-c', 'both')); + const linking = setLinking('source', { target: 'node-b', targetPort: 'in' }); await finishRelinking(mockCommandHandler, { name: 'finishRelinking', position: { x: 0, y: 0 } }); @@ -281,13 +367,9 @@ describe('finishRelinking', () => { expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith({}, 'finishRelinking'); }); - it('should revert a target-end drop on a source-typed port', async () => { - const linking = setLinking('target', { - source: 'node-a', - sourcePort: 'out', - target: 'node-c', - targetPort: 'out-c', - }); + it('should revert when the candidate port is missing from measuredPorts', async () => { + mockFlowCore.getNearestPortInRange.mockReturnValue(hitPort('ghost-port', 'node-c', 'target')); + const linking = setLinking('target', { source: 'node-a', sourcePort: 'out' }); await finishRelinking(mockCommandHandler, { name: 'finishRelinking', position: { x: 0, y: 0 } }); @@ -296,24 +378,62 @@ describe('finishRelinking', () => { expect(mockFlowCore.config.linking.validateConnection).not.toHaveBeenCalled(); }); - it('should revert a source-end drop on a target-typed port', async () => { - const linking = setLinking('source', { - source: 'node-c', - sourcePort: 'in-c', - target: 'node-b', - targetPort: 'in', - }); + it('should revert when the candidate port is template-hidden', async () => { + mockFlowCore.templateVisibilityRegistry = { + isPortHidden: (nodeId: string, portId: string) => nodeId === 'node-c' && portId === 'in-c', + }; + mockFlowCore.getNearestPortInRange.mockReturnValue(hitPort('in-c', 'node-c', 'target')); + const linking = setLinking('target', { source: 'node-a', sourcePort: 'out' }); await finishRelinking(mockCommandHandler, { name: 'finishRelinking', position: { x: 0, y: 0 } }); expect(linking.relinkCancelReason).toBe('invalidConnection'); expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith({}, 'finishRelinking'); + expect(mockFlowCore.config.linking.validateConnection).not.toHaveBeenCalled(); }); }); + it('should align the manual points of a reconnected manual-routing edge to the new port anchor', async () => { + const manualEdge: Edge = { + ...originalEdge, + routingMode: 'manual', + points: [ + { x: 10, y: 20 }, + { x: 60, y: 70 }, + { x: 110, y: 120 }, + ], + }; + mockFlowCore.getEdgeById.mockReturnValue(manualEdge); + mockFlowCore.getNearestPortInRange.mockReturnValue(hitPort('in-c', 'node-c', 'target')); + setLinking('target', { source: 'node-a', sourcePort: 'out' }); + + await finishRelinking(mockCommandHandler, { name: 'finishRelinking', position: { x: 0, y: 0 } }); + + // Port 'in-c': node position (200, 100) + port position (0, 20), side left + // → anchor at (200, 100 + 20 + height / 2) = (200, 125). + expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith( + { + edgesToUpdate: [ + { + id: 'edge-1', + target: 'node-c', + targetPort: 'in-c', + targetPosition: undefined, + points: [ + { x: 10, y: 20 }, + { x: 60, y: 70 }, + { x: 200, y: 125 }, + ], + }, + ], + }, + 'finishRelinking' + ); + }); + it('should revert with cancelled when the relinked edge no longer exists', async () => { mockFlowCore.getEdgeById.mockReturnValue(undefined); - const linking = setLinking('target', { source: 'node-a', sourcePort: 'out', target: 'node-c', targetPort: 'in-c' }); + const linking = setLinking('target', { source: 'node-a', sourcePort: 'out' }); await finishRelinking(mockCommandHandler, { name: 'finishRelinking', position: { x: 0, y: 0 } }); @@ -326,7 +446,8 @@ describe('finishRelinking', () => { mockFlowCore.getNodeById.mockImplementation((id: string) => id === 'node-a' ? { ...sourceNode, computedHidden: true } : nodes[id] ); - const linking = setLinking('target', { source: 'node-a', sourcePort: 'out', target: 'node-c', targetPort: 'in-c' }); + mockFlowCore.getNearestPortInRange.mockReturnValue(hitPort('in-c', 'node-c', 'target')); + const linking = setLinking('target', { source: 'node-a', sourcePort: 'out' }); await finishRelinking(mockCommandHandler, { name: 'finishRelinking', position: { x: 0, y: 0 } }); @@ -335,7 +456,8 @@ describe('finishRelinking', () => { }); it('should record the drop position on the linking state', async () => { - const linking = setLinking('target', { source: 'node-a', sourcePort: 'out', target: 'node-c', targetPort: 'in-c' }); + mockFlowCore.getNearestPortInRange.mockReturnValue(hitPort('in-c', 'node-c', 'target')); + const linking = setLinking('target', { source: 'node-a', sourcePort: 'out' }); await finishRelinking(mockCommandHandler, { name: 'finishRelinking', position: { x: 12, y: 34 } }); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/linking-utils.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/linking-utils.test.ts index 42cf71417..cc42b494c 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/linking-utils.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/linking-utils.test.ts @@ -1,7 +1,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { FlowCore } from '../../../../flow-core'; import { mockEdge, mockNode, mockPort } from '../../../../test-utils'; -import { createTemporaryEdge, isProperSourcePort, isProperTargetPort, validateRelinkOrConnection } from '../utils'; +import type { Edge } from '../../../../types'; +import { + connectionContextForGesture, + createFinalEdge, + createTemporaryEdge, + isProperSourcePort, + isProperTargetPort, + validateConnection, +} from '../utils'; describe('linking utils', () => { describe('isProperTargetPort', () => { @@ -80,7 +88,48 @@ describe('linking utils', () => { }); }); - describe('validateRelinkOrConnection', () => { + describe('createFinalEdge', () => { + const config = { + linking: { + finalEdgeDataBuilder: (edge: Edge) => edge, + }, + computeEdgeId: () => 'fresh-id', + } as unknown as Parameters[0]; + + it('should build a kept dangling edge with a fresh id, temporary false and an undefined free port', () => { + const temporaryEdge: Edge = { + ...mockEdge, + id: 'TEMPORARY_EDGE', + temporary: true, + source: 'node-a', + sourcePort: 'out', + target: '', + targetPort: '', + }; + + const finalEdge = createFinalEdge(config, temporaryEdge, { + target: '', + targetPort: undefined, + targetPosition: { x: 50, y: 60 }, + }); + + // Passthrough builder: this is the exact shape committed to the model. + expect(finalEdge).toEqual({ + ...temporaryEdge, + id: 'fresh-id', + temporary: false, + target: '', + targetPort: undefined, + targetPosition: { x: 50, y: 60 }, + }); + expect(finalEdge.id).toBe('fresh-id'); + expect(finalEdge.temporary).toBe(false); + expect(finalEdge.target).toBe(''); + expect(finalEdge.targetPort).toBeUndefined(); + }); + }); + + describe('validateConnection', () => { const candidateNode = { ...mockNode, id: 'node-c', @@ -90,7 +139,6 @@ describe('linking utils', () => { getEdgeById: ReturnType; getNodeById: ReturnType; config: { - edgeRelinking: { validateRelink?: ReturnType }; linking: { validateConnection: ReturnType }; }; }; @@ -100,82 +148,80 @@ describe('linking utils', () => { getEdgeById: vi.fn().mockReturnValue(mockEdge), getNodeById: vi.fn().mockReturnValue(candidateNode), config: { - edgeRelinking: {}, linking: { validateConnection: vi.fn().mockReturnValue(true) }, }, }; }); - it('should call validateRelink with the edge, end and candidate during a relink', () => { - const validateRelink = vi.fn().mockReturnValue(false); - core.config.edgeRelinking.validateRelink = validateRelink; + it('should pass the given context through to the config validator', () => { + const context = { reason: 'relink' as const, edge: mockEdge, end: 'target' as const }; - const result = validateRelinkOrConnection( + const result = validateConnection( core as unknown as FlowCore, - { edgeId: 'edge-1', end: 'target', originalEdge: mockEdge }, - 'node-a', - 'out', - 'node-c', - 'in-c', - true - ); - - expect(result).toBe(false); - expect(validateRelink).toHaveBeenCalledWith(mockEdge, 'target', candidateNode, candidateNode.measuredPorts[0]); - expect(core.config.linking.validateConnection).not.toHaveBeenCalled(); - }); - - it('should pass the original edge snapshot when the edge is gone from the model', () => { - const validateRelink = vi.fn().mockReturnValue(true); - core.config.edgeRelinking.validateRelink = validateRelink; - core.getEdgeById.mockReturnValue(undefined); - const originalEdge = { ...mockEdge, id: 'edge-1' }; - - validateRelinkOrConnection( - core as unknown as FlowCore, - { edgeId: 'edge-1', end: 'source', originalEdge }, + undefined, + undefined, 'node-c', 'in-c', - 'node-b', - 'in', - true + true, + context ); - expect(validateRelink).toHaveBeenCalledWith( - originalEdge, - 'source', + expect(result).toBe(true); + expect(core.config.linking.validateConnection).toHaveBeenCalledWith( + null, + null, candidateNode, - candidateNode.measuredPorts[0] + candidateNode.measuredPorts[0], + context ); }); - it('should fall back to validateConnection when validateRelink is not configured', () => { - const result = validateRelinkOrConnection( - core as unknown as FlowCore, - { edgeId: 'edge-1', end: 'target', originalEdge: mockEdge }, - undefined, - undefined, - 'node-c', - 'in-c', - true - ); + it('should default the context to a draw when none is given', () => { + validateConnection(core as unknown as FlowCore, undefined, undefined, 'node-c', 'in-c', true); - expect(result).toBe(true); expect(core.config.linking.validateConnection).toHaveBeenCalledWith( null, null, candidateNode, - candidateNode.measuredPorts[0] + candidateNode.measuredPorts[0], + { + reason: 'draw', + } ); }); + }); + + describe('connectionContextForGesture', () => { + it('should build a relink context with the live edge', () => { + const liveEdge = { ...mockEdge, id: 'edge-1' }; + const core = { getEdgeById: vi.fn().mockReturnValue(liveEdge) }; + + const context = connectionContextForGesture(core as unknown as FlowCore, { + edgeId: 'edge-1', + end: 'source', + originalEdge: mockEdge, + }); + + expect(context).toEqual({ reason: 'relink', edge: liveEdge, end: 'source' }); + }); - it('should behave like validateConnection outside a relink', () => { - core.config.edgeRelinking.validateRelink = vi.fn(); + it('should fall back to the original edge snapshot when the edge left the model', () => { + const core = { getEdgeById: vi.fn().mockReturnValue(undefined) }; + const originalEdge = { ...mockEdge, id: 'edge-1' }; + + const context = connectionContextForGesture(core as unknown as FlowCore, { + edgeId: 'edge-1', + end: 'target', + originalEdge, + }); + + expect(context).toEqual({ reason: 'relink', edge: originalEdge, end: 'target' }); + }); - validateRelinkOrConnection(core as unknown as FlowCore, undefined, undefined, undefined, 'node-c', 'in-c', true); + it('should build a draw context outside a relink', () => { + const core = { getEdgeById: vi.fn() }; - expect(core.config.edgeRelinking.validateRelink).not.toHaveBeenCalled(); - expect(core.config.linking.validateConnection).toHaveBeenCalled(); + expect(connectionContextForGesture(core as unknown as FlowCore, undefined)).toEqual({ reason: 'draw' }); }); }); }); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/move-temporary-edge.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/move-temporary-edge.test.ts index f1133af9a..d7943ff81 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/move-temporary-edge.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/move-temporary-edge.test.ts @@ -10,13 +10,15 @@ vi.mock('../utils', () => ({ isProperTargetPort: vi.fn(), isProperSourcePort: vi.fn(), validateConnection: vi.fn(), - validateRelinkOrConnection: vi.fn(), + connectionContextForGesture: vi.fn().mockReturnValue({ reason: 'draw' }), + relinkPreviewBase: vi.fn().mockReturnValue({}), })); -import { createTemporaryEdge, isProperTargetPort, validateRelinkOrConnection } from '../utils'; +import { createTemporaryEdge, isProperSourcePort, isProperTargetPort, validateConnection } from '../utils'; const mockCreateTemporaryEdge = vi.mocked(createTemporaryEdge); const mockIsProperTargetPort = vi.mocked(isProperTargetPort); -const mockValidateRelinkOrConnection = vi.mocked(validateRelinkOrConnection); +const mockIsProperSourcePort = vi.mocked(isProperSourcePort); +const mockValidateConnection = vi.mocked(validateConnection); describe('moveTemporaryEdge', () => { let mockCommandHandler: CommandHandler; @@ -154,7 +156,7 @@ describe('moveTemporaryEdge', () => { mockFlowCore.getNearestPortInRange.mockReturnValue(mockTargetPort); mockIsProperTargetPort.mockReturnValue(true); mockFlowCore.getNodeById.mockReturnValue(mockTargetNode); - mockValidateRelinkOrConnection.mockReturnValue(true); + mockValidateConnection.mockReturnValue(true); const connectedEdge = { ...mockTemporaryEdge, @@ -195,7 +197,7 @@ describe('moveTemporaryEdge', () => { mockFlowCore.getNearestPortInRange.mockReturnValue(mockTargetPort); mockIsProperTargetPort.mockReturnValue(true); mockFlowCore.getNodeById.mockReturnValue(mockTargetNode); - mockValidateRelinkOrConnection.mockReturnValue(false); // Connection invalid + mockValidateConnection.mockReturnValue(false); // Connection invalid const floatingEdge = { ...mockTemporaryEdge, target: '', targetPort: '' }; mockCreateTemporaryEdge.mockReturnValue(floatingEdge); @@ -207,13 +209,14 @@ describe('moveTemporaryEdge', () => { await moveTemporaryEdge(mockCommandHandler, command); - expect(mockValidateRelinkOrConnection).toHaveBeenCalledWith( + expect(mockValidateConnection).toHaveBeenCalledWith( mockFlowCore, - undefined, mockTemporaryEdge.source, mockTemporaryEdge.sourcePort, 'target-node', - 'target-port' + 'target-port', + undefined, + { reason: 'draw' } ); expect(mockCreateTemporaryEdge).toHaveBeenCalledWith(mockFlowCore.config, { @@ -248,6 +251,135 @@ describe('moveTemporaryEdge', () => { ); }); + describe('relinking the source end', () => { + const originalEdge: Edge = { + id: 'edge-1', + source: 'old-source-node', + sourcePort: 'old-source-port', + target: 'fixed-target-node', + targetPort: 'fixed-target-port', + data: {}, + }; + + const sourceRelinkTemporaryEdge: Edge = { + id: 'temp-edge', + source: '', + sourcePort: '', + target: 'fixed-target-node', + targetPort: 'fixed-target-port', + data: {}, + }; + + const candidatePort: Port = { + ...mockPort, + id: 'candidate-port', + type: 'source', + nodeId: 'candidate-node', + }; + + const candidateNode: Node = { + ...mockNode, + id: 'candidate-node', + measuredPorts: [candidatePort], + }; + + const setSourceRelink = (temporaryEdge: Edge = sourceRelinkTemporaryEdge): InternalLinkingActionState => { + const linking: InternalLinkingActionState = { + sourceNodeId: 'old-source-node', + sourcePortId: 'old-source-port', + temporaryEdge, + relink: { edgeId: 'edge-1', end: 'source', originalEdge }, + }; + mockFlowCore.actionStateManager.linking = linking; + return linking; + }; + + it('should snap the dragged source end using isProperSourcePort', async () => { + setSourceRelink(); + mockFlowCore.getNearestPortInRange.mockReturnValue(candidatePort); + mockIsProperSourcePort.mockReturnValue(true); + mockFlowCore.getNodeById.mockReturnValue(candidateNode); + mockValidateConnection.mockReturnValue(true); + const snappedEdge = { ...sourceRelinkTemporaryEdge, source: 'candidate-node', sourcePort: 'candidate-port' }; + mockCreateTemporaryEdge.mockReturnValue(snappedEdge); + + await moveTemporaryEdge(mockCommandHandler, { name: 'moveTemporaryEdge', position: { x: 100, y: 200 } }); + + // The candidate must be source-capable and distinct from the FIXED + // (target) end — the target-port check does not apply. + expect(mockIsProperSourcePort).toHaveBeenCalledWith(candidatePort, 'fixed-target-node', 'fixed-target-port'); + expect(mockIsProperTargetPort).not.toHaveBeenCalled(); + expect(mockCreateTemporaryEdge).toHaveBeenCalledWith(mockFlowCore.config, { + target: 'fixed-target-node', + targetPort: 'fixed-target-port', + targetPosition: undefined, + source: 'candidate-node', + sourcePort: 'candidate-port', + sourcePosition: { x: 100, y: 200 }, + }); + expect(mockFlowCore.actionStateManager.linking!.temporaryEdge).toBe(snappedEdge); + expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith({}, 'moveTemporaryEdge'); + }); + + it('should return early when the source end already snaps to the same candidate', async () => { + const linking = setSourceRelink({ + ...sourceRelinkTemporaryEdge, + source: 'candidate-node', + sourcePort: 'candidate-port', + }); + mockFlowCore.getNearestPortInRange.mockReturnValue(candidatePort); + mockIsProperSourcePort.mockReturnValue(true); + + await moveTemporaryEdge(mockCommandHandler, { name: 'moveTemporaryEdge', position: { x: 100, y: 200 } }); + + expect(mockCreateTemporaryEdge).not.toHaveBeenCalled(); + expect(mockFlowCore.applyUpdate).not.toHaveBeenCalled(); + // No state write — the live linking object is untouched. + expect(mockFlowCore.actionStateManager.linking).toBe(linking); + }); + + it('should validate with the candidate in the source role', async () => { + setSourceRelink(); + mockFlowCore.getNearestPortInRange.mockReturnValue(candidatePort); + mockIsProperSourcePort.mockReturnValue(true); + mockFlowCore.getNodeById.mockReturnValue(candidateNode); + mockValidateConnection.mockReturnValue(true); + mockCreateTemporaryEdge.mockReturnValue(sourceRelinkTemporaryEdge); + + await moveTemporaryEdge(mockCommandHandler, { name: 'moveTemporaryEdge', position: { x: 100, y: 200 } }); + + expect(mockValidateConnection).toHaveBeenCalledWith( + mockFlowCore, + 'candidate-node', + 'candidate-port', + 'fixed-target-node', + 'fixed-target-port', + undefined, + { reason: 'draw' } + ); + }); + + it('should un-snap to a floating source end when the candidate fails validation', async () => { + setSourceRelink(); + mockFlowCore.getNearestPortInRange.mockReturnValue(candidatePort); + mockIsProperSourcePort.mockReturnValue(true); + mockFlowCore.getNodeById.mockReturnValue(candidateNode); + mockValidateConnection.mockReturnValue(false); + mockCreateTemporaryEdge.mockReturnValue(sourceRelinkTemporaryEdge); + + await moveTemporaryEdge(mockCommandHandler, { name: 'moveTemporaryEdge', position: { x: 100, y: 200 } }); + + expect(mockCreateTemporaryEdge).toHaveBeenCalledWith(mockFlowCore.config, { + target: 'fixed-target-node', + targetPort: 'fixed-target-port', + targetPosition: undefined, + source: '', + sourcePort: '', + sourcePosition: { x: 100, y: 200 }, + }); + }); + }); + it('should preserve the gesture stamp when replacing the linking state', async () => { const stamped: InternalLinkingActionState = { sourceNodeId: 'source-node', diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/start-linking-from-position.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/start-linking-from-position.test.ts index ae3c91918..050a7e4c8 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/start-linking-from-position.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/start-linking-from-position.test.ts @@ -25,19 +25,6 @@ describe('startLinkingFromPosition', () => { }; }; - it('should refuse to clobber an in-flight linking gesture', async () => { - mockFlowCore.actionStateManager.isLinking.mockReturnValue(true); - const previousLinking = mockFlowCore.actionStateManager.linking; - - await startLinkingFromPosition(mockCommandHandler, { - name: 'startLinkingFromPosition', - position: { x: 10, y: 20 }, - }); - - expect(mockFlowCore.actionStateManager.linking).toBe(previousLinking); - expect(mockFlowCore.applyUpdate).not.toHaveBeenCalled(); - }); - beforeEach(() => { vi.clearAllMocks(); @@ -61,6 +48,19 @@ describe('startLinkingFromPosition', () => { } as unknown as CommandHandler; }); + it('should refuse to clobber an in-flight linking gesture', async () => { + mockFlowCore.actionStateManager.isLinking.mockReturnValue(true); + const previousLinking = mockFlowCore.actionStateManager.linking; + + await startLinkingFromPosition(mockCommandHandler, { + name: 'startLinkingFromPosition', + position: { x: 10, y: 20 }, + }); + + expect(mockFlowCore.actionStateManager.linking).toBe(previousLinking); + expect(mockFlowCore.applyUpdate).not.toHaveBeenCalled(); + }); + describe('basic functionality', () => { it('should create temporary edge and apply update with correct parameters', async () => { const position = { x: 100, y: 200 }; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/finish-linking-to-position.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/finish-linking-to-position.ts index 8e6f2d80d..7ad188987 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/finish-linking-to-position.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/finish-linking-to-position.ts @@ -20,6 +20,12 @@ export const finishLinkingToPosition = async ( return; } + // A relink owns this state (finishRelinking is its only legal finish), and + // a teardown already in progress must not commit a second edge. + if (linking.relink || linking._finishing) { + return; + } + const gestureId = linking._gestureId; // Same clear-in-finally + gesture-stamp guard as finishLinking — @@ -37,7 +43,8 @@ export const finishLinkingToPosition = async ( edgesToAdd: [ createFinalEdge(commandHandler.flowCore.config, temporaryEdge, { target: '', - targetPort: '', + // Free ends carry no port — undefined, never '' (see finishLinking). + targetPort: undefined, targetPosition: position, }), ], 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 de47f5860..4a6874688 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 @@ -39,9 +39,11 @@ const buildKeptDanglingEdge = ( return null; } + // A free end has no port — normalized to undefined (never '') so the model + // shape is identical across every path that produces a dangling end. const edge = createFinalEdge(config, temporaryEdge, { target: '', - targetPort: '', + targetPort: undefined, targetPosition: dropPosition, }); @@ -94,6 +96,13 @@ export const finishLinking = async (commandHandler: CommandHandler, command: Fin return; } + // A relink owns this state — finishing it as a draw would ADD a duplicate + // edge instead of updating the relinked one (finishRelinking is the only + // legal finish for it). A teardown already in progress must not run twice. + if (linking.relink || linking._finishing) { + 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; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/finish-relinking.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/finish-relinking.ts index 94e256203..79da9db52 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/finish-relinking.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/finish-relinking.ts @@ -3,24 +3,31 @@ import type { CommandHandler, Edge, Point } from '../../../types'; import type { EdgeRelinkCancelReason } from '../../../event-manager/event-types'; import type { InternalLinkingActionState } from '../../../types/action-state.interface'; import { alignManualPointsPatch, getPortFlowPosition } from '../../../utils'; -import { validateRelinkOrConnection } from './utils'; +import { getTargetPortInfo } from './move-temporary-edge'; +import { connectionContextForGesture, isValidEndpointTarget, validateConnection } from './utils'; export interface FinishRelinkingCommand { name: 'finishRelinking'; - position?: Point; + /** The drop position in flow coordinates. */ + position: Point; } /** - * Ends a relink gesture: commits the dragged endpoint to the port it snapped - * to, detaches it into a dangling end on an empty-canvas drop (when the - * dangling-edges feature allows), or reverts the edge (which is just clearing - * the state — the model was never touched during the drag). + * Ends a relink gesture: commits the dragged endpoint to the port under the + * drop position, detaches it into a dangling end on an empty-canvas drop + * (when the dangling-edges feature allows), or reverts the edge (which is + * just clearing the state — the model was never touched during the drag). + * + * A drop back on the endpoint's original node and port changes nothing and + * reports `success: false, reason: 'cancelled'`. */ export const finishRelinking = async (commandHandler: CommandHandler, command: FinishRelinkingCommand) => { const { flowCore } = commandHandler; const linking = flowCore.actionStateManager.linking as InternalLinkingActionState | undefined; - if (!linking?.relink) { + // No relink, or a finishRelinking/cancelLinking already owns the teardown — + // a second call must not commit twice or emit a second edgeRelinkEnded. + if (!linking?.relink || linking._finishing) { return; } @@ -31,8 +38,8 @@ export const finishRelinking = async (commandHandler: CommandHandler, command: F try { const { edgeId, end } = linking.relink; const temporaryEdge = linking.temporaryEdge; - linking.dropPosition = command.position ?? { x: 0, y: 0 }; - const dropPosition = linking.dropPosition; + const dropPosition = command.position; + linking.dropPosition = dropPosition; // An empty 'finishRelinking' pass: the emitter reports the failed relink // and the redraw erases the temporary edge and re-shows the original edge. @@ -47,8 +54,23 @@ export const finishRelinking = async (commandHandler: CommandHandler, command: F return; } - const candidateNodeId = (end === 'source' ? temporaryEdge.source : temporaryEdge.target) || undefined; - const candidatePortId = (end === 'source' ? temporaryEdge.sourcePort : temporaryEdge.targetPort) || undefined; + // Hit-test the drop position itself rather than trusting the preview: + // moveTemporaryEdge un-snaps candidates the validator rejects, so the + // preview can't distinguish "no port under the cursor" from "port the + // validator refused" — and the latter must report invalidConnection. + const dropPortInfo = getTargetPortInfo(commandHandler, dropPosition, temporaryEdge, end); + const candidateNodeId = dropPortInfo.targetNodeId || undefined; + const candidatePortId = dropPortInfo.targetPortId || undefined; + + // A drop back on the original node and port is a no-op, not a reconnect: + // the model is untouched and no success is reported (event parity with + // edgeRelinkStarted is kept through the cancelled revert pass). + const originalNodeId = (end === 'source' ? edge.source : edge.target) || undefined; + const originalPortId = (end === 'source' ? edge.sourcePort : edge.targetPort) || undefined; + if (candidateNodeId && candidateNodeId === originalNodeId && candidatePortId === originalPortId) { + await runRevertPass('cancelled'); + return; + } // The fixed end can become effectively hidden mid-gesture — neither a // reconnect nor a detach may commit an edge anchored to a hidden node. @@ -60,11 +82,10 @@ export const finishRelinking = async (commandHandler: CommandHandler, command: F if (!candidateNodeId) { // Dropped on empty canvas — detach the endpoint when dangling edges are - // enabled, the relink validator accepts a canvas drop (it receives a - // null candidate node/port) and the per-edge callback keeps the - // detached edge. - const { danglingEdges, edgeRelinking } = flowCore.config; - if (danglingEdges?.enabled && (edgeRelinking?.validateRelink?.(edge, end, null, null) ?? true)) { + // enabled and the per-edge callback keeps the detached edge. This is not + // a connection, so the connection validator is not consulted. + const { danglingEdges } = flowCore.config; + if (danglingEdges?.enabled) { const detachUpdate: Partial & { id: Edge['id'] } = end === 'target' ? { id: edgeId, target: '', targetPort: undefined, targetPosition: dropPosition } @@ -85,32 +106,20 @@ export const finishRelinking = async (commandHandler: CommandHandler, command: F // Structural checks on the candidate end, mirroring finishLinking's // validateTarget: hidden nodes, hidden ports, wrong-direction ports and // ports that no longer exist are not valid drop targets. - const candidateNode = flowCore.getNodeById(candidateNodeId); - if (!candidateNode || candidateNode.computedHidden) { + if (!isValidEndpointTarget(flowCore, end, candidateNodeId, candidatePortId)) { await runRevertPass('invalidConnection'); return; } - if (candidatePortId) { - const candidatePort = candidateNode.measuredPorts?.find((port) => port.id === candidatePortId); - const wrongDirection = end === 'target' ? candidatePort?.type === 'source' : candidatePort?.type === 'target'; - if ( - !candidatePort || - wrongDirection || - flowCore.templateVisibilityRegistry?.isPortHidden(candidateNodeId, candidatePortId) - ) { - await runRevertPass('invalidConnection'); - return; - } - } + const candidateNode = flowCore.getNodeById(candidateNodeId); - const isValid = validateRelinkOrConnection( + const isValid = validateConnection( flowCore, - linking.relink, end === 'target' ? edge.source || undefined : candidateNodeId, end === 'target' ? edge.sourcePort : candidatePortId, end === 'target' ? candidateNodeId : edge.target || undefined, end === 'target' ? candidatePortId : edge.targetPort, - true + true, + connectionContextForGesture(flowCore, linking.relink) ); if (!isValid) { await runRevertPass('invalidConnection'); @@ -124,7 +133,7 @@ export const finishRelinking = async (commandHandler: CommandHandler, command: F // Manual-routing edges keep their stored points verbatim — move the // reconnected end's point onto the new anchor so the path follows. - const newAnchor = candidatePortId ? getPortFlowPosition(candidateNode, candidatePortId) : null; + const newAnchor = candidatePortId && candidateNode ? getPortFlowPosition(candidateNode, candidatePortId) : null; const pointsPatch = newAnchor ? alignManualPointsPatch(edge, end, newAnchor) : {}; await flowCore.applyUpdate({ edgesToUpdate: [{ ...reconnectUpdate, ...pointsPatch }] }, 'finishRelinking'); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/move-temporary-edge.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/move-temporary-edge.ts index b33b701bb..ec0b12111 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/move-temporary-edge.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/move-temporary-edge.ts @@ -1,11 +1,12 @@ import type { CommandHandler, Edge, EdgeEnd, Point } from '../../../types'; import type { LinkingRelinkContext } from '../../../types/action-state.interface'; import { + connectionContextForGesture, createTemporaryEdge, isProperSourcePort, isProperTargetPort, relinkPreviewBase, - validateRelinkOrConnection, + validateConnection, } from './utils'; export interface MoveTemporaryEdgeCommand { @@ -95,13 +96,14 @@ export const createNewTemporaryEdge = ( return createFloatingEdge(); } - const isConnectionValid = validateRelinkOrConnection( + const isConnectionValid = validateConnection( commandHandler.flowCore, - relink, draggedEnd === 'target' ? temporaryEdge.source : candidateNodeId, draggedEnd === 'target' ? temporaryEdge.sourcePort : candidatePortId, draggedEnd === 'target' ? candidateNodeId : temporaryEdge.target, - draggedEnd === 'target' ? candidatePortId : temporaryEdge.targetPort + draggedEnd === 'target' ? candidatePortId : temporaryEdge.targetPort, + undefined, + connectionContextForGesture(commandHandler.flowCore, relink) ); if (!isConnectionValid) { diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/utils.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/utils.ts index ec11a5476..cc9f72235 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/utils.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/utils.ts @@ -1,5 +1,5 @@ import { FlowCore } from '../../../flow-core'; -import type { Edge, FlowConfig, Port } from '../../../types'; +import type { ConnectionValidationContext, Edge, EdgeEnd, FlowConfig, Port } from '../../../types'; import type { LinkingRelinkContext } from '../../../types/action-state.interface'; export const createFinalEdge = (config: FlowConfig, temporaryEdge: Edge, partialEdge: Partial): Edge => { @@ -59,7 +59,8 @@ export const validateConnection = ( sourcePortId?: string, targetNodeId?: string, targetPortId?: string, - isFinishLinking?: boolean + isFinishLinking?: boolean, + context?: ConnectionValidationContext ) => { const sourceNode = sourceNodeId ? core.getNodeById(sourceNodeId) : null; const targetNode = targetNodeId ? core.getNodeById(targetNodeId) : null; @@ -75,7 +76,13 @@ export const validateConnection = ( return true; } - return core.config.linking.validateConnection(sourceNode, sourcePort, targetNode, targetPort); + return core.config.linking.validateConnection( + sourceNode, + sourcePort, + targetNode, + targetPort, + context ?? { reason: 'draw' } + ); }; /** @@ -91,6 +98,8 @@ export const relinkPreviewBase = (edge: Edge): Partial => ({ routing: edge.routing, sourceArrowhead: edge.sourceArrowhead, targetArrowhead: edge.targetArrowhead, + // Labels stay visible on the preview while the endpoint is dragged. + measuredLabels: edge.measuredLabels, }); export const createTemporaryEdge = (config: FlowConfig, partialEdge: Partial): Edge => { @@ -109,30 +118,35 @@ export const createTemporaryEdge = (config: FlowConfig, partialEdge: Partial { - const validateRelink = relink ? core.config.edgeRelinking.validateRelink : undefined; - if (relink && validateRelink) { - const edge = core.getEdgeById(relink.edgeId) ?? relink.originalEdge; - const candidateNodeId = relink.end === 'source' ? sourceNodeId : targetNodeId; - const candidatePortId = relink.end === 'source' ? sourcePortId : targetPortId; - const candidateNode = candidateNodeId ? core.getNodeById(candidateNodeId) : null; - const candidatePort = candidatePortId - ? (candidateNode?.measuredPorts?.find((port) => port.id === candidatePortId) ?? null) - : null; - return validateRelink(edge, relink.end, candidateNode, candidatePort); +export const isValidEndpointTarget = (core: FlowCore, end: EdgeEnd, nodeId: string, portId?: string): boolean => { + const node = core.getNodeById(nodeId); + if (!node || node.computedHidden) { + return false; + } + if (portId) { + const port = node.measuredPorts?.find((candidate) => candidate.id === portId); + const wrongDirection = end === 'target' ? port?.type === 'source' : port?.type === 'target'; + if (!port || wrongDirection || core.templateVisibilityRegistry?.isPortHidden(nodeId, portId)) { + return false; + } } - return validateConnection(core, sourceNodeId, sourcePortId, targetNodeId, targetPortId, isFinishLinking); + return true; }; + +/** + * Builds the validation context for a gesture: `reason: 'relink'` with the + * live edge while an endpoint is being relinked, `reason: 'draw'` otherwise. + */ +export const connectionContextForGesture = ( + core: FlowCore, + relink: LinkingRelinkContext | undefined +): ConnectionValidationContext => + relink + ? { reason: 'relink', edge: core.getEdgeById(relink.edgeId) ?? relink.originalEdge, end: relink.end } + : { reason: 'draw' }; 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 36c3caef3..c0b64fafc 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 @@ -342,11 +342,15 @@ export interface EdgeDrawEndedEvent { /** * Reason an edge relink gesture ended without changing the edge. * - * - `noTarget` — dropped on empty canvas while dangling edges are disabled - * - `invalidConnection` — the candidate connection failed validation + * - `noTarget` — dropped on empty canvas while dangling edges are disabled, + * or `danglingEdges.shouldKeepOnDrop` declined the detached edge + * - `invalidConnection` — the drop target failed validation: rejected by + * `linking.validateConnection` (context reason `relink`), or structurally + * invalid (hidden node, hidden/missing port, wrong-direction port) * - `cancelled` — the gesture was aborted (Esc key, - * {@link NgDiagramService.cancelActiveInteraction}, or another gesture - * claimed the pointer) + * {@link NgDiagramService.cancelActiveInteraction}, another gesture claimed + * the pointer) or the endpoint was dropped back on its original node and + * port, which changes nothing * * @public * @since 1.4.0 diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/box-selection/box-selection.handler.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/box-selection/box-selection.handler.test.ts index fa102bb0c..e726b9d04 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/box-selection/box-selection.handler.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/box-selection/box-selection.handler.test.ts @@ -413,6 +413,73 @@ describe('BoxSelectionEventHandler', () => { }); }); + describe('dangling edges', () => { + it('should select a dual dangling edge whose free anchors are inside the box even with zero nodes in it', () => { + const dualDanglingEdge = { + ...mockEdge, + id: 'dualDangling', + source: '', + sourcePosition: { x: 100, y: 100 }, + target: '', + targetPosition: { x: 300, y: 200 }, + }; + mockModel.getEdges.mockReturnValue([dualDanglingEdge]); + mockSpatialHash.queryIds.mockReturnValue(new Set()); + + instance.handle(getSampleBoxSelectionEvent({ phase: 'start', lastInputPoint: { x: 50, y: 50 } })); + instance.handle(getSampleBoxSelectionEvent({ phase: 'end', lastInputPoint: { x: 450, y: 450 } })); + + expect(mockCommandHandler.emit).toHaveBeenCalledWith('select', { + nodeIds: [], + edgeIds: ['dualDangling'], + multiSelection: false, + }); + }); + + it('should not select a single-dangling edge whose free anchor is outside the box', () => { + const danglingEdge = { + ...mockEdge, + id: 'dangling', + source: 'node1', + target: '', + targetPosition: { x: 900, y: 900 }, + }; + mockModel.getEdges.mockReturnValue([danglingEdge]); + // The connected node IS inside the box — the free anchor is not. + mockSpatialHash.queryIds.mockReturnValue(new Set(['node1'])); + + instance.handle(getSampleBoxSelectionEvent({ phase: 'start', lastInputPoint: { x: 50, y: 50 } })); + instance.handle(getSampleBoxSelectionEvent({ phase: 'end', lastInputPoint: { x: 450, y: 450 } })); + + expect(mockCommandHandler.emit).toHaveBeenCalledWith('select', { + nodeIds: ['node1'], + edgeIds: [], + multiSelection: false, + }); + }); + + it('should select a single-dangling edge when both the node and the free anchor are inside the box', () => { + const danglingEdge = { + ...mockEdge, + id: 'dangling', + source: 'node1', + target: '', + targetPosition: { x: 400, y: 400 }, + }; + mockModel.getEdges.mockReturnValue([danglingEdge]); + mockSpatialHash.queryIds.mockReturnValue(new Set(['node1'])); + + instance.handle(getSampleBoxSelectionEvent({ phase: 'start', lastInputPoint: { x: 50, y: 50 } })); + instance.handle(getSampleBoxSelectionEvent({ phase: 'end', lastInputPoint: { x: 450, y: 450 } })); + + expect(mockCommandHandler.emit).toHaveBeenCalledWith('select', { + nodeIds: ['node1'], + edgeIds: ['dangling'], + multiSelection: false, + }); + }); + }); + describe('partialInclusion setting', () => { it('should respect partialInclusion: true', () => { mockFlowCore.config.boxSelection.partialInclusion = true; 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 eb0f2f434..bc941fd76 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 @@ -30,6 +30,14 @@ export class LinkingEventHandler extends EventHandler { throw new Error(LINKING_MISSING_TARGET_ERROR(event)); } + // A draw or relink already owns the linking state — clobbering it + // would strand its gesture (hidden edge, unbalanced started/ended + // events, orphaned document listeners). + if (this.flow.actionStateManager.isLinking()) { + console.warn('[ngDiagram] startLinking ignored: another linking or relinking gesture is in progress.'); + break; + } + this.flow.actionStateManager.linking = createLinkingState({ sourceNodeId, sourcePortId: event.portId, diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/relinking/relinking.handler.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/relinking/relinking.handler.ts index d73eb951a..17007a23d 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/relinking/relinking.handler.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/relinking/relinking.handler.ts @@ -71,14 +71,8 @@ export class RelinkingEventHandler extends EventHandler { } } - override async cancel(): Promise { - const linking = this.flow.actionStateManager.linking as InternalLinkingActionState | undefined; - // Not a relink (or already being torn down) — the linking handler owns it. - if (!linking?.relink || linking._finishing) { - return false; - } - - await this.flow.commandHandler.emit('cancelLinking'); - return true; - } + // No cancel() override: the interaction coordinator's `linking` entry covers + // relinks too — `isLinking()` is true during a relink and `cancelLinking` + // branches on `linking.relink`, so Escape reaches the right teardown through + // the LinkingEventHandler. } 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 index 9ff0ca44e..3e7970cd1 100644 --- 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 @@ -19,6 +19,8 @@ export class InteractionCoordinator { * adding one entry here. */ private readonly cancellableGestures: readonly { event: InputEventName; isActive: () => boolean }[] = [ + // The `linking` entry covers relinking too: a relink keeps `isLinking()` + // true and `cancelLinking` branches on `linking.relink`. { event: 'linking', isActive: () => this.flowCore.actionStateManager.isLinking() }, { event: 'pointerMoveSelection', isActive: () => this.flowCore.actionStateManager.isDragging() }, { event: 'resize', isActive: () => this.flowCore.actionStateManager.isResizing() }, diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/__tests__/get-source-target-positions.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/__tests__/get-source-target-positions.test.ts index bc02a6020..e3f9d3e70 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/__tests__/get-source-target-positions.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/__tests__/get-source-target-positions.test.ts @@ -63,33 +63,10 @@ vi.mock('../../../../utils', () => ({ }), })); -vi.mock('../../../../utils/compute-floating-edge-side', () => ({ - computeFloatingStartSide: vi.fn().mockImplementation((from, to) => { - // Simple mock mirroring the position-to-position angle logic - const dx = to.x - from.x; - const dy = to.y - from.y; - if (Math.abs(dx) >= Math.abs(dy)) { - return dx > 0 ? 'right' : 'left'; - } - return dy > 0 ? 'bottom' : 'top'; - }), - computeFloatingEndSide: vi.fn().mockImplementation((node, _portId, cursorPosition) => { - // Simple mock that returns different sides based on cursor position relative to a fixed point - if (!node) return 'left'; - const nodeCenter = { - x: node.position.x + 50, - y: node.position.y + 25, - }; - const dx = cursorPosition.x - nodeCenter.x; - const dy = cursorPosition.y - nodeCenter.y; - - if (Math.abs(dx) > Math.abs(dy)) { - return dx > 0 ? 'left' : 'right'; - } else { - return dy > 0 ? 'top' : 'bottom'; - } - }), -})); +// The floating-side computation runs REAL geometry (angleBetweenPoints → +// angleToSide) so the side assertions below pin the actual 45°-segment +// behavior instead of a lookalike fake. +vi.mock('../../../../utils/compute-floating-edge-side', async (importOriginal) => await importOriginal()); describe('getSourceTargetPositions', () => { it('should return source and target positions for edge with nodes', () => { @@ -339,8 +316,11 @@ describe('getSourceTargetPositions', () => { const result = getSourceTargetPositions(edge, nodesMap); - expect(result.source).toEqual({ x: 50, y: 50, side: 'right' }); - expect(result.target).toEqual({ x: 150, y: 150, side: 'left' }); + // Real geometry: the 45° diagonal falls into the bottom segment + // (angleToSide treats [45, 135) as bottom), and the reverse angle (225°) + // into the top segment — each free end faces the other. + expect(result.source).toEqual({ x: 50, y: 50, side: 'bottom' }); + expect(result.target).toEqual({ x: 150, y: 150, side: 'top' }); }); }); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/edges-routing.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/edges-routing.ts index 4ed2def56..2370eee3c 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/edges-routing.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/edges-routing.ts @@ -231,11 +231,15 @@ export const edgesRoutingMiddleware: Middleware = { ? processEdgesForRouting(edges, nodesMap, edgeRoutingManager, helpers, modelActionTypes) : []; - // A temporary edge whose source became effectively hidden mid-gesture is - // not re-routed — its geometry is stale and the render layer skips it. - const isTemporarySourceHidden = temporaryEdge ? nodesMap.get(temporaryEdge.source)?.computedHidden : false; + // A temporary edge whose anchored end became effectively hidden mid-gesture + // is not re-routed — its geometry is stale and the render layer skips it. + // The anchored end is the source for draws and target-end relinks, and the + // target while a source endpoint is being relinked. + const anchoredEndNodeId = + actionStateManager.linking?.relink?.end === 'source' ? temporaryEdge?.target : temporaryEdge?.source; + const isAnchoredEndHidden = anchoredEndNodeId ? nodesMap.get(anchoredEndNodeId)?.computedHidden : false; const newTemporaryEdge = - temporaryEdge && !isTemporarySourceHidden + temporaryEdge && !isAnchoredEndHidden ? createUpdatedTemporaryEdge(temporaryEdge, nodesMap, edgeRoutingManager, temporaryEdgeZIndex) : undefined; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/__tests__/edge-draw-ended.emitter.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/__tests__/edge-draw-ended.emitter.test.ts index cb5e92eae..c4d40945f 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/__tests__/edge-draw-ended.emitter.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/__tests__/edge-draw-ended.emitter.test.ts @@ -122,6 +122,37 @@ describe('EdgeDrawEndedEmitter', () => { }); }); + it('should report a kept dangling edge as a successful draw with no target', () => { + helpers.anyEdgesAdded.mockReturnValue(true); + + // A drop over empty canvas kept by the dangling-edges feature: the free + // end is normalized to target '' and targetPort undefined (never ''). + const keptEdge: Edge = { + ...mockEdge, + id: 'kept-edge', + source: 'source-node', + sourcePort: 'port-1', + target: '', + targetPort: undefined, + targetPosition: dropPosition, + temporary: false, + }; + context.edgesMap.set('kept-edge', keptEdge); + + emitter.emit(context, eventManager); + + expect(emitSpy).toHaveBeenCalledOnce(); + expect(emitSpy).toHaveBeenCalledWith('edgeDrawEnded', { + source: sourceNode, + sourcePort: 'port-1', + dropPosition, + success: true, + edge: keptEdge, + target: undefined, + targetPort: undefined, + }); + }); + it('should skip existing edges and only emit for newly added edge', () => { helpers.anyEdgesAdded.mockReturnValue(true); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/__tests__/selection-removed.emitter.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/__tests__/selection-removed.emitter.test.ts index 4e0554064..cf06b2fa9 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/__tests__/selection-removed.emitter.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/__tests__/selection-removed.emitter.test.ts @@ -21,6 +21,7 @@ describe('SelectionRemovedEmitter', () => { context = { modelActionType: 'deleteSelection', modelActionTypes: ['deleteSelection'], + initialUpdate: {}, initialNodesMap: new Map(), initialEdgesMap: new Map(), nodesMap: new Map(), @@ -386,8 +387,12 @@ describe('SelectionRemovedEmitter', () => { context.initialNodesMap.set('node1', deletedNode); context.initialEdgesMap.set('edge1', initialEdge); - // The edge survived the delete pass with a changed endpoint — demoted to - // dangling by detach-on-node-delete rather than deleted. + // The delete command itself issued the detach patch (built by + // partitionIncidentEdges) and the edge survived the pass — demoted to + // dangling rather than deleted. + context.initialUpdate.edgesToUpdate = [ + { id: 'edge1', source: '', sourcePort: undefined, sourcePosition: { x: 100, y: 100 } }, + ]; context.edgesMap.set('edge1', detachedEdge); emitter.emit(context, eventManager); @@ -417,6 +422,9 @@ describe('SelectionRemovedEmitter', () => { }; context.initialEdgesMap.set('edge1', initialEdge); + context.initialUpdate.edgesToUpdate = [ + { id: 'edge1', target: '', targetPort: undefined, targetPosition: { x: 200, y: 200 } }, + ]; context.edgesMap.set('edge1', detachedEdge); emitter.emit(context, eventManager); @@ -444,6 +452,51 @@ describe('SelectionRemovedEmitter', () => { expect(emitSpy).toHaveBeenCalledOnce(); expect(emitSpy.mock.calls[0][1].detachedEdges).toEqual([]); }); + + it('should not report an endpoint change the delete command did not issue as a detach', () => { + // A middleware rewrote the endpoint during the pass — the change is NOT + // part of the command's own initialUpdate, so it is not a detach. + const deletedNode: Node = { ...mockNode, id: 'node1', position: { x: 100, y: 100 } }; + const initialEdge: Edge = { + id: 'edge1', + source: 'node3', + target: 'node4', + data: {}, + }; + const rewrittenEdge: Edge = { ...initialEdge, target: 'node5' }; + + context.initialNodesMap.set('node1', deletedNode); + context.initialEdgesMap.set('edge1', initialEdge); + context.edgesMap.set('edge1', rewrittenEdge); + + emitter.emit(context, eventManager); + + expect(emitSpy).toHaveBeenCalledOnce(); + expect(emitSpy.mock.calls[0][1].detachedEdges).toEqual([]); + }); + + it('should not report an issued detach whose edge was removed during the pass', () => { + // The command issued a detach patch, but a middleware deleted the edge + // anyway — it must not be reported as a (surviving) detached edge. + const initialEdge: Edge = { + id: 'edge1', + source: 'node1', + target: 'node2', + data: {}, + }; + + context.initialEdgesMap.set('edge1', initialEdge); + context.initialUpdate.edgesToUpdate = [ + { id: 'edge1', target: '', targetPort: undefined, targetPosition: { x: 200, y: 200 } }, + ]; + // Edge absent from edgesMap — deleted rather than detached. + + emitter.emit(context, eventManager); + + expect(emitSpy).toHaveBeenCalledOnce(); + expect(emitSpy.mock.calls[0][1].detachedEdges).toEqual([]); + expect(emitSpy.mock.calls[0][1].deletedEdges).toEqual([initialEdge]); + }); }); describe('empty deletion scenarios', () => { diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/selection-removed.emitter.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/selection-removed.emitter.ts index e44329b57..ad552b823 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/selection-removed.emitter.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/selection-removed.emitter.ts @@ -21,14 +21,19 @@ export class SelectionRemovedEmitter implements EventEmitter { } const edgesToDelete: Edge[] = []; - const detachedEdges: Edge[] = []; for (const [id, edge] of initialEdgesMap) { - const currentEdge = edgesMap.get(id); - if (!currentEdge) { + if (!edgesMap.has(id)) { edgesToDelete.push(edge); - } else if (currentEdge.source !== edge.source || currentEdge.target !== edge.target) { - // An endpoint changed within the delete pass — the edge was demoted to - // dangling by detach-on-node-delete rather than deleted. + } + } + + // Detached edges are exactly the edgesToUpdate the delete command itself + // issued (built by partitionIncidentEdges) — a middleware that happens to + // rewrite an endpoint during the pass must not be misreported as a detach. + const detachedEdges: Edge[] = []; + for (const update of context.initialUpdate.edgesToUpdate ?? []) { + const currentEdge = edgesMap.get(update.id); + if (currentEdge && ('source' in update || 'target' in update)) { detachedEdges.push(currentEdge); } } diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/z-index-assignment/__tests__/z-index-assignment.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/z-index-assignment/__tests__/z-index-assignment.test.ts index 1d686a29f..78cd4dab9 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/z-index-assignment/__tests__/z-index-assignment.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/z-index-assignment/__tests__/z-index-assignment.test.ts @@ -121,6 +121,37 @@ describe('zIndexMiddleware', () => { }); }); + describe('edge endpoint change (relink / detach)', () => { + it('should recompute the edge z-index from its new endpoints when source/target change', () => { + const lowNode = { ...mockNode, id: 'low-node', computedZIndex: 5 }; + const highNode = { ...mockNode, id: 'high-node', computedZIndex: 9 }; + // The edge was relinked onto high-node; its stored z still derives from + // the old endpoint pair. + const relinkedEdge = { ...mockEdge, id: 'edge1', source: 'low-node', target: 'high-node', computedZIndex: 5 }; + nodesMap.set('low-node', lowNode); + nodesMap.set('high-node', highNode); + edgesMap.set('edge1', relinkedEdge); + context.state.nodes = [lowNode, highNode]; + context.state.edges = [relinkedEdge]; + + (helpers.anyNodesAdded as ReturnType).mockReturnValue(false); + (helpers.checkIfAnyNodePropsChanged as ReturnType).mockReturnValue(false); + (helpers.checkIfAnyEdgePropsChanged as ReturnType).mockImplementation((props: string[]) => + props.includes('source') + ); + (helpers.getAffectedEdgeIds as ReturnType).mockImplementation((props: string[]) => + props.includes('source') ? ['edge1'] : [] + ); + + executeMiddleware(); + + expect(helpers.checkIfAnyEdgePropsChanged).toHaveBeenCalledWith(['source', 'target']); + expect(nextMock).toHaveBeenCalledWith({ + edgesToUpdate: [{ id: 'edge1', computedZIndex: 9 }], + }); + }); + }); + describe('node selection z-index assignment', () => { it('should assign selectedZIndex to newly selected nodes', () => { const node1 = { ...mockNode, id: 'node1', selected: true, computedZIndex: 0 }; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/model-lookup/model-lookup.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/model-lookup/model-lookup.test.ts index dc8a1aa85..d43ab1ae4 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/model-lookup/model-lookup.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/model-lookup/model-lookup.test.ts @@ -123,6 +123,61 @@ describe('ModelLookup', () => { }); }); + describe('danglingEdges', () => { + const danglingEdge: Edge = { + id: 'dangling', + source: 'node1', + target: '', + targetPosition: { x: 10, y: 20 }, + data: {}, + }; + const dualDanglingEdge: Edge = { + id: 'dual', + source: '', + sourcePosition: { x: 0, y: 0 }, + target: '', + targetPosition: { x: 5, y: 5 }, + data: {}, + }; + + it('should reflect only the edges with at least one free endpoint', () => { + (mockFlowCore.getState as ReturnType).mockReturnValue({ + nodes: mockNodes, + edges: [...mockEdges, danglingEdge, dualDanglingEdge], + metadata: {}, + }); + + expect(modelLookup.danglingEdges.map((edge) => edge.id)).toEqual(['dangling', 'dual']); + }); + + it('should cache the list and recompute it only after desynchronize()', () => { + (mockFlowCore.getState as ReturnType).mockReturnValue({ + nodes: mockNodes, + edges: [...mockEdges, danglingEdge], + metadata: {}, + }); + + const first = modelLookup.danglingEdges; + // Same reference on a second read — no re-filter of all edges. + expect(modelLookup.danglingEdges).toBe(first); + expect((mockFlowCore.getState as ReturnType).mock.calls.length).toBe(1); + + // The state changed, but without desynchronize the cache still stands. + (mockFlowCore.getState as ReturnType).mockReturnValue({ + nodes: mockNodes, + edges: [...mockEdges, danglingEdge, dualDanglingEdge], + metadata: {}, + }); + expect(modelLookup.danglingEdges).toBe(first); + + modelLookup.desynchronize(); + + const recomputed = modelLookup.danglingEdges; + expect(recomputed).not.toBe(first); + expect(recomputed.map((edge) => edge.id)).toEqual(['dangling', 'dual']); + }); + }); + describe('getNodeById', () => { it('should return node by id', () => { const node = modelLookup.getNodeById('node1'); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/model-lookup/model-lookup.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/model-lookup/model-lookup.ts index 3c424bd6d..a1df72dd8 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/model-lookup/model-lookup.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/model-lookup/model-lookup.ts @@ -31,6 +31,7 @@ export class ModelLookup { private _directChildrenMap = { map: new Map(), synchronized: false }; private _descendantsCache = { map: new Map(), synchronized: false }; // Cache for all descendants private _connectedEdgesMap = { map: new Map(), synchronized: false }; + private _danglingEdges = { edges: [] as Edge[], synchronized: false }; constructor(private readonly flowCore: FlowCore) {} @@ -43,6 +44,7 @@ export class ModelLookup { this._directChildrenMap.synchronized = false; this._descendantsCache.synchronized = false; this._connectedEdgesMap.synchronized = false; + this._danglingEdges.synchronized = false; } /** @@ -101,6 +103,23 @@ export class ModelLookup { return this._connectedEdgesMap.map; } + /** + * Committed edges with at least one free (unconnected) endpoint, rebuilt + * lazily after model changes like {@link connectedEdgesMap}. Keeps the + * per-frame consumers (virtualization, endpoint-snap queries) O(dangling) + * instead of O(all edges). + * @returns Dangling edges array + */ + get danglingEdges(): Edge[] { + if (!this._danglingEdges.synchronized) { + this._danglingEdges = { + edges: this.flowCore.getState().edges.filter((edge) => !edge.source || !edge.target), + synchronized: true, + }; + } + return this._danglingEdges.edges; + } + /** * Gets the descendants cache if it is not synchronized synchronize it * @returns Descendants cache diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/base-render-strategy.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/base-render-strategy.ts index ed3c7af2e..aa47036b2 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/base-render-strategy.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/base-render-strategy.ts @@ -30,10 +30,12 @@ export abstract class BaseRenderStrategy implements RenderStrategy { const visibleEdges = relinkedEdgeId ? processedEdges.filter((edge) => edge.id !== relinkedEdgeId) : processedEdges; // The temporary edge lives in action state, so hidden-computation never - // stamps it — check its source here, or hiding the source mid-gesture - // leaves a rubber band dangling from nothing. + // stamps it — check its anchored end here, or hiding that node mid-gesture + // leaves a rubber band dangling from nothing. During a target-end drag the + // anchored end is the source; during a source-end relink it is the target. + const anchoredEndNodeId = linking?.relink?.end === 'source' ? temporaryEdge?.target : temporaryEdge?.source; const isTemporaryEdgeVisible = - temporaryEdge?.temporary && !this.flowCore.getNodeById(temporaryEdge.source)?.computedHidden; + temporaryEdge?.temporary && !(anchoredEndNodeId && this.flowCore.getNodeById(anchoredEndNodeId)?.computedHidden); const finalEdges = isTemporaryEdgeVisible ? [...visibleEdges, temporaryEdge] : visibleEdges; this.performanceLogger.withPerformanceLogging( diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/virtualized-render-strategy.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/virtualized-render-strategy.test.ts index e1214a36a..e1b10db36 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/virtualized-render-strategy.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/virtualized-render-strategy.test.ts @@ -38,6 +38,7 @@ describe('VirtualizedRenderStrategy', () => { spatialHash, modelLookup: { nodesMap, + danglingEdges: [], getNodeById: vi.fn((id: string) => nodesMap.get(id)), getConnectedEdges: vi.fn().mockReturnValue([]), getAllDescendantIds: vi.fn().mockReturnValue([]), @@ -183,6 +184,99 @@ describe('VirtualizedRenderStrategy', () => { }); }); + describe('dangling edges', () => { + const setDanglingEdges = (danglingEdges: Edge[]) => { + (mockFlowCore.modelLookup as unknown as { danglingEdges: Edge[] }).danglingEdges = danglingEdges; + }; + + it('should render a dual dangling edge with anchors inside the viewport even with no visible nodes', () => { + const dualDangling: Edge = { + ...mockEdge, + id: 'dual', + source: '', + sourcePosition: { x: 100, y: 100 }, + target: '', + targetPosition: { x: 300, y: 200 }, + points: undefined, + }; + setDanglingEdges([dualDangling]); + spatialHash.process([]); + updateNodesMap([]); + + const result = strategy.process([], [dualDangling], defaultViewport); + + expect(result.nodes).toEqual([]); + expect(result.edges.map((e) => e.id)).toContain('dual'); + }); + + it('should render a single-dangling edge and add its off-screen connected node as external', () => { + const externalNode: Node = { + ...mockNode, + id: 'far-node', + position: { x: 2000, y: 2000 }, + size: { width: 50, height: 50 }, + }; + const singleDangling: Edge = { + ...mockEdge, + id: 'single', + source: 'far-node', + target: '', + targetPosition: { x: 150, y: 150 }, + points: undefined, + }; + setDanglingEdges([singleDangling]); + spatialHash.process([externalNode]); + updateNodesMap([externalNode]); + + const result = strategy.process([externalNode], [singleDangling], defaultViewport); + + expect(result.edges.map((e) => e.id)).toContain('single'); + // The connected endpoint renders like the external endpoint of a + // node-discovered edge, so the edge is not drawn into nothing. + expect(result.nodes.map((n) => n.id)).toContain('far-node'); + }); + + it('should skip a computedHidden dangling edge', () => { + const hiddenDangling: Edge = { + ...mockEdge, + id: 'hidden-dangling', + source: '', + sourcePosition: { x: 100, y: 100 }, + target: '', + targetPosition: { x: 300, y: 200 }, + points: undefined, + computedHidden: true, + }; + setDanglingEdges([hiddenDangling]); + spatialHash.process([]); + updateNodesMap([]); + + const result = strategy.process([], [hiddenDangling], defaultViewport); + + expect(result.edges).toEqual([]); + }); + + it('should not render a dangling edge whose anchors are far outside the viewport', () => { + const farDangling: Edge = { + ...mockEdge, + id: 'far-dangling', + source: '', + sourcePosition: { x: 5000, y: 5000 }, + target: '', + targetPosition: { x: 5300, y: 5200 }, + points: undefined, + }; + setDanglingEdges([farDangling]); + spatialHash.process([]); + updateNodesMap([]); + + const result = strategy.process([], [farDangling], defaultViewport); + + expect(result.edges).toEqual([]); + expect(result.nodes).toEqual([]); + }); + }); + describe('group node handling', () => { it('should include descendants when group node is visible', () => { const groupNode = { ...mockGroupNode, id: 'g1', position: { x: 100, y: 100 }, size: { width: 200, height: 200 } }; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/virtualized-render-strategy.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/virtualized-render-strategy.ts index b7ebaa9f9..cea94fd02 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/virtualized-render-strategy.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/virtualized-render-strategy.ts @@ -100,7 +100,7 @@ export class VirtualizedRenderStrategy extends BaseRenderStrategy { return this.cache.get(nodes, edges); } - const result = this.visibleElementsResolver.resolve(viewportRect, edges); + const result = this.visibleElementsResolver.resolve(viewportRect); this.cache.set(result, nodes, edges, viewportRect); return result; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/visible-elements-resolver.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/visible-elements-resolver.ts index 521ccf546..2331b365c 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/visible-elements-resolver.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/visible-elements-resolver.ts @@ -10,10 +10,10 @@ import type { RenderStrategyResult } from '../render-strategy.interface'; export class VisibleElementsResolver { constructor(private readonly flowCore: FlowCore) {} - resolve(viewportRect: Rect, allEdges: Edge[]): RenderStrategyResult { + resolve(viewportRect: Rect): RenderStrategyResult { const primaryVisibleIds = this.getPrimaryVisibleIds(viewportRect); const { edges, edgeIds, externalNodeIds } = this.collectVisibleEdges(primaryVisibleIds); - this.collectVisibleDanglingEdges(viewportRect, allEdges, primaryVisibleIds, edges, edgeIds, externalNodeIds); + this.collectVisibleDanglingEdges(viewportRect, primaryVisibleIds, edges, edgeIds, externalNodeIds); const { nodes, nodeIds } = this.buildNodeList(primaryVisibleIds, externalNodeIds); return { nodes, edges, nodeIds, edgeIds }; @@ -28,19 +28,18 @@ export class VisibleElementsResolver { */ private collectVisibleDanglingEdges( viewportRect: Rect, - allEdges: Edge[], primaryVisibleIds: Set, edges: Edge[], edgeIds: Set, externalNodeIds: Set ): void { - for (const edge of allEdges) { + // The model lookup keeps a cached list of dangling edges, so this stays + // O(dangling) — the virtualization guarantee (render cost independent of + // model size) holds when the feature is unused (the list is empty). + for (const edge of this.flowCore.modelLookup.danglingEdges) { if (edgeIds.has(edge.id) || edge.computedHidden) { continue; } - if (edge.source && edge.target) { - continue; - } if (!this.intersectsViewport(edge, viewportRect)) { continue; } 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 38d092338..c5cbf0075 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 @@ -57,7 +57,12 @@ export interface LinkingActionState { * @since 1.4.0 */ relink?: LinkingRelinkContext; - /** Reason the relink gesture ended without changing the edge (set by finishRelinking on failure paths). */ + /** + * Reason the relink gesture ended without changing the edge (set by + * finishRelinking on failure paths). + * + * @since 1.4.0 + */ relinkCancelReason?: EdgeRelinkCancelReason; } diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts index e32cc9299..3e52ecb75 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts @@ -50,10 +50,20 @@ export interface LinkingConfig { portSnapDistance: number; /** * Validates whether a connection between two nodes and ports is allowed. + * + * Called for every connection-producing operation: drawing a new edge, + * relinking an existing edge's endpoint, and `attachEdge`. The optional + * `context` says which operation is being validated (since 1.4.0). + * + * `source` can be `null` for draws that start from a position instead of a + * node (`startLinkingFromPosition`) and when relinking the free end of a + * dangling edge. + * * @param source The source node. * @param sourcePort The source port. * @param target The target node. * @param targetPort The target port. + * @param context The operation asking for validation (`draw` when omitted). * @returns True if the connection is valid, false otherwise. * @default () => true */ @@ -61,7 +71,8 @@ export interface LinkingConfig { source: Node | null, sourcePort: Port | null, target: Node | null, - targetPort: Port | null + targetPort: Port | null, + context?: ConnectionValidationContext ) => boolean; /** * Allows customization of the temporary edge object shown while the user is dragging to create a new edge. @@ -144,9 +155,15 @@ export interface DanglingEdgesConfig { /** * When true, edges connected to a deleted node are detached into dangling * edges — anchored where their port was — instead of being deleted. - * Requires `enabled` to be true. An edge that is itself part of the deleted - * selection is always deleted. An edge losing both endpoints in one delete - * becomes a dual dangling edge. + * Requires `enabled` to be true. + * + * An edge is still deleted, never detached, when: + * - it is itself part of the deleted selection (an explicit delete wins), + * - it or the lost endpoint's node is effectively hidden (detaching would + * materialize invisible wiring as visible dangling edges), or + * - it loses BOTH endpoints in the same delete — it becomes a dual dangling + * edge only when {@link shouldDetachOnNodeDelete} is provided and returns + * true for both ends. * @default false */ detachOnNodeDelete: boolean; @@ -154,8 +171,10 @@ export interface DanglingEdgesConfig { * Per-edge decision whether a given endpoint is detached (kept dangling) or * deleted along with the node. Called only when `enabled` and * `detachOnNodeDelete` are true, once per endpoint losing its node. - * Returning false deletes the edge. - * @default undefined (detach every edge) + * Returning false deletes the edge. For an edge losing both endpoints at + * once, the edge survives as a dual dangling edge only when this callback + * is provided and returns true for both ends. + * @default undefined (detach every edge, except edges losing both ends) */ shouldDetachOnNodeDelete?: (edge: Edge, deletedNode: Node, end: EdgeEnd) => boolean; } @@ -174,17 +193,34 @@ export interface EdgeRelinkingConfig { * endpoint handles; dragging one previews the reconnection live and commits * it on drop. Dropping on empty canvas leaves the endpoint dangling when * `danglingEdges.enabled` is true, otherwise the relink is reverted. + * + * Connections made by relinking are validated through + * `linking.validateConnection`, which receives a context with + * `reason: 'relink'` and the edge being relinked. * @default false */ enabled: boolean; - /** - * Validates a relink drop. Receives the edge being relinked, which endpoint - * is dragged, and the candidate node/port under the pointer. - * When not provided, `linking.validateConnection` is used with the edge's - * endpoints in their proper roles. - * @default undefined (falls back to linking.validateConnection) - */ - validateRelink?: (edge: Edge, end: EdgeEnd, targetNode: Node | null, targetPort: Port | null) => boolean; +} + +/** + * Context passed to {@link LinkingConfig.validateConnection} describing the + * operation that asks for validation. + * + * - `draw` — a new edge is being drawn (gesture or `startLinking*`). + * - `relink` — an endpoint of `edge` is being dragged to a new target. + * - `attach` — `NgDiagramModelService.attachEdge` connects an endpoint of `edge`. + * + * @public + * @since 1.4.0 + * @category Types/Configuration/Features + */ +export interface ConnectionValidationContext { + /** The operation being validated. */ + reason: 'draw' | 'relink' | 'attach'; + /** The existing edge whose endpoint is being connected (relink and attach only). */ + edge?: Edge; + /** Which endpoint of `edge` is being connected (relink and attach only). */ + end?: EdgeEnd; } /** diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/__tests__/dangling-edges.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/__tests__/dangling-edges.test.ts index c9d593b76..55e7d697a 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/__tests__/dangling-edges.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/__tests__/dangling-edges.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { mockEdge } from '../../test-utils'; import type { Edge } from '../../types'; import { + alignManualPointsPatch, getDanglingEndpoints, getNearestDanglingEndpointInRange, hasFreeEndpoint, @@ -127,4 +128,62 @@ describe('dangling-edges utils', () => { expect(getNearestDanglingEndpointInRange([connectedEdge], { x: 0, y: 0 }, 1000)).toBeNull(); }); }); + + describe('alignManualPointsPatch', () => { + const manualEdge: Edge = { + ...mockEdge, + id: 'manual', + routingMode: 'manual', + points: [ + { x: 10, y: 20 }, + { x: 50, y: 60 }, + { x: 110, y: 120 }, + ], + }; + const anchor = { x: 300, y: 400 }; + + it('should return an empty patch for a non-manual edge', () => { + expect(alignManualPointsPatch({ ...manualEdge, routingMode: undefined }, 'target', anchor)).toEqual({}); + expect(alignManualPointsPatch({ ...manualEdge, routingMode: 'auto' }, 'target', anchor)).toEqual({}); + }); + + it('should return an empty patch for a manual edge without points', () => { + expect(alignManualPointsPatch({ ...manualEdge, points: undefined }, 'target', anchor)).toEqual({}); + expect(alignManualPointsPatch({ ...manualEdge, points: [] }, 'target', anchor)).toEqual({}); + }); + + it('should move the last point to the anchor for the target end', () => { + const patch = alignManualPointsPatch(manualEdge, 'target', anchor); + + expect(patch).toEqual({ + points: [ + { x: 10, y: 20 }, + { x: 50, y: 60 }, + { x: 300, y: 400 }, + ], + }); + // The stored edge is not mutated — the patch owns fresh point objects. + expect(manualEdge.points![2]).toEqual({ x: 110, y: 120 }); + }); + + it('should move the first point to the anchor for the source end', () => { + const patch = alignManualPointsPatch(manualEdge, 'source', anchor); + + expect(patch).toEqual({ + points: [ + { x: 300, y: 400 }, + { x: 50, y: 60 }, + { x: 110, y: 120 }, + ], + }); + expect(manualEdge.points![0]).toEqual({ x: 10, y: 20 }); + }); + + it('should move the only point of a single-point edge for either end', () => { + const singlePointEdge: Edge = { ...manualEdge, points: [{ x: 1, y: 2 }] }; + + expect(alignManualPointsPatch(singlePointEdge, 'source', anchor)).toEqual({ points: [{ x: 300, y: 400 }] }); + expect(alignManualPointsPatch(singlePointEdge, 'target', anchor)).toEqual({ points: [{ x: 300, y: 400 }] }); + }); + }); }); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/dangling-edges.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/dangling-edges.ts index 11c6d5095..00b2509b2 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/dangling-edges.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/dangling-edges.ts @@ -138,16 +138,39 @@ export const getNearestDanglingEndpointInRange = ( point: Point, range: number ): DanglingEndpoint | null => { - let best: DanglingEndpoint | null = null; + // Single allocation-free scan (this runs in pointermove handlers): only the + // winning endpoint materializes an object. + let bestEdge: Edge | null = null; + let bestEnd: EdgeEnd = 'source'; + let bestPosition: Point | null = null; let bestDistSq = range * range; - for (const endpoint of getDanglingEndpoints(edges)) { - const dx = endpoint.position.x - point.x; - const dy = endpoint.position.y - point.y; + + const consider = (edge: Edge, end: EdgeEnd, position: Point | undefined) => { + if (!position) { + return; + } + const dx = position.x - point.x; + const dy = position.y - point.y; const distSq = dx * dx + dy * dy; if (distSq <= bestDistSq) { bestDistSq = distSq; - best = endpoint; + bestEdge = edge; + bestEnd = end; + bestPosition = position; + } + }; + + for (const edge of edges) { + if (edge.temporary) { + continue; + } + if (!edge.source) { + consider(edge, 'source', edge.sourcePosition); + } + if (!edge.target) { + consider(edge, 'target', edge.targetPosition); } } - return best; + + return bestEdge && bestPosition ? { edge: bestEdge, end: bestEnd, position: bestPosition } : null; }; diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/diagram/ng-diagram.component.scss b/packages/ng-diagram/projects/ng-diagram/src/lib/components/diagram/ng-diagram.component.scss index b6b20ecdd..7aa8e3825 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/components/diagram/ng-diagram.component.scss +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/diagram/ng-diagram.component.scss @@ -22,6 +22,13 @@ } } + // While an edge endpoint is being dragged the handle itself is unmounted + // (the original edge is hidden), so the grabbing cursor is held at the host. + &.relinking, + &.relinking * { + cursor: grabbing; + } + .diagram-background-container { width: 100%; height: 100%; diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/diagram/ng-diagram.component.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/components/diagram/ng-diagram.component.ts index e567fff28..382bc1278 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/components/diagram/ng-diagram.component.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/diagram/ng-diagram.component.ts @@ -11,6 +11,7 @@ import { OnDestroy, OnInit, Output, + signal, untracked, } from '@angular/core'; import { Edge, Node } from '../../../core/src'; @@ -44,6 +45,7 @@ import type { import { MobileBoxSelectionDirective } from '../../../public-api'; import { DiagramSelectionDirective } from '../../directives'; +import { RelinkingGestureService } from '../../services/input-events/relinking-gesture.service'; import { CursorPositionTrackerDirective } from '../../directives/cursor-position-tracker/cursor-position-tracker.directive'; import { BoxSelectionDirective } from '../../directives/input-events/box-selection/box-selection.directive'; import { KeyboardInputsDirective } from '../../directives/input-events/keyboard-inputs/keyboard-inputs.directive'; @@ -112,6 +114,7 @@ import { NgDiagramWatermarkComponent } from '../watermark/watermark.component'; ], host: { '[class.pannable]': 'viewportPannable()', + '[class.relinking]': 'relinkingActive()', '[attr.tabindex]': `tabbable() ? '0' : '-1'`, }, }) @@ -164,6 +167,10 @@ export class NgDiagramComponent implements OnInit, OnDestroy { /** Whether panning is enabled in the diagram. */ readonly viewportPannable = this.renderer.viewportPannable; + /** Whether an edge endpoint is being dragged — holds the grabbing cursor at the host. */ + protected readonly relinkingActive = + inject(RelinkingGestureService, { optional: true })?.active ?? signal(false).asReadonly(); + /** * Whether the diagram container takes part in the page's sequential Tab order. * diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge.component.html b/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge.component.html index 83766c214..0205bda3f 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge.component.html +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge.component.html @@ -23,6 +23,27 @@ @if (relinkHandlesVisible()) { + + + } diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge.component.scss b/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge.component.scss index 8136b8964..cce228a07 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge.component.scss +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge.component.scss @@ -13,14 +13,35 @@ svg { transition: var(--edge-stroke-transition, none); } + // Theme through --ngd-relink-handle-* (declared in styles.css, palette + // defaults); override per edge through --edge-relink-handle-* like + // --edge-stroke. .ng-diagram-edge__relink-handle { pointer-events: all; cursor: grab; touch-action: none; - r: var(--edge-relink-handle-radius, 5px); - fill: var(--edge-relink-handle-fill, var(--ngd-default-edge-relink-handle-fill, #fff)); - stroke: var(--edge-relink-handle-stroke, var(--ngd-default-edge-relink-handle-stroke, currentColor)); - stroke-width: var(--edge-relink-handle-stroke-width, 1.5); + r: calc(var(--edge-relink-handle-size, var(--ngd-relink-handle-size, 0.625rem)) / 2); + fill: var(--edge-relink-handle-fill, var(--ngd-relink-handle-fill, #fff)); + stroke: var(--edge-relink-handle-stroke, var(--ngd-relink-handle-stroke, currentColor)); + stroke-width: var(--edge-relink-handle-stroke-width, var(--ngd-relink-handle-stroke-width, 1.5px)); + + &:hover { + fill: var(--edge-relink-handle-fill-hover, var(--ngd-relink-handle-fill-hover, currentColor)); + } + + &:active { + cursor: grabbing; + } + } + + // Enlarged invisible grab area for the handles (touch, low zoom). Hovering + // it highlights the visible circle drawn on top. + .ng-diagram-edge__relink-handle-hit { + pointer-events: all; + cursor: grab; + touch-action: none; + fill: transparent; + stroke: none; &:active { cursor: grabbing; diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge.component.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge.component.ts index 904b63536..def5bd865 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge.component.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge.component.ts @@ -5,6 +5,7 @@ import { EdgeSelectionDirective, InlineMarkersDirective, ZIndexDirective } from import { RelinkHandleDirective } from '../../../directives/input-events/relinking/relinking.directive'; import { FlowCoreProviderService } from '../../../services'; import { MarkerRegistryService } from '../../../services/marker-registry/marker-registry.service'; +import { RendererService } from '../../../services/renderer/renderer.service'; import { NgDiagramService } from '../../../public-services/ng-diagram.service'; const INVALID_EDGE_COORDINATES_ERROR = ( @@ -23,6 +24,9 @@ Edge details: Documentation: https://www.ngdiagram.dev/docs/guides/edges/edges/ `; +/** Screen-pixel radius of the relink handles' invisible hit area. */ +const RELINK_HANDLE_HIT_RADIUS_PX = 12; + /** * Base edge component that handles edge rendering. * It can be extended or used directly to render edges in the diagram. @@ -51,7 +55,10 @@ Documentation: https://www.ngdiagram.dev/docs/guides/edges/edges/ export class NgDiagramBaseEdgeComponent { private readonly flowCoreProvider = inject(FlowCoreProviderService); private readonly markerRegistry = inject(MarkerRegistryService); - private readonly diagramService = inject(NgDiagramService); + // Optional so consumer TestBeds that mount the component without + // provideNgDiagram() keep working — without it the relink handles stay off. + private readonly diagramService = inject(NgDiagramService, { optional: true }); + private readonly renderer = inject(RendererService, { optional: true }); /** * Whether to use inline markers (Safari fallback). @@ -171,18 +178,44 @@ export class NgDiagramBaseEdgeComponent { /** * Endpoint handles for the relinking gesture — rendered on selected, * committed edges when `edgeRelinking.enabled` is true. + * + * @since 1.4.0 */ readonly relinkHandlesVisible = computed( () => - (this.diagramService.config().edgeRelinking?.enabled ?? false) && + (this.diagramService?.config().edgeRelinking?.enabled ?? false) && !!this.selected() && !this.temporary() && this.points().length > 0 ); + /** + * Position of the source endpoint handle (the first routed point). + * + * @since 1.4.0 + */ readonly relinkSourceHandle = computed(() => this.points()[0]); + + /** + * Position of the target endpoint handle (the last routed point). + * + * @since 1.4.0 + */ readonly relinkTargetHandle = computed(() => this.points()[this.points().length - 1]); + /** + * Radius of the handles' invisible hit circle, in flow units. Kept at + * roughly a finger-friendly constant size on screen by dividing by the + * viewport scale — at zoom 0.5 the visible 5px circle alone would leave a + * 2.5px touch target. + * + * @since 1.4.0 + */ + readonly relinkHandleHitRadius = computed(() => { + const scale = this.renderer?.viewport().scale || 1; + return RELINK_HANDLE_HIT_RADIUS_PX / scale; + }); + readonly class = computed(() => { const classArray = ['ng-diagram-edge__path']; diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/relinking/relinking.directive.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/relinking/relinking.directive.ts index caf805ca4..9cd622efb 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/relinking/relinking.directive.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/relinking/relinking.directive.ts @@ -7,8 +7,15 @@ import { PointerInputEvent } from '../../../types'; /** * Turns its host element into a grabbable edge-endpoint handle: a pointerdown * starts the relink gesture for the given end of the given edge. The gesture - * itself is driven by {@link RelinkingGestureService} at document level — the - * host element unmounts when the edge is hidden during the drag. + * itself is driven at document level — the host element unmounts when the + * edge is hidden during the drag. + * + * `ng-diagram-base-edge` renders its own handles with this directive; use it + * directly in fully custom edge templates that do not compose the base edge. + * + * @public + * @since 1.4.0 + * @category Directives */ @Directive({ selector: '[ngDiagramRelinkHandle]', diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/public-services/ng-diagram-model.service.dangling.spec.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/public-services/ng-diagram-model.service.dangling.spec.ts new file mode 100644 index 000000000..b471c6b08 --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/public-services/ng-diagram-model.service.dangling.spec.ts @@ -0,0 +1,309 @@ +import { TestBed } from '@angular/core/testing'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Edge, Node } from '../../core/src'; +import { FlowCoreProviderService } from '../services/flow-core-provider/flow-core-provider.service'; +import { NgDiagramModelService } from './ng-diagram-model.service'; +import { NgDiagramService } from './ng-diagram.service'; + +describe('NgDiagramModelService dangling edges (attachEdge / detachEdge)', () => { + let service: NgDiagramModelService; + let mockEmit: ReturnType; + let getEdgeById: ReturnType; + let getNodeById: ReturnType; + let validateConnection: ReturnType; + let danglingEdgesConfig: { enabled: boolean }; + + const sourceNode: Node = { + id: 'node-s', + position: { x: 0, y: 0 }, + data: {}, + measuredPorts: [ + { + id: 'out', + type: 'source', + side: 'right', + position: { x: 90, y: 45 }, + size: { width: 10, height: 10 }, + nodeId: 'node-s', + }, + ], + }; + + // Port 'in': node position (200, 100) + port position (0, 20), side left + // → flow position (200, 125) with the 10x10 port size. + const targetNode: Node = { + id: 'node-t', + position: { x: 200, y: 100 }, + data: {}, + measuredPorts: [ + { + id: 'in', + type: 'target', + side: 'left', + position: { x: 0, y: 20 }, + size: { width: 10, height: 10 }, + nodeId: 'node-t', + }, + ], + }; + + const danglingEdge: Edge = { + id: 'edge-1', + source: 'node-s', + sourcePort: 'out', + target: '', + targetPort: undefined, + targetPosition: { x: 400, y: 500 }, + data: {}, + }; + + const nodes: Record = { 'node-s': sourceNode, 'node-t': targetNode }; + + beforeEach(() => { + mockEmit = vi.fn().mockResolvedValue(undefined); + getEdgeById = vi.fn().mockReturnValue(danglingEdge); + getNodeById = vi.fn((id: string) => nodes[id] ?? null); + validateConnection = vi.fn().mockReturnValue(true); + danglingEdgesConfig = { enabled: true }; + + const mockFlowCore = { + commandHandler: { emit: mockEmit }, + transactionManager: { isActive: vi.fn().mockReturnValue(false) }, + getEdgeById, + getNodeById, + get config() { + return { + danglingEdges: danglingEdgesConfig, + linking: { validateConnection }, + }; + }, + }; + + TestBed.configureTestingModule({ + providers: [ + NgDiagramModelService, + { provide: NgDiagramService, useValue: { isInitialized: () => false } }, + { + provide: FlowCoreProviderService, + useValue: { provide: () => mockFlowCore, isInitialized: () => false }, + }, + ], + }); + + service = TestBed.inject(NgDiagramModelService); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('attachEdge', () => { + it('should return false without updating when the port does not exist on the node', async () => { + const result = await service.attachEdge('edge-1', 'target', 'node-t', 'bogus-port'); + + expect(result).toBe(false); + expect(mockEmit).not.toHaveBeenCalled(); + expect(validateConnection).not.toHaveBeenCalled(); + }); + + it('should return false without updating when the node is effectively hidden', async () => { + getNodeById.mockImplementation((id: string) => + id === 'node-t' ? { ...targetNode, computedHidden: true } : (nodes[id] ?? null) + ); + + const result = await service.attachEdge('edge-1', 'target', 'node-t', 'in'); + + expect(result).toBe(false); + expect(mockEmit).not.toHaveBeenCalled(); + }); + + it('should return false when validateConnection rejects, passing the attach context', async () => { + validateConnection.mockReturnValue(false); + + const result = await service.attachEdge('edge-1', 'target', 'node-t', 'in'); + + expect(result).toBe(false); + expect(validateConnection).toHaveBeenCalledWith( + sourceNode, + sourceNode.measuredPorts![0], + targetNode, + targetNode.measuredPorts![0], + { + reason: 'attach', + edge: danglingEdge, + end: 'target', + } + ); + expect(mockEmit).not.toHaveBeenCalled(); + }); + + it('should attach a valid port and update the edge', async () => { + const result = await service.attachEdge('edge-1', 'target', 'node-t', 'in'); + + expect(result).toBe(true); + expect(mockEmit).toHaveBeenCalledWith('updateEdge', { + id: 'edge-1', + edgeChanges: { target: 'node-t', targetPort: 'in', targetPosition: undefined }, + }); + }); + + it('should align the manual points of an attached manual-routing edge to the port anchor', async () => { + const manualEdge: Edge = { + ...danglingEdge, + routingMode: 'manual', + points: [ + { x: 10, y: 20 }, + { x: 400, y: 500 }, + ], + }; + getEdgeById.mockReturnValue(manualEdge); + + const result = await service.attachEdge('edge-1', 'target', 'node-t', 'in'); + + expect(result).toBe(true); + expect(mockEmit).toHaveBeenCalledWith('updateEdge', { + id: 'edge-1', + edgeChanges: { + target: 'node-t', + targetPort: 'in', + targetPosition: undefined, + points: [ + { x: 10, y: 20 }, + { x: 200, y: 125 }, + ], + }, + }); + }); + + it('should return false when the edge does not exist', async () => { + getEdgeById.mockReturnValue(null); + + const result = await service.attachEdge('missing', 'target', 'node-t', 'in'); + + expect(result).toBe(false); + expect(mockEmit).not.toHaveBeenCalled(); + }); + }); + + describe('detachEdge', () => { + const connectedEdge: Edge = { + id: 'edge-2', + source: 'node-s', + target: 'node-t', + targetPort: 'in', + data: {}, + }; + + it('should warn and no-op when dangling edges are disabled', async () => { + danglingEdgesConfig.enabled = false; + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + getEdgeById.mockReturnValue(connectedEdge); + + await service.detachEdge('edge-2', 'target'); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('detachEdge ignored')); + expect(mockEmit).not.toHaveBeenCalled(); + }); + + it('should anchor the freed endpoint at the port position when the edge was connected to a port', async () => { + getEdgeById.mockReturnValue(connectedEdge); + + await service.detachEdge('edge-2', 'target'); + + expect(mockEmit).toHaveBeenCalledWith('updateEdge', { + id: 'edge-2', + edgeChanges: { target: '', targetPort: undefined, targetPosition: { x: 200, y: 125 } }, + }); + }); + + it('should fall back to the routed endpoint when there is no port', async () => { + getEdgeById.mockReturnValue({ + ...connectedEdge, + targetPort: undefined, + points: [ + { x: 1, y: 2 }, + { x: 300, y: 400 }, + ], + }); + + await service.detachEdge('edge-2', 'target'); + + expect(mockEmit).toHaveBeenCalledWith('updateEdge', { + id: 'edge-2', + edgeChanges: { target: '', targetPort: undefined, targetPosition: { x: 300, y: 400 } }, + }); + }); + + it('should fall back to the node center when there is no port and no routed points', async () => { + getEdgeById.mockReturnValue({ ...connectedEdge, targetPort: undefined, points: undefined }); + getNodeById.mockImplementation((id: string) => + id === 'node-t' ? { ...targetNode, size: { width: 80, height: 40 } } : (nodes[id] ?? null) + ); + + await service.detachEdge('edge-2', 'target'); + + expect(mockEmit).toHaveBeenCalledWith('updateEdge', { + id: 'edge-2', + edgeChanges: { target: '', targetPort: undefined, targetPosition: { x: 240, y: 120 } }, + }); + }); + + it('should use the explicit position and align manual points', async () => { + getEdgeById.mockReturnValue({ + ...connectedEdge, + routingMode: 'manual', + points: [ + { x: 1, y: 2 }, + { x: 300, y: 400 }, + ], + }); + + await service.detachEdge('edge-2', 'target', { x: 600, y: 700 }); + + expect(mockEmit).toHaveBeenCalledWith('updateEdge', { + id: 'edge-2', + edgeChanges: { + target: '', + targetPort: undefined, + targetPosition: { x: 600, y: 700 }, + points: [ + { x: 1, y: 2 }, + { x: 600, y: 700 }, + ], + }, + }); + }); + + it('should apply the manual points patch when moving an already-free endpoint', async () => { + // The end is already dangling — only the anchor moves, and the stored + // manual path must follow it in this branch too. + getEdgeById.mockReturnValue({ + ...danglingEdge, + routingMode: 'manual', + points: [ + { x: 10, y: 20 }, + { x: 400, y: 500 }, + ], + }); + + await service.detachEdge('edge-1', 'target', { x: 42, y: 43 }); + + expect(mockEmit).toHaveBeenCalledWith('updateEdge', { + id: 'edge-1', + edgeChanges: { + targetPosition: { x: 42, y: 43 }, + points: [ + { x: 10, y: 20 }, + { x: 42, y: 43 }, + ], + }, + }); + }); + + it('should no-op on an already-free endpoint when no position is given', async () => { + await service.detachEdge('edge-1', 'target'); + + expect(mockEmit).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/public-services/ng-diagram-model.service.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/public-services/ng-diagram-model.service.ts index 154bce3a1..d0cbaa9a8 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/public-services/ng-diagram-model.service.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/public-services/ng-diagram-model.service.ts @@ -16,7 +16,7 @@ import { Port, Rect, } from '../../core/src'; -import { validateConnection } from '../../core/src/command-handler/commands/linking/utils'; +import { isValidEndpointTarget, validateConnection } from '../../core/src/command-handler/commands/linking/utils'; import { calculatePartsBounds } from '../../core/src/utils/dimensions'; import { emitWithMeasurementOption } from './emit-with-measurement-option'; import { NgDiagramBaseService } from './ng-diagram-base.service'; @@ -176,7 +176,8 @@ export class NgDiagramModelService extends NgDiagramBaseService implements OnDes * @since 1.4.0 */ getDanglingEndpoints(): DanglingEndpoint[] { - return getDanglingEndpoints(this.flowCore.getState().edges); + // The model lookup caches the dangling-edge list — O(dangling), not O(edges). + return getDanglingEndpoints(this.flowCore.modelLookup.danglingEdges); } /** @@ -188,7 +189,10 @@ export class NgDiagramModelService extends NgDiagramBaseService implements OnDes * @since 1.4.0 */ getNearestDanglingEndpointInRange(point: Point, range: number): DanglingEndpoint | null { - return getNearestDanglingEndpointInRange(this.flowCore.getState().edges, point, range); + // The model lookup caches the dangling-edge list, so calling this per + // pointermove costs O(dangling) like getNearestPortInRange's spatial hash + // profile — not a scan over every edge. + return getNearestDanglingEndpointInRange(this.flowCore.modelLookup.danglingEdges, point, range); } /** @@ -198,12 +202,23 @@ export class NgDiagramModelService extends NgDiagramBaseService implements OnDes * at the port's current position when the edge was connected to a port, at * the edge's routed endpoint otherwise. * + * Requires `danglingEdges.enabled` — with the feature off this method is a + * no-op, so dangling edges only ever come into existence behind the flag. + * * @param edgeId The edge to detach. * @param end Which endpoint to detach. * @param position Optional anchor position for the freed endpoint. * @since 1.4.0 */ detachEdge(edgeId: string, end: EdgeEnd, position?: Point): Promise { + if (!this.flowCore.config.danglingEdges.enabled) { + console.warn( + '[ngDiagram] detachEdge ignored: dangling edges are disabled. ' + + 'Set config.danglingEdges.enabled = true to detach edge endpoints.' + ); + return Promise.resolve(); + } + const edge = this.getEdgeById(edgeId); if (!edge) { return Promise.resolve(); @@ -215,7 +230,10 @@ export class NgDiagramModelService extends NgDiagramBaseService implements OnDes if (!position) { return Promise.resolve(); } - return this.updateEdge(edgeId, end === 'source' ? { sourcePosition: position } : { targetPosition: position }); + return this.updateEdge(edgeId, { + ...(end === 'source' ? { sourcePosition: position } : { targetPosition: position }), + ...alignManualPointsPatch(edge, end, position), + }); } const anchor = position ?? computeDetachAnchor(edge, end, this.getNodeById(nodeId)); @@ -245,7 +263,15 @@ export class NgDiagramModelService extends NgDiagramBaseService implements OnDes */ async attachEdge(edgeId: string, end: EdgeEnd, nodeId: string, portId?: string): Promise { const edge = this.getEdgeById(edgeId); - if (!edge || !this.getNodeById(nodeId)) { + if (!edge) { + return false; + } + + // Same structural rules as a relink drop: the node must exist and be + // visible, the port must exist, point the right direction and not be + // template-hidden — otherwise the edge would reference a port that + // routing cannot resolve. + if (!isValidEndpointTarget(this.flowCore, end, nodeId, portId)) { return false; } @@ -254,7 +280,13 @@ export class NgDiagramModelService extends NgDiagramBaseService implements OnDes const targetNodeId = end === 'target' ? nodeId : edge.target || undefined; const targetPortId = end === 'target' ? portId : edge.targetPort; - if (!validateConnection(this.flowCore, sourceNodeId, sourcePortId, targetNodeId, targetPortId, true)) { + if ( + !validateConnection(this.flowCore, sourceNodeId, sourcePortId, targetNodeId, targetPortId, true, { + reason: 'attach', + edge, + end, + }) + ) { return false; } 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 0da56ad9c..32e93ac5c 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,9 +220,12 @@ export class NgDiagramService extends NgDiagramBaseService { * Call this method to start drawing an edge from a position on the canvas * (no source node) from your custom logic. The free end of the edge follows * the pointer until a click finishes the draw — on a port the edge connects - * to it; on empty canvas the edge is kept as a dual dangling edge when - * `danglingEdges.enabled` is true (and `shouldKeepOnDrop` allows it), - * discarded otherwise. + * to it; on empty canvas both ends stay free (a dual dangling edge), kept + * when `shouldKeepOnDrop` allows it. + * + * Requires `danglingEdges.enabled` — an edge drawn from a position has an + * empty source, i.e. it is a dangling edge by construction. With the + * feature off the call is ignored with a console warning. * * @param position The position in flow (diagram) coordinates where the edge starts. * @since 1.4.0 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 index 294906f61..498da4869 100644 --- 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 @@ -41,7 +41,16 @@ describe('ManualLinkingService', () => { }, { provide: FlowCoreProviderService, - useValue: { isInitialized: () => true, provide: () => ({ registerInteractionCleanup, getNodeById }) }, + useValue: { + isInitialized: () => true, + provide: () => ({ + registerInteractionCleanup, + getNodeById, + actionStateManager: { isLinking: () => false }, + config: { danglingEdges: { enabled: true } }, + commandHandler: { emit: vi.fn() }, + }), + }, }, ], }); 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 89a3d12ce..43cdec188 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 @@ -19,7 +19,12 @@ export class ManualLinkingService { // Validate BEFORE attaching document listeners or emitting — an // effectively hidden source is refused by the startLinking command, and // listeners attached here would be orphaned until the next click. - const currentNode = this.flowCoreProvider.provide().getNodeById(node.id); + const flowCore = this.flowCoreProvider.provide(); + if (flowCore.actionStateManager.isLinking()) { + console.warn('[ngDiagram] startLinking ignored: another linking or relinking gesture is in progress.'); + return; + } + const currentNode = flowCore.getNodeById(node.id); if (!currentNode || currentNode.computedHidden) { console.warn(`[ngDiagram] startLinking ignored: source node "${node.id}" is missing or effectively hidden.`); return; @@ -52,15 +57,35 @@ export class ManualLinkingService { * Call this method to start linking from a position on the canvas (no source * node) from your custom logic. The edge follows the pointer until a click * finishes it. + * + * Requires `danglingEdges.enabled` — an edge drawn from a position has an + * empty source, i.e. it is a dangling edge by construction. */ startLinkingFromPosition(position: Point) { - // A previous manual linking still in flight would leave its document - // listeners and its interaction-cleanup entry orphaned — latest call wins. + const flowCore = this.flowCoreProvider.provide(); + // Validate BEFORE attaching document listeners — a refused command would + // leave the click-capture listener swallowing the next click. + if (!flowCore.config.danglingEdges.enabled) { + console.warn( + '[ngDiagram] startLinkingFromPosition ignored: dangling edges are disabled. ' + + 'Set config.danglingEdges.enabled = true to draw edges from a position.' + ); + return; + } + if (flowCore.actionStateManager.isLinking()) { + console.warn( + '[ngDiagram] startLinkingFromPosition ignored: another linking or relinking gesture is in progress.' + ); + return; + } + + // Defensive: a stale set of listeners (previous gesture torn down without + // reaching removeListeners) must not double-drive the new draw. this.removeListeners(); this.node = undefined; this.portId = undefined; - this.flowCoreProvider.provide().commandHandler.emit('startLinkingFromPosition', { position }); + flowCore.commandHandler.emit('startLinkingFromPosition', { position }); document.addEventListener('pointermove', this.onPointerMove); document.addEventListener('click', this.onDocumentClick, true); diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/relinking-gesture.service.spec.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/relinking-gesture.service.spec.ts new file mode 100644 index 000000000..37883c5fe --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/relinking-gesture.service.spec.ts @@ -0,0 +1,266 @@ +import { TestBed } from '@angular/core/testing'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Edge } from '../../../core/src'; +import { PointerInputEvent } from '../../types'; +import { FlowCoreProviderService } from '../flow-core-provider/flow-core-provider.service'; +import { TouchEventsStateService } from '../touch-events-state-service/touch-events-state-service.service'; +import { RelinkingEventService } from './relinking-event.service'; +import { RelinkingGestureService } from './relinking-gesture.service'; + +describe('RelinkingGestureService', () => { + let service: RelinkingGestureService; + let emitStart: ReturnType; + let emitContinue: ReturnType; + let emitEnd: ReturnType; + let cancelActiveInteraction: ReturnType; + let registerInteractionCleanup: ReturnType; + let isLinking: ReturnType; + let isCancellingInteraction: ReturnType; + let panningHandled: ReturnType; + let zoomingHandled: ReturnType; + let edgeRelinkingConfig: { enabled: boolean }; + + const edge: Edge = { id: 'edge-1', source: 'node-a', target: 'node-b', data: {} }; + + const pointerDownEvent = (overrides: Partial = {}): PointerInputEvent => + ({ pointerId: 1, clientX: 0, clientY: 0, button: 0, ...overrides }) as PointerInputEvent; + + /** Dispatches a document-level pointer event (jsdom-safe plain Event with pointer fields). */ + const dispatch = ( + type: 'pointermove' | 'pointerup' | 'pointercancel', + init: { pointerId?: number; clientX?: number; clientY?: number; button?: number } = {} + ) => { + const event = new Event(type); + Object.assign(event, { pointerId: 1, clientX: 0, clientY: 0, button: 0, ...init }); + document.dispatchEvent(event); + }; + + beforeEach(() => { + emitStart = vi.fn(); + emitContinue = vi.fn(); + emitEnd = vi.fn(); + cancelActiveInteraction = vi.fn(); + registerInteractionCleanup = vi.fn().mockReturnValue(vi.fn()); + isLinking = vi.fn().mockReturnValue(false); + isCancellingInteraction = vi.fn().mockReturnValue(false); + panningHandled = vi.fn().mockReturnValue(false); + zoomingHandled = vi.fn().mockReturnValue(false); + edgeRelinkingConfig = { enabled: true }; + + const mockFlowCore = { + actionStateManager: { isLinking }, + isCancellingInteraction, + registerInteractionCleanup, + cancelActiveInteraction, + get config() { + return { + edgeRelinking: edgeRelinkingConfig, + linking: { edgePanningEnabled: false, edgePanningThreshold: 0, edgePanningForce: 0 }, + }; + }, + }; + + TestBed.configureTestingModule({ + providers: [ + RelinkingGestureService, + { provide: RelinkingEventService, useValue: { emitStart, emitContinue, emitEnd } }, + { + provide: FlowCoreProviderService, + useValue: { isInitialized: () => true, provide: () => mockFlowCore }, + }, + { + provide: TouchEventsStateService, + useValue: { + panningHandled, + zoomingHandled, + currentEvent: { set: vi.fn() }, + clearCurrentEvent: vi.fn(), + }, + }, + ], + }); + + service = TestBed.inject(RelinkingGestureService); + }); + + afterEach(() => { + // Make sure no document listeners leak between tests. + service.ngOnDestroy(); + }); + + describe('beginRelink refusals', () => { + it('should return false when edge relinking is disabled', () => { + edgeRelinkingConfig.enabled = false; + + expect(service.beginRelink(pointerDownEvent(), edge, 'target')).toBe(false); + + dispatch('pointermove', { clientX: 100 }); + expect(emitStart).not.toHaveBeenCalled(); + }); + + it('should return false when a gesture is already active', () => { + expect(service.beginRelink(pointerDownEvent(), edge, 'target')).toBe(true); + expect(service.beginRelink(pointerDownEvent({ pointerId: 2 }), edge, 'target')).toBe(false); + }); + + it('should return false when a linking gesture is already in progress', () => { + isLinking.mockReturnValue(true); + + expect(service.beginRelink(pointerDownEvent(), edge, 'target')).toBe(false); + }); + + it('should return false when the pointerdown was claimed by box selection', () => { + expect(service.beginRelink(pointerDownEvent({ boxSelectionHandled: true }), edge, 'target')).toBe(false); + }); + + it('should return false while an interaction cancel is rolling back', () => { + isCancellingInteraction.mockReturnValue(true); + + expect(service.beginRelink(pointerDownEvent(), edge, 'target')).toBe(false); + }); + }); + + describe('movement threshold', () => { + it('should emit nothing on pointerdown alone', () => { + service.beginRelink(pointerDownEvent(), edge, 'target'); + + expect(emitStart).not.toHaveBeenCalled(); + expect(emitContinue).not.toHaveBeenCalled(); + expect(service.active()).toBe(false); + }); + + it('should emit nothing and remove listeners on a pointerup within the threshold', () => { + service.beginRelink(pointerDownEvent(), edge, 'target'); + + dispatch('pointermove', { clientX: 2 }); + dispatch('pointerup'); + + // A plain click on an endpoint handle is not a relink. + expect(emitStart).not.toHaveBeenCalled(); + expect(emitContinue).not.toHaveBeenCalled(); + expect(emitEnd).not.toHaveBeenCalled(); + + // Listeners are gone — later pointer traffic emits nothing. + dispatch('pointermove', { clientX: 100 }); + expect(emitStart).not.toHaveBeenCalled(); + + // The gesture slot is released — a new relink can be claimed. + expect(service.beginRelink(pointerDownEvent(), edge, 'target')).toBe(true); + }); + + it('should emit start once, then continue, after the pointer travels beyond the threshold', () => { + service.beginRelink(pointerDownEvent(), edge, 'target'); + + dispatch('pointermove', { clientX: 10 }); + + expect(emitStart).toHaveBeenCalledTimes(1); + expect(emitStart).toHaveBeenCalledWith(expect.anything(), edge, 'target'); + expect(emitContinue).toHaveBeenCalledTimes(1); + + dispatch('pointermove', { clientX: 20 }); + + expect(emitStart).toHaveBeenCalledTimes(1); + expect(emitContinue).toHaveBeenCalledTimes(2); + }); + }); + + describe('pointerup filtering', () => { + beforeEach(() => { + service.beginRelink(pointerDownEvent(), edge, 'target'); + dispatch('pointermove', { clientX: 10 }); + }); + + it('should ignore a pointerup from a different pointer', () => { + dispatch('pointerup', { pointerId: 2 }); + + expect(emitEnd).not.toHaveBeenCalled(); + expect(service.active()).toBe(true); + }); + + it('should ignore a pointerup of a non-primary button', () => { + dispatch('pointerup', { button: 2 }); + + expect(emitEnd).not.toHaveBeenCalled(); + expect(service.active()).toBe(true); + }); + + it('should finish the relink on the gesture pointer releasing the primary button', () => { + dispatch('pointerup', { clientX: 10 }); + + expect(emitEnd).toHaveBeenCalledTimes(1); + expect(emitEnd).toHaveBeenCalledWith(expect.anything(), edge, 'target', false); + expect(service.active()).toBe(false); + }); + }); + + it('should abort with takenOver on pointercancel after the gesture started', () => { + service.beginRelink(pointerDownEvent(), edge, 'target'); + dispatch('pointermove', { clientX: 10 }); + + dispatch('pointercancel'); + + expect(emitEnd).toHaveBeenCalledTimes(1); + expect(emitEnd).toHaveBeenCalledWith(expect.anything(), edge, 'target', true); + expect(service.active()).toBe(false); + }); + + it('should not emit end on pointercancel before the threshold was crossed', () => { + service.beginRelink(pointerDownEvent(), edge, 'target'); + + dispatch('pointercancel'); + + expect(emitEnd).not.toHaveBeenCalled(); + }); + + describe('ngOnDestroy', () => { + it('should remove the document listeners so later pointer traffic emits nothing', () => { + service.beginRelink(pointerDownEvent(), edge, 'target'); + dispatch('pointermove', { clientX: 10 }); + expect(emitStart).toHaveBeenCalledTimes(1); + + service.ngOnDestroy(); + + dispatch('pointermove', { clientX: 30 }); + dispatch('pointerup'); + expect(emitContinue).toHaveBeenCalledTimes(1); + expect(emitEnd).not.toHaveBeenCalled(); + }); + + it('should cancel the active interaction when destroyed mid-gesture', () => { + service.beginRelink(pointerDownEvent(), edge, 'target'); + dispatch('pointermove', { clientX: 10 }); + + service.ngOnDestroy(); + + // The pointerup will never be routed — the core linking state must not + // stay claimed. + expect(cancelActiveInteraction).toHaveBeenCalledTimes(1); + }); + + it('should not cancel anything when destroyed before the gesture started', () => { + service.beginRelink(pointerDownEvent(), edge, 'target'); + + service.ngOnDestroy(); + + expect(cancelActiveInteraction).not.toHaveBeenCalled(); + }); + }); + + describe('active signal', () => { + it('should be true only between crossing the threshold and the pointerup', () => { + expect(service.active()).toBe(false); + + service.beginRelink(pointerDownEvent(), edge, 'target'); + expect(service.active()).toBe(false); + + dispatch('pointermove', { clientX: 2 }); + expect(service.active()).toBe(false); + + dispatch('pointermove', { clientX: 10 }); + expect(service.active()).toBe(true); + + dispatch('pointerup', { clientX: 10 }); + expect(service.active()).toBe(false); + }); + }); +}); diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/relinking-gesture.service.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/relinking-gesture.service.ts index ca5a69208..62cd73b2d 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/relinking-gesture.service.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/relinking-gesture.service.ts @@ -1,5 +1,6 @@ -import { inject, Injectable } from '@angular/core'; +import { Injectable, OnDestroy, inject, signal } from '@angular/core'; import { Edge, EdgeEnd, FPS_60, NgDiagramMath, Point } from '../../../core/src'; +import { MOVE_THRESHOLD } from '../../../core/src/input-events/handlers/pointer-move-selection/pointer-move-selection.handler'; import { DiagramEventName, PointerInputEvent } from '../../types'; import { FlowCoreProviderService } from '../flow-core-provider/flow-core-provider.service'; import { TouchEventsStateService } from '../touch-events-state-service/touch-events-state-service.service'; @@ -11,15 +12,22 @@ import { RelinkingEventService } from './relinking-event.service'; * The gesture cannot live on the endpoint handle element: starting a relink * hides the original edge from rendering, which unmounts the handle that * received the pointerdown. This diagram-scoped service outlives it. + * + * A pointerdown only registers the candidate; the relink starts once the + * pointer travels beyond {@link MOVE_THRESHOLD} — a plain click on a handle + * must neither detach the edge nor emit relink events. */ @Injectable() -export class RelinkingGestureService { +export class RelinkingGestureService implements OnDestroy { private readonly relinkingEventService = inject(RelinkingEventService); private readonly flowCoreProvider = inject(FlowCoreProviderService); private readonly touchEventsStateService = inject(TouchEventsStateService); private edge: Edge | undefined; private end: EdgeEnd = 'target'; + private pointerId: number | null = null; + private startClientPoint: Point | null = null; + private started = false; private gestureActive = false; private edgePanningInterval: number | null = null; private lastPanningEvent: PointerInputEvent | null = null; @@ -27,7 +35,25 @@ export class RelinkingGestureService { private unregisterInteractionCleanup: (() => void) | null = null; /** - * Starts dragging `end` of `edge` from the given pointerdown. + * True while an endpoint is actually being dragged (the movement threshold + * was crossed). Drives the diagram host's `relinking` class so the grabbing + * cursor survives the handle unmounting mid-gesture. + */ + readonly active = signal(false); + + ngOnDestroy(): void { + const wasMidGesture = this.started; + this.removeListeners(); + // Destroyed mid-gesture (the diagram itself was torn down): the pointerup + // will never be routed and finishRelinking will never run. Run the full + // cancel flow so the core linking state does not stay claimed. + if (wasMidGesture && this.flowCoreProvider.isInitialized()) { + void this.flowCoreProvider.provide().cancelActiveInteraction(); + } + } + + /** + * Registers a pointerdown on an endpoint handle as a relink candidate. * @returns Whether the gesture was claimed. */ beginRelink(event: PointerInputEvent, edge: Edge, end: EdgeEnd): boolean { @@ -41,32 +67,63 @@ export class RelinkingGestureService { if (!flowCore.config.edgeRelinking.enabled) { return false; } - if (this.touchEventsStateService.panningHandled() || this.touchEventsStateService.zoomingHandled()) { + // An Escape-triggered cancel can still be rolling back state. + if (flowCore.isCancellingInteraction()) { + return false; + } + // Another gesture claimed this pointerdown (box selection runs in the + // capture phase; panning/zooming are touch takeovers). + if ( + event.boxSelectionHandled || + this.touchEventsStateService.panningHandled() || + this.touchEventsStateService.zoomingHandled() + ) { return false; } this.edge = edge; this.end = end; + this.pointerId = event.pointerId; + this.startClientPoint = { x: event.clientX, y: event.clientY }; + this.started = false; this.gestureActive = true; this.touchEventsStateService.currentEvent.set(DiagramEventName.Linking); document.addEventListener('pointermove', this.onPointerMove); document.addEventListener('pointerup', this.onPointerUp); + document.addEventListener('pointercancel', this.onPointerCancel); this.unregisterInteractionCleanup = flowCore.registerInteractionCleanup(() => this.removeListeners()); - this.relinkingEventService.emitStart(event, edge, end); return true; } private onPointerMove = ($event: PointerInputEvent) => { + if ($event.pointerId !== this.pointerId) { + return; + } if (this.touchEventsStateService.panningHandled() || this.touchEventsStateService.zoomingHandled()) { // Another touch gesture claimed the pointer — this move may come from the // other finger, so the relink is cancelled instead of finished at its point. - this.relinkingEventService.emitEnd($event, this.edge, this.end, true); + if (this.started) { + this.relinkingEventService.emitEnd($event, this.edge, this.end, true); + } this.removeListeners(); return; } + if (!this.started) { + const start = this.startClientPoint; + const travelled = start + ? NgDiagramMath.distanceBetweenPoints(start, { x: $event.clientX, y: $event.clientY }) + : 0; + if (travelled < MOVE_THRESHOLD) { + return; + } + this.started = true; + this.active.set(true); + this.relinkingEventService.emitStart($event, this.edge, this.end); + } + const flowCore = this.flowCoreProvider.provide(); const { edgePanningThreshold, edgePanningEnabled, edgePanningForce } = flowCore.config.linking; @@ -97,7 +154,28 @@ export class RelinkingGestureService { }; private onPointerUp = ($event: PointerInputEvent) => { - this.relinkingEventService.emitEnd($event, this.edge, this.end); + // Only the gesture's own pointer releasing its primary button finishes the + // relink — a second finger's tap or a right-button release must not commit + // at its coordinates. + if ($event.pointerId !== this.pointerId || $event.button !== 0) { + return; + } + const takenOver = this.touchEventsStateService.panningHandled() || this.touchEventsStateService.zoomingHandled(); + if (this.started) { + this.relinkingEventService.emitEnd($event, this.edge, this.end, takenOver); + } + this.removeListeners(); + }; + + private onPointerCancel = ($event: PointerInputEvent) => { + if ($event.pointerId !== this.pointerId) { + return; + } + // A cancelled pointer carries no usable coordinates — abort the relink + // instead of committing anything. + if (this.started) { + this.relinkingEventService.emitEnd($event, this.edge, this.end, true); + } this.removeListeners(); }; @@ -108,8 +186,14 @@ export class RelinkingGestureService { this.gestureActive = false; this.touchEventsStateService.clearCurrentEvent(); } + this.started = false; + this.active.set(false); + this.edge = undefined; + this.pointerId = null; + this.startClientPoint = null; document.removeEventListener('pointermove', this.onPointerMove); document.removeEventListener('pointerup', this.onPointerUp); + document.removeEventListener('pointercancel', this.onPointerCancel); this.stopEdgePanning(); } diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/styles/styles.css b/packages/ng-diagram/projects/ng-diagram/src/lib/styles/styles.css index 1522cbed2..3858f93cf 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/styles/styles.css +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/styles/styles.css @@ -37,6 +37,12 @@ --ngd-resize-handle-border-color: var(--ngd-transform-box); --ngd-resize-handle-border-radius: 0; + --ngd-relink-handle-size: 0.625rem; + --ngd-relink-handle-fill: var(--ngd-ui-bg-primary-default); + --ngd-relink-handle-fill-hover: var(--ngd-transform-box); + --ngd-relink-handle-stroke: var(--ngd-transform-box); + --ngd-relink-handle-stroke-width: 0.09375rem; + --ngd-port-size: 0.25rem; --ngd-port-background-color: var(--ngd-node-port-fill-default); --ngd-port-border-color: var(--ngd-node-port-stroke-default); 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 6d97fe1f1..86eaa57bb 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/public-api.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/public-api.ts @@ -62,7 +62,13 @@ export { NgDiagramService } from './lib/public-services/ng-diagram.service'; export { configureShortcuts } from './core/src'; // Dangling-edge utilities -export { getDanglingEndpoints, getNearestDanglingEndpointInRange, hasFreeEndpoint, isDanglingEdge } from './core/src'; +export { + computeDetachAnchor, + getDanglingEndpoints, + getNearestDanglingEndpointInRange, + hasFreeEndpoint, + isDanglingEdge, +} from './core/src'; export { NgDiagramMinimapNodeTemplateMap } from './lib/components/minimap/ng-diagram-minimap.types'; export { initializeModel, initializeModelAdapter, type InitializeModelOptions } from './lib/model/initialize-model'; export { @@ -99,6 +105,7 @@ export type { BackgroundConfig, BoxSelectionConfig, ClipboardPastedEvent, + ConnectionValidationContext, CopyPasteActionState, DanglingEdgesConfig, DanglingEndpoint, From b7aeab8cb980293912829017935e470cb07f5ba3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Ja=C5=BAwa?= Date: Wed, 16 Sep 2026 13:47:16 +0200 Subject: [PATCH 03/10] Minor changes do changelog and hit area of relinking handle --- CHANGELOG.md | 17 ++++++------ .../content/docs/guides/edges/relinking.mdx | 2 +- apps/e2e/tests/dangling-relinking.spec.ts | 26 +++++++++++++++++++ .../edge/base-edge/base-edge.component.html | 6 ++++- .../edge/base-edge/base-edge.component.scss | 18 ++++++++----- 5 files changed, 53 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26edca2a5..202afd6d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,14 +14,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **`EdgeDrawEndedEvent.source` is now optional** — a draw can start from a position instead of a node (`startLinkingFromPosition`), so `source?: Node`. Migration: read it as `event.source?.id`. Breaking only for consumers compiling with `strictNullChecks` who accessed `event.source` unconditionally ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) -- **`edgeDrawEnded` reports success for canvas drops kept as dangling edges** — with `danglingEdges.enabled`, a draw released on empty canvas that keeps the edge fires with `success: true` and the created `edge`, but **no** `target`/`targetPort` (previously such drops always cancelled with `noTarget`). Apps that hand-built dangling edges on `reason === 'noTarget'` must remove that workaround before enabling the flag, or they will double-create; the deprecated `edgeDrawn` never fires for kept dangling edges ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) -- **`linking.validateConnection` receives an optional fifth argument** — a `ConnectionValidationContext` with `reason: 'draw' | 'relink' | 'attach'` (plus the `edge` and `end` being reconnected for relink/attach), so one validator can treat drawing, relinking and `attachEdge` differently. The `source` parameter can now be `null` — for draws started with `startLinkingFromPosition` and when relinking an edge whose other end is dangling ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) -- **`SelectionRemovedEvent` gained a required `detachedEdges` field** — the edges detached into dangling edges instead of deleted (see `danglingEdges.detachOnNodeDelete`); always present, `[]` when none. Breaking only for code constructing the event type manually ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) +- **`EdgeDrawEndedEvent.source` is now optional** — a draw can start from a position instead of a node (`startLinkingFromPosition`). Read it as `event.source?.id`. Breaking only for apps compiled with `strictNullChecks` that read `event.source` without a check ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) +- **`edgeDrawEnded` reports success for a canvas drop kept as a dangling edge** — with `danglingEdges.enabled`, such a drop fires with `success: true` and the created `edge`, and without `target`/`targetPort` (before, every canvas drop cancelled with `noTarget`). If your app created its own edge on `reason === 'noTarget'`, remove that code before enabling the flag, or edges are created twice. The deprecated `edgeDrawn` never fires for kept dangling edges ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) +- **`linking.validateConnection` receives an optional fifth argument** — a `ConnectionValidationContext` with `reason: 'draw' | 'relink' | 'attach'` and, for relink and attach, the `edge` and `end` being reconnected, so one validator can treat the three cases differently. `source` can now be `null`: for draws started with `startLinkingFromPosition` and when the other end of a relinked edge is free ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) +- **`SelectionRemovedEvent` has a new required `detachedEdges` field** — the edges kept as dangling edges instead of deleted (`danglingEdges.detachOnNodeDelete`), `[]` when none. Breaking only for code that builds this event object by hand ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) - **`ModelActionType` gained `'startRelinking'`, `'finishRelinking'` and `'startLinkingFromPosition'`** — breaking for middlewares with exhaustive switches over the action type ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) - **Box selection counts dangling edges by their free-endpoint anchors** — a box drawn around a free endpoint's `sourcePosition`/`targetPosition` selects the edge ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) -- **Paste handles free endpoints** — pasting offsets the free endpoints of dangling edges together with the rest of the content (for manual-routing edges the stored `points` too), and paste-at-position centers on the pasted content including those anchors ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) -- **Z-index recomputes when an edge's `source`/`target` changes programmatically** — updating an edge's endpoints (e.g. `attachEdge`, `updateEdge`) now recomputes its layering relative to the newly connected nodes ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) +- **Paste moves free endpoints** — pasting moves the free endpoints of dangling edges together with the rest of the content (for manual-routing edges also their `points`), and paste at a position centers on nodes and free endpoints alike ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) +- **Z-index recomputes when an edge's `source`/`target` changes programmatically** — after `updateEdge` or `attachEdge` changes an endpoint, the edge is layered relative to its new nodes ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) - **`ng-diagram-palette-item-preview` takes no space in the layout** — the preview renders nothing on the page, whatever content it holds and however the palette is laid out. It is a block, so inline content placed next to it inside a palette item now starts on a new line ([#801](https://github.com/synergycodes/ng-diagram/pull/801)) - **`NgDiagramPaletteItemPreviewComponent.preview` is for reading content and size** — the element behind this signal is not rendered in the page flow. Read the preview's natural size or content from it; its position is meaningless ([#801](https://github.com/synergycodes/ng-diagram/pull/801)) - **The drag image zoom is capped at 3x** — at higher zoom levels the image under the cursor stays at 3x, so starting a drag stays smooth; the dropped node still lands at the current zoom ([#801](https://github.com/synergycodes/ng-diagram/pull/801)) @@ -29,8 +29,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **Dangling edges** — edges with one or both endpoints not connected to any node (empty `source`/`target` with the free end anchored at `sourcePosition`/`targetPosition`, never a port) are now a first-class, fully opt-in feature behind the new `danglingEdges` config: `enabled` (master switch — a draw dropped on empty canvas keeps the edge, a relink drop detaches the endpoint), `shouldKeepOnDrop` (per-edge decision for canvas drops, called with the fully-built final edge), `detachOnNodeDelete` (deleting a node detaches its edges into dangling edges anchored where their port was, instead of deleting them; the freed edges are reported in `selectionRemoved.detachedEdges`) and `shouldDetachOnNodeDelete` (per-endpoint decision; an explicitly deleted edge is always deleted, hidden edges/nodes always delete, and an edge losing both endpoints is deleted unless the callback opts both ends into dual dangling). Dual dangling edges (both ends free) route between their anchors, survive the `toJSON()`/`initializeModel` persistence round-trip, travel through copy/paste (an unselected dangling edge is copied along with its one connected node; paste offsets free endpoints and, for manual-routing edges, their `points`), are picked up by box selection via their anchors and by virtualization. New public utilities `isDanglingEdge`, `hasFreeEndpoint`, `getDanglingEndpoints`, `getNearestDanglingEndpointInRange` and `computeDetachAnchor`; new `NgDiagramModelService.detachEdge`/`attachEdge` (attach validates through `linking.validateConnection` with `reason: 'attach'`); new CSS hooks `ng-diagram-edge--dangling` on the edge wrapper and the `dangling` class on `ng-diagram-base-edge` and its path ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) -- **Edge relinking** — reconnect existing edges by dragging their endpoints: with the new `edgeRelinking.enabled` config, selected committed edges show endpoint handles (visible circle plus an enlarged, zoom-compensated ~12px invisible hit area for touch) that preview the reconnection live and commit it on drop. Reconnections validate through `linking.validateConnection` with a context of `reason: 'relink'`; a drop on empty canvas detaches the endpoint when `danglingEdges.enabled` allows it, an invalid drop reverts the edge, a drop back on the original port is a no-op, and Escape cancels the gesture. New `edgeRelinkStarted`/`edgeRelinkEnded` events report the gesture (previous node/port/position, drop position, new target, or the `noTarget`/`invalidConnection`/`cancelled` reason). Style the handles with the new `--ngd-relink-handle-size/-fill/-fill-hover/-stroke/-stroke-width` theme variables or per edge with their `--edge-relink-handle-*` counterparts; fully custom edge templates can render their own handles with the new public `RelinkHandleDirective` ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) +- **Dangling edges** — edges with one or both ends not connected to a node: empty `source`/`target`, the free end anchored at `sourcePosition`/`targetPosition`. Opt in with the new `danglingEdges.enabled` config: a draw dropped on empty canvas then keeps the edge (`shouldKeepOnDrop` decides per edge) and a relink drop detaches the endpoint. Dangling edges route between their anchors, are saved and restored with the model, copy and paste together with their node, and box selection picks them by their anchors. New utilities `isDanglingEdge`, `hasFreeEndpoint`, `getDanglingEndpoints`, `getNearestDanglingEndpointInRange` and `computeDetachAnchor`; new `NgDiagramModelService.detachEdge`/`attachEdge` (attach validates through `linking.validateConnection` with `reason: 'attach'`); new CSS hooks `ng-diagram-edge--dangling` on the edge wrapper and `dangling` on `ng-diagram-base-edge` and its path ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) +- **Keep edges when deleting a node** — with `danglingEdges.detachOnNodeDelete`, deleting a node keeps its edges as dangling edges anchored where their port was, and `selectionRemoved.detachedEdges` lists them. `shouldDetachOnNodeDelete` decides per endpoint. An edge is still deleted when it is deleted explicitly, when it or the lost node is hidden, or when it loses both ends at once (unless the callback keeps both) ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) +- **Edge relinking** — reconnect an edge by dragging one of its ends. With the new `edgeRelinking.enabled` config, a selected edge shows a handle at each end; dragging a handle previews the new connection and commits it on drop. The drop is validated by `linking.validateConnection` with `reason: 'relink'`. A drop on empty canvas detaches the end when `danglingEdges.enabled` allows it; an invalid drop, a drop back on the same port and Escape leave the edge unchanged. New `edgeRelinkStarted`/`edgeRelinkEnded` events report the gesture and its result (`noTarget`, `invalidConnection` or `cancelled` on failure). The handles have a touch-friendly hit area of about 12 px at any zoom; style them with the `--ngd-relink-handle-size/-fill/-fill-hover/-stroke/-stroke-width` theme variables or per edge with `--edge-relink-handle-*`. Custom edge templates can render their own handles with the new `RelinkHandleDirective` ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) - **`NgDiagramService.startLinkingFromPosition(position)`** — starts drawing an edge from a point on the canvas (flow coordinates) instead of from a node; the free end follows the pointer until a click connects it to a port or leaves a dual dangling edge on empty canvas. Requires `danglingEdges.enabled` (ignored with a console warning otherwise) ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) - **`hidden` flag on nodes and edges** — new `hidden?: boolean` property on `SimpleNode` and `Edge` makes hidden content a first-class concept: hidden elements stay mounted in the DOM as `display: none`, keep their geometry, never block initialization or `waitForMeasurements`, and re-measure automatically when unhidden. Effective visibility is derived — hiding a group hides all of its descendants, and an edge is hidden when either endpoint node is hidden — and exposed as the system-computed `computedHidden` property. Visible nodes, edges and labels never get an inline `display` value, so existing CSS-based workarounds keep working (ports keep managing their own inline `display` as before — hide them via the port's `hidden` input, not CSS). Note for upgrades: the library now owns the `hidden` property name on nodes and edges — pre-existing app-level `hidden: true` data activates these semantics after upgrading ([#799](https://github.com/synergycodes/ng-diagram/pull/799)) - **Hidden elements are excluded from every interactive surface** — effectively hidden nodes and edges are not hit-testable and don't participate in `selectAll`, keyboard move, drag, box selection, linking (starting a link from a hidden node is a no-op with a console warning), `zoomToFit`/`computePartsBounds` bounds, virtualization, edge routing, or the resize constraints of their group (a collapsed group is freely resizable — its hidden children reappear where they were on expand). The minimap mirrors the canvas: effectively hidden nodes are not drawn (neither as default rectangles nor as custom minimap templates) and don't inflate the minimap's diagram bounds, in both rendering modes. Programmatic APIs (`select`, `centerOnNode`, z-order and group commands) deliberately do not filter hidden elements. Hiding does not deselect: a hidden element keeps its `selected` flag but cannot be moved, and `deleteSelection` skips hidden selected elements (descendants of a deleted visible group and edges of deleted nodes are still removed) ([#799](https://github.com/synergycodes/ng-diagram/pull/799)) diff --git a/apps/docs/src/content/docs/guides/edges/relinking.mdx b/apps/docs/src/content/docs/guides/edges/relinking.mdx index 4f0abedce..9ce449070 100644 --- a/apps/docs/src/content/docs/guides/edges/relinking.mdx +++ b/apps/docs/src/content/docs/guides/edges/relinking.mdx @@ -113,7 +113,7 @@ ng-diagram-base-edge.my-edge { } ``` -Each visible handle is paired with an enlarged invisible hit circle of roughly 12 screen pixels that is compensated for zoom, so the handles stay touch-friendly at any zoom level without growing visually. +Each visible handle is paired with an enlarged invisible hit circle of roughly 12 screen pixels that is compensated for zoom, so the handles stay touch-friendly at any zoom level without growing visually. Hovering the hit circle highlights the visible handle exactly like hovering the handle itself. ### Custom Edge Templates diff --git a/apps/e2e/tests/dangling-relinking.spec.ts b/apps/e2e/tests/dangling-relinking.spec.ts index 5cf8d24b5..4301a0bfb 100644 --- a/apps/e2e/tests/dangling-relinking.spec.ts +++ b/apps/e2e/tests/dangling-relinking.spec.ts @@ -450,6 +450,32 @@ test.describe('edge relinking', () => { await diagram.linkPorts({ node: 'node-a', port: 'port-right' }, { node: 'node-c', port: 'port-left' }); await expect.poll(async () => (await diagram.model.edges()).length).toBe(2); }); + + test('the hit area keeps its screen size at low zoom and highlights the handle', async ({ diagram }) => { + await diagram.load({ model: trio, config: relinkOn }); + await diagram.viewport.zoom(0.5); + await diagram.selection.select([], ['edge-ab']); + + const visible = diagram.edge('edge-ab').locator('[data-relink-handle="target"]'); + const handle = await diagram.centerOf(visible, 'target handle of edge-ab'); + // At zoom 0.5 the visible circle is 2.5px in radius, so 9px above its + // center only the 12px hit circle can be under the pointer. + const ring = { x: handle.x, y: handle.y - 9 }; + const under = await diagram.page.evaluate( + ({ x, y }) => document.elementFromPoint(x, y)?.getAttribute('class') ?? null, + ring + ); + expect(under).toContain('ng-diagram-edge__relink-handle-hit'); + + // Hovering the hit area highlights the visible circle exactly like hovering the circle itself. + const fill = () => visible.evaluate((element) => getComputedStyle(element).fill); + const restFill = await fill(); + await diagram.page.mouse.move(handle.x, handle.y); + await expect.poll(fill).not.toBe(restFill); + const hoverFill = await fill(); + await diagram.page.mouse.move(ring.x, ring.y); + await expect.poll(fill).toBe(hoverFill); + }); }); test.describe('edge relinking on touch', () => { diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge.component.html b/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge.component.html index 0205bda3f..286ef16ca 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge.component.html +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge.component.html @@ -25,9 +25,12 @@ @if (relinkHandlesVisible()) { + and carrying the same directive, so grabbing either starts the relink; + data-relink-handle-hit lets the stylesheet highlight the matching + visible circle on hover. --> Date: Wed, 16 Sep 2026 16:21:27 +0200 Subject: [PATCH 04/10] Refuse relink and draw drops on unconnectable ports, align manual points --- CHANGELOG.md | 1 + .../api/Services/NgDiagramModelService.md | 6 +- .../Features/DanglingEdgesConfig.md | 4 + .../api/Utilities/getDanglingEndpoints.md | 3 +- .../getNearestDanglingEndpointInRange.md | 2 +- .../docs/guides/edges/dangling-edges.mdx | 2 +- apps/e2e/tests/dangling-relinking.spec.ts | 68 +++++++++++ .../commands/__tests__/copy-paste.test.ts | 45 +++++++ .../__tests__/detach-on-node-delete.test.ts | 114 ++++++++++++++++++ .../command-handler/commands/copy-paste.ts | 9 +- .../commands/detach-on-node-delete.ts | 30 +++-- .../finish-linking-to-position.test.ts | 83 ++++++++++++- .../linking/__tests__/finish-linking.test.ts | 56 ++++++++- .../__tests__/finish-relinking.test.ts | 61 ++++++++++ .../linking/__tests__/linking-utils.test.ts | 44 +++++++ .../linking/finish-linking-to-position.ts | 34 +++--- .../commands/linking/finish-linking.ts | 14 ++- .../commands/linking/finish-relinking.ts | 30 +++-- .../commands/linking/move-temporary-edge.ts | 7 +- .../command-handler/commands/linking/utils.ts | 12 +- .../__tests__/edges-routing-helpers.test.ts | 42 +++++++ .../edges-routing/edges-routing.ts | 3 + .../src/core/src/model-lookup/model-lookup.ts | 8 +- .../virtualized/visible-elements-resolver.ts | 23 ++-- .../core/src/types/flow-config.interface.ts | 4 + .../utils/__tests__/dangling-edges.test.ts | 14 +++ .../src/core/src/utils/dangling-edges.ts | 9 +- .../ng-diagram-model.service.ts | 16 ++- 28 files changed, 652 insertions(+), 92 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 202afd6d5..fa210877f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Arrow keys pan the viewport when the selection cannot move** — with `nodeDraggingEnabled: false` and a node selected, the arrow keys used to do nothing. The same happened when only `draggable: false` nodes were selected. Now the arrow keys pan the viewport whenever the selection would not move; with node dragging enabled, a movable selection still moves as before ([#809](https://github.com/synergycodes/ng-diagram/pull/809)) - **`zoomToFit({ nodeIds })` frames only the requested nodes** — without `edgeIds`, the fit used to include every edge of the diagram, so fitting a few nodes in a connected diagram zoomed out to the whole network, and fitting only hidden nodes framed the whole diagram instead of doing nothing. Now only edges with both ends among the fitted nodes count; passing `edgeIds` works as before ([#808](https://github.com/synergycodes/ng-diagram/pull/808)) +- **Pasted manual-routing edges keep their shape at the new location** — an edge with `routingMode: 'manual'` pasted together with its nodes used to be drawn at the original location; its `points` now move with the pasted nodes ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) - **Pasting an edge without its nodes no longer duplicates the original connection** — an edge copied without one or both of its endpoint nodes used to be pasted between the original nodes, adding an identical connection on every paste. The end whose node was not copied is now pasted as a free end that moves with the rest of the pasted content, so you can move it or reconnect it. Cutting an edge on its own and pasting it therefore gives a free edge, not the original connection. Edges copied together with their nodes are pasted between the new nodes, as before ([#807](https://github.com/synergycodes/ng-diagram/pull/807)) - **`deleteNodes` on a group deletes its whole subtree** — `NgDiagramModelService.deleteNodes` now cascades to all descendants of the deleted nodes and removes their edges, matching `deleteSelection`. Previously the children survived with a `groupId` pointing at a node that no longer existed, and children of a hidden group reappeared as orphans. To delete a group but keep its children, call `NgDiagramGroupsService.removeFromGroup` first ([#806](https://github.com/synergycodes/ng-diagram/pull/806)) - **Palette preview no longer bleeds over the palette or stretches the page** — at high zoom levels `ng-diagram-palette-item-preview` no longer paints over the palette items and no longer adds scrollbars to the page, whatever the layout around the palette ([#801](https://github.com/synergycodes/ng-diagram/pull/801)) diff --git a/apps/docs/src/content/docs/api/Services/NgDiagramModelService.md b/apps/docs/src/content/docs/api/Services/NgDiagramModelService.md index 551e7db23..24592709c 100644 --- a/apps/docs/src/content/docs/api/Services/NgDiagramModelService.md +++ b/apps/docs/src/content/docs/api/Services/NgDiagramModelService.md @@ -420,7 +420,8 @@ Array of nodes connected to the given node > **getDanglingEndpoints**(): [`DanglingEndpoint`](/docs/api/types/model/danglingendpoint/)[] Collects the free (unconnected) endpoints of all committed edges — a dual -dangling edge yields two entries. +dangling edge yields two entries. Temporary and effectively hidden edges +are skipped. #### Returns @@ -482,7 +483,8 @@ Returns null if flowCore is not initialized. > **getNearestDanglingEndpointInRange**(`point`, `range`): `null` \| [`DanglingEndpoint`](/docs/api/types/model/danglingendpoint/) Gets the free edge endpoint nearest to a point within a range — the -dangling-edges sibling of [getNearestPortInRange](/docs/api/services/ngdiagrammodelservice/#getnearestportinrange). +dangling-edges sibling of [getNearestPortInRange](/docs/api/services/ngdiagrammodelservice/#getnearestportinrange). Temporary and +effectively hidden edges are skipped. #### Parameters diff --git a/apps/docs/src/content/docs/api/Types/Configuration/Features/DanglingEdgesConfig.md b/apps/docs/src/content/docs/api/Types/Configuration/Features/DanglingEdgesConfig.md index d349a5e74..069093fae 100644 --- a/apps/docs/src/content/docs/api/Types/Configuration/Features/DanglingEdgesConfig.md +++ b/apps/docs/src/content/docs/api/Types/Configuration/Features/DanglingEdgesConfig.md @@ -48,6 +48,10 @@ Master switch for dangling edges. When true, an edge draw that ends on empty canvas keeps the edge as a dangling edge instead of discarding it, and an edge relink dropped on empty canvas detaches that endpoint. +A drop over a port the edge cannot connect to (for example a +wrong-direction port) is not an empty-canvas drop and is discarded as +before. + #### Default ```ts diff --git a/apps/docs/src/content/docs/api/Utilities/getDanglingEndpoints.md b/apps/docs/src/content/docs/api/Utilities/getDanglingEndpoints.md index 756d1c0db..31149eb2d 100644 --- a/apps/docs/src/content/docs/api/Utilities/getDanglingEndpoints.md +++ b/apps/docs/src/content/docs/api/Utilities/getDanglingEndpoints.md @@ -10,7 +10,8 @@ title: "getDanglingEndpoints" Collects the free endpoints of the given edges. A dual dangling edge yields two entries. Endpoints whose anchor position is missing are skipped — -they cannot be rendered or snapped to. +they cannot be rendered or snapped to. Temporary and effectively hidden +edges are skipped. ## Parameters diff --git a/apps/docs/src/content/docs/api/Utilities/getNearestDanglingEndpointInRange.md b/apps/docs/src/content/docs/api/Utilities/getNearestDanglingEndpointInRange.md index e029a227b..448020a99 100644 --- a/apps/docs/src/content/docs/api/Utilities/getNearestDanglingEndpointInRange.md +++ b/apps/docs/src/content/docs/api/Utilities/getNearestDanglingEndpointInRange.md @@ -10,7 +10,7 @@ title: "getNearestDanglingEndpointInRange" Finds the free edge endpoint nearest to `point` within `range`, or null when none is close enough. Sibling of `getNearestPortInRange` for snapping to -dangling ends. +dangling ends. Temporary and effectively hidden edges are skipped. ## Parameters diff --git a/apps/docs/src/content/docs/guides/edges/dangling-edges.mdx b/apps/docs/src/content/docs/guides/edges/dangling-edges.mdx index 6888f66a4..ea5563b71 100644 --- a/apps/docs/src/content/docs/guides/edges/dangling-edges.mdx +++ b/apps/docs/src/content/docs/guides/edges/dangling-edges.mdx @@ -52,7 +52,7 @@ const config: NgDiagramConfig = { ## Keeping Edges Dropped on Empty Canvas -With `enabled: true`, every edge draw released on empty canvas is kept as a dangling edge, anchored where the pointer was released. To decide per edge, provide [`shouldKeepOnDrop`](/docs/api/types/configuration/features/danglingedgesconfig/#shouldkeepondrop): +With `enabled: true`, every edge draw released on empty canvas is kept as a dangling edge, anchored where the pointer was released. A drop over a port the edge cannot connect to (for example a wrong-direction port) is not a drop on empty canvas — the draw is discarded. To decide per edge, provide [`shouldKeepOnDrop`](/docs/api/types/configuration/features/danglingedgesconfig/#shouldkeepondrop): ```typescript const config: NgDiagramConfig = { diff --git a/apps/e2e/tests/dangling-relinking.spec.ts b/apps/e2e/tests/dangling-relinking.spec.ts index 4301a0bfb..0b425103e 100644 --- a/apps/e2e/tests/dangling-relinking.spec.ts +++ b/apps/e2e/tests/dangling-relinking.spec.ts @@ -45,6 +45,21 @@ function relinkEnded(diagram: Diagram): Promise { return diagram.page.evaluate(() => (window as unknown as Record).__relinkEnded as unknown[]); } +/** Record every edgeDrawEnded payload on the page for later assertions. */ +async function recordDrawEnded(diagram: Diagram): Promise { + await diagram.page.evaluate(() => { + const events: unknown[] = []; + (window as unknown as Record).__drawEnded = events; + window.__diagram!.diagram.addEventListener('edgeDrawEnded', (event) => { + events.push({ success: event.success, reason: event.reason ?? null }); + }); + }); +} + +function drawEnded(diagram: Diagram): Promise { + return diagram.page.evaluate(() => (window as unknown as Record).__drawEnded as unknown[]); +} + /** Drag from a port onto empty canvas and release. */ async function dragFromPortToCanvas(diagram: Diagram, node: string, port: string): Promise<{ x: number; y: number }> { const from = await diagram.centerOf(diagram.port(node, port), `port ${node}/${port}`); @@ -82,6 +97,23 @@ test.describe('dangling edges', () => { await expect(diagram.edge(edge.id)).toHaveClass(/ng-diagram-edge--dangling/); }); + test('link drop on a port the edge cannot connect to keeps no dangling edge', async ({ diagram }) => { + await diagram.load({ model: pair, config: { danglingEdges: { enabled: true } } }); + await recordDrawEnded(diagram); + + // The draw's own source port can never become its target, so the preview + // never snaps back to it — the release over it is a refused connection, + // not a drop on empty canvas. + const from = await diagram.centerOf(diagram.port('node-a', 'port-right'), 'port node-a/port-right'); + await diagram.beginDrag(from, { x: from.x + 200, y: from.y + 140 }); + await diagram.page.mouse.move(from.x, from.y, { steps: 4 }); + await diagram.page.mouse.up(); + + await expect.poll(() => drawEnded(diagram)).toEqual([{ success: false, reason: 'noTarget' }]); + expect(await diagram.model.edges()).toEqual([]); + await expect(diagram.allEdges).toHaveCount(0); + }); + test('deleting a node deletes its edges by default', async ({ diagram }) => { await diagram.load({ model: trio }); @@ -266,6 +298,42 @@ test.describe('edge relinking', () => { expect(edge?.source).toBe('node-a'); }); + test('dropping an endpoint on a port that cannot take it reverts with invalidConnection', async ({ diagram }) => { + await diagram.load({ + model: trio, + config: { ...relinkOn, danglingEdges: { enabled: true } }, + }); + await recordRelinkEnded(diagram); + await diagram.selection.select([], ['edge-ab']); + + const handle = await diagram.centerOf( + diagram.edge('edge-ab').locator('[data-relink-handle="target"]'), + 'target handle of edge-ab' + ); + // node-a is the edge's own source, so none of its ports can become the + // target end — a refused connection, not a drop on empty canvas. + const dst = await diagram.centerOf(diagram.port('node-a', 'port-left'), 'port node-a/port-left'); + await diagram.beginDrag(handle, dst); + await diagram.page.mouse.up(); + + await expect + .poll(() => relinkEnded(diagram)) + .toEqual([ + { + edge: 'edge-ab', + end: 'target', + success: false, + reason: 'invalidConnection', + previousNode: 'node-b', + target: null, + targetPort: null, + }, + ]); + const edge = await diagram.model.getEdgeById('edge-ab'); + expect(edge).toMatchObject({ source: 'node-a', target: 'node-b' }); + expect(edge?.target).not.toBe(''); + }); + test('relinking the source endpoint works too', async ({ diagram }) => { await diagram.load({ model: trio, config: relinkOn }); await diagram.selection.select([], ['edge-ab']); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/copy-paste.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/copy-paste.test.ts index 1cf3c7a13..1be0961de 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/copy-paste.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/copy-paste.test.ts @@ -1215,6 +1215,51 @@ describe('Copy-Paste Commands', () => { ]); }); + it('should offset the points of a manual-routing edge pasted with both of its nodes', async () => { + const originalPoints = [ + { x: 10, y: 10 }, + { x: 30, y: 40 }, + { x: 60, y: 60 }, + ]; + commandHandler.flowCore.getState = () => ({ + nodes: [ + { ...mockNode, id: 'node1', position: { x: 0, y: 0 }, selected: true }, + { ...mockNode, id: 'node2', position: { x: 50, y: 50 }, selected: true }, + ], + edges: [ + { + ...mockEdge, + id: 'edge1', + source: 'node1', + target: 'node2', + routingMode: 'manual' as const, + points: originalPoints, + selected: true, + }, + ], + metadata: mockMetadata, + }); + + await copy(commandHandler); + await paste(commandHandler, { name: 'paste' }); + + const updateCall = commandHandler.flowCore.applyUpdate as unknown as ReturnType; + const [update] = updateCall.mock.calls[0]; + + const pastedEdge = update.edgesToAdd[0]; + // Both ends move by the default (20, 20) paste offset, so the stored + // path moves with them. + expect(pastedEdge.points).toEqual([ + { x: 30, y: 30 }, + { x: 50, y: 60 }, + { x: 80, y: 80 }, + ]); + expect(pastedEdge.source).toBe(update.nodesToAdd[0].id); + expect(pastedEdge.target).toBe(update.nodesToAdd[1].id); + expect(pastedEdge.sourcePosition).toBeUndefined(); + expect(pastedEdge.targetPosition).toBeUndefined(); + }); + it('should not offset the points of fully-connected pasted edges', async () => { const originalPoints = [ { x: 5, y: 5 }, diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/detach-on-node-delete.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/detach-on-node-delete.test.ts index ac537c3c6..7a4b4c794 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/detach-on-node-delete.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/detach-on-node-delete.test.ts @@ -260,6 +260,120 @@ describe('partitionIncidentEdges', () => { ]); }); + it('should ask the callback once per lost end', () => { + const shouldDetachOnNodeDelete = vi.fn((_edge: Edge, _node: Node, end: string) => end !== 'source'); + mockFlowCore.config.danglingEdges!.shouldDetachOnNodeDelete = shouldDetachOnNodeDelete; + + const result = partitionIncidentEdges(flowCore(), [incidentEdge], new Set(['node-a', 'node-b'])); + + // Both ends are reported even though the first answer already decides. + expect(shouldDetachOnNodeDelete).toHaveBeenCalledTimes(2); + expect(shouldDetachOnNodeDelete).toHaveBeenCalledWith(incidentEdge, nodeWithPort, 'source'); + expect(shouldDetachOnNodeDelete).toHaveBeenCalledWith(incidentEdge, nodeWithoutPorts, 'target'); + expect(result.edgesToRemove).toEqual(['edge-ab']); + expect(result.edgesToUpdate).toEqual([]); + }); + + describe('manual routing points', () => { + // A second ported node so both detach anchors differ from every stored + // point: port-b flow position = (500 + 90 + 10, 600 + 45 + 5). + const nodeWithPortB: Node = { + ...mockNode, + id: 'node-b', + position: { x: 500, y: 600 }, + size: { width: 80, height: 40 }, + measuredPorts: [ + { + ...mockPort, + id: 'port-b', + type: 'target', + side: 'right', + position: { x: 90, y: 45 }, + size: { width: 10, height: 10 }, + nodeId: 'node-b', + }, + ], + }; + const portBAnchor = { x: 600, y: 650 }; + + const manualEdge: Edge = { + ...mockEdge, + id: 'edge-manual', + source: 'node-a', + sourcePort: 'port-a', + target: 'node-b', + targetPort: 'port-b', + routingMode: 'manual', + points: [ + { x: 10, y: 20 }, + { x: 60, y: 70 }, + { x: 110, y: 120 }, + ], + }; + + beforeEach(() => { + mockFlowCore.getNodeById.mockImplementation((id: string) => + id === 'node-a' ? nodeWithPort : id === 'node-b' ? nodeWithPortB : undefined + ); + }); + + it('should move the last manual point onto the anchor of a lost target end', () => { + const result = partitionIncidentEdges(flowCore(), [manualEdge], new Set(['node-b'])); + + expect(result.edgesToUpdate).toEqual([ + { + id: 'edge-manual', + target: '', + targetPort: undefined, + targetPosition: portBAnchor, + points: [{ x: 10, y: 20 }, { x: 60, y: 70 }, portBAnchor], + }, + ]); + }); + + it('should move the first manual point onto the anchor of a lost source end', () => { + const result = partitionIncidentEdges(flowCore(), [manualEdge], new Set(['node-a'])); + + expect(result.edgesToUpdate).toEqual([ + { + id: 'edge-manual', + source: '', + sourcePort: undefined, + sourcePosition: portAnchor, + points: [portAnchor, { x: 60, y: 70 }, { x: 110, y: 120 }], + }, + ]); + }); + + it('should move both manual end points when the edge loses both ends', () => { + mockFlowCore.config.danglingEdges!.shouldDetachOnNodeDelete = vi.fn().mockReturnValue(true); + + const result = partitionIncidentEdges(flowCore(), [manualEdge], new Set(['node-a', 'node-b'])); + + expect(result.edgesToUpdate).toEqual([ + { + id: 'edge-manual', + source: '', + sourcePort: undefined, + sourcePosition: portAnchor, + target: '', + targetPort: undefined, + targetPosition: portBAnchor, + points: [portAnchor, { x: 60, y: 70 }, portBAnchor], + }, + ]); + }); + + it('should not touch the points of an auto-routed edge', () => { + const autoEdge: Edge = { ...manualEdge, routingMode: 'auto' }; + + const result = partitionIncidentEdges(flowCore(), [autoEdge], new Set(['node-b'])); + + expect(result.edgesToUpdate).toHaveLength(1); + expect(result.edgesToUpdate[0]).not.toHaveProperty('points'); + }); + }); + it('should leave non-incident edges untouched', () => { const result = partitionIncidentEdges(flowCore(), [incidentEdge, nonIncidentEdge], new Set(['node-a'])); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/copy-paste.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/copy-paste.ts index 64f5bc4d7..dc288929c 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/copy-paste.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/copy-paste.ts @@ -200,12 +200,11 @@ const createPastedEdges = ( continue; } - // A manual-routing edge keeps its stored points verbatim, so an edge with - // a free pasted end must have its whole path shifted along with the free - // endpoint — otherwise the drawn path stays at the original location. - const hasFreePastedEnd = !source.nodeId || !target.nodeId; + // A manual-routing edge keeps its stored points verbatim, and every end + // that survives resolvePastedEnd moves by exactly `offset`, so the whole + // stored path moves with it. Auto-routed edges re-route from their new ends. const points = - edge.routingMode === 'manual' && edge.points && hasFreePastedEnd + edge.routingMode === 'manual' && edge.points ? edge.points.map((point) => ({ x: point.x + offset.x, y: point.y + offset.y })) : edge.points; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/detach-on-node-delete.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/detach-on-node-delete.ts index d08dc93c2..845affb75 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/detach-on-node-delete.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/detach-on-node-delete.ts @@ -1,6 +1,6 @@ import type { FlowCore } from '../../flow-core'; import type { Edge, EdgeEnd } from '../../types'; -import { computeDetachAnchor } from '../../utils'; +import { alignManualPointsPatch, computeDetachAnchor } from '../../utils'; /** * How the edges incident to a set of deleted nodes are handled: removed along @@ -8,7 +8,7 @@ import { computeDetachAnchor } from '../../utils'; */ export interface IncidentEdgesPartition { edgesToRemove: string[]; - edgesToUpdate: (Partial & { id: Edge['id'] })[]; + edgesToUpdate: (Partial & { id: string })[]; } /** @@ -41,7 +41,7 @@ export const partitionIncidentEdges = ( const detachEnabled = !!danglingEdges?.enabled && !!danglingEdges.detachOnNodeDelete; const edgesToRemove: string[] = []; - const edgesToUpdate: (Partial & { id: Edge['id'] })[] = []; + const edgesToUpdate: (Partial & { id: string })[] = []; const mayDetach = (edge: Edge, end: EdgeEnd, nodeId: string): boolean => { const node = flowCore.getNodeById(nodeId); @@ -67,19 +67,22 @@ export const partitionIncidentEdges = ( // dangling explicitly through the callback. const dualWithoutOptIn = sourceLost && targetLost && !danglingEdges?.shouldDetachOnNodeDelete; - if ( - !detachEnabled || - explicitlyDeletedEdgeIds?.has(edge.id) || - edge.computedHidden || - dualWithoutOptIn || - (sourceLost && !mayDetach(edge, 'source', edge.source)) || - (targetLost && !mayDetach(edge, 'target', edge.target)) - ) { + if (!detachEnabled || explicitlyDeletedEdgeIds?.has(edge.id) || edge.computedHidden || dualWithoutOptIn) { edgesToRemove.push(edge.id); continue; } - const update: Partial & { id: Edge['id'] } = { id: edge.id }; + // Both decisions are computed eagerly: the callback sees every end this + // edge loses, so an app can count or log them per end without a + // short-circuit hiding the second call. + const sourceOk = !sourceLost || mayDetach(edge, 'source', edge.source); + const targetOk = !targetLost || mayDetach(edge, 'target', edge.target); + if (!sourceOk || !targetOk) { + edgesToRemove.push(edge.id); + continue; + } + + const update: Partial & { id: string } = { id: edge.id }; if (sourceLost) { const anchor = computeDetachAnchor(edge, 'source', flowCore.getNodeById(edge.source)); if (!anchor) { @@ -89,6 +92,7 @@ export const partitionIncidentEdges = ( update.source = ''; update.sourcePort = undefined; update.sourcePosition = anchor; + Object.assign(update, alignManualPointsPatch(edge, 'source', anchor)); } if (targetLost) { const anchor = computeDetachAnchor(edge, 'target', flowCore.getNodeById(edge.target)); @@ -99,6 +103,8 @@ export const partitionIncidentEdges = ( update.target = ''; update.targetPort = undefined; update.targetPosition = anchor; + // Built on the patch above so an edge losing both ends keeps points[0]. + Object.assign(update, alignManualPointsPatch({ ...edge, ...update }, 'target', anchor)); } edgesToUpdate.push(update); } diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/finish-linking-to-position.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/finish-linking-to-position.test.ts index 077ee78c0..3fc0e860c 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/finish-linking-to-position.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/finish-linking-to-position.test.ts @@ -15,8 +15,9 @@ describe('finishLinkingToPosition', () => { let mockCommandHandler: CommandHandler; let mockFlowCore: { getState: ReturnType; + getNodeById: ReturnType; applyUpdate: ReturnType; - config: object; + config: { danglingEdges?: { enabled: boolean; shouldKeepOnDrop?: ReturnType } }; actionStateManager: { linking: LinkingActionState | null; clearLinking: ReturnType; @@ -37,8 +38,11 @@ describe('finishLinkingToPosition', () => { mockFlowCore = { getState: vi.fn(), + // A drop to a position keeps a dangling edge, so the feature is on and + // the source node is visible unless a test says otherwise. + getNodeById: vi.fn().mockReturnValue({ id: 'source-node', position: { x: 0, y: 0 }, data: {} }), applyUpdate: vi.fn().mockResolvedValue(undefined), - config: {}, + config: { danglingEdges: { enabled: true } }, actionStateManager: { linking: null, clearLinking: vi.fn(), @@ -269,6 +273,81 @@ describe('finishLinkingToPosition', () => { expect(mockFlowCore.actionStateManager.clearLinking).toHaveBeenCalled(); }); + it('should claim the teardown so a racing cancel no-ops', async () => { + const linking: InternalLinkingActionState = { + sourceNodeId: 'source-node', + sourcePortId: 'source-port', + temporaryEdge: mockTemporaryEdge, + }; + mockFlowCore.actionStateManager.linking = linking; + mockCreateFinalEdge.mockReturnValue({ id: 'final-edge', source: 'source-node', target: '', data: {} }); + + await finishLinkingToPosition(mockCommandHandler, { name: 'finishLinkingToPosition', position: { x: 1, y: 2 } }); + + // clearLinkingForGesture replaces the manager's slot, not this object. + expect(linking._finishing).toBe(true); + }); + + it('should cancel with noTarget when dangling edges are off', async () => { + mockFlowCore.config.danglingEdges = { enabled: false }; + const linking: InternalLinkingActionState = { + sourceNodeId: 'source-node', + sourcePortId: 'source-port', + temporaryEdge: mockTemporaryEdge, + }; + mockFlowCore.actionStateManager.linking = linking; + + await finishLinkingToPosition(mockCommandHandler, { name: 'finishLinkingToPosition', position: { x: 1, y: 2 } }); + + expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith({}, 'finishLinking'); + expect(mockFlowCore.applyUpdate).not.toHaveBeenCalledWith( + expect.objectContaining({ edgesToAdd: expect.anything() }), + 'finishLinking' + ); + expect(linking.cancelReason).toBe('noTarget'); + expect(mockFlowCore.actionStateManager.clearLinking).toHaveBeenCalled(); + }); + + it('should cancel with noTarget when shouldKeepOnDrop declines the edge', async () => { + const shouldKeepOnDrop = vi.fn().mockReturnValue(false); + mockFlowCore.config.danglingEdges = { enabled: true, shouldKeepOnDrop }; + const linking: InternalLinkingActionState = { + sourceNodeId: 'source-node', + sourcePortId: 'source-port', + temporaryEdge: mockTemporaryEdge, + }; + mockFlowCore.actionStateManager.linking = linking; + mockCreateFinalEdge.mockReturnValue({ id: 'final-edge', source: 'source-node', target: '', data: {} }); + + await finishLinkingToPosition(mockCommandHandler, { name: 'finishLinkingToPosition', position: { x: 1, y: 2 } }); + + expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith({}, 'finishLinking'); + expect(mockFlowCore.applyUpdate).not.toHaveBeenCalledWith( + expect.objectContaining({ edgesToAdd: expect.anything() }), + 'finishLinking' + ); + expect(linking.cancelReason).toBe('noTarget'); + expect(mockFlowCore.actionStateManager.clearLinking).toHaveBeenCalled(); + }); + + it('should pass the built final edge and the drop position to shouldKeepOnDrop', async () => { + const shouldKeepOnDrop = vi.fn().mockReturnValue(true); + mockFlowCore.config.danglingEdges = { enabled: true, shouldKeepOnDrop }; + const finalEdge = { id: 'final-edge', source: 'source-node', target: '', data: {} }; + mockFlowCore.actionStateManager.linking = { + sourceNodeId: 'source-node', + sourcePortId: 'source-port', + temporaryEdge: mockTemporaryEdge, + }; + mockCreateFinalEdge.mockReturnValue(finalEdge); + + await finishLinkingToPosition(mockCommandHandler, { name: 'finishLinkingToPosition', position: { x: 7, y: 8 } }); + + // The callback decides on the edge that would actually be committed. + expect(shouldKeepOnDrop).toHaveBeenCalledWith(finalEdge, { x: 7, y: 8 }); + expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith({ edgesToAdd: [finalEdge] }, 'finishLinking'); + }); + it('should not clear a different gesture that replaced the state while the update was in flight', async () => { const ownGesture: InternalLinkingActionState = { sourceNodeId: 'source-node', diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/finish-linking.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/finish-linking.test.ts index c00fbc75b..79a68faf8 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/finish-linking.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/finish-linking.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { FlowCore } from '../../../../flow-core'; -import { mockNode } from '../../../../test-utils'; +import { mockNode, mockPort } from '../../../../test-utils'; import type { CommandHandler, Edge, LinkingActionState, Node } from '../../../../types'; import type { InternalLinkingActionState } from '../../../../types/action-state.interface'; import { finishLinking } from '../finish-linking'; @@ -25,6 +25,7 @@ describe('finishLinking', () => { let mockFlowCore: { getState: ReturnType; getNodeById: ReturnType; + getNearestPortInRange: ReturnType; applyUpdate: ReturnType; config: object; actionStateManager: { @@ -64,6 +65,7 @@ describe('finishLinking', () => { mockFlowCore = { getState: vi.fn(), getNodeById: vi.fn(), + getNearestPortInRange: vi.fn().mockReturnValue(null), applyUpdate: vi.fn().mockResolvedValue(undefined), config: {}, actionStateManager: { @@ -489,7 +491,10 @@ describe('finishLinking', () => { }; beforeEach(() => { - mockFlowCore.config = { danglingEdges: { enabled: true, detachOnNodeDelete: false } }; + mockFlowCore.config = { + danglingEdges: { enabled: true, detachOnNodeDelete: false }, + linking: { portSnapDistance: 10 }, + }; mockFlowCore.actionStateManager.linking = { sourceNodeId: 'source-node', sourcePortId: 'source-port', @@ -510,6 +515,10 @@ describe('finishLinking', () => { expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith({ edgesToAdd: [keptEdge] }, 'finishLinking'); expect(mockFlowCore.actionStateManager.linking!.cancelReason).toBeUndefined(); expect(mockFlowCore.actionStateManager.clearLinking).toHaveBeenCalled(); + // The drop is hit-tested at the drop position: no port there is what + // makes it an empty-canvas drop. + expect(mockFlowCore.getNearestPortInRange).toHaveBeenCalledWith({ x: 50, y: 60 }, 10); + expect(mockFlowCore.getNearestPortInRange).toHaveReturnedWith(null); }); it('should commit the real kept-edge shape (real createFinalEdge, passthrough builders)', async () => { @@ -517,7 +526,7 @@ describe('finishLinking', () => { mockCreateFinalEdge.mockImplementation(actualUtils.createFinalEdge); mockFlowCore.config = { danglingEdges: { enabled: true }, - linking: { finalEdgeDataBuilder: (edge: Edge) => edge }, + linking: { portSnapDistance: 10, finalEdgeDataBuilder: (edge: Edge) => edge }, computeEdgeId: () => 'fresh-id', }; @@ -555,7 +564,7 @@ describe('finishLinking', () => { callOrder.push('shouldKeepOnDrop'); return true; }); - mockFlowCore.config = { danglingEdges: { enabled: true, shouldKeepOnDrop } }; + mockFlowCore.config = { danglingEdges: { enabled: true, shouldKeepOnDrop }, linking: { portSnapDistance: 10 } }; await finishLinking(mockCommandHandler, { name: 'finishLinking', position: { x: 50, y: 60 } }); @@ -567,7 +576,10 @@ describe('finishLinking', () => { }); it('should fall back to the cancelled pass when shouldKeepOnDrop declines', async () => { - mockFlowCore.config = { danglingEdges: { enabled: true, shouldKeepOnDrop: () => false } }; + mockFlowCore.config = { + danglingEdges: { enabled: true, shouldKeepOnDrop: () => false }, + linking: { portSnapDistance: 10 }, + }; await finishLinking(mockCommandHandler, { name: 'finishLinking', position: { x: 50, y: 60 } }); @@ -591,6 +603,40 @@ describe('finishLinking', () => { expect(mockFlowCore.actionStateManager.clearLinking).toHaveBeenCalled(); }); + it('should not keep a dangling edge when the drop lands on a port the edge cannot connect to', async () => { + const shouldKeepOnDrop = vi.fn(() => true); + mockFlowCore.config = { + danglingEdges: { enabled: true, shouldKeepOnDrop }, + linking: { portSnapDistance: 10 }, + }; + // The preview never snaps to a port the dragged end cannot take, so such + // a drop also arrives with an empty target — only the raw hit test tells + // it apart from empty canvas. + mockFlowCore.getNearestPortInRange.mockReturnValue({ ...mockPort, id: 'source-port', nodeId: 'source-node' }); + + await finishLinking(mockCommandHandler, { name: 'finishLinking', position: { x: 50, y: 60 } }); + + expect(mockFlowCore.getNearestPortInRange).toHaveBeenCalledWith({ x: 50, y: 60 }, 10); + expect(shouldKeepOnDrop).not.toHaveBeenCalled(); + expect(mockCreateFinalEdge).not.toHaveBeenCalled(); + expect(mockFlowCore.actionStateManager.linking!.cancelReason).toBe('noTarget'); + expect(mockFlowCore.applyUpdate).toHaveBeenCalledTimes(1); + expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith({}, 'finishLinking'); + expect(mockFlowCore.actionStateManager.clearLinking).toHaveBeenCalled(); + }); + + it('should not hit-test the drop when dangling edges are disabled', async () => { + mockFlowCore.config = { danglingEdges: { enabled: false }, linking: { portSnapDistance: 10 } }; + mockFlowCore.getNearestPortInRange.mockReturnValue({ ...mockPort, id: 'source-port', nodeId: 'source-node' }); + + await finishLinking(mockCommandHandler, { name: 'finishLinking', position: { x: 50, y: 60 } }); + + // With the feature off the drop is discarded without any hit test. + expect(mockFlowCore.getNearestPortInRange).not.toHaveBeenCalled(); + expect(mockFlowCore.actionStateManager.linking!.cancelReason).toBe('noTarget'); + expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith({}, 'finishLinking'); + }); + it('should not keep a dangling edge from an effectively hidden source', async () => { mockFlowCore.getNodeById.mockImplementation((id: string) => id === 'source-node' ? { ...mockNode, id: 'source-node', computedHidden: true } : null diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/finish-relinking.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/finish-relinking.test.ts index e7d93b379..4862f6b35 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/finish-relinking.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/finish-relinking.test.ts @@ -283,6 +283,67 @@ describe('finishRelinking', () => { }); describe('validation', () => { + it('should revert a target-end drop on a source-typed port instead of detaching it', async () => { + // A port that cannot take the dragged end is a refused connection: the + // dangling-edges feature must not turn it into a detach. + mockFlowCore.config.danglingEdges.enabled = true; + mockFlowCore.getNearestPortInRange.mockReturnValue(hitPort('out-c', 'node-c', 'source')); + const linking = setLinking('target', { source: 'node-a', sourcePort: 'out' }); + + await finishRelinking(mockCommandHandler, { name: 'finishRelinking', position: { x: 0, y: 0 } }); + + expect(linking.relinkCancelReason).toBe('invalidConnection'); + expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith({}, 'finishRelinking'); + expect(mockFlowCore.applyUpdate).not.toHaveBeenCalledWith( + expect.objectContaining({ edgesToUpdate: expect.anything() }), + 'finishRelinking' + ); + expect(mockFlowCore.config.linking.validateConnection).not.toHaveBeenCalled(); + }); + + it('should revert a source-end drop on a target-typed port instead of detaching it', async () => { + mockFlowCore.config.danglingEdges.enabled = true; + mockFlowCore.getNearestPortInRange.mockReturnValue(hitPort('in-c', 'node-c', 'target')); + const linking = setLinking('source', { target: 'node-b', targetPort: 'in' }); + + await finishRelinking(mockCommandHandler, { name: 'finishRelinking', position: { x: 0, y: 0 } }); + + expect(linking.relinkCancelReason).toBe('invalidConnection'); + expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith({}, 'finishRelinking'); + expect(mockFlowCore.applyUpdate).not.toHaveBeenCalledWith( + expect.objectContaining({ edgesToUpdate: expect.anything() }), + 'finishRelinking' + ); + expect(mockFlowCore.config.linking.validateConnection).not.toHaveBeenCalled(); + }); + + it('should revert a target-end drop on the fixed source node when the edge has no source port', async () => { + // With no source port the fixed end is the whole node, so any port of it + // is rejected as the target end — a detach would silently change the edge. + const sourceNodeWithInput: Node = { + ...sourceNode, + measuredPorts: [ + ...sourceNode.measuredPorts!, + { ...mockPort, id: 'in-a', type: 'target', side: 'left', nodeId: 'node-a' }, + ], + }; + mockFlowCore.config.danglingEdges.enabled = true; + mockFlowCore.getEdgeById.mockReturnValue({ ...originalEdge, sourcePort: undefined }); + mockFlowCore.getNodeById.mockImplementation((id: string) => (id === 'node-a' ? sourceNodeWithInput : nodes[id])); + mockFlowCore.getNearestPortInRange.mockReturnValue(hitPort('in-a', 'node-a', 'target')); + const linking = setLinking('target', { source: 'node-a', sourcePort: '' }); + + await finishRelinking(mockCommandHandler, { name: 'finishRelinking', position: { x: 0, y: 0 } }); + + expect(linking.relinkCancelReason).toBe('invalidConnection'); + expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith({}, 'finishRelinking'); + expect(mockFlowCore.applyUpdate).not.toHaveBeenCalledWith( + expect.objectContaining({ edgesToUpdate: expect.anything() }), + 'finishRelinking' + ); + expect(mockFlowCore.config.linking.validateConnection).not.toHaveBeenCalled(); + }); + it('should pass reason relink and the endpoints in their proper roles (target end)', async () => { mockFlowCore.getNearestPortInRange.mockReturnValue(hitPort('in-c', 'node-c', 'target')); setLinking('target', { source: 'node-a', sourcePort: 'out' }); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/linking-utils.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/linking-utils.test.ts index cc42b494c..7f4682263 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/linking-utils.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/linking-utils.test.ts @@ -153,6 +153,50 @@ describe('linking utils', () => { }; }); + it('should skip the validator while drawing before the dragged end snaps to a port', () => { + const result = validateConnection(core as unknown as FlowCore, 'node-c', 'in-c', 'node-c', undefined, false); + + expect(result).toBe(true); + expect(core.config.linking.validateConnection).not.toHaveBeenCalled(); + }); + + it('should validate a source-end relink preview whose dragged source snapped to a port', () => { + // Relinking the source swaps the roles: the dragged end is the source, + // so a port-less fixed target must not skip the validator. + core.config.linking.validateConnection.mockReturnValue(false); + const context = { reason: 'relink' as const, edge: mockEdge, end: 'source' as const }; + + const result = validateConnection( + core as unknown as FlowCore, + 'node-c', + 'in-c', + 'node-b', + undefined, + false, + context + ); + + expect(result).toBe(false); + expect(core.config.linking.validateConnection).toHaveBeenCalled(); + }); + + it('should skip the validator on a source-end relink preview before the dragged source snaps', () => { + const context = { reason: 'relink' as const, edge: mockEdge, end: 'source' as const }; + + const result = validateConnection( + core as unknown as FlowCore, + 'node-a', + undefined, + 'node-c', + 'in-c', + false, + context + ); + + expect(result).toBe(true); + expect(core.config.linking.validateConnection).not.toHaveBeenCalled(); + }); + it('should pass the given context through to the config validator', () => { const context = { reason: 'relink' as const, edge: mockEdge, end: 'target' as const }; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/finish-linking-to-position.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/finish-linking-to-position.ts index 7ad188987..67c6ddcf6 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/finish-linking-to-position.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/finish-linking-to-position.ts @@ -1,7 +1,7 @@ +import { buildKeptDanglingEdge, runCancelledFinishPass } from './finish-linking'; import { clearLinkingForGesture } from './linking-gesture'; import type { CommandHandler, Point } from '../../../types'; import type { InternalLinkingActionState } from '../../../types/action-state.interface'; -import { createFinalEdge } from './utils'; export interface FinishLinkingToPositionCommand { name: 'finishLinkingToPosition'; @@ -26,11 +26,13 @@ export const finishLinkingToPosition = async ( return; } + // Claims the teardown — a cancelLinking racing this finish must no-op. + linking._finishing = true; const gestureId = linking._gestureId; - // Same clear-in-finally + gesture-stamp guard as finishLinking — - // createFinalEdge runs user callbacks that can throw, and the awaited - // update pass can reject. + // Same clear-in-finally + gesture-stamp guard as finishLinking — building + // the final edge runs user callbacks that can throw, and the awaited update + // pass can reject. try { if (!temporaryEdge) { return; @@ -38,19 +40,17 @@ export const finishLinkingToPosition = async ( linking.dropPosition = position; - await commandHandler.flowCore.applyUpdate( - { - edgesToAdd: [ - createFinalEdge(commandHandler.flowCore.config, temporaryEdge, { - target: '', - // Free ends carry no port — undefined, never '' (see finishLinking). - targetPort: undefined, - targetPosition: position, - }), - ], - }, - 'finishLinking' - ); + // The drop lands on empty canvas by construction, so it goes through the + // same dangling-edges gate as finishLinking: feature flag, hidden source + // and shouldKeepOnDrop all decide whether an edge is kept. + const keptEdge = buildKeptDanglingEdge(commandHandler, temporaryEdge, position); + if (!keptEdge) { + linking.cancelReason = 'noTarget'; + await runCancelledFinishPass(commandHandler); + return; + } + + await commandHandler.flowCore.applyUpdate({ edgesToAdd: [keptEdge] }, 'finishLinking'); } finally { clearLinkingForGesture(commandHandler.flowCore.actionStateManager, gestureId); } diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/finish-linking.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/finish-linking.ts index 4a6874688..da546ce1f 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 @@ -23,7 +23,7 @@ export const runCancelledFinishPass = async (commandHandler: CommandHandler): Pr * callback runs so `shouldKeepOnDrop` sees what would actually be committed * (after `finalEdgeDataBuilder`). */ -const buildKeptDanglingEdge = ( +export const buildKeptDanglingEdge = ( commandHandler: CommandHandler, temporaryEdge: Edge, dropPosition: Point @@ -124,7 +124,17 @@ export const finishLinking = async (commandHandler: CommandHandler, command: Fin const targetPortId = targetPort || undefined; if (!targetNodeId) { - const keptEdge = buildKeptDanglingEdge(commandHandler, temporaryEdge, linking.dropPosition); + const { config } = commandHandler.flowCore; + // A drop over a port the preview refused (wrong direction, or the source's + // own port) is not an empty-canvas drop: no dangling edge is kept and the + // draw cancels with 'noTarget' exactly as with the feature off. + const droppedOnPort = + !!config.danglingEdges?.enabled && + !!commandHandler.flowCore.getNearestPortInRange(linking.dropPosition, config.linking.portSnapDistance); + + const keptEdge = droppedOnPort + ? null + : buildKeptDanglingEdge(commandHandler, temporaryEdge, linking.dropPosition); if (keptEdge) { await commandHandler.flowCore.applyUpdate({ edgesToAdd: [keptEdge] }, 'finishLinking'); return; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/finish-relinking.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/finish-relinking.ts index 79da9db52..1766ea7c3 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/finish-relinking.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/finish-relinking.ts @@ -55,10 +55,12 @@ export const finishRelinking = async (commandHandler: CommandHandler, command: F } // Hit-test the drop position itself rather than trusting the preview: - // moveTemporaryEdge un-snaps candidates the validator rejects, so the - // preview can't distinguish "no port under the cursor" from "port the - // validator refused" — and the latter must report invalidConnection. + // moveTemporaryEdge un-snaps candidates it rejects, so the preview can't + // distinguish "no port under the cursor" from "port that cannot take this + // end". The raw hit classifies the drop: no port at all is an empty-canvas + // drop, a port the end cannot take is a refused connection. const dropPortInfo = getTargetPortInfo(commandHandler, dropPosition, temporaryEdge, end); + const { hitPort } = dropPortInfo; const candidateNodeId = dropPortInfo.targetNodeId || undefined; const candidatePortId = dropPortInfo.targetPortId || undefined; @@ -67,7 +69,7 @@ export const finishRelinking = async (commandHandler: CommandHandler, command: F // edgeRelinkStarted is kept through the cancelled revert pass). const originalNodeId = (end === 'source' ? edge.source : edge.target) || undefined; const originalPortId = (end === 'source' ? edge.sourcePort : edge.targetPort) || undefined; - if (candidateNodeId && candidateNodeId === originalNodeId && candidatePortId === originalPortId) { + if (hitPort && hitPort.nodeId === originalNodeId && hitPort.id === originalPortId) { await runRevertPass('cancelled'); return; } @@ -80,13 +82,13 @@ export const finishRelinking = async (commandHandler: CommandHandler, command: F return; } - if (!candidateNodeId) { + if (!hitPort) { // Dropped on empty canvas — detach the endpoint when dangling edges are // enabled and the per-edge callback keeps the detached edge. This is not // a connection, so the connection validator is not consulted. const { danglingEdges } = flowCore.config; if (danglingEdges?.enabled) { - const detachUpdate: Partial & { id: Edge['id'] } = + const detachUpdate: Partial & { id: string } = end === 'target' ? { id: edgeId, target: '', targetPort: undefined, targetPosition: dropPosition } : { id: edgeId, source: '', sourcePort: undefined, sourcePosition: dropPosition }; @@ -103,9 +105,17 @@ export const finishRelinking = async (commandHandler: CommandHandler, command: F return; } - // Structural checks on the candidate end, mirroring finishLinking's - // validateTarget: hidden nodes, hidden ports, wrong-direction ports and - // ports that no longer exist are not valid drop targets. + // A port that cannot take this end — wrong direction, or the fixed end's + // own node/port — is a refused connection, not a detach: the edge reverts + // and the app validator is never consulted. + if (!candidateNodeId) { + await runRevertPass('invalidConnection'); + return; + } + + // The shared structural gate with attachEdge: the node must exist, be + // visible, and the port must be present, right-directed and not + // template-hidden. The gesture hit-test already guarantees most of it. if (!isValidEndpointTarget(flowCore, end, candidateNodeId, candidatePortId)) { await runRevertPass('invalidConnection'); return; @@ -126,7 +136,7 @@ export const finishRelinking = async (commandHandler: CommandHandler, command: F return; } - const reconnectUpdate: Partial & { id: Edge['id'] } = + const reconnectUpdate: Partial & { id: string } = end === 'target' ? { id: edgeId, target: candidateNodeId, targetPort: candidatePortId, targetPosition: undefined } : { id: edgeId, source: candidateNodeId, sourcePort: candidatePortId, sourcePosition: undefined }; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/move-temporary-edge.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/move-temporary-edge.ts index ec0b12111..5f0281f20 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/move-temporary-edge.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/move-temporary-edge.ts @@ -1,4 +1,4 @@ -import type { CommandHandler, Edge, EdgeEnd, Point } from '../../../types'; +import type { CommandHandler, Edge, EdgeEnd, Point, Port } from '../../../types'; import type { LinkingRelinkContext } from '../../../types/action-state.interface'; import { connectionContextForGesture, @@ -18,12 +18,16 @@ interface TargetPortInfo { targetNodeId: string; targetPortId: string; isValid: boolean; + /** The raw hit-test result, before the properness filter — null when the position is over no port at all. */ + hitPort: Port | null; } /** * Finds the port the dragged end of the temporary edge would snap to at * `position`. For a normal draw (and a target-end relink) the candidate must * be target-capable; while relinking the source end it must be source-capable. + * `hitPort` reports what the position is over regardless of properness, so + * callers can tell "no port here" from "port this end cannot take". */ export const getTargetPortInfo = ( commandHandler: CommandHandler, @@ -45,6 +49,7 @@ export const getTargetPortInfo = ( targetNodeId: isProperCandidate ? candidatePort.nodeId : '', targetPortId: isProperCandidate ? candidatePort.id : '', isValid: !!isProperCandidate, + hitPort: candidatePort ?? null, }; }; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/utils.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/utils.ts index cc9f72235..7627ade32 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/utils.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/utils.ts @@ -71,8 +71,12 @@ export const validateConnection = ( ? (targetNode?.measuredPorts?.find((port) => port.id === targetPortId) ?? null) : null; - // Dragging temporary edge case without snapping to target port - if (!isFinishLinking && sourcePort && !targetPort) { + // A preview pass has nothing to validate while the dragged end has not + // snapped to a port; a source-end relink drags the source, so the roles of + // the fixed and the dragged port are swapped. + const draggedEnd: EdgeEnd = context?.reason === 'relink' ? (context.end ?? 'target') : 'target'; + const draggedEndUnsnapped = draggedEnd === 'target' ? sourcePort && !targetPort : targetPort && !sourcePort; + if (!isFinishLinking && draggedEndUnsnapped) { return true; } @@ -98,7 +102,9 @@ export const relinkPreviewBase = (edge: Edge): Partial => ({ routing: edge.routing, sourceArrowhead: edge.sourceArrowhead, targetArrowhead: edge.targetArrowhead, - // Labels stay visible on the preview while the endpoint is dragged. + // Labels are carried over so they stay visible while the endpoint is + // dragged; the routing middleware repositions them along the preview path + // on every pass. measuredLabels: edge.measuredLabels, }); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/__tests__/edges-routing-helpers.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/__tests__/edges-routing-helpers.test.ts index de1f9a374..2dd9aaa8b 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/__tests__/edges-routing-helpers.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/__tests__/edges-routing-helpers.test.ts @@ -806,6 +806,48 @@ describe('Edge Routing Helper Functions', () => { }); }); + it('should reposition the labels of a relink preview along the new points', () => { + const temporaryEdge: Edge = { + ...mockEdge, + id: 'temp-edge', + temporary: true, + routing: 'polyline', + measuredLabels: [{ id: 'label-1', positionOnEdge: 0.5, position: { x: 999, y: 999 } }], + }; + mockRoutingManager.computePointOnPath = vi.fn().mockReturnValue({ x: 50, y: 50 }); + + const result = createUpdatedTemporaryEdge( + temporaryEdge, + new Map(), + mockRoutingManager as EdgeRoutingManager, + 1000 + ); + + expect(mockRoutingManager.computePointOnPath).toHaveBeenCalledWith( + 'polyline', + [ + { x: 10, y: 10 }, + { x: 50, y: 50 }, + { x: 90, y: 90 }, + ], + 0.5 + ); + expect(result.measuredLabels).toEqual([{ id: 'label-1', positionOnEdge: 0.5, position: { x: 50, y: 50 } }]); + }); + + it('should keep measuredLabels undefined for a preview without labels', () => { + const temporaryEdge: Edge = { ...mockEdge, id: 'temp-edge', temporary: true }; + + const result = createUpdatedTemporaryEdge( + temporaryEdge, + new Map(), + mockRoutingManager as EdgeRoutingManager, + 1000 + ); + + expect(result.measuredLabels).toBeUndefined(); + }); + it('should preserve other edge properties', () => { const temporaryEdge: Edge = { ...mockEdge, diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/edges-routing.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/edges-routing.ts index 2370eee3c..1251f8288 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/edges-routing.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/edges-routing.ts @@ -200,6 +200,9 @@ export const createUpdatedTemporaryEdge = ( points, sourcePosition: sourcePoint, targetPosition: targetPoint, + // A relink preview carries the original edge's labels; repositioning them + // on every pass keeps them on the path that follows the pointer. + measuredLabels: updateLabelPositions(temporaryEdge, points ?? [], routingManager), computedZIndex: zIndex, }; }; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/model-lookup/model-lookup.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/model-lookup/model-lookup.ts index a1df72dd8..4239a084a 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/model-lookup/model-lookup.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/model-lookup/model-lookup.ts @@ -104,10 +104,10 @@ export class ModelLookup { } /** - * Committed edges with at least one free (unconnected) endpoint, rebuilt - * lazily after model changes like {@link connectedEdgesMap}. Keeps the - * per-frame consumers (virtualization, endpoint-snap queries) O(dangling) - * instead of O(all edges). + * Committed edges with at least one free (unconnected) endpoint. Rebuilt + * lazily after a state change with one pass over all edges (the same profile + * as {@link connectedEdgesMap}), then served from cache until the next + * change, so consumers iterate only the dangling subset. * @returns Dangling edges array */ get danglingEdges(): Edge[] { diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/visible-elements-resolver.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/visible-elements-resolver.ts index 2331b365c..debea96f9 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/visible-elements-resolver.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/visible-elements-resolver.ts @@ -1,6 +1,6 @@ import type { FlowCore } from '../../flow-core'; import type { Edge, Node, Rect } from '../../types'; -import { isGroup } from '../../utils'; +import { boundingRectOfPoints, doesRectsIntersect, isGroup } from '../../utils'; import type { RenderStrategyResult } from '../render-strategy.interface'; /** @@ -33,9 +33,9 @@ export class VisibleElementsResolver { edgeIds: Set, externalNodeIds: Set ): void { - // The model lookup keeps a cached list of dangling edges, so this stays - // O(dangling) — the virtualization guarantee (render cost independent of - // model size) holds when the feature is unused (the list is empty). + // The model lookup rebuilds its dangling-edge list lazily after a state + // change with one pass over all edges (like connectedEdgesMap) and serves + // it from cache until the next change; this loop iterates only that subset. for (const edge of this.flowCore.modelLookup.danglingEdges) { if (edgeIds.has(edge.id) || edge.computedHidden) { continue; @@ -72,18 +72,9 @@ export class VisibleElementsResolver { return false; } - let minX = Infinity; - let minY = Infinity; - let maxX = -Infinity; - let maxY = -Infinity; - for (const point of anchors) { - minX = Math.min(minX, point.x); - minY = Math.min(minY, point.y); - maxX = Math.max(maxX, point.x); - maxY = Math.max(maxY, point.y); - } - - return minX <= rect.x + rect.width && maxX >= rect.x && minY <= rect.y + rect.height && maxY >= rect.y; + // A bounding box that only touches the viewport edge counts as outside, + // like every other rect test in the engine. + return doesRectsIntersect(boundingRectOfPoints(anchors), rect); } private getPrimaryVisibleIds(viewportRect: Rect): Set { diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts index 3e52ecb75..a539df463 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts @@ -140,6 +140,10 @@ export interface DanglingEdgesConfig { * Master switch for dangling edges. When true, an edge draw that ends on * empty canvas keeps the edge as a dangling edge instead of discarding it, * and an edge relink dropped on empty canvas detaches that endpoint. + * + * A drop over a port the edge cannot connect to (for example a + * wrong-direction port) is not an empty-canvas drop and is discarded as + * before. * @default false */ enabled: boolean; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/__tests__/dangling-edges.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/__tests__/dangling-edges.test.ts index 55e7d697a..fc2bef09b 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/__tests__/dangling-edges.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/__tests__/dangling-edges.test.ts @@ -94,6 +94,12 @@ describe('dangling-edges utils', () => { expect(getDanglingEndpoints([temporaryEdge])).toEqual([]); }); + it('should skip effectively hidden edges', () => { + const hidden: Edge = { ...targetDanglingEdge, id: 'hidden', computedHidden: true }; + + expect(getDanglingEndpoints([hidden])).toEqual([]); + }); + it('should skip free endpoints without a stored position', () => { const positionless: Edge = { ...mockEdge, id: 'positionless', source: '', target: 'node-b' }; @@ -124,6 +130,14 @@ describe('dangling-edges utils', () => { expect(nearest).toBeNull(); }); + it('should not pick an effectively hidden edge even when it is the nearest', () => { + const hidden: Edge = { ...sourceDanglingEdge, id: 'hidden', computedHidden: true }; + + const nearest = getNearestDanglingEndpointInRange([hidden, targetDanglingEdge], { x: 10, y: 20 }, 1000); + + expect(nearest).toEqual({ edge: targetDanglingEdge, end: 'target', position: { x: 100, y: 200 } }); + }); + it('should return null for connected edges only', () => { expect(getNearestDanglingEndpointInRange([connectedEdge], { x: 0, y: 0 }, 1000)).toBeNull(); }); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/dangling-edges.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/dangling-edges.ts index 00b2509b2..92a8f9e6f 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/dangling-edges.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/dangling-edges.ts @@ -53,7 +53,8 @@ export const isDanglingEdge = (edge: Edge): boolean => hasFreeEndpoint(edge); /** * Collects the free endpoints of the given edges. A dual dangling edge yields * two entries. Endpoints whose anchor position is missing are skipped — - * they cannot be rendered or snapped to. + * they cannot be rendered or snapped to. Temporary and effectively hidden + * edges are skipped. * * @public * @since 1.4.0 @@ -62,7 +63,7 @@ export const isDanglingEdge = (edge: Edge): boolean => hasFreeEndpoint(edge); export const getDanglingEndpoints = (edges: readonly Edge[]): DanglingEndpoint[] => { const endpoints: DanglingEndpoint[] = []; for (const edge of edges) { - if (edge.temporary) { + if (edge.temporary || edge.computedHidden) { continue; } if (!edge.source && edge.sourcePosition) { @@ -127,7 +128,7 @@ export const alignManualPointsPatch = (edge: Edge, end: EdgeEnd, anchor: Point): /** * Finds the free edge endpoint nearest to `point` within `range`, or null when * none is close enough. Sibling of `getNearestPortInRange` for snapping to - * dangling ends. + * dangling ends. Temporary and effectively hidden edges are skipped. * * @public * @since 1.4.0 @@ -161,7 +162,7 @@ export const getNearestDanglingEndpointInRange = ( }; for (const edge of edges) { - if (edge.temporary) { + if (edge.temporary || edge.computedHidden) { continue; } if (!edge.source) { diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/public-services/ng-diagram-model.service.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/public-services/ng-diagram-model.service.ts index d0cbaa9a8..231c7f7bd 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/public-services/ng-diagram-model.service.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/public-services/ng-diagram-model.service.ts @@ -171,27 +171,31 @@ export class NgDiagramModelService extends NgDiagramBaseService implements OnDes /** * Collects the free (unconnected) endpoints of all committed edges — a dual - * dangling edge yields two entries. + * dangling edge yields two entries. Temporary and effectively hidden edges + * are skipped. * @returns Dangling endpoints with their edge, end and anchor position. * @since 1.4.0 */ getDanglingEndpoints(): DanglingEndpoint[] { - // The model lookup caches the dangling-edge list — O(dangling), not O(edges). + // The model lookup rebuilds its dangling-edge list lazily after a state + // change with one pass over all edges, then serves it from cache until the + // next change; this walks only the dangling subset. return getDanglingEndpoints(this.flowCore.modelLookup.danglingEdges); } /** * Gets the free edge endpoint nearest to a point within a range — the - * dangling-edges sibling of {@link getNearestPortInRange}. + * dangling-edges sibling of {@link getNearestPortInRange}. Temporary and + * effectively hidden edges are skipped. * @param point Point to check from. * @param range Range to check in. * @returns Nearest dangling endpoint in range or null. * @since 1.4.0 */ getNearestDanglingEndpointInRange(point: Point, range: number): DanglingEndpoint | null { - // The model lookup caches the dangling-edge list, so calling this per - // pointermove costs O(dangling) like getNearestPortInRange's spatial hash - // profile — not a scan over every edge. + // The model lookup rebuilds its dangling-edge list lazily after a state + // change with one pass over all edges, then serves it from cache until the + // next change; each call walks only the dangling subset. return getNearestDanglingEndpointInRange(this.flowCore.modelLookup.danglingEdges, point, range); } From ff9efe7d050b83729635e6ec9651cc2dc9989f48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Ja=C5=BAwa?= Date: Thu, 17 Sep 2026 08:21:31 +0200 Subject: [PATCH 05/10] Improvements to public api for relinking --- .gitignore | 5 ++ CHANGELOG.md | 8 +-- apps/angular-demo/src/app/app.component.ts | 8 +-- .../Components/NgDiagramBaseEdgeComponent.md | 2 +- .../docs/api/Components/NgDiagramComponent.md | 2 +- .../Features/DanglingEdgesConfig.md | 7 ++- .../Features/EdgeRelinkingConfig.md | 31 ---------- .../Configuration/Features/LinkingConfig.md | 27 ++++++++ .../api/Types/Configuration/FlowConfig.md | 12 ---- apps/docs/src/content/docs/api/_readme.md | 1 - apps/docs/src/content/docs/changelog.mdx | 13 ++++ .../docs/guides/edges/dangling-edges.mdx | 2 +- .../src/content/docs/guides/edges/edges.mdx | 2 +- .../content/docs/guides/edges/relinking.mdx | 6 +- .../src/content/docs/guides/flow-config.mdx | 3 +- apps/e2e/tests/dangling-relinking.spec.ts | 8 ++- .../ng-diagram/api-report/ng-diagram.api.md | 7 +-- .../__tests__/detach-on-node-delete.test.ts | 61 +++++++++++++++++-- .../command-handler/commands/copy-paste.ts | 13 ++-- .../commands/detach-on-node-delete.ts | 58 +++++++++++++----- .../finish-linking-to-position.test.ts | 18 ++++++ .../linking/__tests__/finish-linking.test.ts | 43 +++++++++++++ .../__tests__/finish-relinking.test.ts | 14 +++-- .../linking/__tests__/linking-utils.test.ts | 61 +++++++++++++++++++ .../linking/__tests__/start-relinking.test.ts | 9 ++- .../commands/linking/finish-linking.ts | 4 +- .../commands/linking/start-relinking.ts | 2 +- .../command-handler/commands/linking/utils.ts | 5 ++ .../src/flow-config/default-flow-config.ts | 7 +-- .../get-source-target-positions.ts | 6 +- .../core/src/types/flow-config.interface.ts | 52 +++++++--------- .../diagram/ng-diagram.component.ts | 2 +- .../edge/base-edge/base-edge.component.ts | 4 +- .../relinking-gesture.service.spec.ts | 14 +++-- .../input-events/relinking-gesture.service.ts | 2 +- .../projects/ng-diagram/src/public-api.ts | 1 - 36 files changed, 358 insertions(+), 162 deletions(-) delete mode 100644 apps/docs/src/content/docs/api/Types/Configuration/Features/EdgeRelinkingConfig.md diff --git a/.gitignore b/.gitignore index 5c04304cc..8f79a19dd 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,8 @@ __tmp__* # Kiro .kiro + +# Codex +.codex +.agents +AGENTS.md diff --git a/CHANGELOG.md b/CHANGELOG.md index fa210877f..f8faeeef7 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 - **`EdgeDrawEndedEvent.source` is now optional** — a draw can start from a position instead of a node (`startLinkingFromPosition`). Read it as `event.source?.id`. Breaking only for apps compiled with `strictNullChecks` that read `event.source` without a check ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) - **`edgeDrawEnded` reports success for a canvas drop kept as a dangling edge** — with `danglingEdges.enabled`, such a drop fires with `success: true` and the created `edge`, and without `target`/`targetPort` (before, every canvas drop cancelled with `noTarget`). If your app created its own edge on `reason === 'noTarget'`, remove that code before enabling the flag, or edges are created twice. The deprecated `edgeDrawn` never fires for kept dangling edges ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) -- **`linking.validateConnection` receives an optional fifth argument** — a `ConnectionValidationContext` with `reason: 'draw' | 'relink' | 'attach'` and, for relink and attach, the `edge` and `end` being reconnected, so one validator can treat the three cases differently. `source` can now be `null`: for draws started with `startLinkingFromPosition` and when the other end of a relinked edge is free ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) +- **`linking.validateConnection` receives an optional fifth argument** — a `ConnectionValidationContext` with `reason: 'draw' | 'relink' | 'attach'` and, for relink and attach, the `edge` and `end` being reconnected, so one validator can treat the three cases differently. The `source` node argument can now be `null`: for draws started with `startLinkingFromPosition` and when the other end of a relinked edge is free ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) - **`SelectionRemovedEvent` has a new required `detachedEdges` field** — the edges kept as dangling edges instead of deleted (`danglingEdges.detachOnNodeDelete`), `[]` when none. Breaking only for code that builds this event object by hand ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) - **`ModelActionType` gained `'startRelinking'`, `'finishRelinking'` and `'startLinkingFromPosition'`** — breaking for middlewares with exhaustive switches over the action type ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) - **Box selection counts dangling edges by their free-endpoint anchors** — a box drawn around a free endpoint's `sourcePosition`/`targetPosition` selects the edge ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) @@ -29,9 +29,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **Dangling edges** — edges with one or both ends not connected to a node: empty `source`/`target`, the free end anchored at `sourcePosition`/`targetPosition`. Opt in with the new `danglingEdges.enabled` config: a draw dropped on empty canvas then keeps the edge (`shouldKeepOnDrop` decides per edge) and a relink drop detaches the endpoint. Dangling edges route between their anchors, are saved and restored with the model, copy and paste together with their node, and box selection picks them by their anchors. New utilities `isDanglingEdge`, `hasFreeEndpoint`, `getDanglingEndpoints`, `getNearestDanglingEndpointInRange` and `computeDetachAnchor`; new `NgDiagramModelService.detachEdge`/`attachEdge` (attach validates through `linking.validateConnection` with `reason: 'attach'`); new CSS hooks `ng-diagram-edge--dangling` on the edge wrapper and `dangling` on `ng-diagram-base-edge` and its path ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) -- **Keep edges when deleting a node** — with `danglingEdges.detachOnNodeDelete`, deleting a node keeps its edges as dangling edges anchored where their port was, and `selectionRemoved.detachedEdges` lists them. `shouldDetachOnNodeDelete` decides per endpoint. An edge is still deleted when it is deleted explicitly, when it or the lost node is hidden, or when it loses both ends at once (unless the callback keeps both) ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) -- **Edge relinking** — reconnect an edge by dragging one of its ends. With the new `edgeRelinking.enabled` config, a selected edge shows a handle at each end; dragging a handle previews the new connection and commits it on drop. The drop is validated by `linking.validateConnection` with `reason: 'relink'`. A drop on empty canvas detaches the end when `danglingEdges.enabled` allows it; an invalid drop, a drop back on the same port and Escape leave the edge unchanged. New `edgeRelinkStarted`/`edgeRelinkEnded` events report the gesture and its result (`noTarget`, `invalidConnection` or `cancelled` on failure). The handles have a touch-friendly hit area of about 12 px at any zoom; style them with the `--ngd-relink-handle-size/-fill/-fill-hover/-stroke/-stroke-width` theme variables or per edge with `--edge-relink-handle-*`. Custom edge templates can render their own handles with the new `RelinkHandleDirective` ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) +- **Dangling edges** — edges with one or both ends not connected to a node. A free end has `source`/`target` set to the empty string `''` (never `null` or `undefined`), no `sourcePort`/`targetPort` (`undefined`), and its position in `sourcePosition`/`targetPosition`. Opt in with the new `danglingEdges.enabled` config: a draw dropped on empty canvas then keeps the edge (`shouldKeepOnDrop` decides per edge) and a relink drop detaches the endpoint. Dangling edges route between their anchors, are saved and restored with the model, copy and paste together with their node, and box selection picks them by their anchors. New utilities `isDanglingEdge`, `hasFreeEndpoint`, `getDanglingEndpoints`, `getNearestDanglingEndpointInRange` and `computeDetachAnchor`; new `NgDiagramModelService.detachEdge`/`attachEdge` (attach validates through `linking.validateConnection` with `reason: 'attach'`); new CSS hooks `ng-diagram-edge--dangling` on the edge wrapper and `dangling` on `ng-diagram-base-edge` and its path ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) +- **Keep edges when deleting a node** — with `danglingEdges.detachOnNodeDelete`, deleting a node keeps its edges as dangling edges anchored where their port was, and `selectionRemoved.detachedEdges` lists them. `shouldDetachOnNodeDelete` decides per endpoint. An edge is still deleted when it is deleted explicitly, when it is hidden only because of the node it loses (an edge that stays hidden on its own is detached like any other), or when it loses both ends at once (unless the callback keeps both) ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) +- **Edge relinking** — reconnect an edge by dragging one of its ends. With the new `linking.relinkingEnabled` config, a selected edge shows a handle at each end; dragging a handle previews the new connection and commits it on drop. The drop is validated by `linking.validateConnection` with `reason: 'relink'`. A drop on empty canvas detaches the end when `danglingEdges.enabled` allows it; an invalid drop, a drop back on the same port and Escape leave the edge unchanged. New `edgeRelinkStarted`/`edgeRelinkEnded` events report the gesture and its result (`noTarget`, `invalidConnection` or `cancelled` on failure). The handles have a touch-friendly hit area of about 12 px at any zoom; style them with the `--ngd-relink-handle-size/-fill/-fill-hover/-stroke/-stroke-width` theme variables or per edge with `--edge-relink-handle-*`. Custom edge templates can render their own handles with the new `RelinkHandleDirective` ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) - **`NgDiagramService.startLinkingFromPosition(position)`** — starts drawing an edge from a point on the canvas (flow coordinates) instead of from a node; the free end follows the pointer until a click connects it to a port or leaves a dual dangling edge on empty canvas. Requires `danglingEdges.enabled` (ignored with a console warning otherwise) ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) - **`hidden` flag on nodes and edges** — new `hidden?: boolean` property on `SimpleNode` and `Edge` makes hidden content a first-class concept: hidden elements stay mounted in the DOM as `display: none`, keep their geometry, never block initialization or `waitForMeasurements`, and re-measure automatically when unhidden. Effective visibility is derived — hiding a group hides all of its descendants, and an edge is hidden when either endpoint node is hidden — and exposed as the system-computed `computedHidden` property. Visible nodes, edges and labels never get an inline `display` value, so existing CSS-based workarounds keep working (ports keep managing their own inline `display` as before — hide them via the port's `hidden` input, not CSS). Note for upgrades: the library now owns the `hidden` property name on nodes and edges — pre-existing app-level `hidden: true` data activates these semantics after upgrading ([#799](https://github.com/synergycodes/ng-diagram/pull/799)) - **Hidden elements are excluded from every interactive surface** — effectively hidden nodes and edges are not hit-testable and don't participate in `selectAll`, keyboard move, drag, box selection, linking (starting a link from a hidden node is a no-op with a console warning), `zoomToFit`/`computePartsBounds` bounds, virtualization, edge routing, or the resize constraints of their group (a collapsed group is freely resizable — its hidden children reappear where they were on expand). The minimap mirrors the canvas: effectively hidden nodes are not drawn (neither as default rectangles nor as custom minimap templates) and don't inflate the minimap's diagram bounds, in both rendering modes. Programmatic APIs (`select`, `centerOnNode`, z-order and group commands) deliberately do not filter hidden elements. Hiding does not deselect: a hidden element keeps its `selected` flag but cannot be moved, and `deleteSelection` skips hidden selected elements (descendants of a deleted visible group and edges of deleted nodes are still removed) ([#799](https://github.com/synergycodes/ng-diagram/pull/799)) diff --git a/apps/angular-demo/src/app/app.component.ts b/apps/angular-demo/src/app/app.component.ts index 427ec3cae..a13ef4766 100644 --- a/apps/angular-demo/src/app/app.component.ts +++ b/apps/angular-demo/src/app/app.component.ts @@ -126,6 +126,9 @@ export class AppComponent { }, linking: { selectNodeOnPortPress: false, + // Every selected edge shows grabbable endpoint handles — drag one to + // reconnect it to another port or drop it on empty canvas to detach it. + relinkingEnabled: true, }, // Dangling edges: a link drawn onto empty canvas is kept (with a free // endpoint), and deleting a node detaches its edges instead of deleting @@ -134,11 +137,6 @@ export class AppComponent { enabled: true, detachOnNodeDelete: true, }, - // Every selected edge shows grabbable endpoint handles — drag one to - // reconnect it to another port or drop it on empty canvas to detach it. - edgeRelinking: { - enabled: true, - }, shortcuts: configureShortcuts([ { actionName: 'keyboardMoveSelectionUp', diff --git a/apps/docs/src/content/docs/api/Components/NgDiagramBaseEdgeComponent.md b/apps/docs/src/content/docs/api/Components/NgDiagramBaseEdgeComponent.md index 6328d01f3..9f72f029e 100644 --- a/apps/docs/src/content/docs/api/Components/NgDiagramBaseEdgeComponent.md +++ b/apps/docs/src/content/docs/api/Components/NgDiagramBaseEdgeComponent.md @@ -49,7 +49,7 @@ viewport scale — at zoom 0.5 the visible 5px circle alone would leave a > `readonly` **relinkHandlesVisible**: `Signal`\<`boolean`\> Endpoint handles for the relinking gesture — rendered on selected, -committed edges when `edgeRelinking.enabled` is true. +committed edges when `linking.relinkingEnabled` is true. #### Since diff --git a/apps/docs/src/content/docs/api/Components/NgDiagramComponent.md b/apps/docs/src/content/docs/api/Components/NgDiagramComponent.md index 29d707bed..06d026606 100644 --- a/apps/docs/src/content/docs/api/Components/NgDiagramComponent.md +++ b/apps/docs/src/content/docs/api/Components/NgDiagramComponent.md @@ -92,7 +92,7 @@ dangling on empty canvas, or reverted (invalid drop or cancelled gesture). > **edgeRelinkStarted**: `EventEmitter`\<[`EdgeRelinkStartedEvent`](/docs/api/types/events/edgerelinkstartedevent/)\> Event emitted when the user starts dragging an endpoint of an existing -edge (the relinking gesture, see `edgeRelinking` config). +edge (the relinking gesture, see `linking.relinkingEnabled` config). #### Since diff --git a/apps/docs/src/content/docs/api/Types/Configuration/Features/DanglingEdgesConfig.md b/apps/docs/src/content/docs/api/Types/Configuration/Features/DanglingEdgesConfig.md index 069093fae..27360d37d 100644 --- a/apps/docs/src/content/docs/api/Types/Configuration/Features/DanglingEdgesConfig.md +++ b/apps/docs/src/content/docs/api/Types/Configuration/Features/DanglingEdgesConfig.md @@ -26,8 +26,11 @@ Requires `enabled` to be true. An edge is still deleted, never detached, when: - it is itself part of the deleted selection (an explicit delete wins), -- it or the lost endpoint's node is effectively hidden (detaching would - materialize invisible wiring as visible dangling edges), or +- it is hidden only because of the node it loses — detaching would make + invisible wiring (e.g. the collapsed children of a deleted group) appear + as a visible dangling edge; an edge that stays hidden on its own (its + `hidden` flag, a template binding, or a hidden other endpoint) is + detached like any other and remains hidden, or - it loses BOTH endpoints in the same delete — it becomes a dual dangling edge only when [shouldDetachOnNodeDelete](/docs/api/types/configuration/features/danglingedgesconfig/#shoulddetachonnodedelete) is provided and returns true for both ends. diff --git a/apps/docs/src/content/docs/api/Types/Configuration/Features/EdgeRelinkingConfig.md b/apps/docs/src/content/docs/api/Types/Configuration/Features/EdgeRelinkingConfig.md deleted file mode 100644 index e814fc75d..000000000 --- a/apps/docs/src/content/docs/api/Types/Configuration/Features/EdgeRelinkingConfig.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -version: "since v1.4.0" -editUrl: false -next: false -prev: false -title: "EdgeRelinkingConfig" ---- - -Configuration for interactive edge relinking — dragging an endpoint of an -existing edge to reconnect it to another port or leave it dangling. - -## Properties - -### enabled - -> **enabled**: `boolean` - -Enables the relinking gesture. When true, a selected edge shows grabbable -endpoint handles; dragging one previews the reconnection live and commits -it on drop. Dropping on empty canvas leaves the endpoint dangling when -`danglingEdges.enabled` is true, otherwise the relink is reverted. - -Connections made by relinking are validated through -`linking.validateConnection`, which receives a context with -`reason: 'relink'` and the edge being relinked. - -#### Default - -```ts -false -``` diff --git a/apps/docs/src/content/docs/api/Types/Configuration/Features/LinkingConfig.md b/apps/docs/src/content/docs/api/Types/Configuration/Features/LinkingConfig.md index e9ddc5e73..9727b77c9 100644 --- a/apps/docs/src/content/docs/api/Types/Configuration/Features/LinkingConfig.md +++ b/apps/docs/src/content/docs/api/Types/Configuration/Features/LinkingConfig.md @@ -97,6 +97,33 @@ The maximum distance (in pixels) at temporary edge will snap to target port. *** +### relinkingEnabled + +> **relinkingEnabled**: `boolean` + +Enables edge relinking — dragging an endpoint of an existing edge to +another port. When true, a selected edge shows grabbable endpoint +handles; dragging one previews the reconnection live and commits it on +drop. Dropping on empty canvas leaves the endpoint dangling when +`danglingEdges.enabled` is true, otherwise the relink is reverted. + +The gesture shares this section's snap distance, edge panning and +temporary edge builder. Its drops are validated through +`validateConnection`, which receives a context with `reason: 'relink'` +and the edge being relinked. + +#### Default + +```ts +false +``` + +#### Since + +1.4.0 + +*** + ### selectNodeOnPortPress > **selectNodeOnPortPress**: `boolean` diff --git a/apps/docs/src/content/docs/api/Types/Configuration/FlowConfig.md b/apps/docs/src/content/docs/api/Types/Configuration/FlowConfig.md index 317110564..87ca6f4ab 100644 --- a/apps/docs/src/content/docs/api/Types/Configuration/FlowConfig.md +++ b/apps/docs/src/content/docs/api/Types/Configuration/FlowConfig.md @@ -96,18 +96,6 @@ Configuration options for the default Node Templates *** -### edgeRelinking - -> **edgeRelinking**: [`EdgeRelinkingConfig`](/docs/api/types/configuration/features/edgerelinkingconfig/) - -Configuration for interactive edge relinking. - -#### Since - -1.4.0 - -*** - ### edgeRouting > **edgeRouting**: [`EdgeRoutingConfig`](/docs/api/types/configuration/features/edgeroutingconfig/) diff --git a/apps/docs/src/content/docs/api/_readme.md b/apps/docs/src/content/docs/api/_readme.md index cf42adde6..cd73b9441 100644 --- a/apps/docs/src/content/docs/api/_readme.md +++ b/apps/docs/src/content/docs/api/_readme.md @@ -76,7 +76,6 @@ title: "ng-diagram" - [ConnectionValidationContext](/docs/api/types/configuration/features/connectionvalidationcontext/) - [DanglingEdgesConfig](/docs/api/types/configuration/features/danglingedgesconfig/) - [DefaultNodeTemplateConfig](/docs/api/types/configuration/features/defaultnodetemplateconfig/) -- [EdgeRelinkingConfig](/docs/api/types/configuration/features/edgerelinkingconfig/) - [EdgeRoutingConfig](/docs/api/types/configuration/features/edgeroutingconfig/) - [GroupingConfig](/docs/api/types/configuration/features/groupingconfig/) - [LinkingConfig](/docs/api/types/configuration/features/linkingconfig/) diff --git a/apps/docs/src/content/docs/changelog.mdx b/apps/docs/src/content/docs/changelog.mdx index 783b2bfb1..7800079c4 100644 --- a/apps/docs/src/content/docs/changelog.mdx +++ b/apps/docs/src/content/docs/changelog.mdx @@ -19,6 +19,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **`EdgeDrawEndedEvent.source` is now optional** — a draw can start from a position instead of a node (`startLinkingFromPosition`). Read it as `event.source?.id`. Breaking only for apps compiled with `strictNullChecks` that read `event.source` without a check ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) +- **`edgeDrawEnded` reports success for a canvas drop kept as a dangling edge** — with `danglingEdges.enabled`, such a drop fires with `success: true` and the created `edge`, and without `target`/`targetPort` (before, every canvas drop cancelled with `noTarget`). If your app created its own edge on `reason === 'noTarget'`, remove that code before enabling the flag, or edges are created twice. The deprecated `edgeDrawn` never fires for kept dangling edges ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) +- **`linking.validateConnection` receives an optional fifth argument** — a `ConnectionValidationContext` with `reason: 'draw' | 'relink' | 'attach'` and, for relink and attach, the `edge` and `end` being reconnected, so one validator can treat the three cases differently. The `source` node argument can now be `null`: for draws started with `startLinkingFromPosition` and when the other end of a relinked edge is free ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) +- **`SelectionRemovedEvent` has a new required `detachedEdges` field** — the edges kept as dangling edges instead of deleted (`danglingEdges.detachOnNodeDelete`), `[]` when none. Breaking only for code that builds this event object by hand ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) +- **`ModelActionType` gained `'startRelinking'`, `'finishRelinking'` and `'startLinkingFromPosition'`** — breaking for middlewares with exhaustive switches over the action type ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) +- **Box selection counts dangling edges by their free-endpoint anchors** — a box drawn around a free endpoint's `sourcePosition`/`targetPosition` selects the edge ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) +- **Paste moves free endpoints** — pasting moves the free endpoints of dangling edges together with the rest of the content (for manual-routing edges also their `points`), and paste at a position centers on nodes and free endpoints alike ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) +- **Z-index recomputes when an edge's `source`/`target` changes programmatically** — after `updateEdge` or `attachEdge` changes an endpoint, the edge is layered relative to its new nodes ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) - **`ng-diagram-palette-item-preview` takes no space in the layout** — the preview renders nothing on the page, whatever content it holds and however the palette is laid out. It is a block, so inline content placed next to it inside a palette item now starts on a new line ([#801](https://github.com/synergycodes/ng-diagram/pull/801)) - **`NgDiagramPaletteItemPreviewComponent.preview` is for reading content and size** — the element behind this signal is not rendered in the page flow. Read the preview's natural size or content from it; its position is meaningless ([#801](https://github.com/synergycodes/ng-diagram/pull/801)) - **The drag image zoom is capped at 3x** — at higher zoom levels the image under the cursor stays at 3x, so starting a drag stays smooth; the dropped node still lands at the current zoom ([#801](https://github.com/synergycodes/ng-diagram/pull/801)) @@ -26,6 +34,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Dangling edges** — edges with one or both ends not connected to a node. A free end has `source`/`target` set to the empty string `''` (never `null` or `undefined`), no `sourcePort`/`targetPort` (`undefined`), and its position in `sourcePosition`/`targetPosition`. Opt in with the new `danglingEdges.enabled` config: a draw dropped on empty canvas then keeps the edge (`shouldKeepOnDrop` decides per edge) and a relink drop detaches the endpoint. Dangling edges route between their anchors, are saved and restored with the model, copy and paste together with their node, and box selection picks them by their anchors. New utilities `isDanglingEdge`, `hasFreeEndpoint`, `getDanglingEndpoints`, `getNearestDanglingEndpointInRange` and `computeDetachAnchor`; new `NgDiagramModelService.detachEdge`/`attachEdge` (attach validates through `linking.validateConnection` with `reason: 'attach'`); new CSS hooks `ng-diagram-edge--dangling` on the edge wrapper and `dangling` on `ng-diagram-base-edge` and its path ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) +- **Keep edges when deleting a node** — with `danglingEdges.detachOnNodeDelete`, deleting a node keeps its edges as dangling edges anchored where their port was, and `selectionRemoved.detachedEdges` lists them. `shouldDetachOnNodeDelete` decides per endpoint. An edge is still deleted when it is deleted explicitly, when it is hidden only because of the node it loses (an edge that stays hidden on its own is detached like any other), or when it loses both ends at once (unless the callback keeps both) ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) +- **Edge relinking** — reconnect an edge by dragging one of its ends. With the new `linking.relinkingEnabled` config, a selected edge shows a handle at each end; dragging a handle previews the new connection and commits it on drop. The drop is validated by `linking.validateConnection` with `reason: 'relink'`. A drop on empty canvas detaches the end when `danglingEdges.enabled` allows it; an invalid drop, a drop back on the same port and Escape leave the edge unchanged. New `edgeRelinkStarted`/`edgeRelinkEnded` events report the gesture and its result (`noTarget`, `invalidConnection` or `cancelled` on failure). The handles have a touch-friendly hit area of about 12 px at any zoom; style them with the `--ngd-relink-handle-size/-fill/-fill-hover/-stroke/-stroke-width` theme variables or per edge with `--edge-relink-handle-*`. Custom edge templates can render their own handles with the new `RelinkHandleDirective` ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) +- **`NgDiagramService.startLinkingFromPosition(position)`** — starts drawing an edge from a point on the canvas (flow coordinates) instead of from a node; the free end follows the pointer until a click connects it to a port or leaves a dual dangling edge on empty canvas. Requires `danglingEdges.enabled` (ignored with a console warning otherwise) ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) - **`hidden` flag on nodes and edges** — new `hidden?: boolean` property on `SimpleNode` and `Edge` makes hidden content a first-class concept: hidden elements stay mounted in the DOM as `display: none`, keep their geometry, never block initialization or `waitForMeasurements`, and re-measure automatically when unhidden. Effective visibility is derived — hiding a group hides all of its descendants, and an edge is hidden when either endpoint node is hidden — and exposed as the system-computed `computedHidden` property. Visible nodes, edges and labels never get an inline `display` value, so existing CSS-based workarounds keep working (ports keep managing their own inline `display` as before — hide them via the port's `hidden` input, not CSS). Note for upgrades: the library now owns the `hidden` property name on nodes and edges — pre-existing app-level `hidden: true` data activates these semantics after upgrading ([#799](https://github.com/synergycodes/ng-diagram/pull/799)) - **Hidden elements are excluded from every interactive surface** — effectively hidden nodes and edges are not hit-testable and don't participate in `selectAll`, keyboard move, drag, box selection, linking (starting a link from a hidden node is a no-op with a console warning), `zoomToFit`/`computePartsBounds` bounds, virtualization, edge routing, or the resize constraints of their group (a collapsed group is freely resizable — its hidden children reappear where they were on expand). The minimap mirrors the canvas: effectively hidden nodes are not drawn (neither as default rectangles nor as custom minimap templates) and don't inflate the minimap's diagram bounds, in both rendering modes. Programmatic APIs (`select`, `centerOnNode`, z-order and group commands) deliberately do not filter hidden elements. Hiding does not deselect: a hidden element keeps its `selected` flag but cannot be moved, and `deleteSelection` skips hidden selected elements (descendants of a deleted visible group and edges of deleted nodes are still removed) ([#799](https://github.com/synergycodes/ng-diagram/pull/799)) - **Declarative `hidden` in templates** — `ng-diagram-port` and `ng-diagram-base-edge-label` accept a `hidden` input to hide ports and labels independently of their owner (e.g. ports visible only in an edit mode); a hidden port is not a linking target or a port-snap candidate, and edges attached to it keep its last measured geometry as their anchor. The new `NgDiagramHiddenDirective` (`[ngDiagramHidden]`) hides a node or edge from inside its template — model flag, template binding, or inheritance: any source hides the element. Template-driven visibility changes are coalesced: any number of bindings toggling in one change-detection cycle cost a single recompute pass (bulk hiding through the model `hidden` flag remains the cheapest path). Not supported with virtualization (hiding would unmount the declaring template): the binding is then ignored with a console warning — use the model `hidden` flag instead ([#799](https://github.com/synergycodes/ng-diagram/pull/799)) @@ -35,6 +47,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Arrow keys pan the viewport when the selection cannot move** — with `nodeDraggingEnabled: false` and a node selected, the arrow keys used to do nothing. The same happened when only `draggable: false` nodes were selected. Now the arrow keys pan the viewport whenever the selection would not move; with node dragging enabled, a movable selection still moves as before ([#809](https://github.com/synergycodes/ng-diagram/pull/809)) - **`zoomToFit({ nodeIds })` frames only the requested nodes** — without `edgeIds`, the fit used to include every edge of the diagram, so fitting a few nodes in a connected diagram zoomed out to the whole network, and fitting only hidden nodes framed the whole diagram instead of doing nothing. Now only edges with both ends among the fitted nodes count; passing `edgeIds` works as before ([#808](https://github.com/synergycodes/ng-diagram/pull/808)) +- **Pasted manual-routing edges keep their shape at the new location** — an edge with `routingMode: 'manual'` pasted together with its nodes used to be drawn at the original location; its `points` now move with the pasted nodes ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) - **Pasting an edge without its nodes no longer duplicates the original connection** — an edge copied without one or both of its endpoint nodes used to be pasted between the original nodes, adding an identical connection on every paste. The end whose node was not copied is now pasted as a free end that moves with the rest of the pasted content, so you can move it or reconnect it. Cutting an edge on its own and pasting it therefore gives a free edge, not the original connection. Edges copied together with their nodes are pasted between the new nodes, as before ([#807](https://github.com/synergycodes/ng-diagram/pull/807)) - **`deleteNodes` on a group deletes its whole subtree** — `NgDiagramModelService.deleteNodes` now cascades to all descendants of the deleted nodes and removes their edges, matching `deleteSelection`. Previously the children survived with a `groupId` pointing at a node that no longer existed, and children of a hidden group reappeared as orphans. To delete a group but keep its children, call `NgDiagramGroupsService.removeFromGroup` first ([#806](https://github.com/synergycodes/ng-diagram/pull/806)) - **Palette preview no longer bleeds over the palette or stretches the page** — at high zoom levels `ng-diagram-palette-item-preview` no longer paints over the palette items and no longer adds scrollbars to the page, whatever the layout around the palette ([#801](https://github.com/synergycodes/ng-diagram/pull/801)) diff --git a/apps/docs/src/content/docs/guides/edges/dangling-edges.mdx b/apps/docs/src/content/docs/guides/edges/dangling-edges.mdx index ea5563b71..02839e166 100644 --- a/apps/docs/src/content/docs/guides/edges/dangling-edges.mdx +++ b/apps/docs/src/content/docs/guides/edges/dangling-edges.mdx @@ -94,7 +94,7 @@ const config: NgDiagramConfig = { The detach rules are: - **An explicit delete always wins.** An edge that is itself part of the deleted selection is deleted, never detached. -- **Hidden elements always delete.** An effectively hidden edge, or an endpoint losing an effectively hidden node (for example the collapsed children of a deleted group), is deleted — detaching would materialize invisible wiring as visible dangling edges. +- **Hidden wiring never materializes.** An edge that is hidden only because of the node it loses (for example the edges of the collapsed children of a deleted group) is deleted — detaching it would make invisible wiring appear as a visible dangling edge. An edge that stays hidden on its own — through its `hidden` flag, a template binding, or a hidden surviving endpoint — is detached like any other edge and remains hidden. - **An edge losing both endpoints in one delete is deleted by default.** It becomes a dual dangling edge only when `shouldDetachOnNodeDelete` is defined and returns `true` for both ends. - Deleting a node with the keyboard Delete key follows the same rules — the detached edges are reported in the [`selectionRemoved`](/docs/api/types/events/selectionremovedevent) event (see [Events](#events) below). diff --git a/apps/docs/src/content/docs/guides/edges/edges.mdx b/apps/docs/src/content/docs/guides/edges/edges.mdx index 03fee7538..b6bf27f79 100644 --- a/apps/docs/src/content/docs/guides/edges/edges.mdx +++ b/apps/docs/src/content/docs/guides/edges/edges.mdx @@ -219,7 +219,7 @@ onEdgeDrawEnded(event: EdgeDrawEndedEvent) { Two opt-in features extend what users can do with existing edges: -- **[Relinking](/docs/guides/edges/relinking)** — with [`edgeRelinking.enabled`](/docs/api/types/configuration/features/edgerelinkingconfig/#enabled), selected edges show endpoint handles that users can drag to reconnect the edge to another port. +- **[Relinking](/docs/guides/edges/relinking)** — with [`linking.relinkingEnabled`](/docs/api/types/configuration/features/linkingconfig/#relinkingenabled), selected edges show endpoint handles that users can drag to reconnect the edge to another port. - **[Dangling edges](/docs/guides/edges/dangling-edges)** — with [`danglingEdges.enabled`](/docs/api/types/configuration/features/danglingedgesconfig/#enabled), edges may have free (unconnected) endpoints: a draw or relink dropped on empty canvas keeps the edge, and deleting a node can detach its edges instead of deleting them. ## Selection During Linking diff --git a/apps/docs/src/content/docs/guides/edges/relinking.mdx b/apps/docs/src/content/docs/guides/edges/relinking.mdx index 9ce449070..deb096003 100644 --- a/apps/docs/src/content/docs/guides/edges/relinking.mdx +++ b/apps/docs/src/content/docs/guides/edges/relinking.mdx @@ -13,12 +13,12 @@ The **edge relinking** feature lets users reconnect existing edges by dragging t ## Enabling Relinking -Relinking is controlled by the [`edgeRelinking`](/docs/api/types/configuration/features/edgerelinkingconfig) configuration: +Relinking is controlled by the [`linking.relinkingEnabled`](/docs/api/types/configuration/features/linkingconfig/#relinkingenabled) flag: ```typescript const config: NgDiagramConfig = { - edgeRelinking: { - enabled: true, + linking: { + relinkingEnabled: true, }, }; ``` diff --git a/apps/docs/src/content/docs/guides/flow-config.mdx b/apps/docs/src/content/docs/guides/flow-config.mdx index 937ccb9aa..f1b1a1e84 100644 --- a/apps/docs/src/content/docs/guides/flow-config.mdx +++ b/apps/docs/src/content/docs/guides/flow-config.mdx @@ -78,10 +78,9 @@ The most important categories include: - [`boxSelection`](/docs/api/types/configuration/features/boxselectionconfig): Configures box selection behavior. - [`danglingEdges`](/docs/api/types/configuration/features/danglingedgesconfig): Enables edges with free (unconnected) endpoints — see [Dangling Edges](/docs/guides/edges/dangling-edges). - [`debugMode`](/docs/api/types/configuration/flowconfig/#debugmode): Enables verbose logging for development and debugging. -- [`edgeRelinking`](/docs/api/types/configuration/features/edgerelinkingconfig): Enables reconnecting existing edges by dragging their endpoints — see [Relinking](/docs/guides/edges/relinking). - [`edgeRouting`](/docs/api/types/configuration/features/edgeroutingconfig): Defines edge routing algorithms and their parameters. - [`grouping`](/docs/api/types/configuration/features/groupingconfig): Enables node grouping and related logic. -- [`linking`](/docs/api/types/configuration/features/linkingconfig): Customizes edge creation and connection validation. +- [`linking`](/docs/api/types/configuration/features/linkingconfig): Customizes edge creation and connection validation, and enables edge relinking through `linking.relinkingEnabled` — see [Relinking](/docs/guides/edges/relinking). - [`nodeRotation`](/docs/api/types/configuration/features/noderotationconfig): Enables and customizes node rotation and snapping. - [`resize`](/docs/api/types/configuration/features/resizeconfig): Manages node resizing logic, minimum sizes, and resizability. - [`shortcuts`](/docs/api/types/configuration/shortcuts/shortcutdefinition): Defines keyboard shortcuts for diagram actions. diff --git a/apps/e2e/tests/dangling-relinking.spec.ts b/apps/e2e/tests/dangling-relinking.spec.ts index 0b425103e..fc6b43961 100644 --- a/apps/e2e/tests/dangling-relinking.spec.ts +++ b/apps/e2e/tests/dangling-relinking.spec.ts @@ -4,7 +4,7 @@ import { pair, trio } from './fixtures/models'; /** * Dangling edges (config `danglingEdges`) and edge relinking - * (config `edgeRelinking`) — both opt-in, default off. + * (config `linking.relinkingEnabled`) — both opt-in, default off. */ /** Both free endpoints set — a dual dangling edge plus one anchored node. */ @@ -197,7 +197,7 @@ test.describe('dangling edges', () => { }); test.describe('edge relinking', () => { - const relinkOn = { edgeRelinking: { enabled: true } }; + const relinkOn = { linking: { relinkingEnabled: true } }; test('handles render only on selected edges and only when enabled', async ({ diagram }) => { await diagram.load({ model: trio }); @@ -549,7 +549,7 @@ test.describe('edge relinking', () => { test.describe('edge relinking on touch', () => { test.use({ hasTouch: true }); - const relinkOn = { edgeRelinking: { enabled: true } }; + const relinkOn = { linking: { relinkingEnabled: true } }; /** Dispatch a raw CDP touch sequence (Playwright's touchscreen has no drag). */ async function touchSequence( @@ -626,6 +626,8 @@ test.describe('startLinkingFromPosition', () => { const [edge] = await diagram.model.edges(); expect(edge).toMatchObject({ source: '', target: 'node-b', targetPort: 'port-left' }); expect(edge.sourcePosition).toEqual(startFlow); + // A free end has no port: undefined, never the preview's ''. + expect(edge.sourcePort).toBeUndefined(); }); test('a draw started from a position kept on empty canvas becomes a dual dangling edge', async ({ diagram }) => { diff --git a/packages/ng-diagram/api-report/ng-diagram.api.md b/packages/ng-diagram/api-report/ng-diagram.api.md index cdea21978..de220c0ae 100644 --- a/packages/ng-diagram/api-report/ng-diagram.api.md +++ b/packages/ng-diagram/api-report/ng-diagram.api.md @@ -314,11 +314,6 @@ export interface EdgeRelinkEndedEvent { targetPort?: string; } -// @public -export interface EdgeRelinkingConfig { - enabled: boolean; -} - // @public export interface EdgeRelinkStartedEvent { edge: Edge; @@ -408,7 +403,6 @@ export interface FlowConfig { danglingEdges: DanglingEdgesConfig; debugMode: boolean; defaultNode?: DefaultNodeTemplateConfig; - edgeRelinking: EdgeRelinkingConfig; edgeRouting: EdgeRoutingConfig; grouping: GroupingConfig; hideWatermark?: boolean; @@ -584,6 +578,7 @@ export interface LinkingConfig { edgePanningThreshold: number; finalEdgeDataBuilder: (defaultFinalEdgeData: Edge) => Edge; portSnapDistance: number; + relinkingEnabled: boolean; selectNodeOnPortPress: boolean; temporaryEdgeDataBuilder: (defaultTemporaryEdgeData: Edge) => Edge; validateConnection: (source: Node_2 | null, sourcePort: Port | null, target: Node_2 | null, targetPort: Port | null, context?: ConnectionValidationContext) => boolean; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/detach-on-node-delete.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/detach-on-node-delete.test.ts index 7a4b4c794..ef964f483 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/detach-on-node-delete.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/detach-on-node-delete.test.ts @@ -55,7 +55,11 @@ describe('partitionIncidentEdges', () => { target: 'node-y', }; - let mockFlowCore: { config: { danglingEdges?: Partial }; getNodeById: ReturnType }; + let mockFlowCore: { + config: { danglingEdges?: Partial }; + getNodeById: ReturnType; + templateVisibilityRegistry?: { isEdgeHidden: (edgeId: string) => boolean }; + }; const flowCore = () => mockFlowCore as unknown as FlowCore; @@ -191,24 +195,69 @@ describe('partitionIncidentEdges', () => { expect(result.edgesToUpdate).toEqual([]); }); - it('should remove the edge when the lost endpoint node is effectively hidden', () => { + it('should remove an edge hidden only through the lost endpoint node', () => { mockFlowCore.getNodeById.mockImplementation((id: string) => id === 'node-a' ? { ...nodeWithPort, computedHidden: true } : id === 'node-b' ? nodeWithoutPorts : undefined ); + // The edge carries the hidden stamp derived from its hidden endpoint; + // once that endpoint is gone nothing would keep it hidden. + const hiddenEdge: Edge = { ...incidentEdge, computedHidden: true }; - const result = partitionIncidentEdges(flowCore(), [incidentEdge], new Set(['node-a'])); + const result = partitionIncidentEdges(flowCore(), [hiddenEdge], new Set(['node-a'])); expect(result.edgesToRemove).toEqual(['edge-ab']); expect(result.edgesToUpdate).toEqual([]); }); - it('should remove an effectively hidden edge instead of detaching it', () => { + it('should detach an edge hidden by its own flag and leave it hidden', () => { + const hiddenEdge: Edge = { ...incidentEdge, hidden: true, computedHidden: true }; + + const result = partitionIncidentEdges(flowCore(), [hiddenEdge], new Set(['node-a'])); + + expect(result.edgesToRemove).toEqual([]); + expect(result.edgesToUpdate).toEqual([ + { id: 'edge-ab', source: '', sourcePort: undefined, sourcePosition: portAnchor }, + ]); + }); + + it('should detach an edge hidden by a template binding', () => { + mockFlowCore.templateVisibilityRegistry = { isEdgeHidden: (edgeId) => edgeId === 'edge-ab' }; const hiddenEdge: Edge = { ...incidentEdge, computedHidden: true }; const result = partitionIncidentEdges(flowCore(), [hiddenEdge], new Set(['node-a'])); - expect(result.edgesToRemove).toEqual(['edge-ab']); - expect(result.edgesToUpdate).toEqual([]); + expect(result.edgesToRemove).toEqual([]); + expect(result.edgesToUpdate).toEqual([ + { id: 'edge-ab', source: '', sourcePort: undefined, sourcePosition: portAnchor }, + ]); + }); + + it('should detach an edge whose surviving endpoint is hidden', () => { + mockFlowCore.getNodeById.mockImplementation((id: string) => + id === 'node-a' ? nodeWithPort : id === 'node-b' ? { ...nodeWithoutPorts, computedHidden: true } : undefined + ); + const hiddenEdge: Edge = { ...incidentEdge, computedHidden: true }; + + const result = partitionIncidentEdges(flowCore(), [hiddenEdge], new Set(['node-a'])); + + expect(result.edgesToRemove).toEqual([]); + expect(result.edgesToUpdate).toEqual([ + { id: 'edge-ab', source: '', sourcePort: undefined, sourcePosition: portAnchor }, + ]); + }); + + it('should detach an edge hidden by its own flag even when the lost node is hidden', () => { + mockFlowCore.getNodeById.mockImplementation((id: string) => + id === 'node-a' ? { ...nodeWithPort, computedHidden: true } : id === 'node-b' ? nodeWithoutPorts : undefined + ); + const hiddenEdge: Edge = { ...incidentEdge, hidden: true, computedHidden: true }; + + const result = partitionIncidentEdges(flowCore(), [hiddenEdge], new Set(['node-a'])); + + expect(result.edgesToRemove).toEqual([]); + expect(result.edgesToUpdate).toEqual([ + { id: 'edge-ab', source: '', sourcePort: undefined, sourcePosition: portAnchor }, + ]); }); it('should delete the edges of hidden children when a collapsed group cascade is deleted', () => { diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/copy-paste.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/copy-paste.ts index dc288929c..5c5bd5ae8 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/copy-paste.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/copy-paste.ts @@ -273,12 +273,10 @@ export const copy = async (commandHandler: CommandHandler) => { const copiedNodes = nodes.filter((node) => copiedNodeIds.has(node.id)); - // Edges: explicitly selected visible edges, plus every edge fully inside the - // copied node set (the internal wiring of copied groups, hidden or not). - // With dangling edges enabled, "fully inside" counts only the connected - // endpoints — a dangling edge travels with its one node (a dual dangling - // edge still only copies when selected). With the feature off the old rule - // applies unchanged, so the same model copies identically to before. + // "Fully inside" the copied node set: with dangling edges enabled only the + // connected endpoints count, so a dangling edge travels with its one node (a + // dual dangling edge still only copies when selected). With the feature off + // the old rule applies unchanged, so the same model copies identically. const danglingEnabled = commandHandler.flowCore.config.danglingEdges?.enabled; const isInsideCopiedSet = (edge: Edge): boolean => { if (danglingEnabled) { @@ -287,6 +285,9 @@ export const copy = async (commandHandler: CommandHandler) => { } return copiedNodeIds.has(edge.source) && copiedNodeIds.has(edge.target); }; + + // Edges: explicitly selected visible edges, plus every edge fully inside the + // copied node set (the internal wiring of copied groups, hidden or not). const copiedEdges = edges.filter((edge) => (edge.selected && !edge.computedHidden) || isInsideCopiedSet(edge)); commandHandler.flowCore.actionStateManager.copyPaste = { diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/detach-on-node-delete.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/detach-on-node-delete.ts index 845affb75..1cbbb049d 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/detach-on-node-delete.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/detach-on-node-delete.ts @@ -1,6 +1,7 @@ import type { FlowCore } from '../../flow-core'; import type { Edge, EdgeEnd } from '../../types'; import { alignManualPointsPatch, computeDetachAnchor } from '../../utils'; +import { isEdgeRawHidden } from '../../visibility/effective-visibility'; /** * How the edges incident to a set of deleted nodes are handled: removed along @@ -21,9 +22,11 @@ export interface IncidentEdgesPartition { * its port was) unless: * - it is part of `explicitlyDeletedEdgeIds` (e.g. selected in a * deleteSelection) — an explicit delete always wins, or - * - the edge itself or the lost endpoint's node is effectively hidden — - * detaching would materialize invisible wiring (e.g. the collapsed children - * of a deleted group) as visible dangling edges, or + * - it is hidden only through the node(s) it loses (e.g. the collapsed + * children of a deleted group) — detaching would materialize invisible + * wiring as a visible dangling edge; an edge that stays hidden on its own + * (its `hidden` flag, a template binding, a hidden surviving endpoint) + * detaches like any other, or * - `shouldDetachOnNodeDelete` returns false for any endpoint being lost — * then the whole edge is deleted, or * - the edge loses BOTH endpoints in the same cascade — such an edge is @@ -45,9 +48,7 @@ export const partitionIncidentEdges = ( const mayDetach = (edge: Edge, end: EdgeEnd, nodeId: string): boolean => { const node = flowCore.getNodeById(nodeId); - // A hidden lost endpoint must not demote its (hidden) edge into a visible - // dangling edge. - if (!node || node.computedHidden) { + if (!node) { return false; } if (!danglingEdges?.shouldDetachOnNodeDelete) { @@ -56,6 +57,20 @@ export const partitionIncidentEdges = ( return danglingEdges.shouldDetachOnNodeDelete(edge, node, end); }; + // A hidden edge is deleted only when the detach would make it visible: it is + // hidden through the node(s) it loses and nothing else keeps it hidden (own + // `hidden` flag, template binding, hidden surviving endpoint). That is the + // invisible wiring of e.g. a deleted collapsed group, which must not + // reappear as a dangling edge. An edge that stays hidden detaches like a + // visible one — hiding is not a reason to lose wiring the user kept. + const wouldBecomeVisible = (edge: Edge, sourceLost: boolean, targetLost: boolean): boolean => { + if (!edge.computedHidden || isEdgeRawHidden(edge, flowCore.templateVisibilityRegistry)) { + return false; + } + const survivingNodeId = sourceLost && targetLost ? '' : sourceLost ? edge.target : edge.source; + return !(survivingNodeId && flowCore.getNodeById(survivingNodeId)?.computedHidden); + }; + for (const edge of edges) { const sourceLost = nodesToDeleteIds.has(edge.source); const targetLost = nodesToDeleteIds.has(edge.target); @@ -67,7 +82,12 @@ export const partitionIncidentEdges = ( // dangling explicitly through the callback. const dualWithoutOptIn = sourceLost && targetLost && !danglingEdges?.shouldDetachOnNodeDelete; - if (!detachEnabled || explicitlyDeletedEdgeIds?.has(edge.id) || edge.computedHidden || dualWithoutOptIn) { + if ( + !detachEnabled || + explicitlyDeletedEdgeIds?.has(edge.id) || + wouldBecomeVisible(edge, sourceLost, targetLost) || + dualWithoutOptIn + ) { edgesToRemove.push(edge.id); continue; } @@ -82,17 +102,20 @@ export const partitionIncidentEdges = ( continue; } - const update: Partial & { id: string } = { id: edge.id }; + let update: Partial & { id: string } = { id: edge.id }; if (sourceLost) { const anchor = computeDetachAnchor(edge, 'source', flowCore.getNodeById(edge.source)); if (!anchor) { edgesToRemove.push(edge.id); continue; } - update.source = ''; - update.sourcePort = undefined; - update.sourcePosition = anchor; - Object.assign(update, alignManualPointsPatch(edge, 'source', anchor)); + update = { + ...update, + source: '', + sourcePort: undefined, + sourcePosition: anchor, + ...alignManualPointsPatch(edge, 'source', anchor), + }; } if (targetLost) { const anchor = computeDetachAnchor(edge, 'target', flowCore.getNodeById(edge.target)); @@ -100,11 +123,14 @@ export const partitionIncidentEdges = ( edgesToRemove.push(edge.id); continue; } - update.target = ''; - update.targetPort = undefined; - update.targetPosition = anchor; // Built on the patch above so an edge losing both ends keeps points[0]. - Object.assign(update, alignManualPointsPatch({ ...edge, ...update }, 'target', anchor)); + update = { + ...update, + target: '', + targetPort: undefined, + targetPosition: anchor, + ...alignManualPointsPatch({ ...edge, ...update }, 'target', anchor), + }; } edgesToUpdate.push(update); } diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/finish-linking-to-position.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/finish-linking-to-position.test.ts index 3fc0e860c..a304e6491 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/finish-linking-to-position.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/finish-linking-to-position.test.ts @@ -211,6 +211,24 @@ describe('finishLinkingToPosition', () => { expect(createFinalEdgeCall.targetPosition).toEqual(position); }); + it('should keep a connected source port untouched', async () => { + mockFlowCore.actionStateManager.linking = { + sourceNodeId: 'source-node', + sourcePortId: 'source-port', + temporaryEdge: mockTemporaryEdge, + }; + mockCreateFinalEdge.mockReturnValue({ id: 'final-edge', source: 'source-node', target: '', data: {} }); + + await finishLinkingToPosition(mockCommandHandler, { + name: 'finishLinkingToPosition', + position: { x: 300, y: 400 }, + }); + + // The partial must not mention sourcePort, so the temporary edge's real + // port survives the spread in createFinalEdge. + expect(mockCreateFinalEdge.mock.calls[0][2]).not.toHaveProperty('sourcePort'); + }); + it('should clear temporary edge', async () => { const position = { x: 500, y: 600 }; const finalEdge = { id: 'final-edge', source: 'source-node', target: '', data: {} }; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/finish-linking.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/finish-linking.test.ts index 79a68faf8..040b6cdd8 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/finish-linking.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/finish-linking.test.ts @@ -473,6 +473,49 @@ describe('finishLinking', () => { expect(mockFlowCore.actionStateManager.clearLinking).toHaveBeenCalled(); }); + it('should commit an undefined source port when a draw started from a position lands on a port', async () => { + // startLinkingFromPosition draws with a free source; after the first + // moveTemporaryEdge the preview carries sourcePort: '' for that end. The + // committed edge uses the model shape of a free end: an undefined port. + const actualUtils = await vi.importActual('../utils'); + mockCreateFinalEdge.mockImplementation(actualUtils.createFinalEdge); + mockFlowCore.config = { + danglingEdges: { enabled: true }, + linking: { portSnapDistance: 10, finalEdgeDataBuilder: (edge: Edge) => edge }, + computeEdgeId: () => 'fresh-id', + }; + const freeSourceTemporaryEdge: Edge = { + ...mockTemporaryEdge, + source: '', + sourcePort: '', + sourcePosition: { x: 10, y: 20 }, + }; + const targetPosition = { x: 150, y: 250 }; + + mockFlowCore.actionStateManager.linking = { + sourceNodeId: '', + sourcePortId: '', + temporaryEdge: freeSourceTemporaryEdge, + }; + mockValidateConnection.mockReturnValue(true); + mockFlowCore.getNodeById.mockImplementation((id: string) => (id === 'target-node' ? mockTargetNode : undefined)); + mockGetPortFlowPosition.mockReturnValue(targetPosition); + + await finishLinking(mockCommandHandler, { name: 'finishLinking', position: { x: 0, y: 0 } }); + + const [added] = mockFlowCore.applyUpdate.mock.calls[0][0].edgesToAdd as Edge[]; + expect(added).toMatchObject({ + id: 'fresh-id', + source: '', + target: 'target-node', + targetPort: 'target-port', + targetPosition, + temporary: false, + }); + expect(added.sourcePosition).toEqual({ x: 10, y: 20 }); + expect(added.sourcePort).toBeUndefined(); + }); + describe('dangling edges: keep on drop', () => { const temporaryEdgeNoTarget: Edge = { ...mockTemporaryEdge, diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/finish-relinking.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/finish-relinking.test.ts index 4862f6b35..a6249bd51 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/finish-relinking.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/finish-relinking.test.ts @@ -18,8 +18,11 @@ describe('finishRelinking', () => { enabled: boolean; shouldKeepOnDrop?: ReturnType; }; - edgeRelinking: { enabled: boolean }; - linking: { portSnapDistance: number; validateConnection: ReturnType }; + linking: { + relinkingEnabled: boolean; + portSnapDistance: number; + validateConnection: ReturnType; + }; }; actionStateManager: { linking: LinkingActionState | null; @@ -87,8 +90,11 @@ describe('finishRelinking', () => { applyUpdate: vi.fn().mockResolvedValue(undefined), config: { danglingEdges: { enabled: false }, - edgeRelinking: { enabled: true }, - linking: { portSnapDistance: 12, validateConnection: vi.fn().mockReturnValue(true) }, + linking: { + relinkingEnabled: true, + portSnapDistance: 12, + validateConnection: vi.fn().mockReturnValue(true), + }, }, actionStateManager: { linking: null, diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/linking-utils.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/linking-utils.test.ts index 7f4682263..2dc2e5cef 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/linking-utils.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/linking-utils.test.ts @@ -127,6 +127,67 @@ describe('linking utils', () => { expect(finalEdge.target).toBe(''); expect(finalEdge.targetPort).toBeUndefined(); }); + + it('should normalize the port of a free source to undefined when the draw lands on a port', () => { + // A draw started from a position: after the first moveTemporaryEdge the + // preview carries sourcePort: '' for the fixed free end. + const temporaryEdge: Edge = { + ...mockEdge, + id: 'TEMPORARY_EDGE', + temporary: true, + source: '', + sourcePort: '', + sourcePosition: { x: 10, y: 20 }, + target: '', + targetPort: '', + }; + + const finalEdge = createFinalEdge(config, temporaryEdge, { + target: 'node-b', + targetPort: 'in', + targetPosition: { x: 50, y: 60 }, + }); + + expect(finalEdge.source).toBe(''); + expect(finalEdge.sourcePort).toBeUndefined(); + expect(finalEdge.sourcePosition).toEqual({ x: 10, y: 20 }); + expect(finalEdge.target).toBe('node-b'); + expect(finalEdge.targetPort).toBe('in'); + }); + + it('should normalize the port of a free target the caller left as an empty string', () => { + const temporaryEdge: Edge = { + ...mockEdge, + id: 'TEMPORARY_EDGE', + temporary: true, + source: 'node-a', + sourcePort: 'out', + target: '', + targetPort: '', + }; + + const finalEdge = createFinalEdge(config, temporaryEdge, { target: '', targetPosition: { x: 50, y: 60 } }); + + expect(finalEdge.target).toBe(''); + expect(finalEdge.targetPort).toBeUndefined(); + }); + + it('should leave the ports of connected ends untouched', () => { + const temporaryEdge: Edge = { + ...mockEdge, + id: 'TEMPORARY_EDGE', + temporary: true, + source: 'node-a', + sourcePort: 'out', + target: '', + targetPort: '', + }; + + const finalEdge = createFinalEdge(config, temporaryEdge, { target: 'node-b', targetPort: 'in' }); + + expect(finalEdge.sourcePort).toBe('out'); + expect(finalEdge.targetPort).toBe('in'); + }); }); describe('validateConnection', () => { diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/start-relinking.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/start-relinking.test.ts index 654de18a2..c2550bdae 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/start-relinking.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/start-relinking.test.ts @@ -11,8 +11,7 @@ describe('startRelinking', () => { getEdgeById: ReturnType; applyUpdate: ReturnType; config: { - edgeRelinking: { enabled: boolean }; - linking: { temporaryEdgeDataBuilder: ReturnType }; + linking: { relinkingEnabled: boolean; temporaryEdgeDataBuilder: ReturnType }; computeEdgeId: ReturnType; }; actionStateManager: { @@ -58,8 +57,8 @@ describe('startRelinking', () => { getEdgeById: vi.fn().mockReturnValue(edge), applyUpdate: vi.fn().mockResolvedValue(undefined), config: { - edgeRelinking: { enabled: true }, linking: { + relinkingEnabled: true, // Passthrough builder — mirrors the default config's identity builder. temporaryEdgeDataBuilder: vi.fn((temporaryEdge: Edge) => temporaryEdge), }, @@ -76,8 +75,8 @@ describe('startRelinking', () => { }); describe('refusals', () => { - it('should do nothing when edgeRelinking is disabled', async () => { - mockFlowCore.config.edgeRelinking.enabled = false; + it('should do nothing when relinking is disabled', async () => { + mockFlowCore.config.linking.relinkingEnabled = false; await startRelinking(mockCommandHandler, { name: 'startRelinking', edgeId: 'edge-1', end: 'target' }); 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 da546ce1f..02cabf94b 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 @@ -39,8 +39,8 @@ export const buildKeptDanglingEdge = ( return null; } - // A free end has no port — normalized to undefined (never '') so the model - // shape is identical across every path that produces a dangling end. + // A free end has no port — undefined, never ''. createFinalEdge applies the + // same normalization to a free source (a draw started from a position). const edge = createFinalEdge(config, temporaryEdge, { target: '', targetPort: undefined, diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/start-relinking.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/start-relinking.ts index 985494e87..84bf31f9b 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/start-relinking.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/start-relinking.ts @@ -20,7 +20,7 @@ export const startRelinking = async (commandHandler: CommandHandler, command: St const { flowCore } = commandHandler; const { actionStateManager } = flowCore; - if (!flowCore.config.edgeRelinking.enabled) { + if (!flowCore.config.linking.relinkingEnabled) { return; } diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/utils.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/utils.ts index 7627ade32..e3f7e7ed3 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/utils.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/utils.ts @@ -11,6 +11,11 @@ export const createFinalEdge = (config: FlowConfig, temporaryEdge: Edge, partial return config.linking.finalEdgeDataBuilder({ ...data, id: config.computeEdgeId(), + // A temporary edge marks a free end with an empty-string port; the model + // shape of a free end is an undefined port, whichever end is free and + // whichever drop (canvas, port, draw started from a position) commits it. + ...(data.source ? {} : { sourcePort: undefined }), + ...(data.target ? {} : { targetPort: undefined }), }); }; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-config/default-flow-config.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-config/default-flow-config.ts index 967e880ea..b132c318f 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-config/default-flow-config.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-config/default-flow-config.ts @@ -5,7 +5,6 @@ import type { BackgroundConfig, BoxSelectionConfig, DanglingEdgesConfig, - EdgeRelinkingConfig, EdgeRoutingConfig, FlowConfig, GroupingConfig, @@ -38,6 +37,7 @@ const defaultLinkingConfig: LinkingConfig = { edgePanningForce: 10, edgePanningThreshold: 30, selectNodeOnPortPress: true, + relinkingEnabled: false, validateConnection: (): boolean => { // Allow connection by default return true; @@ -59,10 +59,6 @@ const defaultDanglingEdgesConfig: DanglingEdgesConfig = { detachOnNodeDelete: false, }; -const defaultEdgeRelinkingConfig: EdgeRelinkingConfig = { - enabled: false, -}; - const defaultGroupingConfig: GroupingConfig = { canGroup: (): boolean => { // Allow grouping by default @@ -167,7 +163,6 @@ export const createFlowConfig = (config: DeepPartial, flowCore: Flow resize: defaultResizeConfig, linking: defaultLinkingConfig, danglingEdges: defaultDanglingEdgesConfig, - edgeRelinking: defaultEdgeRelinkingConfig, grouping: defaultGroupingConfig, zoom: defaultZoomConfig, background: defaultBackgroundConfig, diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/get-source-target-positions.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/get-source-target-positions.ts index 3c158d8ce..df5a886a3 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/get-source-target-positions.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/get-source-target-positions.ts @@ -23,14 +23,16 @@ const getFloatingEndSides = ( }; } - // Floating target (dangling end, or drawing from source) + // Floating target (dangling end, a draw in progress, or a target-end relink + // preview — a draw always drags the target end) if (!edge.target && edge.targetPosition) { const startNode = nodesMap.get(edge.source); const targetSide = computeFloatingEndSide(startNode, edge.sourcePort, edge.targetPosition); return { targetSide }; } - // Floating source (dangling start, or reverse drawing from target) + // Floating source (dangling start, or a source-end relink preview — the only + // gesture that moves the source end) if (!edge.source && edge.sourcePosition) { const endNode = nodesMap.get(edge.target); const sourceSide = computeFloatingEndSide(endNode, edge.targetPort, edge.sourcePosition); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts index a539df463..a6dfecc89 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts @@ -120,6 +120,22 @@ export interface LinkingConfig { * @since 1.2.0 */ selectNodeOnPortPress: boolean; + /** + * Enables edge relinking — dragging an endpoint of an existing edge to + * another port. When true, a selected edge shows grabbable endpoint + * handles; dragging one previews the reconnection live and commits it on + * drop. Dropping on empty canvas leaves the endpoint dangling when + * `danglingEdges.enabled` is true, otherwise the relink is reverted. + * + * The gesture shares this section's snap distance, edge panning and + * temporary edge builder. Its drops are validated through + * `validateConnection`, which receives a context with `reason: 'relink'` + * and the edge being relinked. + * + * @default false + * @since 1.4.0 + */ + relinkingEnabled: boolean; } /** @@ -163,8 +179,11 @@ export interface DanglingEdgesConfig { * * An edge is still deleted, never detached, when: * - it is itself part of the deleted selection (an explicit delete wins), - * - it or the lost endpoint's node is effectively hidden (detaching would - * materialize invisible wiring as visible dangling edges), or + * - it is hidden only because of the node it loses — detaching would make + * invisible wiring (e.g. the collapsed children of a deleted group) appear + * as a visible dangling edge; an edge that stays hidden on its own (its + * `hidden` flag, a template binding, or a hidden other endpoint) is + * detached like any other and remains hidden, or * - it loses BOTH endpoints in the same delete — it becomes a dual dangling * edge only when {@link shouldDetachOnNodeDelete} is provided and returns * true for both ends. @@ -183,29 +202,6 @@ export interface DanglingEdgesConfig { shouldDetachOnNodeDelete?: (edge: Edge, deletedNode: Node, end: EdgeEnd) => boolean; } -/** - * Configuration for interactive edge relinking — dragging an endpoint of an - * existing edge to reconnect it to another port or leave it dangling. - * - * @public - * @since 1.4.0 - * @category Types/Configuration/Features - */ -export interface EdgeRelinkingConfig { - /** - * Enables the relinking gesture. When true, a selected edge shows grabbable - * endpoint handles; dragging one previews the reconnection live and commits - * it on drop. Dropping on empty canvas leaves the endpoint dangling when - * `danglingEdges.enabled` is true, otherwise the relink is reverted. - * - * Connections made by relinking are validated through - * `linking.validateConnection`, which receives a context with - * `reason: 'relink'` and the edge being relinked. - * @default false - */ - enabled: boolean; -} - /** * Context passed to {@link LinkingConfig.validateConnection} describing the * operation that asks for validation. @@ -656,12 +652,6 @@ export interface FlowConfig { */ danglingEdges: DanglingEdgesConfig; - /** - * Configuration for interactive edge relinking. - * @since 1.4.0 - */ - edgeRelinking: EdgeRelinkingConfig; - /** * Configuration for node grouping. */ diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/diagram/ng-diagram.component.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/components/diagram/ng-diagram.component.ts index 382bc1278..7c431632f 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/components/diagram/ng-diagram.component.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/diagram/ng-diagram.component.ts @@ -226,7 +226,7 @@ export class NgDiagramComponent implements OnInit, OnDestroy { /** * Event emitted when the user starts dragging an endpoint of an existing - * edge (the relinking gesture, see `edgeRelinking` config). + * edge (the relinking gesture, see `linking.relinkingEnabled` config). * * @since 1.4.0 */ diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge.component.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge.component.ts index def5bd865..b0d49c277 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge.component.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge.component.ts @@ -177,13 +177,13 @@ export class NgDiagramBaseEdgeComponent { /** * Endpoint handles for the relinking gesture — rendered on selected, - * committed edges when `edgeRelinking.enabled` is true. + * committed edges when `linking.relinkingEnabled` is true. * * @since 1.4.0 */ readonly relinkHandlesVisible = computed( () => - (this.diagramService?.config().edgeRelinking?.enabled ?? false) && + (this.diagramService?.config().linking?.relinkingEnabled ?? false) && !!this.selected() && !this.temporary() && this.points().length > 0 diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/relinking-gesture.service.spec.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/relinking-gesture.service.spec.ts index 37883c5fe..eca758e3a 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/relinking-gesture.service.spec.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/relinking-gesture.service.spec.ts @@ -18,7 +18,7 @@ describe('RelinkingGestureService', () => { let isCancellingInteraction: ReturnType; let panningHandled: ReturnType; let zoomingHandled: ReturnType; - let edgeRelinkingConfig: { enabled: boolean }; + let relinkingEnabled: boolean; const edge: Edge = { id: 'edge-1', source: 'node-a', target: 'node-b', data: {} }; @@ -45,7 +45,7 @@ describe('RelinkingGestureService', () => { isCancellingInteraction = vi.fn().mockReturnValue(false); panningHandled = vi.fn().mockReturnValue(false); zoomingHandled = vi.fn().mockReturnValue(false); - edgeRelinkingConfig = { enabled: true }; + relinkingEnabled = true; const mockFlowCore = { actionStateManager: { isLinking }, @@ -54,8 +54,12 @@ describe('RelinkingGestureService', () => { cancelActiveInteraction, get config() { return { - edgeRelinking: edgeRelinkingConfig, - linking: { edgePanningEnabled: false, edgePanningThreshold: 0, edgePanningForce: 0 }, + linking: { + relinkingEnabled, + edgePanningEnabled: false, + edgePanningThreshold: 0, + edgePanningForce: 0, + }, }; }, }; @@ -90,7 +94,7 @@ describe('RelinkingGestureService', () => { describe('beginRelink refusals', () => { it('should return false when edge relinking is disabled', () => { - edgeRelinkingConfig.enabled = false; + relinkingEnabled = false; expect(service.beginRelink(pointerDownEvent(), edge, 'target')).toBe(false); diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/relinking-gesture.service.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/relinking-gesture.service.ts index 62cd73b2d..7ff0cad99 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/relinking-gesture.service.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/relinking-gesture.service.ts @@ -64,7 +64,7 @@ export class RelinkingGestureService implements OnDestroy { if (this.gestureActive || flowCore.actionStateManager.isLinking()) { return false; } - if (!flowCore.config.edgeRelinking.enabled) { + if (!flowCore.config.linking.relinkingEnabled) { return false; } // An Escape-triggered cancel can still be rolling back state. 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 86eaa57bb..2c3e95e2a 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/public-api.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/public-api.ts @@ -123,7 +123,6 @@ export type { EdgeLabelPosition, EdgeRelinkCancelReason, EdgeRelinkEndedEvent, - EdgeRelinkingConfig, EdgeRelinkStartedEvent, EdgeRouting, EdgeRoutingConfig, From cb6d8dc037f14572f85fb4c4f1b576d9d0b61e0f Mon Sep 17 00:00:00 2001 From: Jacek Debek Date: Thu, 17 Sep 2026 16:45:12 +0200 Subject: [PATCH 06/10] Further updates, flag changes --- CHANGELOG.md | 2 +- apps/angular-demo/src/app/app.component.ts | 7 +- .../src/app/data/default-model.ts | 37 +++++++ .../dangling-edges/dangling-edges.astro | 5 + .../dangling-edges/diagram.component.ts | 83 ++++++++++++++ .../relinking/relinking/diagram.component.ts | 101 ++++++++++++++++++ .../edges/relinking/relinking/relinking.astro | 5 + .../Components/NgDiagramBaseEdgeComponent.md | 26 +++-- .../docs/api/Components/NgDiagramComponent.md | 2 +- .../Features/DanglingEdgesConfig.md | 8 +- .../Configuration/Features/LinkingConfig.md | 56 +++++----- .../src/content/docs/api/Types/Model/Edge.md | 16 +++ .../docs/api/Utilities/isEdgeEndRelinkable.md | 31 ++++++ apps/docs/src/content/docs/api/_readme.md | 1 + apps/docs/src/content/docs/changelog.mdx | 2 +- .../docs/guides/edges/dangling-edges.mdx | 40 ++----- .../src/content/docs/guides/edges/edges.mdx | 2 +- .../content/docs/guides/edges/relinking.mdx | 61 +++++++++-- .../src/content/docs/guides/flow-config.mdx | 2 +- apps/docs/src/content/docs/intro/roadmap.mdx | 31 ++++-- apps/e2e/tests/dangling-relinking.spec.ts | 61 ++++++++++- .../ng-diagram/api-report/ng-diagram.api.md | 9 +- .../__tests__/finish-relinking.test.ts | 4 +- .../linking/__tests__/start-relinking.test.ts | 50 ++++++++- .../commands/linking/start-relinking.ts | 8 +- .../src/flow-config/default-flow-config.ts | 2 +- .../src/core/src/types/edge.interface.ts | 9 ++ .../core/src/types/flow-config.interface.ts | 22 ++-- .../src/utils/__tests__/relinking.test.ts | 46 ++++++++ .../ng-diagram/src/core/src/utils/index.ts | 1 + .../src/core/src/utils/relinking.ts | 15 +++ .../diagram/ng-diagram.component.ts | 2 +- .../base-edge/base-edge-component.spec.ts | 79 +++++++++++++- .../edge/base-edge/base-edge.component.html | 32 +++--- .../edge/base-edge/base-edge.component.ts | 37 +++++-- .../relinking-gesture.service.spec.ts | 30 ++++-- .../input-events/relinking-gesture.service.ts | 4 +- .../projects/ng-diagram/src/public-api.ts | 3 +- 38 files changed, 774 insertions(+), 158 deletions(-) create mode 100644 apps/docs/src/components/angular/edges/dangling-edges/dangling-edges/dangling-edges.astro create mode 100644 apps/docs/src/components/angular/edges/dangling-edges/dangling-edges/diagram.component.ts create mode 100644 apps/docs/src/components/angular/edges/relinking/relinking/diagram.component.ts create mode 100644 apps/docs/src/components/angular/edges/relinking/relinking/relinking.astro create mode 100644 apps/docs/src/content/docs/api/Utilities/isEdgeEndRelinkable.md create mode 100644 packages/ng-diagram/projects/ng-diagram/src/core/src/utils/__tests__/relinking.test.ts create mode 100644 packages/ng-diagram/projects/ng-diagram/src/core/src/utils/relinking.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f8faeeef7..e09d5d7d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Dangling edges** — edges with one or both ends not connected to a node. A free end has `source`/`target` set to the empty string `''` (never `null` or `undefined`), no `sourcePort`/`targetPort` (`undefined`), and its position in `sourcePosition`/`targetPosition`. Opt in with the new `danglingEdges.enabled` config: a draw dropped on empty canvas then keeps the edge (`shouldKeepOnDrop` decides per edge) and a relink drop detaches the endpoint. Dangling edges route between their anchors, are saved and restored with the model, copy and paste together with their node, and box selection picks them by their anchors. New utilities `isDanglingEdge`, `hasFreeEndpoint`, `getDanglingEndpoints`, `getNearestDanglingEndpointInRange` and `computeDetachAnchor`; new `NgDiagramModelService.detachEdge`/`attachEdge` (attach validates through `linking.validateConnection` with `reason: 'attach'`); new CSS hooks `ng-diagram-edge--dangling` on the edge wrapper and `dangling` on `ng-diagram-base-edge` and its path ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) - **Keep edges when deleting a node** — with `danglingEdges.detachOnNodeDelete`, deleting a node keeps its edges as dangling edges anchored where their port was, and `selectionRemoved.detachedEdges` lists them. `shouldDetachOnNodeDelete` decides per endpoint. An edge is still deleted when it is deleted explicitly, when it is hidden only because of the node it loses (an edge that stays hidden on its own is detached like any other), or when it loses both ends at once (unless the callback keeps both) ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) -- **Edge relinking** — reconnect an edge by dragging one of its ends. With the new `linking.relinkingEnabled` config, a selected edge shows a handle at each end; dragging a handle previews the new connection and commits it on drop. The drop is validated by `linking.validateConnection` with `reason: 'relink'`. A drop on empty canvas detaches the end when `danglingEdges.enabled` allows it; an invalid drop, a drop back on the same port and Escape leave the edge unchanged. New `edgeRelinkStarted`/`edgeRelinkEnded` events report the gesture and its result (`noTarget`, `invalidConnection` or `cancelled` on failure). The handles have a touch-friendly hit area of about 12 px at any zoom; style them with the `--ngd-relink-handle-size/-fill/-fill-hover/-stroke/-stroke-width` theme variables or per edge with `--edge-relink-handle-*`. Custom edge templates can render their own handles with the new `RelinkHandleDirective` ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) +- **Edge relinking** — reconnect an edge by dragging one of its ends. Set `linking.defaultRelinkable` to `true` and a selected edge shows a handle at each end; set it to `'source'` or `'target'` to allow only that end. An edge overrides the default with its own `relinkable` property, so you can lock single edges or open only some; `isEdgeEndRelinkable(edge, end, defaultRelinkable)` resolves the two for custom templates. Dragging a handle previews the new connection and commits it on drop. The drop is validated by `linking.validateConnection` with `reason: 'relink'`. A drop on empty canvas detaches the end when `danglingEdges.enabled` allows it; an invalid drop, a drop back on the same port and Escape leave the edge unchanged. New `edgeRelinkStarted`/`edgeRelinkEnded` events report the gesture and its result (`noTarget`, `invalidConnection` or `cancelled` on failure). The handles have a touch-friendly hit area of about 12 px at any zoom; style them with the `--ngd-relink-handle-size/-fill/-fill-hover/-stroke/-stroke-width` theme variables or per edge with `--edge-relink-handle-*`. Custom edge templates can render their own handles with the new `RelinkHandleDirective` ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) - **`NgDiagramService.startLinkingFromPosition(position)`** — starts drawing an edge from a point on the canvas (flow coordinates) instead of from a node; the free end follows the pointer until a click connects it to a port or leaves a dual dangling edge on empty canvas. Requires `danglingEdges.enabled` (ignored with a console warning otherwise) ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) - **`hidden` flag on nodes and edges** — new `hidden?: boolean` property on `SimpleNode` and `Edge` makes hidden content a first-class concept: hidden elements stay mounted in the DOM as `display: none`, keep their geometry, never block initialization or `waitForMeasurements`, and re-measure automatically when unhidden. Effective visibility is derived — hiding a group hides all of its descendants, and an edge is hidden when either endpoint node is hidden — and exposed as the system-computed `computedHidden` property. Visible nodes, edges and labels never get an inline `display` value, so existing CSS-based workarounds keep working (ports keep managing their own inline `display` as before — hide them via the port's `hidden` input, not CSS). Note for upgrades: the library now owns the `hidden` property name on nodes and edges — pre-existing app-level `hidden: true` data activates these semantics after upgrading ([#799](https://github.com/synergycodes/ng-diagram/pull/799)) - **Hidden elements are excluded from every interactive surface** — effectively hidden nodes and edges are not hit-testable and don't participate in `selectAll`, keyboard move, drag, box selection, linking (starting a link from a hidden node is a no-op with a console warning), `zoomToFit`/`computePartsBounds` bounds, virtualization, edge routing, or the resize constraints of their group (a collapsed group is freely resizable — its hidden children reappear where they were on expand). The minimap mirrors the canvas: effectively hidden nodes are not drawn (neither as default rectangles nor as custom minimap templates) and don't inflate the minimap's diagram bounds, in both rendering modes. Programmatic APIs (`select`, `centerOnNode`, z-order and group commands) deliberately do not filter hidden elements. Hiding does not deselect: a hidden element keeps its `selected` flag but cannot be moved, and `deleteSelection` skips hidden selected elements (descendants of a deleted visible group and edges of deleted nodes are still removed) ([#799](https://github.com/synergycodes/ng-diagram/pull/799)) diff --git a/apps/angular-demo/src/app/app.component.ts b/apps/angular-demo/src/app/app.component.ts index a13ef4766..0c8ace0eb 100644 --- a/apps/angular-demo/src/app/app.component.ts +++ b/apps/angular-demo/src/app/app.component.ts @@ -126,9 +126,10 @@ export class AppComponent { }, linking: { selectNodeOnPortPress: false, - // Every selected edge shows grabbable endpoint handles — drag one to - // reconnect it to another port or drop it on empty canvas to detach it. - relinkingEnabled: true, + // Every selected edge shows grabbable endpoint handles unless its own + // `relinkable` says otherwise — drag one to reconnect it to another + // port or drop it on empty canvas to detach it. + defaultRelinkable: true, }, // Dangling edges: a link drawn onto empty canvas is kept (with a free // endpoint), and deleting a node detaches its edges instead of deleting diff --git a/apps/angular-demo/src/app/data/default-model.ts b/apps/angular-demo/src/app/data/default-model.ts index bbddf6748..5ac62d9aa 100644 --- a/apps/angular-demo/src/app/data/default-model.ts +++ b/apps/angular-demo/src/app/data/default-model.ts @@ -190,6 +190,23 @@ export const defaultModel: DiagramModel = { position: { x: 1000, y: 870 }, data: { label: 'the default label chip' }, }, + // Relinking demo: the config default allows both ends; the edges leaving + // this node override it with their own `relinkable`. + { + id: '27', + position: { x: 1000, y: 1050 }, + data: { label: 'relinkable overrides' }, + }, + { + id: '28', + position: { x: 1350, y: 1000 }, + data: { label: 'target end only' }, + }, + { + id: '29', + position: { x: 1350, y: 1160 }, + data: { label: 'locked' }, + }, ], edges: [ { @@ -380,5 +397,25 @@ export const defaultModel: DiagramModel = { data: { label: 'select me' }, type: 'default-labelled-edge', }, + // Only the target handle shows when selected; the source end stays put. + { + id: 'relink-target-only', + source: '27', + sourcePort: 'port-right', + target: '28', + targetPort: 'port-left', + relinkable: 'target', + data: { label: "relinkable: 'target'" }, + }, + // No handles at all when selected, although the config default is true. + { + id: 'relink-locked', + source: '27', + sourcePort: 'port-right', + target: '29', + targetPort: 'port-left', + relinkable: false, + data: { label: 'relinkable: false' }, + }, ], }; diff --git a/apps/docs/src/components/angular/edges/dangling-edges/dangling-edges/dangling-edges.astro b/apps/docs/src/components/angular/edges/dangling-edges/dangling-edges/dangling-edges.astro new file mode 100644 index 000000000..3df7e17e2 --- /dev/null +++ b/apps/docs/src/components/angular/edges/dangling-edges/dangling-edges/dangling-edges.astro @@ -0,0 +1,5 @@ +--- +import { DiagramComponent } from './diagram.component'; +--- + + diff --git a/apps/docs/src/components/angular/edges/dangling-edges/dangling-edges/diagram.component.ts b/apps/docs/src/components/angular/edges/dangling-edges/dangling-edges/diagram.component.ts new file mode 100644 index 000000000..40130f02b --- /dev/null +++ b/apps/docs/src/components/angular/edges/dangling-edges/dangling-edges/diagram.component.ts @@ -0,0 +1,83 @@ +import '@angular/compiler'; + +import { Component } from '@angular/core'; +import { + initializeModel, + NgDiagramBackgroundComponent, + NgDiagramComponent, + provideNgDiagram, + type NgDiagramConfig, +} from 'ng-diagram'; + +@Component({ + imports: [NgDiagramComponent, NgDiagramBackgroundComponent], + providers: [provideNgDiagram()], + template: ` +
+ + + +
+ `, + styles: ` + .diagram { + display: flex; + height: var(--ng-diagram-height); + border: var(--ng-diagram-border); + } + `, +}) +export class DiagramComponent { + // @section-start:config + config = { + // @collapse-start:config + zoom: { + zoomToFit: { + onInit: true, + padding: 80, + }, + }, + // @collapse-end:config + danglingEdges: { + // Keep edges dropped on empty canvas + // @mark-start:config + enabled: true, + // @mark-end:config + // Deleting a node detaches its edges instead of deleting them + detachOnNodeDelete: true, + }, + } satisfies NgDiagramConfig; + // @section-end:config + + // @section-start:model-shape + model = initializeModel({ + // @collapse-start:model-shape + nodes: [ + { id: 'node-1', position: { x: 100, y: 100 }, data: { label: 'Node 1' } }, + { id: 'node-2', position: { x: 500, y: 100 }, data: { label: 'Node 2' } }, + { id: 'node-3', position: { x: 500, y: 300 }, data: { label: 'Node 3' } }, + ], + // @collapse-end:model-shape + edges: [ + // @collapse-start:model-shape + { + id: 'connected', + source: 'node-1', + sourcePort: 'port-right', + target: 'node-2', + targetPort: 'port-left', + data: {}, + }, + // @collapse-end:model-shape + { + id: 'dangling', + source: '', // free endpoint — no node, no port + sourcePosition: { x: 200, y: 340 }, // anchored here, in flow coordinates + target: 'node-3', + targetPort: 'port-left', + data: {}, + }, + ], + }); + // @section-end:model-shape +} diff --git a/apps/docs/src/components/angular/edges/relinking/relinking/diagram.component.ts b/apps/docs/src/components/angular/edges/relinking/relinking/diagram.component.ts new file mode 100644 index 000000000..d974d1fbf --- /dev/null +++ b/apps/docs/src/components/angular/edges/relinking/relinking/diagram.component.ts @@ -0,0 +1,101 @@ +import '@angular/compiler'; + +import { Component } from '@angular/core'; +import { + initializeModel, + NgDiagramBackgroundComponent, + NgDiagramComponent, + provideNgDiagram, + type NgDiagramConfig, +} from 'ng-diagram'; + +@Component({ + imports: [NgDiagramComponent, NgDiagramBackgroundComponent], + providers: [provideNgDiagram()], + template: ` +
+ + + +
+ `, + styles: ` + .diagram { + display: flex; + height: var(--ng-diagram-height); + border: var(--ng-diagram-border); + } + `, +}) +export class DiagramComponent { + // @section-start:config + config = { + // @collapse-start:config + zoom: { + zoomToFit: { + onInit: true, + padding: 80, + }, + }, + // @collapse-end:config + linking: { + // Both ends of every edge, unless the edge sets its own `relinkable` + // @mark-start:config + defaultRelinkable: true, + // @mark-end:config + }, + // A handle dropped on empty canvas detaches that end instead of reverting + danglingEdges: { + enabled: true, + }, + } satisfies NgDiagramConfig; + // @section-end:config + + // @section-start:relinkable + model = initializeModel({ + // @collapse-start:relinkable + nodes: [ + { id: 'a', position: { x: 100, y: 60 }, data: { label: 'A' } }, + { id: 'b', position: { x: 550, y: 60 }, data: { label: 'B' } }, + { id: 'c', position: { x: 100, y: 190 }, data: { label: 'C' } }, + { id: 'd', position: { x: 550, y: 190 }, data: { label: 'D' } }, + { id: 'e', position: { x: 100, y: 320 }, data: { label: 'E' } }, + { id: 'f', position: { x: 550, y: 320 }, data: { label: 'F' } }, + ], + // @collapse-end:relinkable + edges: [ + { + id: 'both-ends', + source: 'a', + sourcePort: 'port-right', + target: 'b', + targetPort: 'port-left', + // No `relinkable`: the config default applies + data: { label: 'both ends' }, + }, + { + id: 'target-only', + source: 'c', + sourcePort: 'port-right', + target: 'd', + targetPort: 'port-left', + // @mark-start:relinkable + relinkable: 'target', + // @mark-end:relinkable + data: { label: 'target end only' }, + }, + { + id: 'locked', + source: 'e', + sourcePort: 'port-right', + target: 'f', + targetPort: 'port-left', + // @mark-start:relinkable + relinkable: false, + // @mark-end:relinkable + data: { label: 'locked' }, + }, + ], + }); + // @section-end:relinkable +} diff --git a/apps/docs/src/components/angular/edges/relinking/relinking/relinking.astro b/apps/docs/src/components/angular/edges/relinking/relinking/relinking.astro new file mode 100644 index 000000000..3df7e17e2 --- /dev/null +++ b/apps/docs/src/components/angular/edges/relinking/relinking/relinking.astro @@ -0,0 +1,5 @@ +--- +import { DiagramComponent } from './diagram.component'; +--- + + diff --git a/apps/docs/src/content/docs/api/Components/NgDiagramBaseEdgeComponent.md b/apps/docs/src/content/docs/api/Components/NgDiagramBaseEdgeComponent.md index 9f72f029e..94a8e5cbb 100644 --- a/apps/docs/src/content/docs/api/Components/NgDiagramBaseEdgeComponent.md +++ b/apps/docs/src/content/docs/api/Components/NgDiagramBaseEdgeComponent.md @@ -44,12 +44,11 @@ viewport scale — at zoom 0.5 the visible 5px circle alone would leave a *** -### relinkHandlesVisible +### relinkSourceHandle -> `readonly` **relinkHandlesVisible**: `Signal`\<`boolean`\> +> `readonly` **relinkSourceHandle**: `Signal`\<[`Point`](/docs/api/types/geometry/point/)\> -Endpoint handles for the relinking gesture — rendered on selected, -committed edges when `linking.relinkingEnabled` is true. +Position of the source endpoint handle (the first routed point). #### Since @@ -57,11 +56,12 @@ committed edges when `linking.relinkingEnabled` is true. *** -### relinkSourceHandle +### relinkSourceHandleVisible -> `readonly` **relinkSourceHandle**: `Signal`\<[`Point`](/docs/api/types/geometry/point/)\> +> `readonly` **relinkSourceHandleVisible**: `Signal`\<`boolean`\> -Position of the source endpoint handle (the first routed point). +Whether the source endpoint handle is rendered: the edge is selected, +committed, routed, and its source end can be relinked. #### Since @@ -81,6 +81,18 @@ Position of the target endpoint handle (the last routed point). *** +### relinkTargetHandleVisible + +> `readonly` **relinkTargetHandleVisible**: `Signal`\<`boolean`\> + +Same as [relinkSourceHandleVisible](/docs/api/components/ngdiagrambaseedgecomponent/#relinksourcehandlevisible) for the target end. + +#### Since + +1.4.0 + +*** + ### routing > **routing**: `InputSignal`\<`undefined` \| `string`\> diff --git a/apps/docs/src/content/docs/api/Components/NgDiagramComponent.md b/apps/docs/src/content/docs/api/Components/NgDiagramComponent.md index 06d026606..b49a31ba9 100644 --- a/apps/docs/src/content/docs/api/Components/NgDiagramComponent.md +++ b/apps/docs/src/content/docs/api/Components/NgDiagramComponent.md @@ -92,7 +92,7 @@ dangling on empty canvas, or reverted (invalid drop or cancelled gesture). > **edgeRelinkStarted**: `EventEmitter`\<[`EdgeRelinkStartedEvent`](/docs/api/types/events/edgerelinkstartedevent/)\> Event emitted when the user starts dragging an endpoint of an existing -edge (the relinking gesture, see `linking.relinkingEnabled` config). +edge (the relinking gesture, see `linking.defaultRelinkable` config). #### Since diff --git a/apps/docs/src/content/docs/api/Types/Configuration/Features/DanglingEdgesConfig.md b/apps/docs/src/content/docs/api/Types/Configuration/Features/DanglingEdgesConfig.md index 27360d37d..a689a90e0 100644 --- a/apps/docs/src/content/docs/api/Types/Configuration/Features/DanglingEdgesConfig.md +++ b/apps/docs/src/content/docs/api/Types/Configuration/Features/DanglingEdgesConfig.md @@ -10,9 +10,8 @@ Configuration for dangling edges — edges with one or both endpoints not connected to any node (an empty `source`/`target` with the free end anchored at `sourcePosition`/`targetPosition`). -Everything here is opt-in; with the defaults the diagram behaves exactly as -before: a link drop on empty canvas discards the edge and deleting a node -deletes its edges. +Everything here is opt-in. With the defaults, a link dropped on empty +canvas is discarded and deleting a node deletes its edges. ## Properties @@ -52,8 +51,7 @@ empty canvas keeps the edge as a dangling edge instead of discarding it, and an edge relink dropped on empty canvas detaches that endpoint. A drop over a port the edge cannot connect to (for example a -wrong-direction port) is not an empty-canvas drop and is discarded as -before. +wrong-direction port) is not an empty-canvas drop and is discarded. #### Default diff --git a/apps/docs/src/content/docs/api/Types/Configuration/Features/LinkingConfig.md b/apps/docs/src/content/docs/api/Types/Configuration/Features/LinkingConfig.md index 9727b77c9..9e65d8b50 100644 --- a/apps/docs/src/content/docs/api/Types/Configuration/Features/LinkingConfig.md +++ b/apps/docs/src/content/docs/api/Types/Configuration/Features/LinkingConfig.md @@ -10,6 +10,35 @@ Configuration for linking (edge creation) behavior. ## Properties +### defaultRelinkable + +> **defaultRelinkable**: `boolean` \| [`EdgeEnd`](/docs/api/types/model/edgeend/) + +Default `relinkable` value for edges that do not set their own. `true` +lets the user drag both ends of an edge to another port, an `EdgeEnd` +only that end, `false` neither. A selected edge shows a handle at each +end that can be relinked; dragging a handle previews the reconnection +live and commits it on drop. Dropping on empty canvas leaves the endpoint +dangling when `danglingEdges.enabled` is true, otherwise the relink is +reverted. + +The gesture shares this section's snap distance, edge panning and +temporary edge builder. Its drops are validated through +`validateConnection`, which receives a context with `reason: 'relink'` +and the edge being relinked. + +#### Default + +```ts +false +``` + +#### Since + +1.4.0 + +*** + ### edgePanningEnabled > **edgePanningEnabled**: `boolean` @@ -97,33 +126,6 @@ The maximum distance (in pixels) at temporary edge will snap to target port. *** -### relinkingEnabled - -> **relinkingEnabled**: `boolean` - -Enables edge relinking — dragging an endpoint of an existing edge to -another port. When true, a selected edge shows grabbable endpoint -handles; dragging one previews the reconnection live and commits it on -drop. Dropping on empty canvas leaves the endpoint dangling when -`danglingEdges.enabled` is true, otherwise the relink is reverted. - -The gesture shares this section's snap distance, edge panning and -temporary edge builder. Its drops are validated through -`validateConnection`, which receives a context with `reason: 'relink'` -and the edge being relinked. - -#### Default - -```ts -false -``` - -#### Since - -1.4.0 - -*** - ### selectNodeOnPortPress > **selectNodeOnPortPress**: `boolean` diff --git a/apps/docs/src/content/docs/api/Types/Model/Edge.md b/apps/docs/src/content/docs/api/Types/Model/Edge.md index 0cf49f01c..0d2eaf3ad 100644 --- a/apps/docs/src/content/docs/api/Types/Model/Edge.md +++ b/apps/docs/src/content/docs/api/Types/Model/Edge.md @@ -109,6 +109,22 @@ The points of the edge defining the path. *** +### relinkable? + +> `optional` **relinkable**: `boolean` \| [`EdgeEnd`](/docs/api/types/model/edgeend/) + +Whether the user can relink the edge's endpoints. `true` allows both +ends, an `EdgeEnd` allows only that end. When absent, +`linking.defaultRelinkable` applies. Any other value locks both ends. + +Set by the user; the library only reads it. + +#### Since + +1.4.0 + +*** + ### routing? > `optional` **routing**: [`EdgeRoutingName`](/docs/api/types/routing/edgeroutingname/) diff --git a/apps/docs/src/content/docs/api/Utilities/isEdgeEndRelinkable.md b/apps/docs/src/content/docs/api/Utilities/isEdgeEndRelinkable.md new file mode 100644 index 000000000..dc535e29e --- /dev/null +++ b/apps/docs/src/content/docs/api/Utilities/isEdgeEndRelinkable.md @@ -0,0 +1,31 @@ +--- +version: "since v1.4.0" +editUrl: false +next: false +prev: false +title: "isEdgeEndRelinkable" +--- + +> **isEdgeEndRelinkable**(`edge`, `end`, `defaultRelinkable`): `boolean` + +Whether the user can relink the given end of the edge: the edge's own +`relinkable` when set, otherwise `defaultRelinkable` from the linking config. +`true` allows both ends, an `EdgeEnd` only that end, any other value neither. + +## Parameters + +### edge + +[`Edge`](/docs/api/types/model/edge/) + +### end + +[`EdgeEnd`](/docs/api/types/model/edgeend/) + +### defaultRelinkable + +`boolean` | [`EdgeEnd`](/docs/api/types/model/edgeend/) + +## Returns + +`boolean` diff --git a/apps/docs/src/content/docs/api/_readme.md b/apps/docs/src/content/docs/api/_readme.md index cd73b9441..63e4c199f 100644 --- a/apps/docs/src/content/docs/api/_readme.md +++ b/apps/docs/src/content/docs/api/_readme.md @@ -218,6 +218,7 @@ title: "ng-diagram" - [initializeModel](/docs/api/utilities/initializemodel/) - [initializeModelAdapter](/docs/api/utilities/initializemodeladapter/) - [isDanglingEdge](/docs/api/utilities/isdanglingedge/) +- [isEdgeEndRelinkable](/docs/api/utilities/isedgeendrelinkable/) - [provideNgDiagram](/docs/api/utilities/providengdiagram/) - [stripEdgeRuntimeProperties](/docs/api/utilities/stripedgeruntimeproperties/) - [stripNodeRuntimeProperties](/docs/api/utilities/stripnoderuntimeproperties/) diff --git a/apps/docs/src/content/docs/changelog.mdx b/apps/docs/src/content/docs/changelog.mdx index 7800079c4..753d1de98 100644 --- a/apps/docs/src/content/docs/changelog.mdx +++ b/apps/docs/src/content/docs/changelog.mdx @@ -36,7 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Dangling edges** — edges with one or both ends not connected to a node. A free end has `source`/`target` set to the empty string `''` (never `null` or `undefined`), no `sourcePort`/`targetPort` (`undefined`), and its position in `sourcePosition`/`targetPosition`. Opt in with the new `danglingEdges.enabled` config: a draw dropped on empty canvas then keeps the edge (`shouldKeepOnDrop` decides per edge) and a relink drop detaches the endpoint. Dangling edges route between their anchors, are saved and restored with the model, copy and paste together with their node, and box selection picks them by their anchors. New utilities `isDanglingEdge`, `hasFreeEndpoint`, `getDanglingEndpoints`, `getNearestDanglingEndpointInRange` and `computeDetachAnchor`; new `NgDiagramModelService.detachEdge`/`attachEdge` (attach validates through `linking.validateConnection` with `reason: 'attach'`); new CSS hooks `ng-diagram-edge--dangling` on the edge wrapper and `dangling` on `ng-diagram-base-edge` and its path ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) - **Keep edges when deleting a node** — with `danglingEdges.detachOnNodeDelete`, deleting a node keeps its edges as dangling edges anchored where their port was, and `selectionRemoved.detachedEdges` lists them. `shouldDetachOnNodeDelete` decides per endpoint. An edge is still deleted when it is deleted explicitly, when it is hidden only because of the node it loses (an edge that stays hidden on its own is detached like any other), or when it loses both ends at once (unless the callback keeps both) ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) -- **Edge relinking** — reconnect an edge by dragging one of its ends. With the new `linking.relinkingEnabled` config, a selected edge shows a handle at each end; dragging a handle previews the new connection and commits it on drop. The drop is validated by `linking.validateConnection` with `reason: 'relink'`. A drop on empty canvas detaches the end when `danglingEdges.enabled` allows it; an invalid drop, a drop back on the same port and Escape leave the edge unchanged. New `edgeRelinkStarted`/`edgeRelinkEnded` events report the gesture and its result (`noTarget`, `invalidConnection` or `cancelled` on failure). The handles have a touch-friendly hit area of about 12 px at any zoom; style them with the `--ngd-relink-handle-size/-fill/-fill-hover/-stroke/-stroke-width` theme variables or per edge with `--edge-relink-handle-*`. Custom edge templates can render their own handles with the new `RelinkHandleDirective` ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) +- **Edge relinking** — reconnect an edge by dragging one of its ends. Set `linking.defaultRelinkable` to `true` and a selected edge shows a handle at each end; set it to `'source'` or `'target'` to allow only that end. An edge overrides the default with its own `relinkable` property, so you can lock single edges or open only some; `isEdgeEndRelinkable(edge, end, defaultRelinkable)` resolves the two for custom templates. Dragging a handle previews the new connection and commits it on drop. The drop is validated by `linking.validateConnection` with `reason: 'relink'`. A drop on empty canvas detaches the end when `danglingEdges.enabled` allows it; an invalid drop, a drop back on the same port and Escape leave the edge unchanged. New `edgeRelinkStarted`/`edgeRelinkEnded` events report the gesture and its result (`noTarget`, `invalidConnection` or `cancelled` on failure). The handles have a touch-friendly hit area of about 12 px at any zoom; style them with the `--ngd-relink-handle-size/-fill/-fill-hover/-stroke/-stroke-width` theme variables or per edge with `--edge-relink-handle-*`. Custom edge templates can render their own handles with the new `RelinkHandleDirective` ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) - **`NgDiagramService.startLinkingFromPosition(position)`** — starts drawing an edge from a point on the canvas (flow coordinates) instead of from a node; the free end follows the pointer until a click connects it to a port or leaves a dual dangling edge on empty canvas. Requires `danglingEdges.enabled` (ignored with a console warning otherwise) ([#810](https://github.com/synergycodes/ng-diagram/pull/810)) - **`hidden` flag on nodes and edges** — new `hidden?: boolean` property on `SimpleNode` and `Edge` makes hidden content a first-class concept: hidden elements stay mounted in the DOM as `display: none`, keep their geometry, never block initialization or `waitForMeasurements`, and re-measure automatically when unhidden. Effective visibility is derived — hiding a group hides all of its descendants, and an edge is hidden when either endpoint node is hidden — and exposed as the system-computed `computedHidden` property. Visible nodes, edges and labels never get an inline `display` value, so existing CSS-based workarounds keep working (ports keep managing their own inline `display` as before — hide them via the port's `hidden` input, not CSS). Note for upgrades: the library now owns the `hidden` property name on nodes and edges — pre-existing app-level `hidden: true` data activates these semantics after upgrading ([#799](https://github.com/synergycodes/ng-diagram/pull/799)) - **Hidden elements are excluded from every interactive surface** — effectively hidden nodes and edges are not hit-testable and don't participate in `selectAll`, keyboard move, drag, box selection, linking (starting a link from a hidden node is a no-op with a console warning), `zoomToFit`/`computePartsBounds` bounds, virtualization, edge routing, or the resize constraints of their group (a collapsed group is freely resizable — its hidden children reappear where they were on expand). The minimap mirrors the canvas: effectively hidden nodes are not drawn (neither as default rectangles nor as custom minimap templates) and don't inflate the minimap's diagram bounds, in both rendering modes. Programmatic APIs (`select`, `centerOnNode`, z-order and group commands) deliberately do not filter hidden elements. Hiding does not deselect: a hidden element keeps its `selected` flag but cannot be moved, and `deleteSelection` skips hidden selected elements (descendants of a deleted visible group and edges of deleted nodes are still removed) ([#799](https://github.com/synergycodes/ng-diagram/pull/799)) diff --git a/apps/docs/src/content/docs/guides/edges/dangling-edges.mdx b/apps/docs/src/content/docs/guides/edges/dangling-edges.mdx index 02839e166..dffdf1ec4 100644 --- a/apps/docs/src/content/docs/guides/edges/dangling-edges.mdx +++ b/apps/docs/src/content/docs/guides/edges/dangling-edges.mdx @@ -7,28 +7,22 @@ sidebar: badge: New --- -import { Aside } from '@astrojs/starlight/components'; +import CodeSnippet from '@components/code-snippet/code-snippet.astro'; +import DanglingEdgesDemo from '@components/angular/edges/dangling-edges/dangling-edges/dangling-edges.astro'; Dangling edges are edges with one or both endpoints not connected to any node. They are useful for diagrams where connections are sketched before both ends exist, where deleting a node should keep its wiring, or where free-floating annotation lines are part of the domain. -Everything about dangling edges is opt-in. With the defaults the diagram behaves exactly as before: a link drop on empty canvas discards the edge and deleting a node deletes its edges. +Everything about dangling edges is opt-in. With the defaults, a link dropped on empty canvas is discarded and deleting a node deletes its edges. + +Draw an edge from a port onto empty canvas to keep it as a dangling edge; select a node and press Delete to detach its edges instead of deleting them. + + ## Model Shape A free (unconnected) endpoint is represented by an empty string in [`source`](/docs/api/types/model/edge/#source)/[`target`](/docs/api/types/model/edge/#target) with the anchor position stored in [`sourcePosition`](/docs/api/types/model/edge/#sourceposition)/[`targetPosition`](/docs/api/types/model/edge/#targetposition): -```typescript -edges: [ - { - id: '1', - source: 'node-1', - sourcePort: 'port-right', - target: '', // free endpoint — no node - targetPosition: { x: 400, y: 150 }, // anchored here, in flow coordinates - data: {}, - }, -], -``` + A free endpoint never has a port: when an endpoint is detached, its `sourcePort`/`targetPort` is cleared. An edge with both endpoints free is a **dual dangling edge** — it routes between its two anchor positions. @@ -36,13 +30,7 @@ A free endpoint never has a port: when an endpoint is detached, its `sourcePort` Dangling edges are controlled by the [`danglingEdges`](/docs/api/types/configuration/features/danglingedgesconfig) configuration: -```typescript -const config: NgDiagramConfig = { - danglingEdges: { - enabled: true, - }, -}; -``` + [`enabled`](/docs/api/types/configuration/features/danglingedgesconfig/#enabled) is the master switch. When true: @@ -169,7 +157,7 @@ ng-diagram-base-edge.dangling { ## Events -- [`edgeDrawEnded`](/docs/api/types/events/edgedrawendedevent) — a canvas drop kept as a dangling edge reports `success: true` with the created `edge` but **no** `target`/`targetPort`. Distinguish a connected draw from a kept dangling draw by checking `event.target`. +- [`edgeDrawEnded`](/docs/api/types/events/edgedrawendedevent) — a canvas drop kept as a dangling edge reports `success: true` with the created `edge` but **no** `target`/`targetPort`. Distinguish a connected draw from a kept dangling draw by checking `event.target`. The deprecated `edgeDrawn` event does not fire for dangling edges — it only reports node-to-node connections. - [`selectionRemoved`](/docs/api/types/events/selectionremovedevent) — the new `detachedEdges` field lists the edges that were detached instead of deleted (snapshots taken after the detach — the freed endpoints are already empty with their positions set). It is always present, `[]` when nothing was detached. ```typescript @@ -180,14 +168,6 @@ onEdgeDrawEnded(event: EdgeDrawEndedEvent) { } ``` - - ## Related Topics [Edge Relinking →](/docs/guides/edges/relinking) | [Floating Edges →](/docs/guides/edges/floating-edges) | [Configuration →](/docs/guides/configuration/) diff --git a/apps/docs/src/content/docs/guides/edges/edges.mdx b/apps/docs/src/content/docs/guides/edges/edges.mdx index b6bf27f79..5626d8776 100644 --- a/apps/docs/src/content/docs/guides/edges/edges.mdx +++ b/apps/docs/src/content/docs/guides/edges/edges.mdx @@ -219,7 +219,7 @@ onEdgeDrawEnded(event: EdgeDrawEndedEvent) { Two opt-in features extend what users can do with existing edges: -- **[Relinking](/docs/guides/edges/relinking)** — with [`linking.relinkingEnabled`](/docs/api/types/configuration/features/linkingconfig/#relinkingenabled), selected edges show endpoint handles that users can drag to reconnect the edge to another port. +- **[Relinking](/docs/guides/edges/relinking)** — with [`linking.defaultRelinkable`](/docs/api/types/configuration/features/linkingconfig/#defaultrelinkable), selected edges show endpoint handles that users can drag to reconnect the edge to another port, unless the edge's own `relinkable` says otherwise. - **[Dangling edges](/docs/guides/edges/dangling-edges)** — with [`danglingEdges.enabled`](/docs/api/types/configuration/features/danglingedgesconfig/#enabled), edges may have free (unconnected) endpoints: a draw or relink dropped on empty canvas keeps the edge, and deleting a node can detach its edges instead of deleting them. ## Selection During Linking diff --git a/apps/docs/src/content/docs/guides/edges/relinking.mdx b/apps/docs/src/content/docs/guides/edges/relinking.mdx index deb096003..ffba2b56f 100644 --- a/apps/docs/src/content/docs/guides/edges/relinking.mdx +++ b/apps/docs/src/content/docs/guides/edges/relinking.mdx @@ -8,22 +8,52 @@ sidebar: --- import { Aside } from '@astrojs/starlight/components'; +import CodeSnippet from '@components/code-snippet/code-snippet.astro'; +import RelinkingDemo from '@components/angular/edges/relinking/relinking/relinking.astro'; -The **edge relinking** feature lets users reconnect existing edges by dragging their endpoints. A selected edge shows grabbable handles at both ends; dragging one previews the reconnection live and commits it on drop. +The **edge relinking** feature lets users reconnect existing edges by dragging their endpoints. A selected edge shows a grabbable handle at each end that can be relinked; dragging one previews the reconnection live and commits it on drop. + + + +Select an edge to see its handles: the first edge can be relinked at both ends, the second only at its target end, the third is locked. Drag a handle to another port to reconnect the edge, or drop it on empty canvas to detach that end. ## Enabling Relinking -Relinking is controlled by the [`linking.relinkingEnabled`](/docs/api/types/configuration/features/linkingconfig/#relinkingenabled) flag: +Relinking is off by default. [`linking.defaultRelinkable`](/docs/api/types/configuration/features/linkingconfig/#defaultrelinkable) sets the default for every edge: `true` lets users drag both ends, `'source'` or `'target'` only that end. + + + +With `defaultRelinkable: true`, every selected committed edge rendered by [`ng-diagram-base-edge`](/docs/api/components/ngdiagrambaseedgecomponent) shows an endpoint handle at each end (temporary draw previews never show handles). + +## Controlling Which Edges Can Be Relinked + +An edge overrides the default with its own [`relinkable`](/docs/api/types/model/edge/#relinkable) property. The resolved value — the edge's `relinkable` when set, otherwise `linking.defaultRelinkable` — decides which ends the user can drag: + +| Resolved value | Source end | Target end | +| -------------- | ---------- | ---------- | +| `true` | yes | yes | +| `'source'` | yes | no | +| `'target'` | no | yes | +| `false` | no | no | + +Edges in the initial model, edges added with [`addEdges`](/docs/api/services/ngdiagrammodelservice/#addedges) and pasted edges set `relinkable` directly in their data: + + + +Edges drawn by the user get it in [`finalEdgeDataBuilder`](/docs/api/types/configuration/features/linkingconfig/#finaledgedatabuilder), the same place where an app assigns their `type`. To make only one edge type relinkable, leave the default at `false` and opt that type in: ```typescript const config: NgDiagramConfig = { linking: { - relinkingEnabled: true, + // defaultRelinkable stays false: only edges that opt in can be relinked + finalEdgeDataBuilder: (edge) => ({ ...edge, type: 'draft', relinkable: true }), }, }; ``` -When enabled, every selected committed edge rendered by [`ng-diagram-base-edge`](/docs/api/components/ngdiagrambaseedgecomponent) shows an endpoint handle at each end (temporary draw previews never show handles). +For the opposite — everything relinkable except a few locked edges — set `defaultRelinkable: true` and `relinkable: false` on the locked ones. + +The builder runs only for edges the user draws. Loaded edges rely on the default or on their own `relinkable` property. ## How It Works @@ -50,18 +80,21 @@ Connections made by relinking run through the same [`linking.validateConnection` const config: NgDiagramConfig = { linking: { validateConnection: (source, sourcePort, target, targetPort, context) => { + if (source?.id === target?.id) { + return false; // no self-connections + } if (context?.reason === 'relink') { - // Locked edges keep their connections - if (context.edge?.data?.['locked']) { - return false; - } + // A relinked end stays inside the edge's current group + return source?.groupId === target?.groupId; } - return source?.id !== target?.id; // no self-connections + return true; }, }, }; ``` +`context.reason === 'relink'` is for rules about the drop target, like the same-group rule above. To lock an edge or one of its ends, use [`relinkable`](#controlling-which-edges-can-be-relinked) instead — the validator runs only on drop, so a locked edge would still show handles and let the user drag them. + Note that `source` can be `null` — when relinking the target end of an edge whose source is dangling (and for draws started with [`startLinkingFromPosition`](/docs/api/services/ngdiagramservice/#startlinkingfromposition)), so guard your validator accordingly. ## Events @@ -132,6 +165,16 @@ Handles are rendered only by `ng-diagram-base-edge`. A fully custom edge templat A pointerdown on the host element starts the relink gesture for the given `end` of the given `edge`; the gesture itself is driven at document level, so the host element may unmount during the drag. +The directive refuses the pointerdown on an end that cannot be relinked, so a handle on a locked end never starts a gesture. To hide such a handle as well, resolve the edge's `relinkable` against the config default with [`isEdgeEndRelinkable`](/docs/api/utilities/isedgeendrelinkable): + +```typescript +private readonly diagramService = inject(NgDiagramService); + +readonly targetHandleVisible = computed(() => + isEdgeEndRelinkable(this.edge(), 'target', this.diagramService.config().linking.defaultRelinkable) +); +``` +
-
-

Assembly Line Template

-

Demo app for assembly line diagrams

- -
@@ -65,8 +60,8 @@ ngDiagram is stable and actively developed. Here's what we're working on right n
-

Awaitable Service Methods

-

Every mutating service call returns a promise you can await

+

Dangling Edges

+

Edges with free ends: keep them on canvas drops and node deletes, reconnect them later

@@ -80,6 +75,28 @@ ngDiagram is stable and actively developed. Here's what we're working on right n
+ +
+

Assembly Line Template

+

Demo app for assembly line diagrams

+ +
+ + + + + + +
+

Awaitable Service Methods

+

Every mutating service call returns a promise you can await

+ +
+ + + + +

Resize Snap Offset

diff --git a/apps/e2e/tests/dangling-relinking.spec.ts b/apps/e2e/tests/dangling-relinking.spec.ts index fc6b43961..2bb236d08 100644 --- a/apps/e2e/tests/dangling-relinking.spec.ts +++ b/apps/e2e/tests/dangling-relinking.spec.ts @@ -4,7 +4,8 @@ import { pair, trio } from './fixtures/models'; /** * Dangling edges (config `danglingEdges`) and edge relinking - * (config `linking.relinkingEnabled`) — both opt-in, default off. + * (config `linking.defaultRelinkable`, overridable per edge with + * `edge.relinkable`) — both opt-in, default off. */ /** Both free endpoints set — a dual dangling edge plus one anchored node. */ @@ -197,7 +198,7 @@ test.describe('dangling edges', () => { }); test.describe('edge relinking', () => { - const relinkOn = { linking: { relinkingEnabled: true } }; + const relinkOn = { linking: { defaultRelinkable: true } }; test('handles render only on selected edges and only when enabled', async ({ diagram }) => { await diagram.load({ model: trio }); @@ -210,6 +211,60 @@ test.describe('edge relinking', () => { await expect(diagram.page.locator('[data-relink-handle]')).toHaveCount(2); }); + /** The trio model with `edge-ab` carrying the given `relinkable` value. */ + const trioWithRelinkable = (relinkable: boolean | 'source' | 'target'): Partial => ({ + nodes: trio.nodes, + edges: trio.edges!.map((edge) => ({ ...edge, relinkable })), + }); + + test("an edge with relinkable 'target' shows only the target handle and still reconnects", async ({ diagram }) => { + await diagram.load({ model: trioWithRelinkable('target'), config: relinkOn }); + await diagram.selection.select([], ['edge-ab']); + + await expect(diagram.page.locator('[data-relink-handle]')).toHaveCount(1); + await expect(diagram.page.locator('[data-relink-handle="source"]')).toHaveCount(0); + + const handle = await diagram.centerOf( + diagram.edge('edge-ab').locator('[data-relink-handle="target"]'), + 'target handle of edge-ab' + ); + const dst = await diagram.centerOf(diagram.port('node-c', 'port-left'), 'port node-c/port-left'); + await diagram.beginDrag(handle, dst); + await diagram.page.mouse.up(); + + await expect.poll(async () => (await diagram.model.getEdgeById('edge-ab'))?.target).toBe('node-c'); + }); + + test('an edge with relinkable false shows no handles and cannot be relinked', async ({ diagram }) => { + await diagram.load({ model: trioWithRelinkable(false), config: relinkOn }); + await diagram.page.evaluate(() => { + (window as unknown as Record).__relinkStarted = 0; + window.__diagram!.diagram.addEventListener('edgeRelinkStarted', () => { + (window as unknown as Record).__relinkStarted += 1; + }); + }); + // Where the target handle would sit: the edge's last routed point. + const edge = await diagram.model.getEdgeById('edge-ab'); + const handle = await diagram.viewport.flowToClientPosition(edge!.points!.at(-1)!); + + await diagram.selection.select([], ['edge-ab']); + await expect(diagram.page.locator('[data-relink-handle]')).toHaveCount(0); + + await diagram.beginDrag(handle, { x: handle.x + 120, y: handle.y + 100 }); + await diagram.page.mouse.up(); + await diagram.nextFrame(); + + expect(await diagram.page.evaluate(() => (window as unknown as Record).__relinkStarted)).toBe(0); + expect(await diagram.model.getEdgeById('edge-ab')).toMatchObject({ source: 'node-a', target: 'node-b' }); + }); + + test('an edge with relinkable true shows both handles although the default is false', async ({ diagram }) => { + await diagram.load({ model: trioWithRelinkable(true) }); + await diagram.selection.select([], ['edge-ab']); + + await expect(diagram.page.locator('[data-relink-handle]')).toHaveCount(2); + }); + test('dragging the target handle onto another port reconnects the edge', async ({ diagram }) => { await diagram.load({ model: trio, config: relinkOn }); await recordRelinkEnded(diagram); @@ -549,7 +604,7 @@ test.describe('edge relinking', () => { test.describe('edge relinking on touch', () => { test.use({ hasTouch: true }); - const relinkOn = { linking: { relinkingEnabled: true } }; + const relinkOn = { linking: { defaultRelinkable: true } }; /** Dispatch a raw CDP touch sequence (Playwright's touchscreen has no drag). */ async function touchSequence( diff --git a/packages/ng-diagram/api-report/ng-diagram.api.md b/packages/ng-diagram/api-report/ng-diagram.api.md index de220c0ae..2382ca3b7 100644 --- a/packages/ng-diagram/api-report/ng-diagram.api.md +++ b/packages/ng-diagram/api-report/ng-diagram.api.md @@ -243,6 +243,7 @@ export interface Edge { // (undocumented) readonly measuredLabels?: EdgeLabel[]; points?: Point[]; + relinkable?: boolean | EdgeEnd; routing?: EdgeRoutingName; routingMode?: RoutingMode; selected?: boolean; @@ -521,6 +522,9 @@ export interface InvalidateMeasurementsOptions { // @public export const isDanglingEdge: (edge: Edge) => boolean; +// @public +export const isEdgeEndRelinkable: (edge: Edge, end: EdgeEnd, defaultRelinkable: boolean | EdgeEnd) => boolean; + // @public export type KeyboardActionName = KeyboardMoveSelectionAction | KeyboardPanAction | KeyboardZoomAction | Extract; @@ -573,12 +577,12 @@ export interface LinkingActionState { // @public export interface LinkingConfig { + defaultRelinkable: boolean | EdgeEnd; edgePanningEnabled: boolean; edgePanningForce: number; edgePanningThreshold: number; finalEdgeDataBuilder: (defaultFinalEdgeData: Edge) => Edge; portSnapDistance: number; - relinkingEnabled: boolean; selectNodeOnPortPress: boolean; temporaryEdgeDataBuilder: (defaultTemporaryEdgeData: Edge) => Edge; validateConnection: (source: Node_2 | null, sourcePort: Port | null, target: Node_2 | null, targetPort: Port | null, context?: ConnectionValidationContext) => boolean; @@ -821,9 +825,10 @@ export class NgDiagramBaseEdgeComponent { // (undocumented) readonly points: Signal; readonly relinkHandleHitRadius: Signal; - readonly relinkHandlesVisible: Signal; readonly relinkSourceHandle: Signal; + readonly relinkSourceHandleVisible: Signal; readonly relinkTargetHandle: Signal; + readonly relinkTargetHandleVisible: Signal; routing: InputSignal; // (undocumented) readonly selected: Signal; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/finish-relinking.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/finish-relinking.test.ts index a6249bd51..3b05f3983 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/finish-relinking.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/finish-relinking.test.ts @@ -19,7 +19,7 @@ describe('finishRelinking', () => { shouldKeepOnDrop?: ReturnType; }; linking: { - relinkingEnabled: boolean; + defaultRelinkable: boolean; portSnapDistance: number; validateConnection: ReturnType; }; @@ -91,7 +91,7 @@ describe('finishRelinking', () => { config: { danglingEdges: { enabled: false }, linking: { - relinkingEnabled: true, + defaultRelinkable: true, portSnapDistance: 12, validateConnection: vi.fn().mockReturnValue(true), }, diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/start-relinking.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/start-relinking.test.ts index c2550bdae..c941aa103 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/start-relinking.test.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/start-relinking.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { FlowCore } from '../../../../flow-core'; import { mockEdge } from '../../../../test-utils'; -import type { CommandHandler, Edge, LinkingActionState } from '../../../../types'; +import type { CommandHandler, Edge, EdgeEnd, LinkingActionState } from '../../../../types'; import type { InternalLinkingActionState } from '../../../../types/action-state.interface'; import { startRelinking } from '../start-relinking'; @@ -11,7 +11,7 @@ describe('startRelinking', () => { getEdgeById: ReturnType; applyUpdate: ReturnType; config: { - linking: { relinkingEnabled: boolean; temporaryEdgeDataBuilder: ReturnType }; + linking: { defaultRelinkable: boolean | EdgeEnd; temporaryEdgeDataBuilder: ReturnType }; computeEdgeId: ReturnType; }; actionStateManager: { @@ -58,7 +58,7 @@ describe('startRelinking', () => { applyUpdate: vi.fn().mockResolvedValue(undefined), config: { linking: { - relinkingEnabled: true, + defaultRelinkable: true, // Passthrough builder — mirrors the default config's identity builder. temporaryEdgeDataBuilder: vi.fn((temporaryEdge: Edge) => temporaryEdge), }, @@ -75,8 +75,8 @@ describe('startRelinking', () => { }); describe('refusals', () => { - it('should do nothing when relinking is disabled', async () => { - mockFlowCore.config.linking.relinkingEnabled = false; + it('should do nothing when the default is false and the edge sets no relinkable', async () => { + mockFlowCore.config.linking.defaultRelinkable = false; await startRelinking(mockCommandHandler, { name: 'startRelinking', edgeId: 'edge-1', end: 'target' }); @@ -84,6 +84,25 @@ describe('startRelinking', () => { expect(mockFlowCore.applyUpdate).not.toHaveBeenCalled(); }); + it('should do nothing when the edge locks itself although the default allows relinking', async () => { + mockFlowCore.config.linking.defaultRelinkable = true; + mockFlowCore.getEdgeById.mockReturnValue({ ...edge, relinkable: false }); + + await startRelinking(mockCommandHandler, { name: 'startRelinking', edgeId: 'edge-1', end: 'target' }); + + expect(mockFlowCore.actionStateManager.linking).toBeNull(); + expect(mockFlowCore.applyUpdate).not.toHaveBeenCalled(); + }); + + it('should do nothing for the source end of an edge that only allows its target end', async () => { + mockFlowCore.getEdgeById.mockReturnValue({ ...edge, relinkable: 'target' }); + + await startRelinking(mockCommandHandler, { name: 'startRelinking', edgeId: 'edge-1', end: 'source' }); + + expect(mockFlowCore.actionStateManager.linking).toBeNull(); + expect(mockFlowCore.applyUpdate).not.toHaveBeenCalled(); + }); + it('should do nothing when a linking gesture is already in progress', async () => { const existingLinking: LinkingActionState = { sourceNodeId: 'other-node', @@ -135,6 +154,27 @@ describe('startRelinking', () => { }); }); + describe('per-edge relinkable', () => { + it('should start for the target end of an edge that only allows its target end', async () => { + mockFlowCore.getEdgeById.mockReturnValue({ ...edge, relinkable: 'target' }); + + await startRelinking(mockCommandHandler, { name: 'startRelinking', edgeId: 'edge-1', end: 'target' }); + + expect(linkingState().relink?.end).toBe('target'); + expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith({}, 'startRelinking'); + }); + + it('should start when the edge opts in although the default is false', async () => { + mockFlowCore.config.linking.defaultRelinkable = false; + mockFlowCore.getEdgeById.mockReturnValue({ ...edge, relinkable: true }); + + await startRelinking(mockCommandHandler, { name: 'startRelinking', edgeId: 'edge-1', end: 'source' }); + + expect(linkingState().relink?.end).toBe('source'); + expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith({}, 'startRelinking'); + }); + }); + describe('dragging the target end', () => { it('should set the linking state with a temporary edge mirroring the edge', async () => { await startRelinking(mockCommandHandler, { name: 'startRelinking', edgeId: 'edge-1', end: 'target' }); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/start-relinking.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/start-relinking.ts index 84bf31f9b..1954281fe 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/start-relinking.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/start-relinking.ts @@ -1,5 +1,6 @@ import { createLinkingState } from './linking-gesture'; import type { CommandHandler, EdgeEnd } from '../../../types'; +import { isEdgeEndRelinkable } from '../../../utils/relinking'; import { createTemporaryEdge, relinkPreviewBase } from './utils'; export interface StartRelinkingCommand { @@ -20,10 +21,6 @@ export const startRelinking = async (commandHandler: CommandHandler, command: St const { flowCore } = commandHandler; const { actionStateManager } = flowCore; - if (!flowCore.config.linking.relinkingEnabled) { - return; - } - // A draw or another relink already owns the linking state. if (actionStateManager.isLinking()) { return; @@ -33,6 +30,9 @@ export const startRelinking = async (commandHandler: CommandHandler, command: St if (!edge || edge.temporary || edge.computedHidden) { return; } + if (!isEdgeEndRelinkable(edge, end, flowCore.config.linking.defaultRelinkable)) { + return; + } // The dragged end starts where the edge currently ends: the routed endpoint // when available, the stored dangling position otherwise. diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-config/default-flow-config.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-config/default-flow-config.ts index b132c318f..03f8e7477 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-config/default-flow-config.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-config/default-flow-config.ts @@ -37,7 +37,7 @@ const defaultLinkingConfig: LinkingConfig = { edgePanningForce: 10, edgePanningThreshold: 30, selectNodeOnPortPress: true, - relinkingEnabled: false, + defaultRelinkable: false, validateConnection: (): boolean => { // Allow connection by default return true; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/edge.interface.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/edge.interface.ts index 951884290..3210212e7 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/edge.interface.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/edge.interface.ts @@ -69,6 +69,15 @@ export interface Edge { * @since 1.4.0 */ hidden?: boolean; + /** + * Whether the user can relink the edge's endpoints. `true` allows both + * ends, an `EdgeEnd` allows only that end. When absent, + * `linking.defaultRelinkable` applies. Any other value locks both ends. + * + * Set by the user; the library only reads it. + * @since 1.4.0 + */ + relinkable?: boolean | EdgeEnd; /** * @readonly * @remarks ComputedHidden is computed by the system and should not be set manually. diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts index a6dfecc89..412993d66 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts @@ -121,11 +121,13 @@ export interface LinkingConfig { */ selectNodeOnPortPress: boolean; /** - * Enables edge relinking — dragging an endpoint of an existing edge to - * another port. When true, a selected edge shows grabbable endpoint - * handles; dragging one previews the reconnection live and commits it on - * drop. Dropping on empty canvas leaves the endpoint dangling when - * `danglingEdges.enabled` is true, otherwise the relink is reverted. + * Default `relinkable` value for edges that do not set their own. `true` + * lets the user drag both ends of an edge to another port, an `EdgeEnd` + * only that end, `false` neither. A selected edge shows a handle at each + * end that can be relinked; dragging a handle previews the reconnection + * live and commits it on drop. Dropping on empty canvas leaves the endpoint + * dangling when `danglingEdges.enabled` is true, otherwise the relink is + * reverted. * * The gesture shares this section's snap distance, edge panning and * temporary edge builder. Its drops are validated through @@ -135,7 +137,7 @@ export interface LinkingConfig { * @default false * @since 1.4.0 */ - relinkingEnabled: boolean; + defaultRelinkable: boolean | EdgeEnd; } /** @@ -143,9 +145,8 @@ export interface LinkingConfig { * connected to any node (an empty `source`/`target` with the free end anchored * at `sourcePosition`/`targetPosition`). * - * Everything here is opt-in; with the defaults the diagram behaves exactly as - * before: a link drop on empty canvas discards the edge and deleting a node - * deletes its edges. + * Everything here is opt-in. With the defaults, a link dropped on empty + * canvas is discarded and deleting a node deletes its edges. * * @public * @since 1.4.0 @@ -158,8 +159,7 @@ export interface DanglingEdgesConfig { * and an edge relink dropped on empty canvas detaches that endpoint. * * A drop over a port the edge cannot connect to (for example a - * wrong-direction port) is not an empty-canvas drop and is discarded as - * before. + * wrong-direction port) is not an empty-canvas drop and is discarded. * @default false */ enabled: boolean; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/__tests__/relinking.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/__tests__/relinking.test.ts new file mode 100644 index 000000000..dc779080a --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/__tests__/relinking.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { mockEdge } from '../../test-utils'; +import type { Edge, EdgeEnd } from '../../types'; +import { isEdgeEndRelinkable } from '../relinking'; + +describe('isEdgeEndRelinkable', () => { + const edge: Edge = { ...mockEdge, id: 'edge-1', source: 'node-a', target: 'node-b' }; + + describe('resolved values', () => { + it.each<[boolean | EdgeEnd, boolean, boolean]>([ + [true, true, true], + ['source', true, false], + ['target', false, true], + [false, false, false], + ])('%j allows source=%s target=%s', (value, source, target) => { + expect(isEdgeEndRelinkable({ ...edge, relinkable: value }, 'source', false)).toBe(source); + expect(isEdgeEndRelinkable({ ...edge, relinkable: value }, 'target', false)).toBe(target); + }); + + it('should lock both ends for a value outside the type', () => { + const locked: Edge = { ...edge, relinkable: 'both' as never }; + + expect(isEdgeEndRelinkable(locked, 'source', true)).toBe(false); + expect(isEdgeEndRelinkable(locked, 'target', true)).toBe(false); + }); + }); + + describe('fallback to the default', () => { + it('should use the default when the edge has no relinkable property', () => { + expect(isEdgeEndRelinkable(edge, 'source', true)).toBe(true); + expect(isEdgeEndRelinkable(edge, 'target', true)).toBe(true); + expect(isEdgeEndRelinkable(edge, 'source', 'target')).toBe(false); + expect(isEdgeEndRelinkable(edge, 'target', 'target')).toBe(true); + expect(isEdgeEndRelinkable(edge, 'source', false)).toBe(false); + expect(isEdgeEndRelinkable(edge, 'target', false)).toBe(false); + }); + + it('should ignore the default when the edge sets relinkable', () => { + expect(isEdgeEndRelinkable({ ...edge, relinkable: false }, 'source', true)).toBe(false); + expect(isEdgeEndRelinkable({ ...edge, relinkable: false }, 'target', true)).toBe(false); + expect(isEdgeEndRelinkable({ ...edge, relinkable: true }, 'source', false)).toBe(true); + expect(isEdgeEndRelinkable({ ...edge, relinkable: 'source' }, 'source', 'target')).toBe(true); + expect(isEdgeEndRelinkable({ ...edge, relinkable: 'source' }, 'target', 'target')).toBe(false); + }); + }); +}); diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/index.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/index.ts index fe6b3604d..4f06fcc7d 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/index.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/index.ts @@ -10,6 +10,7 @@ export * from './is-group'; export * from './measurement-validation'; export * from './object-properties'; export * from './rects-points-sizes'; +export * from './relinking'; export * from './semaphore'; export * from './snap-node-position'; export * from './sort-nodes-by-z-index'; diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/relinking.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/relinking.ts new file mode 100644 index 000000000..0524e7b1f --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/relinking.ts @@ -0,0 +1,15 @@ +import type { Edge, EdgeEnd } from '../types/edge.interface'; + +/** + * Whether the user can relink the given end of the edge: the edge's own + * `relinkable` when set, otherwise `defaultRelinkable` from the linking config. + * `true` allows both ends, an `EdgeEnd` only that end, any other value neither. + * + * @public + * @since 1.4.0 + * @category Utilities + */ +export const isEdgeEndRelinkable = (edge: Edge, end: EdgeEnd, defaultRelinkable: boolean | EdgeEnd): boolean => { + const value = edge.relinkable ?? defaultRelinkable; + return value === true || value === end; +}; diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/diagram/ng-diagram.component.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/components/diagram/ng-diagram.component.ts index 7c431632f..6633f5381 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/components/diagram/ng-diagram.component.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/diagram/ng-diagram.component.ts @@ -226,7 +226,7 @@ export class NgDiagramComponent implements OnInit, OnDestroy { /** * Event emitted when the user starts dragging an endpoint of an existing - * edge (the relinking gesture, see `linking.relinkingEnabled` config). + * edge (the relinking gesture, see `linking.defaultRelinkable` config). * * @since 1.4.0 */ diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge-component.spec.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge-component.spec.ts index 75f1008b5..3c0fefe4f 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge-component.spec.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge-component.spec.ts @@ -1,9 +1,10 @@ import { Component } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { Edge, Point } from '../../../../core/src'; +import { Edge, EdgeEnd, Point } from '../../../../core/src'; import { FlowCoreProviderService, RendererService } from '../../../services'; import { InputEventsRouterService } from '../../../services/input-events/input-events-router.service'; +import { RelinkingGestureService } from '../../../services/input-events/relinking-gesture.service'; import { MarkerRegistryService } from '../../../services/marker-registry/marker-registry.service'; import { NgDiagramService } from '../../../public-services/ng-diagram.service'; import { NgDiagramBaseEdgeLabelComponent } from '../../edge-label/base-edge-label/base-edge-label.component'; @@ -22,8 +23,10 @@ describe('NgDiagramBaseEdgeComponent', () => { let mockEdge: Edge; let mockFlowCore: any; // eslint-disable-line @typescript-eslint/no-explicit-any let mockFlowCoreProvider: any; // eslint-disable-line @typescript-eslint/no-explicit-any + let defaultRelinkable: boolean | EdgeEnd; beforeEach(async () => { + defaultRelinkable = false; // Create mock for EdgeRoutingManager const mockEdgeRoutingManager = { hasRouting: vi.fn().mockReturnValue(true), @@ -54,7 +57,8 @@ describe('NgDiagramBaseEdgeComponent', () => { await TestBed.configureTestingModule({ providers: [ { provide: FlowCoreProviderService, useValue: mockFlowCoreProvider }, - { provide: NgDiagramService, useValue: { config: () => ({}) } }, + { provide: NgDiagramService, useValue: { config: () => ({ linking: { defaultRelinkable } }) } }, + { provide: RelinkingGestureService, useValue: { beginRelink: vi.fn().mockReturnValue(false) } }, RendererService, InputEventsRouterService, MarkerRegistryService, @@ -281,4 +285,75 @@ describe('NgDiagramBaseEdgeComponent', () => { const pathElement = fixture.nativeElement.querySelector('path'); expect(pathElement.getAttribute('stroke-dasharray')).toBe('var(--edge-stroke-dasharray, none)'); }); + + describe('relink handles', () => { + const handles = () => ({ + source: component.relinkSourceHandleVisible(), + target: component.relinkTargetHandleVisible(), + }); + + const renderedHandles = () => ({ + source: fixture.nativeElement.querySelectorAll('[data-relink-handle="source"]').length, + target: fixture.nativeElement.querySelectorAll('[data-relink-handle="target"]').length, + }); + + it.each<[boolean | EdgeEnd, boolean, boolean]>([ + [true, true, true], + ['source', true, false], + ['target', false, true], + [false, false, false], + ])('relinkable %j on a selected edge shows source=%s target=%s', (relinkable, source, target) => { + fixture.componentRef.setInput('edge', { ...mockEdge, selected: true, relinkable }); + fixture.detectChanges(); + + expect(handles()).toEqual({ source, target }); + expect(renderedHandles()).toEqual({ source: source ? 1 : 0, target: target ? 1 : 0 }); + }); + + it.each<[boolean | EdgeEnd, boolean, boolean]>([ + [true, true, true], + ['source', true, false], + ['target', false, true], + [false, false, false], + ])('defaultRelinkable %j applies to an edge without relinkable: source=%s target=%s', (value, source, target) => { + defaultRelinkable = value; + fixture.componentRef.setInput('edge', { ...mockEdge, selected: true }); + fixture.detectChanges(); + + expect(handles()).toEqual({ source, target }); + }); + + it('should let the edge override the default', () => { + defaultRelinkable = true; + fixture.componentRef.setInput('edge', { ...mockEdge, selected: true, relinkable: false }); + fixture.detectChanges(); + + expect(handles()).toEqual({ source: false, target: false }); + }); + + it('should hide both handles when the edge is not selected', () => { + defaultRelinkable = true; + fixture.componentRef.setInput('edge', { ...mockEdge, selected: false }); + fixture.detectChanges(); + + expect(handles()).toEqual({ source: false, target: false }); + expect(renderedHandles()).toEqual({ source: 0, target: 0 }); + }); + + it('should hide both handles on a temporary edge', () => { + defaultRelinkable = true; + fixture.componentRef.setInput('edge', { ...mockEdge, selected: true, temporary: true }); + fixture.detectChanges(); + + expect(handles()).toEqual({ source: false, target: false }); + }); + + it('should hide both handles on an edge without points', () => { + defaultRelinkable = true; + fixture.componentRef.setInput('edge', { ...mockEdge, selected: true, points: [] }); + fixture.detectChanges(); + + expect(handles()).toEqual({ source: false, target: false }); + }); + }); }); diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge.component.html b/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge.component.html index 286ef16ca..0b515d836 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge.component.html +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge.component.html @@ -22,12 +22,12 @@ /> - @if (relinkHandlesVisible()) { - + + @if (relinkSourceHandleVisible()) { + + } + @if (relinkTargetHandleVisible()) { - this.edge().measuredLabels ?? []); /** - * Endpoint handles for the relinking gesture — rendered on selected, - * committed edges when `linking.relinkingEnabled` is true. + * Whether the source endpoint handle is rendered: the edge is selected, + * committed, routed, and its source end can be relinked. * * @since 1.4.0 */ - readonly relinkHandlesVisible = computed( - () => - (this.diagramService?.config().linking?.relinkingEnabled ?? false) && - !!this.selected() && - !this.temporary() && - this.points().length > 0 - ); + readonly relinkSourceHandleVisible = computed(() => this.relinkHandleVisible('source')); + + /** + * Same as {@link relinkSourceHandleVisible} for the target end. + * + * @since 1.4.0 + */ + readonly relinkTargetHandleVisible = computed(() => this.relinkHandleVisible('target')); /** * Position of the source endpoint handle (the first routed point). @@ -238,6 +247,14 @@ export class NgDiagramBaseEdgeComponent { private prevRoutingMode: RoutingMode | undefined; private prevPoints: Point[] | undefined; + private relinkHandleVisible(end: EdgeEnd): boolean { + if (!this.selected() || this.temporary() || this.points().length === 0) { + return false; + } + const defaultRelinkable = this.diagramService?.config().linking?.defaultRelinkable ?? false; + return isEdgeEndRelinkable(this.edge(), end, defaultRelinkable); + } + constructor() { // Sync edge properties from custom components back to the model effect(() => this.syncEdgePropertiesToModel()); diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/relinking-gesture.service.spec.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/relinking-gesture.service.spec.ts index eca758e3a..b83b0dc1c 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/relinking-gesture.service.spec.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/relinking-gesture.service.spec.ts @@ -1,6 +1,6 @@ import { TestBed } from '@angular/core/testing'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { Edge } from '../../../core/src'; +import type { Edge, EdgeEnd } from '../../../core/src'; import { PointerInputEvent } from '../../types'; import { FlowCoreProviderService } from '../flow-core-provider/flow-core-provider.service'; import { TouchEventsStateService } from '../touch-events-state-service/touch-events-state-service.service'; @@ -18,7 +18,7 @@ describe('RelinkingGestureService', () => { let isCancellingInteraction: ReturnType; let panningHandled: ReturnType; let zoomingHandled: ReturnType; - let relinkingEnabled: boolean; + let defaultRelinkable: boolean | EdgeEnd; const edge: Edge = { id: 'edge-1', source: 'node-a', target: 'node-b', data: {} }; @@ -45,7 +45,7 @@ describe('RelinkingGestureService', () => { isCancellingInteraction = vi.fn().mockReturnValue(false); panningHandled = vi.fn().mockReturnValue(false); zoomingHandled = vi.fn().mockReturnValue(false); - relinkingEnabled = true; + defaultRelinkable = true; const mockFlowCore = { actionStateManager: { isLinking }, @@ -55,7 +55,7 @@ describe('RelinkingGestureService', () => { get config() { return { linking: { - relinkingEnabled, + defaultRelinkable, edgePanningEnabled: false, edgePanningThreshold: 0, edgePanningForce: 0, @@ -93,8 +93,8 @@ describe('RelinkingGestureService', () => { }); describe('beginRelink refusals', () => { - it('should return false when edge relinking is disabled', () => { - relinkingEnabled = false; + it('should return false when neither the edge nor the default allows relinking', () => { + defaultRelinkable = false; expect(service.beginRelink(pointerDownEvent(), edge, 'target')).toBe(false); @@ -102,6 +102,24 @@ describe('RelinkingGestureService', () => { expect(emitStart).not.toHaveBeenCalled(); }); + it('should return false and register no document listeners for a locked end', () => { + const addEventListener = vi.spyOn(document, 'addEventListener'); + + expect(service.beginRelink(pointerDownEvent(), { ...edge, relinkable: 'target' }, 'source')).toBe(false); + + expect(addEventListener).not.toHaveBeenCalled(); + expect(registerInteractionCleanup).not.toHaveBeenCalled(); + dispatch('pointermove', { clientX: 100 }); + expect(emitStart).not.toHaveBeenCalled(); + addEventListener.mockRestore(); + }); + + it('should claim the gesture for an end the edge allows although the default is false', () => { + defaultRelinkable = false; + + expect(service.beginRelink(pointerDownEvent(), { ...edge, relinkable: 'target' }, 'target')).toBe(true); + }); + it('should return false when a gesture is already active', () => { expect(service.beginRelink(pointerDownEvent(), edge, 'target')).toBe(true); expect(service.beginRelink(pointerDownEvent({ pointerId: 2 }), edge, 'target')).toBe(false); diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/relinking-gesture.service.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/relinking-gesture.service.ts index 7ff0cad99..55cbde579 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/relinking-gesture.service.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/relinking-gesture.service.ts @@ -1,5 +1,5 @@ import { Injectable, OnDestroy, inject, signal } from '@angular/core'; -import { Edge, EdgeEnd, FPS_60, NgDiagramMath, Point } from '../../../core/src'; +import { Edge, EdgeEnd, FPS_60, isEdgeEndRelinkable, NgDiagramMath, Point } from '../../../core/src'; import { MOVE_THRESHOLD } from '../../../core/src/input-events/handlers/pointer-move-selection/pointer-move-selection.handler'; import { DiagramEventName, PointerInputEvent } from '../../types'; import { FlowCoreProviderService } from '../flow-core-provider/flow-core-provider.service'; @@ -64,7 +64,7 @@ export class RelinkingGestureService implements OnDestroy { if (this.gestureActive || flowCore.actionStateManager.isLinking()) { return false; } - if (!flowCore.config.linking.relinkingEnabled) { + if (!isEdgeEndRelinkable(edge, end, flowCore.config.linking.defaultRelinkable)) { return false; } // An Escape-triggered cancel can still be rolling back state. 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 2c3e95e2a..77d57d0e9 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/public-api.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/public-api.ts @@ -61,13 +61,14 @@ export { NgDiagramService } from './lib/public-services/ng-diagram.service'; // Configuration helpers export { configureShortcuts } from './core/src'; -// Dangling-edge utilities +// Dangling-edge and relinking utilities export { computeDetachAnchor, getDanglingEndpoints, getNearestDanglingEndpointInRange, hasFreeEndpoint, isDanglingEdge, + isEdgeEndRelinkable, } from './core/src'; export { NgDiagramMinimapNodeTemplateMap } from './lib/components/minimap/ng-diagram-minimap.types'; export { initializeModel, initializeModelAdapter, type InitializeModelOptions } from './lib/model/initialize-model'; From 7ad4985be1c9f6575bf6b55a446cb3b0fa313b67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Ja=C5=BAwa?= Date: Mon, 21 Sep 2026 14:38:57 +0200 Subject: [PATCH 07/10] Render relink hit circles under both handles, update refusal comment --- .../edge/base-edge/base-edge.component.html | 32 +++++++++++-------- .../relinking/relinking.directive.ts | 2 +- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge.component.html b/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge.component.html index 0b515d836..99c697699 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge.component.html +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge.component.html @@ -23,10 +23,12 @@ + constant screen size at any zoom. Both hit circles render before both + visible circles, so a visible handle is never covered by the other + end's hit area on a short edge. Each circle carries the same directive, + so grabbing either starts the relink; data-relink-handle-hit lets the + stylesheet highlight the matching visible circle on hover. Each end + renders only when it can be relinked. --> @if (relinkSourceHandleVisible()) { - } @if (relinkTargetHandleVisible()) { + } + @if (relinkSourceHandleVisible()) { + + } + @if (relinkTargetHandleVisible()) { Date: Mon, 21 Sep 2026 22:25:08 +0200 Subject: [PATCH 08/10] Add a relinking and dangling test mode to the demo app --- apps/angular-demo/src/app/app.component.html | 3 + apps/angular-demo/src/app/app.component.ts | 39 ++ .../src/app/data/relinking-model.ts | 183 +++++++++ .../toolbar/relinking-toolbar.component.html | 101 +++++ .../toolbar/relinking-toolbar.component.scss | 85 ++++ .../toolbar/relinking-toolbar.component.ts | 363 ++++++++++++++++++ .../src/app/toolbar/toolbar.component.html | 2 +- .../src/app/toolbar/toolbar.component.ts | 13 +- 8 files changed, 776 insertions(+), 13 deletions(-) create mode 100644 apps/angular-demo/src/app/data/relinking-model.ts create mode 100644 apps/angular-demo/src/app/toolbar/relinking-toolbar.component.html create mode 100644 apps/angular-demo/src/app/toolbar/relinking-toolbar.component.scss create mode 100644 apps/angular-demo/src/app/toolbar/relinking-toolbar.component.ts diff --git a/apps/angular-demo/src/app/app.component.html b/apps/angular-demo/src/app/app.component.html index 6ba2c571e..e0a7ffe6e 100644 --- a/apps/angular-demo/src/app/app.component.html +++ b/apps/angular-demo/src/app/app.component.html @@ -36,12 +36,15 @@ } @else if (hiddenElementsDemoMode()) { + } @else if (relinkingTestMode()) { + } @else { true }, + danglingEdges: { + enabled: true, + detachOnNodeDelete: true, + shouldKeepOnDrop: () => true, + shouldDetachOnNodeDelete: () => true, + }, + }); + if (this.savedModelData) { + this.modelData.set(this.savedModelData); + this.savedModelData = null; + } + } + + /** Rebuilds the relinking scene from scratch, keeping the mode open. */ + resetRelinkingScene(): void { + this.modelData.set(createRelinkingModel()); + } + enterBatchTest(): void { this.savedModelData = this.modelData(); this.batchTestMode.set(true); diff --git a/apps/angular-demo/src/app/data/relinking-model.ts b/apps/angular-demo/src/app/data/relinking-model.ts new file mode 100644 index 000000000..739e888a2 --- /dev/null +++ b/apps/angular-demo/src/app/data/relinking-model.ts @@ -0,0 +1,183 @@ +import type { Edge, Node } from 'ng-diagram'; + +/** Data carried by the scene edges — the label chip plus the flags the config rules read. */ +export interface RelinkingEdgeData { + label?: string; + /** Read by the "by edge data" `shouldKeepOnDrop` rule. */ + keepOnDrop?: boolean; + /** Read by the "by edge data" `shouldDetachOnNodeDelete` rule. */ + deleteWithNode?: boolean; +} + +export const RL_EDGE_DEFAULT = 'rl-edge-default'; +export const RL_EDGE_TRUE = 'rl-edge-true'; +export const RL_EDGE_SOURCE_ONLY = 'rl-edge-source-only'; +export const RL_EDGE_TARGET_ONLY = 'rl-edge-target-only'; +export const RL_EDGE_LOCKED = 'rl-edge-locked'; +export const RL_EDGE_DANGLING_SOURCE = 'rl-edge-dangling-source'; +export const RL_EDGE_DUAL_DANGLING = 'rl-edge-dual-dangling'; +export const RL_EDGE_IN_GROUP = 'rl-edge-in-group'; +export const RL_EDGE_KEEP_ON_DROP = 'rl-edge-keep-on-drop'; +export const RL_EDGE_DELETE_WITH_NODE = 'rl-edge-delete-with-node'; + +export const RL_GROUP_ID = 'rl-group'; +export const RL_DROP_TARGET_1 = 'rl-drop-1'; +export const RL_DROP_TARGET_2 = 'rl-drop-2'; + +/** Vertical distance between two consecutive scene rows. */ +const ROW_PITCH = 110; + +/** Left column (sources) and right column (targets) of the row grid. */ +const SOURCE_X = 0; +const TARGET_X = 350; + +const rowY = (row: number) => 40 + row * ROW_PITCH; + +/** + * Model for the relinking & dangling test mode. Every row demonstrates one + * rule of the relinking surface: the `relinkable` variants, edges that start + * dangling at one or both ends, a group whose members exercise the + * "same group only" validation, and two edges whose `data` flags drive the + * `shouldKeepOnDrop` / `shouldDetachOnNodeDelete` config callbacks. + * + * The two nodes on the far right are free drop targets — their ports are + * unused, so a dragged endpoint handle always has somewhere valid to land. + */ +export function createRelinkingModel(): { nodes: Node[]; edges: Edge[] } { + const nodes: Node[] = [ + { id: 'rl-a1', position: { x: SOURCE_X, y: rowY(0) }, data: { label: 'A1' } }, + { id: 'rl-b1', position: { x: TARGET_X, y: rowY(0) }, data: { label: 'B1' } }, + { id: 'rl-a2', position: { x: SOURCE_X, y: rowY(1) }, data: { label: 'A2' } }, + { id: 'rl-b2', position: { x: TARGET_X, y: rowY(1) }, data: { label: 'B2' } }, + { id: 'rl-a3', position: { x: SOURCE_X, y: rowY(2) }, data: { label: 'A3' } }, + { id: 'rl-b3', position: { x: TARGET_X, y: rowY(2) }, data: { label: 'B3' } }, + { id: 'rl-a4', position: { x: SOURCE_X, y: rowY(3) }, data: { label: 'A4' } }, + { id: 'rl-b4', position: { x: TARGET_X, y: rowY(3) }, data: { label: 'B4' } }, + { id: 'rl-a5', position: { x: SOURCE_X, y: rowY(4) }, data: { label: 'A5' } }, + { id: 'rl-b5', position: { x: TARGET_X, y: rowY(4) }, data: { label: 'B5' } }, + // Row 6 has no source node — the edge below starts free at the source end. + { id: 'rl-b6', position: { x: TARGET_X, y: rowY(5) }, data: { label: 'B6' } }, + // Row 7 is a dual dangling edge, so it needs no node at all. + { + id: RL_GROUP_ID, + type: 'custom-group', + isGroup: true, + position: { x: SOURCE_X, y: rowY(7) }, + size: { width: 640, height: 200 }, + autoSize: false, + data: { title: 'Group (same-group rule)' }, + }, + { + id: 'rl-g1', + groupId: RL_GROUP_ID, + position: { x: SOURCE_X + 30, y: rowY(7) + 70 }, + data: { label: 'G1' }, + }, + { + id: 'rl-g2', + groupId: RL_GROUP_ID, + position: { x: SOURCE_X + 400, y: rowY(7) + 70 }, + data: { label: 'G2' }, + }, + // Outside the group: relinking a group edge onto this node is refused by + // the "relink only inside the same group" validation rule. + { id: 'rl-outside', position: { x: 720, y: rowY(7) + 70 }, data: { label: 'Outside' } }, + { id: 'rl-a9', position: { x: SOURCE_X, y: rowY(9) + 30 }, data: { label: 'A9' } }, + { id: 'rl-b9', position: { x: TARGET_X, y: rowY(9) + 30 }, data: { label: 'B9' } }, + { id: 'rl-a10', position: { x: SOURCE_X, y: rowY(10) + 30 }, data: { label: 'A10' } }, + { id: 'rl-b10', position: { x: TARGET_X, y: rowY(10) + 30 }, data: { label: 'B10' } }, + { id: RL_DROP_TARGET_1, position: { x: 800, y: rowY(1) }, data: { label: 'Drop target 1' } }, + { id: RL_DROP_TARGET_2, position: { x: 800, y: rowY(3) }, data: { label: 'Drop target 2' } }, + ]; + + const edges: Edge[] = [ + { + id: RL_EDGE_DEFAULT, + source: 'rl-a1', + sourcePort: 'port-right', + target: 'rl-b1', + targetPort: 'port-left', + data: { label: 'default from config' }, + }, + { + id: RL_EDGE_TRUE, + source: 'rl-a2', + sourcePort: 'port-right', + target: 'rl-b2', + targetPort: 'port-left', + relinkable: true, + data: { label: 'relinkable: true' }, + }, + { + id: RL_EDGE_SOURCE_ONLY, + source: 'rl-a3', + sourcePort: 'port-right', + target: 'rl-b3', + targetPort: 'port-left', + relinkable: 'source', + data: { label: "relinkable: 'source'" }, + }, + { + id: RL_EDGE_TARGET_ONLY, + source: 'rl-a4', + sourcePort: 'port-right', + target: 'rl-b4', + targetPort: 'port-left', + relinkable: 'target', + data: { label: "relinkable: 'target'" }, + }, + { + id: RL_EDGE_LOCKED, + source: 'rl-a5', + sourcePort: 'port-right', + target: 'rl-b5', + targetPort: 'port-left', + relinkable: false, + data: { label: 'locked' }, + }, + // Free at the source from the first render: empty `source` plus a + // `sourcePosition` anchor and no `sourcePort`. + { + id: RL_EDGE_DANGLING_SOURCE, + source: '', + sourcePosition: { x: SOURCE_X + 60, y: rowY(5) + 25 }, + target: 'rl-b6', + targetPort: 'port-left', + data: { label: 'dangling source' }, + }, + { + id: RL_EDGE_DUAL_DANGLING, + source: '', + sourcePosition: { x: SOURCE_X + 60, y: rowY(6) + 25 }, + target: '', + targetPosition: { x: TARGET_X + 60, y: rowY(6) + 25 }, + data: { label: 'dual dangling' }, + }, + { + id: RL_EDGE_IN_GROUP, + source: 'rl-g1', + sourcePort: 'port-right', + target: 'rl-g2', + targetPort: 'port-left', + data: { label: 'inside the group' }, + }, + { + id: RL_EDGE_KEEP_ON_DROP, + source: 'rl-a9', + sourcePort: 'port-right', + target: 'rl-b9', + targetPort: 'port-left', + data: { label: 'keepOnDrop: false', keepOnDrop: false }, + }, + { + id: RL_EDGE_DELETE_WITH_NODE, + source: 'rl-a10', + sourcePort: 'port-right', + target: 'rl-b10', + targetPort: 'port-left', + data: { label: 'deleteWithNode: true', deleteWithNode: true }, + }, + ]; + + return { nodes, edges }; +} diff --git a/apps/angular-demo/src/app/toolbar/relinking-toolbar.component.html b/apps/angular-demo/src/app/toolbar/relinking-toolbar.component.html new file mode 100644 index 000000000..1d53b4130 --- /dev/null +++ b/apps/angular-demo/src/app/toolbar/relinking-toolbar.component.html @@ -0,0 +1,101 @@ +
+
+ + Select an edge to see its endpoint handles. Drag a handle onto a port, or onto empty canvas to detach it. Select a + node and press Delete to see what happens to its edges. + +
+ +
+ Config: + + + + | + + + +
+ +
+ Selected edge: + @if (selectedEdge(); as edge) { + + {{ edge.id }} · {{ endpointsLabel() }} · relinkable: {{ rawRelinkableLabel() }} · + {{ resolvedRelinkableLabel() }} + + | + Set relinkable: + + + + + + | + + + + } @else { + No edge selected. + } +
+ +
+ Actions: + + + + +
+ +
+ Events: + + Newest first, last {{ maxLogEntries }} entries. +
+ +
+ @for (entry of logEntries(); track $index) { +
{{ entry }}
+ } @empty { +
No events yet.
+ } +
+
diff --git a/apps/angular-demo/src/app/toolbar/relinking-toolbar.component.scss b/apps/angular-demo/src/app/toolbar/relinking-toolbar.component.scss new file mode 100644 index 000000000..bca82babd --- /dev/null +++ b/apps/angular-demo/src/app/toolbar/relinking-toolbar.component.scss @@ -0,0 +1,85 @@ +:host { + position: absolute; + left: 50%; + bottom: 8px; + transform: translateX(-50%); + max-width: 92vw; +} + +.relinking-toolbar { + display: flex; + flex-direction: column; + gap: 8px; + padding: 8px; + background: #ffffff; + border-radius: 4px; + max-height: 70vh; + overflow-y: auto; +} + +.toolbar-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + + .separator { + color: #ccc; + margin: 0 -2px; + user-select: none; + } + + .group-label { + font-size: 12px; + font-weight: 600; + color: #666; + white-space: nowrap; + } + + .demo-toggle { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + color: #444; + white-space: nowrap; + cursor: pointer; + user-select: none; + } + + .hint { + font-size: 11px; + color: #999; + font-style: italic; + } + + .edge-state { + font-size: 12px; + font-family: monospace; + color: #333; + white-space: nowrap; + } +} + +.event-log { + display: flex; + flex-direction: column; + gap: 1px; + max-height: 140px; + overflow-y: auto; + padding: 4px 6px; + background: #f6f6f6; + border-radius: 3px; + font-size: 11px; + font-family: monospace; + + .event-log-entry { + white-space: nowrap; + color: #333; + + &.empty { + color: #999; + font-style: italic; + } + } +} diff --git a/apps/angular-demo/src/app/toolbar/relinking-toolbar.component.ts b/apps/angular-demo/src/app/toolbar/relinking-toolbar.component.ts new file mode 100644 index 000000000..917f31e8e --- /dev/null +++ b/apps/angular-demo/src/app/toolbar/relinking-toolbar.component.ts @@ -0,0 +1,363 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + OnDestroy, + output, + signal, + untracked, +} from '@angular/core'; +import { + ConnectionValidationContext, + Edge, + EdgeDrawEndedEvent, + EdgeEnd, + EdgeRelinkEndedEvent, + EdgeRelinkStartedEvent, + isEdgeEndRelinkable, + NgDiagramModelService, + NgDiagramSelectionService, + NgDiagramService, + NgDiagramViewportService, + Node, + Point, + Port, + SelectionRemovedEvent, +} from 'ng-diagram'; +import { RelinkingEdgeData } from '../data/relinking-model'; + +type ValidateConnection = ( + source: Node | null, + sourcePort: Port | null, + target: Node | null, + targetPort: Port | null, + context?: ConnectionValidationContext +) => boolean; + +type ShouldKeepOnDrop = (edge: Edge, dropPosition: Point) => boolean; +type ShouldDetachOnNodeDelete = (edge: Edge, deletedNode: Node, end: EdgeEnd) => boolean; + +type KeepOnDropRule = 'always' | 'never' | 'byData'; +type DetachOnDeleteRule = 'always' | 'never' | 'byData'; +type ValidateRule = 'all' | 'noSelf' | 'sameGroup' | 'noRelink' | 'noAttach'; + +/** Port every "attach the free end" action connects to. */ +const ATTACH_PORT_ID = 'port-left'; + +/** Oldest entries fall off the event log once it is this long. */ +const MAX_LOG_ENTRIES = 30; + +const edgeData = (edge: Edge): RelinkingEdgeData => edge.data as RelinkingEdgeData; + +const keepOnDropRules: Record = { + always: () => true, + never: () => false, + byData: (edge) => edgeData(edge).keepOnDrop !== false, +}; + +const detachOnDeleteRules: Record = { + always: () => true, + never: () => false, + byData: (edge) => !edgeData(edge).deleteWithNode, +}; + +const validateRules: Record = { + all: () => true, + noSelf: (source, _sourcePort, target) => source?.id !== target?.id, + // `source` is null when the other end of the relinked edge is free, so a + // dangling edge counts as "ungrouped" and only reaches ungrouped nodes. + sameGroup: (source, _sourcePort, target, _targetPort, context) => + context?.reason !== 'relink' || source?.groupId === target?.groupId, + noRelink: (_source, _sourcePort, _target, _targetPort, context) => context?.reason !== 'relink', + noAttach: (_source, _sourcePort, _target, _targetPort, context) => context?.reason !== 'attach', +}; + +/** + * Test panel for relinking and dangling edges: it drives the whole + * `linking` / `danglingEdges` configuration live through `updateConfig`, + * inspects the `relinkable` state of the selected edge, detaches and attaches + * endpoints through the model service, and logs the relink, draw and removal + * events the diagram emits. + */ +@Component({ + selector: 'app-relinking-toolbar', + templateUrl: './relinking-toolbar.component.html', + styleUrl: './relinking-toolbar.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RelinkingToolbarComponent implements OnDestroy { + private readonly ngDiagramService = inject(NgDiagramService); + private readonly modelService = inject(NgDiagramModelService); + private readonly selectionService = inject(NgDiagramSelectionService); + private readonly viewportService = inject(NgDiagramViewportService); + + exit = output(); + resetScene = output(); + + private readonly unsubscribes: (() => void)[] = []; + + constructor() { + // Setting a new model destroys the diagram core and builds a fresh one + // from the `[config]` input, which drops both the event listeners and + // every setting this panel applied. The panel re-applies them on each + // initialization, so "Reset scene" keeps the log and the settings alive. + effect(() => { + if (!this.ngDiagramService.isInitialized()) { + return; + } + untracked(() => { + this.subscribeToEvents(); + this.applySettings(); + }); + }); + } + + ngOnDestroy(): void { + this.unsubscribe(); + } + + private subscribeToEvents(): void { + this.unsubscribe(); + this.unsubscribes.push( + this.ngDiagramService.addEventListener('edgeRelinkStarted', (event: EdgeRelinkStartedEvent) => + this.log(`relinkStarted ${event.edge.id} ${event.end}`) + ), + this.ngDiagramService.addEventListener('edgeRelinkEnded', (event: EdgeRelinkEndedEvent) => + this.log(this.formatRelinkEnded(event)) + ), + this.ngDiagramService.addEventListener('edgeDrawEnded', (event: EdgeDrawEndedEvent) => + this.log(this.formatDrawEnded(event)) + ), + this.ngDiagramService.addEventListener('selectionRemoved', (event: SelectionRemovedEvent) => + this.log( + `selectionRemoved nodes=[${ids(event.deletedNodes)}] deleted=[${ids(event.deletedEdges)}] ` + + `detached=[${ids(event.detachedEdges)}]` + ) + ) + ); + } + + private unsubscribe(): void { + this.unsubscribes.forEach((unsubscribe) => unsubscribe()); + this.unsubscribes.length = 0; + } + + /** Pushes every setting the panel holds into the live config. */ + private applySettings(): void { + // `updateConfig` deep-merges, so every callback is passed as an explicit + // function — omitting it would leave the previous one in place. + this.ngDiagramService.updateConfig({ + linking: { + defaultRelinkable: this.defaultRelinkable(), + validateConnection: validateRules[this.validateRule()], + }, + danglingEdges: { + enabled: this.danglingEnabled(), + detachOnNodeDelete: this.detachOnNodeDelete(), + shouldKeepOnDrop: keepOnDropRules[this.keepOnDropRule()], + shouldDetachOnNodeDelete: detachOnDeleteRules[this.detachOnDeleteRule()], + }, + }); + } + + // ============================================= + // Config row + // ============================================= + + // The panel owns every setting it exposes (the initial values match the + // demo's `[config]` input). A stored callback cannot be read back from the + // config, and the primitives would be lost on every core rebuild, so the + // panel is the single source of truth while the mode is open. + protected readonly defaultRelinkable = signal(true); + protected readonly danglingEnabled = signal(true); + protected readonly detachOnNodeDelete = signal(true); + protected readonly keepOnDropRule = signal('always'); + protected readonly detachOnDeleteRule = signal('always'); + protected readonly validateRule = signal('all'); + + protected readonly defaultRelinkableValue = computed(() => String(this.defaultRelinkable())); + + onDefaultRelinkableChange(event: Event): void { + this.defaultRelinkable.set(parseRelinkable(selectValue(event))); + this.applySettings(); + } + + onDanglingEnabledChange(): void { + this.danglingEnabled.update((enabled) => !enabled); + this.applySettings(); + } + + onDetachOnNodeDeleteChange(): void { + this.detachOnNodeDelete.update((detach) => !detach); + this.applySettings(); + } + + onKeepOnDropRuleChange(event: Event): void { + this.keepOnDropRule.set(selectValue(event) as KeepOnDropRule); + this.applySettings(); + } + + onDetachOnDeleteRuleChange(event: Event): void { + this.detachOnDeleteRule.set(selectValue(event) as DetachOnDeleteRule); + this.applySettings(); + } + + onValidateRuleChange(event: Event): void { + this.validateRule.set(selectValue(event) as ValidateRule); + this.applySettings(); + } + + // ============================================= + // Selected edge row + // ============================================= + + protected readonly selectedEdge = computed(() => this.selectionService.selection().edges[0] ?? null); + + protected readonly selectedNode = computed(() => this.selectionService.selection().nodes[0] ?? null); + + protected readonly endpointsLabel = computed(() => { + const edge = this.selectedEdge(); + if (!edge) { + return ''; + } + return `${edge.source || 'free'} → ${edge.target || 'free'}`; + }); + + protected readonly rawRelinkableLabel = computed(() => { + const edge = this.selectedEdge(); + if (!edge) { + return ''; + } + return edge.relinkable === undefined ? '(default)' : String(edge.relinkable); + }); + + /** What the resolver makes of the edge's own value plus the config default. */ + protected readonly resolvedRelinkableLabel = computed(() => { + const edge = this.selectedEdge(); + if (!edge) { + return ''; + } + const fallback = this.defaultRelinkable(); + const resolve = (end: EdgeEnd) => (isEdgeEndRelinkable(edge, end, fallback) ? 'yes' : 'no'); + return `source: ${resolve('source')} · target: ${resolve('target')}`; + }); + + /** The first endpoint of the selected edge that is not connected to a node. */ + protected readonly freeEnd = computed(() => { + const edge = this.selectedEdge(); + if (!edge) { + return null; + } + if (edge.source === '') { + return 'source'; + } + return edge.target === '' ? 'target' : null; + }); + + protected readonly canDetachSource = computed(() => { + const edge = this.selectedEdge(); + return this.danglingEnabled() && !!edge && edge.source !== ''; + }); + + protected readonly canDetachTarget = computed(() => { + const edge = this.selectedEdge(); + return this.danglingEnabled() && !!edge && edge.target !== ''; + }); + + protected readonly canAttachFreeEnd = computed(() => !!this.freeEnd() && !!this.selectedNode()); + + setRelinkable(relinkable: boolean | EdgeEnd | undefined): void { + const edge = this.selectedEdge(); + if (!edge) { + return; + } + // An explicit `undefined` clears the edge's own value, so the resolved + // state falls back to `linking.defaultRelinkable` again. + this.modelService.updateEdge(edge.id, { relinkable }); + } + + detach(end: EdgeEnd): void { + const edge = this.selectedEdge(); + if (!edge) { + return; + } + this.modelService.detachEdge(edge.id, end); + } + + async attachFreeEnd(): Promise { + const edge = this.selectedEdge(); + const end = this.freeEnd(); + const node = this.selectedNode(); + if (!edge || !end || !node) { + return; + } + const attached = await this.modelService.attachEdge(edge.id, end, node.id, ATTACH_PORT_ID); + this.log(`attachEdge → ${attached}`); + } + + // ============================================= + // Actions row + // ============================================= + + /** Starts a draw gesture from the middle of the screen, with no source node. */ + drawFromCenter(): void { + const center = this.viewportService.clientToFlowPosition({ + x: window.innerWidth / 2, + y: window.innerHeight / 2, + }); + this.ngDiagramService.startLinkingFromPosition(center); + } + + zoomToFit(): void { + this.viewportService.zoomToFit(); + } + + // ============================================= + // Event log + // ============================================= + + protected readonly logEntries = signal([]); + protected readonly maxLogEntries = MAX_LOG_ENTRIES; + + clearLog(): void { + this.logEntries.set([]); + } + + private log(entry: string): void { + this.logEntries.update((entries) => [entry, ...entries].slice(0, MAX_LOG_ENTRIES)); + } + + private formatRelinkEnded(event: EdgeRelinkEndedEvent): string { + const head = `relinkEnded ${event.edge.id} ${event.end}`; + if (!event.success) { + return `${head} ✘ ${event.reason ?? 'reverted'}`; + } + if (!event.target) { + return `${head} ✔ → free`; + } + return `${head} ✔ → ${event.target.id}/${event.targetPort ?? 'no port'}`; + } + + private formatDrawEnded(event: EdgeDrawEndedEvent): string { + if (!event.success) { + return `drawEnded ✘ ${event.reason ?? 'cancelled'}`; + } + if (!event.target) { + return 'drawEnded ✔ dangling (no target)'; + } + return `drawEnded ✔ → ${event.target.id}/${event.targetPort ?? 'no port'}`; + } +} + +const selectValue = (event: Event): string => (event.target as HTMLSelectElement).value; + +const parseRelinkable = (value: string): boolean | EdgeEnd => { + if (value === 'source' || value === 'target') { + return value; + } + return value === 'true'; +}; + +const ids = (parts: { id: string }[]): string => parts.map((part) => part.id).join(', '); diff --git a/apps/angular-demo/src/app/toolbar/toolbar.component.html b/apps/angular-demo/src/app/toolbar/toolbar.component.html index 6010d427d..6928b6a57 100644 --- a/apps/angular-demo/src/app/toolbar/toolbar.component.html +++ b/apps/angular-demo/src/app/toolbar/toolbar.component.html @@ -1,7 +1,6 @@
- @@ -25,4 +24,5 @@ +
diff --git a/apps/angular-demo/src/app/toolbar/toolbar.component.ts b/apps/angular-demo/src/app/toolbar/toolbar.component.ts index 3ac94408e..67d09b2d7 100644 --- a/apps/angular-demo/src/app/toolbar/toolbar.component.ts +++ b/apps/angular-demo/src/app/toolbar/toolbar.component.ts @@ -37,6 +37,7 @@ export class ToolbarComponent { measurementTestEnter = output(); awaitableTestEnter = output(); hiddenElementsDemoEnter = output(); + relinkingTestEnter = output(); isNodeSelected = computed(() => this.ngDiagramSelectionService.selection().nodes.length > 0); isAnythingSelected = computed(() => { const selection = this.ngDiagramSelectionService.selection(); @@ -109,18 +110,6 @@ export class ToolbarComponent { } } - onLinkFromPositionClick() { - // Start drawing an edge from the middle of the screen — no source node. - // Finishing on empty canvas keeps it as a dual dangling edge (the - // danglingEdges feature is enabled in this demo); finishing on a port - // connects it. - const center = this.ngDiagramViewportService.clientToFlowPosition({ - x: window.innerWidth / 2, - y: window.innerHeight / 2, - }); - this.ngDiagramService.startLinkingFromPosition(center); - } - onCenterOnClick() { const node = this.ngDiagramSelectionService.selection().nodes[0]; From 14759ad2fbaef191f9f0a0510ffe2a148a9d7d0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Ja=C5=BAwa?= Date: Mon, 21 Sep 2026 22:59:31 +0200 Subject: [PATCH 09/10] Simplify the English in relinking and dangling edges docs --- .../dangling-edges/diagram.component.ts | 2 +- .../relinking/relinking/diagram.component.ts | 4 +- .../Components/NgDiagramBaseEdgeComponent.md | 17 +-- .../docs/api/Components/NgDiagramComponent.md | 7 +- .../api/Directives/RelinkHandleDirective.md | 9 +- .../docs/api/Internals/LinkingActionState.md | 10 +- .../api/Services/NgDiagramModelService.md | 46 ++++--- .../docs/api/Services/NgDiagramService.md | 19 +-- .../Features/ConnectionValidationContext.md | 7 +- .../Features/DanglingEdgesConfig.md | 81 +++++++----- .../Configuration/Features/LinkingConfig.md | 40 +++--- .../docs/api/Types/Events/DiagramEventMap.md | 5 +- .../Types/Events/EdgeRelinkCancelReason.md | 18 +-- .../api/Types/Events/EdgeRelinkEndedEvent.md | 8 +- .../api/Types/Events/SelectionRemovedEvent.md | 5 +- .../src/content/docs/api/Types/Model/Edge.md | 6 +- .../docs/api/Utilities/computeDetachAnchor.md | 16 ++- .../api/Utilities/getDanglingEndpoints.md | 12 +- .../getNearestDanglingEndpointInRange.md | 15 ++- .../docs/api/Utilities/hasFreeEndpoint.md | 10 +- .../docs/api/Utilities/isDanglingEdge.md | 9 +- .../docs/api/Utilities/isEdgeEndRelinkable.md | 15 ++- .../docs/guides/edges/dangling-edges.mdx | 56 ++++---- .../src/content/docs/guides/edges/edges.mdx | 4 +- .../content/docs/guides/edges/relinking.mdx | 77 +++++------ .../src/content/docs/guides/flow-config.mdx | 2 +- apps/docs/src/content/docs/intro/roadmap.mdx | 5 +- apps/docs/src/content/docs/intro/services.mdx | 2 +- .../src/core/src/event-manager/event-types.ts | 36 +++--- .../core/src/types/action-state.interface.ts | 10 +- .../src/core/src/types/edge.interface.ts | 6 +- .../core/src/types/flow-config.interface.ts | 121 ++++++++++-------- .../src/core/src/utils/dangling-edges.ts | 47 ++++--- .../src/core/src/utils/relinking.ts | 11 +- .../diagram/ng-diagram.component.ts | 7 +- .../edge/base-edge/base-edge.component.ts | 17 +-- .../relinking/relinking.directive.ts | 9 +- .../ng-diagram-model.service.ts | 46 ++++--- .../lib/public-services/ng-diagram.service.ts | 17 +-- 39 files changed, 480 insertions(+), 354 deletions(-) diff --git a/apps/docs/src/components/angular/edges/dangling-edges/dangling-edges/diagram.component.ts b/apps/docs/src/components/angular/edges/dangling-edges/dangling-edges/diagram.component.ts index 40130f02b..a6483e653 100644 --- a/apps/docs/src/components/angular/edges/dangling-edges/dangling-edges/diagram.component.ts +++ b/apps/docs/src/components/angular/edges/dangling-edges/dangling-edges/diagram.component.ts @@ -71,7 +71,7 @@ export class DiagramComponent { // @collapse-end:model-shape { id: 'dangling', - source: '', // free endpoint — no node, no port + source: '', // free endpoint: no node, no port sourcePosition: { x: 200, y: 340 }, // anchored here, in flow coordinates target: 'node-3', targetPort: 'port-left', diff --git a/apps/docs/src/components/angular/edges/relinking/relinking/diagram.component.ts b/apps/docs/src/components/angular/edges/relinking/relinking/diagram.component.ts index d974d1fbf..52d5c72f4 100644 --- a/apps/docs/src/components/angular/edges/relinking/relinking/diagram.component.ts +++ b/apps/docs/src/components/angular/edges/relinking/relinking/diagram.component.ts @@ -39,12 +39,12 @@ export class DiagramComponent { }, // @collapse-end:config linking: { - // Both ends of every edge, unless the edge sets its own `relinkable` + // Both ends of every edge can be relinked, unless the edge sets its own `relinkable` // @mark-start:config defaultRelinkable: true, // @mark-end:config }, - // A handle dropped on empty canvas detaches that end instead of reverting + // A handle dropped on empty canvas detaches that end instead of reverting the relink danglingEdges: { enabled: true, }, diff --git a/apps/docs/src/content/docs/api/Components/NgDiagramBaseEdgeComponent.md b/apps/docs/src/content/docs/api/Components/NgDiagramBaseEdgeComponent.md index 94a8e5cbb..1763dd438 100644 --- a/apps/docs/src/content/docs/api/Components/NgDiagramBaseEdgeComponent.md +++ b/apps/docs/src/content/docs/api/Components/NgDiagramBaseEdgeComponent.md @@ -16,8 +16,8 @@ It can be extended or used directly to render edges in the diagram. > `readonly` **dangling**: `Signal`\<`boolean`\> Whether the edge has at least one free (unconnected) endpoint. Temporary -edges are excluded — a draw preview always has a free end and must not -pick up dangling styling. +edges are excluded: a draw preview always has a free end, but it must not +get the dangling styling. *** @@ -33,10 +33,10 @@ Edge data model > `readonly` **relinkHandleHitRadius**: `Signal`\<`number`\> -Radius of the handles' invisible hit circle, in flow units. Kept at -roughly a finger-friendly constant size on screen by dividing by the -viewport scale — at zoom 0.5 the visible 5px circle alone would leave a -2.5px touch target. +Radius of the invisible hit circle around each handle, in flow units. The +radius is divided by the viewport scale, so the hit area keeps a constant, +finger-friendly size on screen at any zoom level. Without this, at zoom +0.5 the visible 5px circle would give only a 2.5px touch target. #### Since @@ -60,8 +60,9 @@ Position of the source endpoint handle (the first routed point). > `readonly` **relinkSourceHandleVisible**: `Signal`\<`boolean`\> -Whether the source endpoint handle is rendered: the edge is selected, -committed, routed, and its source end can be relinked. +Whether the source endpoint handle is rendered. It is rendered when the +edge is selected, is not a temporary edge, has routed points, and its +source end can be relinked. #### Since diff --git a/apps/docs/src/content/docs/api/Components/NgDiagramComponent.md b/apps/docs/src/content/docs/api/Components/NgDiagramComponent.md index b49a31ba9..de32283f4 100644 --- a/apps/docs/src/content/docs/api/Components/NgDiagramComponent.md +++ b/apps/docs/src/content/docs/api/Components/NgDiagramComponent.md @@ -78,8 +78,9 @@ Use `edgeDrawEnded` instead, which fires for both successful and cancelled draws Event emitted when an edge relink gesture ends, regardless of outcome. -Fires when the dragged endpoint is dropped — reconnected to a port, left -dangling on empty canvas, or reverted (invalid drop or cancelled gesture). +Fires when the dragged endpoint is dropped, whether it was reconnected to +a port, left dangling on empty canvas, or reverted (invalid drop or +cancelled gesture). #### Since @@ -92,7 +93,7 @@ dangling on empty canvas, or reverted (invalid drop or cancelled gesture). > **edgeRelinkStarted**: `EventEmitter`\<[`EdgeRelinkStartedEvent`](/docs/api/types/events/edgerelinkstartedevent/)\> Event emitted when the user starts dragging an endpoint of an existing -edge (the relinking gesture, see `linking.defaultRelinkable` config). +edge (the relinking gesture; see the `linking.defaultRelinkable` config). #### Since diff --git a/apps/docs/src/content/docs/api/Directives/RelinkHandleDirective.md b/apps/docs/src/content/docs/api/Directives/RelinkHandleDirective.md index 80b5700c5..b9fe54de4 100644 --- a/apps/docs/src/content/docs/api/Directives/RelinkHandleDirective.md +++ b/apps/docs/src/content/docs/api/Directives/RelinkHandleDirective.md @@ -8,8 +8,9 @@ title: "RelinkHandleDirective" Turns its host element into a grabbable edge-endpoint handle: a pointerdown starts the relink gesture for the given end of the given edge. The gesture -itself is driven at document level — the host element unmounts when the -edge is hidden during the drag. +itself is handled at document level, so it keeps running after the host +element is removed from the DOM. This happens on every relink, because the +original edge is not rendered while its endpoint is being dragged. -`ng-diagram-base-edge` renders its own handles with this directive; use it -directly in fully custom edge templates that do not compose the base edge. +`ng-diagram-base-edge` renders its own handles with this directive. Use it +directly in fully custom edge templates that do not use the base edge. diff --git a/apps/docs/src/content/docs/api/Internals/LinkingActionState.md b/apps/docs/src/content/docs/api/Internals/LinkingActionState.md index e500726f8..9f42cbaf7 100644 --- a/apps/docs/src/content/docs/api/Internals/LinkingActionState.md +++ b/apps/docs/src/content/docs/api/Internals/LinkingActionState.md @@ -30,10 +30,10 @@ Position where the pointer was released. > `optional` **relink**: [`LinkingRelinkContext`](/docs/api/internals/linkingrelinkcontext/) -Present while an existing edge's endpoint is being relinked. The original -edge is hidden and represented by the temporary edge until the gesture -ends; `relink.end` is the end of the temporary edge that follows the -pointer (a normal draw always drags the target end). +Present while an endpoint of an existing edge is being relinked. Until the +gesture ends, the original edge is not rendered and the temporary edge +represents it. `relink.end` is the end of the temporary edge that follows +the pointer (a normal draw always drags the target end). #### Since @@ -46,7 +46,7 @@ pointer (a normal draw always drags the target end). > `optional` **relinkCancelReason**: [`EdgeRelinkCancelReason`](/docs/api/types/events/edgerelinkcancelreason/) Reason the relink gesture ended without changing the edge (set by -finishRelinking on failure paths). +`finishRelinking` when the relink fails). #### Since diff --git a/apps/docs/src/content/docs/api/Services/NgDiagramModelService.md b/apps/docs/src/content/docs/api/Services/NgDiagramModelService.md index 24592709c..56ca78b83 100644 --- a/apps/docs/src/content/docs/api/Services/NgDiagramModelService.md +++ b/apps/docs/src/content/docs/api/Services/NgDiagramModelService.md @@ -117,9 +117,14 @@ A promise that resolves once the change has been applied to the model. Inside a > **attachEdge**(`edgeId`, `end`, `nodeId`, `portId?`): `Promise`\<`boolean`\> -Attaches one endpoint of an edge to a node (and optionally a port), -running `linking.validateConnection` with the edge's endpoints in their -proper roles — the symmetric counterpart of [detachEdge](/docs/api/services/ngdiagrammodelservice/#detachedge). +Attaches one endpoint of an edge to a node and, optionally, to a port. +This is the opposite of [detachEdge](/docs/api/services/ngdiagrammodelservice/#detachedge). + +The same checks as for a relink drop apply: the node must exist and be +visible, and the port must exist, be visible and have the right direction. +The connection is then validated with `linking.validateConnection`, which +receives the attached node as `source` or `target` according to `end`, +and a context with `reason: 'attach'`. #### Parameters @@ -127,7 +132,7 @@ proper roles — the symmetric counterpart of [detachEdge](/docs/api/services/ng `string` -The edge to attach. +ID of the edge to attach. ##### end @@ -139,13 +144,13 @@ Which endpoint to attach. `string` -The node to attach to. +ID of the node to attach to. ##### portId? `string` -The port to attach to. +ID of the port to attach to. When omitted, the endpoint is attached to the node without a port. #### Returns @@ -256,14 +261,15 @@ A promise that resolves once the change has been applied to the model. Inside a > **detachEdge**(`edgeId`, `end`, `position?`): `Promise`\<`void`\> -Detaches one endpoint of an edge into a free (dangling) endpoint. +Detaches one endpoint of an edge, so that it becomes a free (dangling) +endpoint. When `position` is omitted, the endpoint stays anchored where it is now: -at the port's current position when the edge was connected to a port, at -the edge's routed endpoint otherwise. +at the current position of the port when the edge was connected to a +port, otherwise at the routed endpoint of the edge. -Requires `danglingEdges.enabled` — with the feature off this method is a -no-op, so dangling edges only ever come into existence behind the flag. +Requires `danglingEdges.enabled`. With the feature off, this method does +nothing and logs a console warning. #### Parameters @@ -271,7 +277,7 @@ no-op, so dangling edges only ever come into existence behind the flag. `string` -The edge to detach. +ID of the edge to detach. ##### end @@ -283,7 +289,7 @@ Which endpoint to detach. [`Point`](/docs/api/types/geometry/point/) -Optional anchor position for the freed endpoint. +Optional anchor position for the freed endpoint, in flow coordinates. #### Returns @@ -419,15 +425,15 @@ Array of nodes connected to the given node > **getDanglingEndpoints**(): [`DanglingEndpoint`](/docs/api/types/model/danglingendpoint/)[] -Collects the free (unconnected) endpoints of all committed edges — a dual -dangling edge yields two entries. Temporary and effectively hidden edges +Returns the free (unconnected) endpoints of all edges in the model. A dual +dangling edge gives two entries. Temporary and effectively hidden edges are skipped. #### Returns [`DanglingEndpoint`](/docs/api/types/model/danglingendpoint/)[] -Dangling endpoints with their edge, end and anchor position. +The free endpoints with their edge, end and anchor position. #### Since @@ -482,9 +488,9 @@ Returns null if flowCore is not initialized. > **getNearestDanglingEndpointInRange**(`point`, `range`): `null` \| [`DanglingEndpoint`](/docs/api/types/model/danglingendpoint/) -Gets the free edge endpoint nearest to a point within a range — the -dangling-edges sibling of [getNearestPortInRange](/docs/api/services/ngdiagrammodelservice/#getnearestportinrange). Temporary and -effectively hidden edges are skipped. +Finds the free edge endpoint nearest to a point within a range. It works +like [getNearestPortInRange](/docs/api/services/ngdiagrammodelservice/#getnearestportinrange), but for the free endpoints of dangling +edges. Temporary and effectively hidden edges are skipped. #### Parameters @@ -504,7 +510,7 @@ Range to check in. `null` \| [`DanglingEndpoint`](/docs/api/types/model/danglingendpoint/) -Nearest dangling endpoint in range or null. +Nearest free endpoint in range, or null. #### Since diff --git a/apps/docs/src/content/docs/api/Services/NgDiagramService.md b/apps/docs/src/content/docs/api/Services/NgDiagramService.md index 7bd7ae43c..9d6924da9 100644 --- a/apps/docs/src/content/docs/api/Services/NgDiagramService.md +++ b/apps/docs/src/content/docs/api/Services/NgDiagramService.md @@ -525,15 +525,16 @@ The port ID from which the linking starts. Creates a floating edge when undefine > **startLinkingFromPosition**(`position`): `void` -Call this method to start drawing an edge from a position on the canvas -(no source node) from your custom logic. The free end of the edge follows -the pointer until a click finishes the draw — on a port the edge connects -to it; on empty canvas both ends stay free (a dual dangling edge), kept -when `shouldKeepOnDrop` allows it. - -Requires `danglingEdges.enabled` — an edge drawn from a position has an -empty source, i.e. it is a dangling edge by construction. With the -feature off the call is ignored with a console warning. +Starts drawing an edge from a position on the canvas instead of from a +node, for example from a context menu action. The free end of the edge +follows the pointer until a click finishes the draw: a click on a port +connects the edge to that port, and a click on empty canvas leaves both +ends free (a dual dangling edge), kept when `danglingEdges.shouldKeepOnDrop` +allows it. + +Requires `danglingEdges.enabled`, because an edge drawn from a position +has no source node and is therefore a dangling edge. With the feature off, +the call is ignored and a console warning is logged. #### Parameters diff --git a/apps/docs/src/content/docs/api/Types/Configuration/Features/ConnectionValidationContext.md b/apps/docs/src/content/docs/api/Types/Configuration/Features/ConnectionValidationContext.md index 48f9becc9..757505675 100644 --- a/apps/docs/src/content/docs/api/Types/Configuration/Features/ConnectionValidationContext.md +++ b/apps/docs/src/content/docs/api/Types/Configuration/Features/ConnectionValidationContext.md @@ -6,10 +6,11 @@ prev: false title: "ConnectionValidationContext" --- -Context passed to [LinkingConfig.validateConnection](/docs/api/types/configuration/features/linkingconfig/#validateconnection) describing the -operation that asks for validation. +Context passed to [LinkingConfig.validateConnection](/docs/api/types/configuration/features/linkingconfig/#validateconnection). It describes the +operation that is being validated. -- `draw` — a new edge is being drawn (gesture or `startLinking*`). +- `draw` — a new edge is being drawn, by a pointer gesture or by + `startLinking` / `startLinkingFromPosition`. - `relink` — an endpoint of `edge` is being dragged to a new target. - `attach` — `NgDiagramModelService.attachEdge` connects an endpoint of `edge`. diff --git a/apps/docs/src/content/docs/api/Types/Configuration/Features/DanglingEdgesConfig.md b/apps/docs/src/content/docs/api/Types/Configuration/Features/DanglingEdgesConfig.md index a689a90e0..e129bc742 100644 --- a/apps/docs/src/content/docs/api/Types/Configuration/Features/DanglingEdgesConfig.md +++ b/apps/docs/src/content/docs/api/Types/Configuration/Features/DanglingEdgesConfig.md @@ -6,12 +6,12 @@ prev: false title: "DanglingEdgesConfig" --- -Configuration for dangling edges — edges with one or both endpoints not -connected to any node (an empty `source`/`target` with the free end anchored -at `sourcePosition`/`targetPosition`). +Configuration for dangling edges: edges with one or both endpoints not +connected to any node. A free endpoint has an empty `source` or `target`, +and its position is stored in `sourcePosition` or `targetPosition`. -Everything here is opt-in. With the defaults, a link dropped on empty -canvas is discarded and deleting a node deletes its edges. +The feature is off by default: an edge dropped on empty canvas is +discarded, and deleting a node deletes its edges. ## Properties @@ -19,20 +19,22 @@ canvas is discarded and deleting a node deletes its edges. > **detachOnNodeDelete**: `boolean` -When true, edges connected to a deleted node are detached into dangling -edges — anchored where their port was — instead of being deleted. +When true, deleting a node keeps its edges as dangling edges instead of +deleting them. Each freed endpoint stays anchored where its port was. Requires `enabled` to be true. -An edge is still deleted, never detached, when: -- it is itself part of the deleted selection (an explicit delete wins), -- it is hidden only because of the node it loses — detaching would make - invisible wiring (e.g. the collapsed children of a deleted group) appear - as a visible dangling edge; an edge that stays hidden on its own (its - `hidden` flag, a template binding, or a hidden other endpoint) is - detached like any other and remains hidden, or -- it loses BOTH endpoints in the same delete — it becomes a dual dangling - edge only when [shouldDetachOnNodeDelete](/docs/api/types/configuration/features/danglingedgesconfig/#shoulddetachonnodedelete) is provided and returns - true for both ends. +An edge is still deleted, not detached, in these cases: +- The edge itself is part of the deleted selection. An explicit delete + always wins. +- The edge is hidden only because of the node it loses, for example the + edges of the collapsed children of a deleted group. Detaching it would + turn invisible wiring into a visible dangling edge. An edge that is + hidden for another reason (its own `hidden` flag, a template binding, + or a hidden node at the other end) is detached like any other edge and + stays hidden. +- The edge loses both endpoints in the same delete. It becomes a dual + dangling edge only when [shouldDetachOnNodeDelete](/docs/api/types/configuration/features/danglingedgesconfig/#shoulddetachonnodedelete) is provided + and returns true for both ends. #### Default @@ -46,12 +48,14 @@ false > **enabled**: `boolean` -Master switch for dangling edges. When true, an edge draw that ends on -empty canvas keeps the edge as a dangling edge instead of discarding it, -and an edge relink dropped on empty canvas detaches that endpoint. +Master switch for dangling edges. When true, an edge drawn onto empty +canvas is kept as a dangling edge instead of being discarded, and a +relink dropped on empty canvas detaches that endpoint. It also enables +`detachEdge` and `startLinkingFromPosition`. -A drop over a port the edge cannot connect to (for example a -wrong-direction port) is not an empty-canvas drop and is discarded. +A drop on a port that the edge cannot connect to (for example a port +with the wrong direction) does not count as a drop on empty canvas. Such +a draw is discarded and such a relink is reverted. #### Default @@ -65,12 +69,12 @@ false > `optional` **shouldDetachOnNodeDelete**: (`edge`, `deletedNode`, `end`) => `boolean` -Per-edge decision whether a given endpoint is detached (kept dangling) or -deleted along with the node. Called only when `enabled` and -`detachOnNodeDelete` are true, once per endpoint losing its node. -Returning false deletes the edge. For an edge losing both endpoints at -once, the edge survives as a dual dangling edge only when this callback -is provided and returns true for both ends. +Decides per endpoint whether it is detached (kept as a free endpoint) or +deleted together with the node. Called only when `enabled` and +`detachOnNodeDelete` are true, once for each endpoint that loses its +node. Returning false deletes the edge. An edge that loses both +endpoints at once survives as a dual dangling edge only when this +callback is provided and returns true for both ends. #### Parameters @@ -78,14 +82,20 @@ is provided and returns true for both ends. [`Edge`](/docs/api/types/model/edge/) +The edge that loses a node. + ##### deletedNode [`Node`](/docs/api/types/model/node/) +The node being deleted. + ##### end [`EdgeEnd`](/docs/api/types/model/edgeend/) +The endpoint of `edge` that is connected to `deletedNode`. + #### Returns `boolean` @@ -102,11 +112,12 @@ undefined (detach every edge, except edges losing both ends) > `optional` **shouldKeepOnDrop**: (`edge`, `dropPosition`) => `boolean` -Per-edge decision whether a link dropped on empty canvas is kept as a -dangling edge. Called only when `enabled` is true. The edge passed in is -the fully-built final edge (after `linking.finalEdgeDataBuilder`). -Returning false discards the edge (the default behavior when the feature -is off). +Decides per edge whether a draw or relink dropped on empty canvas keeps +the edge as a dangling edge. Called only when `enabled` is true. For a +draw, `edge` is the final edge, after `linking.finalEdgeDataBuilder` has +run. For a relink, `edge` is the edge as it would be after the detach. +Returning false discards the drawn edge or reverts the relink, which is +also what happens when the feature is off. #### Parameters @@ -114,10 +125,14 @@ is off). [`Edge`](/docs/api/types/model/edge/) +The edge that would be kept. + ##### dropPosition [`Point`](/docs/api/types/geometry/point/) +The position where the pointer was released, in flow coordinates. + #### Returns `boolean` diff --git a/apps/docs/src/content/docs/api/Types/Configuration/Features/LinkingConfig.md b/apps/docs/src/content/docs/api/Types/Configuration/Features/LinkingConfig.md index 9e65d8b50..8368a7665 100644 --- a/apps/docs/src/content/docs/api/Types/Configuration/Features/LinkingConfig.md +++ b/apps/docs/src/content/docs/api/Types/Configuration/Features/LinkingConfig.md @@ -15,17 +15,17 @@ Configuration for linking (edge creation) behavior. > **defaultRelinkable**: `boolean` \| [`EdgeEnd`](/docs/api/types/model/edgeend/) Default `relinkable` value for edges that do not set their own. `true` -lets the user drag both ends of an edge to another port, an `EdgeEnd` -only that end, `false` neither. A selected edge shows a handle at each -end that can be relinked; dragging a handle previews the reconnection -live and commits it on drop. Dropping on empty canvas leaves the endpoint -dangling when `danglingEdges.enabled` is true, otherwise the relink is -reverted. - -The gesture shares this section's snap distance, edge panning and -temporary edge builder. Its drops are validated through -`validateConnection`, which receives a context with `reason: 'relink'` -and the edge being relinked. +lets the user drag both ends of an edge to another port, `'source'` or +`'target'` allows only that end, and `false` allows neither. A selected +edge shows a handle at each end that can be relinked. Dragging a handle +previews the new connection and commits it on drop. A drop on empty +canvas detaches the endpoint when `danglingEdges.enabled` is true; +otherwise the relink is reverted. + +Relinking uses the same `portSnapDistance`, edge panning and +`temporaryEdgeDataBuilder` settings as edge drawing. Each drop is +validated with `validateConnection`, which receives a context with +`reason: 'relink'` and the edge being relinked. #### Default @@ -182,19 +182,19 @@ The Edge object to use for the temporary edge. Validates whether a connection between two nodes and ports is allowed. -Called for every connection-producing operation: drawing a new edge, -relinking an existing edge's endpoint, and `attachEdge`. The optional -`context` says which operation is being validated (since 1.4.0). +Called for every operation that creates a connection: drawing a new edge, +relinking an endpoint of an existing edge, and `attachEdge`. The optional +`context` tells which operation is being validated (since 1.4.0). -`source` can be `null` for draws that start from a position instead of a -node (`startLinkingFromPosition`) and when relinking the free end of a -dangling edge. +`source` is `null` for draws started with `startLinkingFromPosition`. +When an edge is relinked or attached, the other end of that edge can be +free (dangling); the `source` or `target` for that end is then `null`. #### Parameters ##### source -The source node. +The source node, or `null` when the source end is free. `null` | [`Node`](/docs/api/types/model/node/) @@ -206,7 +206,7 @@ The source port. ##### target -The target node. +The target node, or `null` when the target end is free. `null` | [`Node`](/docs/api/types/model/node/) @@ -220,7 +220,7 @@ The target port. [`ConnectionValidationContext`](/docs/api/types/configuration/features/connectionvalidationcontext/) -The operation asking for validation (`draw` when omitted). +The operation being validated (`draw` when omitted). #### Returns diff --git a/apps/docs/src/content/docs/api/Types/Events/DiagramEventMap.md b/apps/docs/src/content/docs/api/Types/Events/DiagramEventMap.md index 025874cdc..c194ee90f 100644 --- a/apps/docs/src/content/docs/api/Types/Events/DiagramEventMap.md +++ b/apps/docs/src/content/docs/api/Types/Events/DiagramEventMap.md @@ -69,8 +69,9 @@ Use `edgeDrawEnded` instead, which fires for both successful and cancelled draws Event emitted when an edge relink gesture ends, regardless of outcome. -Fires when the dragged endpoint is dropped — reconnected to a port, left -dangling on empty canvas, or reverted (invalid drop or cancelled gesture). +Fires when the dragged endpoint is dropped, whether it was reconnected to +a port, left dangling on empty canvas, or reverted (invalid drop or +cancelled gesture). #### Since diff --git a/apps/docs/src/content/docs/api/Types/Events/EdgeRelinkCancelReason.md b/apps/docs/src/content/docs/api/Types/Events/EdgeRelinkCancelReason.md index 1ab07ec19..bb1af5a9a 100644 --- a/apps/docs/src/content/docs/api/Types/Events/EdgeRelinkCancelReason.md +++ b/apps/docs/src/content/docs/api/Types/Events/EdgeRelinkCancelReason.md @@ -10,12 +10,14 @@ title: "EdgeRelinkCancelReason" Reason an edge relink gesture ended without changing the edge. -- `noTarget` — dropped on empty canvas while dangling edges are disabled, - or `danglingEdges.shouldKeepOnDrop` declined the detached edge -- `invalidConnection` — the drop target failed validation: rejected by - `linking.validateConnection` (context reason `relink`), or structurally - invalid (hidden node, hidden/missing port, wrong-direction port) +- `noTarget` — the endpoint was dropped on empty canvas while dangling + edges are disabled, or `danglingEdges.shouldKeepOnDrop` returned false + for the detached edge +- `invalidConnection` — the drop target failed validation: it was rejected + by `linking.validateConnection` (context reason `relink`), or it is not a + valid target at all (hidden node, hidden or missing port, port with the + wrong direction) - `cancelled` — the gesture was aborted (Esc key, - [NgDiagramService.cancelActiveInteraction](/docs/api/services/ngdiagramservice/#cancelactiveinteraction), another gesture claimed - the pointer) or the endpoint was dropped back on its original node and - port, which changes nothing + [NgDiagramService.cancelActiveInteraction](/docs/api/services/ngdiagramservice/#cancelactiveinteraction), or another gesture took + over the pointer), or the endpoint was dropped back on its original node + and port, which changes nothing diff --git a/apps/docs/src/content/docs/api/Types/Events/EdgeRelinkEndedEvent.md b/apps/docs/src/content/docs/api/Types/Events/EdgeRelinkEndedEvent.md index 3e1017bb4..cf5f0afb5 100644 --- a/apps/docs/src/content/docs/api/Types/Events/EdgeRelinkEndedEvent.md +++ b/apps/docs/src/content/docs/api/Types/Events/EdgeRelinkEndedEvent.md @@ -8,9 +8,9 @@ title: "EdgeRelinkEndedEvent" Event payload emitted when an edge relink gesture ends, regardless of outcome. -On success the edge was either reconnected (`target`/`targetPort` populated) -or left dangling (`edge` has an empty endpoint anchored at `dropPosition`). -On failure the edge is unchanged and `reason` explains why. +On success the edge was either reconnected (`target` and `targetPort` are +set) or left dangling (`edge` has a free endpoint anchored at +`dropPosition`). On failure the edge is unchanged and `reason` explains why. ## Properties @@ -58,7 +58,7 @@ The port the endpoint was connected to before the relink, if any. > `optional` **previousPosition**: [`Point`](/docs/api/types/geometry/point/) -The dangling position the endpoint had before the relink, if it was dangling. +The anchor position of the endpoint before the relink, present only when the endpoint was free (dangling). *** diff --git a/apps/docs/src/content/docs/api/Types/Events/SelectionRemovedEvent.md b/apps/docs/src/content/docs/api/Types/Events/SelectionRemovedEvent.md index e5720b7d5..3a19c7761 100644 --- a/apps/docs/src/content/docs/api/Types/Events/SelectionRemovedEvent.md +++ b/apps/docs/src/content/docs/api/Types/Events/SelectionRemovedEvent.md @@ -34,8 +34,9 @@ Nodes that were deleted from the diagram > **detachedEdges**: [`Edge`](/docs/api/types/model/edge/)\<`object`\>[] Edges that were detached into dangling edges instead of being deleted -(see `danglingEdges.detachOnNodeDelete`). Snapshots taken after the -detach — the freed endpoints are already empty with their positions set. +(see `danglingEdges.detachOnNodeDelete`). The snapshots are taken after +the detach: the freed endpoints already have an empty `source`/`target` +and their anchor positions set. Empty when nothing was detached. #### Since diff --git a/apps/docs/src/content/docs/api/Types/Model/Edge.md b/apps/docs/src/content/docs/api/Types/Model/Edge.md index 0d2eaf3ad..be283641e 100644 --- a/apps/docs/src/content/docs/api/Types/Model/Edge.md +++ b/apps/docs/src/content/docs/api/Types/Model/Edge.md @@ -113,9 +113,9 @@ The points of the edge defining the path. > `optional` **relinkable**: `boolean` \| [`EdgeEnd`](/docs/api/types/model/edgeend/) -Whether the user can relink the edge's endpoints. `true` allows both -ends, an `EdgeEnd` allows only that end. When absent, -`linking.defaultRelinkable` applies. Any other value locks both ends. +Whether the user can relink the ends of this edge. `true` allows both +ends, `'source'` or `'target'` allows only that end, and `false` allows +neither. When not set, `linking.defaultRelinkable` applies. Set by the user; the library only reads it. diff --git a/apps/docs/src/content/docs/api/Utilities/computeDetachAnchor.md b/apps/docs/src/content/docs/api/Utilities/computeDetachAnchor.md index c8ed59fc1..1326da17a 100644 --- a/apps/docs/src/content/docs/api/Utilities/computeDetachAnchor.md +++ b/apps/docs/src/content/docs/api/Utilities/computeDetachAnchor.md @@ -8,10 +8,10 @@ title: "computeDetachAnchor" > **computeDetachAnchor**(`edge`, `end`, `node`): `null` \| [`Point`](/docs/api/types/geometry/point/) -Computes the anchor a detached endpoint stays at: the port's current flow -position when the edge was connected to a port, the edge's routed endpoint -otherwise, the node's center as a last resort. Must run while the node is -still in the state. +Computes the position where a detached endpoint stays: the current position +of the port when the edge was connected to a port, otherwise the routed +endpoint of the edge, or the center of the node as a last resort. Call it +while the node still exists in the model. ## Parameters @@ -19,14 +19,22 @@ still in the state. [`Edge`](/docs/api/types/model/edge/) +The edge whose endpoint is being detached. + ### end [`EdgeEnd`](/docs/api/types/model/edgeend/) +The endpoint to detach. + ### node +The node the endpoint is connected to, if it still exists. + `undefined` | `null` | [`Node`](/docs/api/types/model/node/) ## Returns `null` \| [`Point`](/docs/api/types/geometry/point/) + +The anchor position, or `null` when none can be computed. diff --git a/apps/docs/src/content/docs/api/Utilities/getDanglingEndpoints.md b/apps/docs/src/content/docs/api/Utilities/getDanglingEndpoints.md index 31149eb2d..ee1513b03 100644 --- a/apps/docs/src/content/docs/api/Utilities/getDanglingEndpoints.md +++ b/apps/docs/src/content/docs/api/Utilities/getDanglingEndpoints.md @@ -8,10 +8,10 @@ title: "getDanglingEndpoints" > **getDanglingEndpoints**(`edges`): [`DanglingEndpoint`](/docs/api/types/model/danglingendpoint/)[] -Collects the free endpoints of the given edges. A dual dangling edge yields -two entries. Endpoints whose anchor position is missing are skipped — -they cannot be rendered or snapped to. Temporary and effectively hidden -edges are skipped. +Collects the free endpoints of the given edges. A dual dangling edge gives +two entries. Endpoints without an anchor position are skipped, because they +cannot be rendered or snapped to. Temporary and effectively hidden edges are +skipped as well. ## Parameters @@ -19,6 +19,10 @@ edges are skipped. readonly [`Edge`](/docs/api/types/model/edge/)\<`object`\>[] +The edges to scan. + ## Returns [`DanglingEndpoint`](/docs/api/types/model/danglingendpoint/)[] + +The free endpoints with their edge, end and anchor position. diff --git a/apps/docs/src/content/docs/api/Utilities/getNearestDanglingEndpointInRange.md b/apps/docs/src/content/docs/api/Utilities/getNearestDanglingEndpointInRange.md index 448020a99..6d03c813e 100644 --- a/apps/docs/src/content/docs/api/Utilities/getNearestDanglingEndpointInRange.md +++ b/apps/docs/src/content/docs/api/Utilities/getNearestDanglingEndpointInRange.md @@ -8,9 +8,10 @@ title: "getNearestDanglingEndpointInRange" > **getNearestDanglingEndpointInRange**(`edges`, `point`, `range`): `null` \| [`DanglingEndpoint`](/docs/api/types/model/danglingendpoint/) -Finds the free edge endpoint nearest to `point` within `range`, or null when -none is close enough. Sibling of `getNearestPortInRange` for snapping to -dangling ends. Temporary and effectively hidden edges are skipped. +Finds the free edge endpoint nearest to `point` within `range`, or `null` +when none is close enough. It works like `getNearestPortInRange`, but for +the free endpoints of dangling edges. Temporary and effectively hidden edges +are skipped. ## Parameters @@ -18,14 +19,22 @@ dangling ends. Temporary and effectively hidden edges are skipped. readonly [`Edge`](/docs/api/types/model/edge/)\<`object`\>[] +The edges to scan. + ### point [`Point`](/docs/api/types/geometry/point/) +The point to measure from. + ### range `number` +The maximum distance from `point`. + ## Returns `null` \| [`DanglingEndpoint`](/docs/api/types/model/danglingendpoint/) + +The nearest free endpoint, or `null`. diff --git a/apps/docs/src/content/docs/api/Utilities/hasFreeEndpoint.md b/apps/docs/src/content/docs/api/Utilities/hasFreeEndpoint.md index bb512b3db..21e02ab1e 100644 --- a/apps/docs/src/content/docs/api/Utilities/hasFreeEndpoint.md +++ b/apps/docs/src/content/docs/api/Utilities/hasFreeEndpoint.md @@ -9,8 +9,8 @@ title: "hasFreeEndpoint" > **hasFreeEndpoint**(`edge`, `end?`): `boolean` Checks whether the given endpoint of an edge is free (not connected to a -node). A free endpoint is represented by an empty `source`/`target` with the -position stored in `sourcePosition`/`targetPosition`. +node). A free endpoint has an empty `source` or `target`, and its position +is stored in `sourcePosition` or `targetPosition`. When `end` is omitted, checks whether either endpoint is free. @@ -20,10 +20,16 @@ When `end` is omitted, checks whether either endpoint is free. [`Edge`](/docs/api/types/model/edge/) +The edge to check. + ### end? [`EdgeEnd`](/docs/api/types/model/edgeend/) +The endpoint to check, or none to check both. + ## Returns `boolean` + +`true` when the endpoint is free. diff --git a/apps/docs/src/content/docs/api/Utilities/isDanglingEdge.md b/apps/docs/src/content/docs/api/Utilities/isDanglingEdge.md index 41ae873ba..46dafa571 100644 --- a/apps/docs/src/content/docs/api/Utilities/isDanglingEdge.md +++ b/apps/docs/src/content/docs/api/Utilities/isDanglingEdge.md @@ -8,8 +8,9 @@ title: "isDanglingEdge" > **isDanglingEdge**(`edge`): `boolean` -Checks whether an edge is dangling — has at least one endpoint not connected -to a node. An edge with both endpoints free is a dual dangling edge. +Checks whether an edge is dangling, that is, whether at least one of its +endpoints is not connected to a node. An edge with both endpoints free is a +dual dangling edge. ## Parameters @@ -17,6 +18,10 @@ to a node. An edge with both endpoints free is a dual dangling edge. [`Edge`](/docs/api/types/model/edge/) +The edge to check. + ## Returns `boolean` + +`true` when at least one endpoint is free. diff --git a/apps/docs/src/content/docs/api/Utilities/isEdgeEndRelinkable.md b/apps/docs/src/content/docs/api/Utilities/isEdgeEndRelinkable.md index dc535e29e..03f482ac3 100644 --- a/apps/docs/src/content/docs/api/Utilities/isEdgeEndRelinkable.md +++ b/apps/docs/src/content/docs/api/Utilities/isEdgeEndRelinkable.md @@ -8,9 +8,10 @@ title: "isEdgeEndRelinkable" > **isEdgeEndRelinkable**(`edge`, `end`, `defaultRelinkable`): `boolean` -Whether the user can relink the given end of the edge: the edge's own -`relinkable` when set, otherwise `defaultRelinkable` from the linking config. -`true` allows both ends, an `EdgeEnd` only that end, any other value neither. +Returns whether the user can relink the given end of an edge. It uses the +edge's own `relinkable` value when set, otherwise `defaultRelinkable` from +the linking config. `true` allows both ends, `'source'` or `'target'` allows +only that end, and any other value allows neither. ## Parameters @@ -18,14 +19,22 @@ Whether the user can relink the given end of the edge: the edge's own [`Edge`](/docs/api/types/model/edge/) +The edge to check. + ### end [`EdgeEnd`](/docs/api/types/model/edgeend/) +The endpoint to check. + ### defaultRelinkable +The `linking.defaultRelinkable` config value. + `boolean` | [`EdgeEnd`](/docs/api/types/model/edgeend/) ## Returns `boolean` + +`true` when the user can relink that end. diff --git a/apps/docs/src/content/docs/guides/edges/dangling-edges.mdx b/apps/docs/src/content/docs/guides/edges/dangling-edges.mdx index dffdf1ec4..8bfe01164 100644 --- a/apps/docs/src/content/docs/guides/edges/dangling-edges.mdx +++ b/apps/docs/src/content/docs/guides/edges/dangling-edges.mdx @@ -10,21 +10,21 @@ sidebar: import CodeSnippet from '@components/code-snippet/code-snippet.astro'; import DanglingEdgesDemo from '@components/angular/edges/dangling-edges/dangling-edges/dangling-edges.astro'; -Dangling edges are edges with one or both endpoints not connected to any node. They are useful for diagrams where connections are sketched before both ends exist, where deleting a node should keep its wiring, or where free-floating annotation lines are part of the domain. +Dangling edges are edges with one or both endpoints not connected to any node. They are useful when connections are sketched before both nodes exist, when deleting a node should keep its connections, or when free-floating lines (for example annotations) are part of your diagram. -Everything about dangling edges is opt-in. With the defaults, a link dropped on empty canvas is discarded and deleting a node deletes its edges. +The feature is fully opt-in. With the default settings, an edge dropped on empty canvas is discarded, and deleting a node deletes its edges. -Draw an edge from a port onto empty canvas to keep it as a dangling edge; select a node and press Delete to detach its edges instead of deleting them. +Try it below: draw an edge from a port onto empty canvas to keep it as a dangling edge, or select a node and press Delete to detach its edges instead of deleting them. ## Model Shape -A free (unconnected) endpoint is represented by an empty string in [`source`](/docs/api/types/model/edge/#source)/[`target`](/docs/api/types/model/edge/#target) with the anchor position stored in [`sourcePosition`](/docs/api/types/model/edge/#sourceposition)/[`targetPosition`](/docs/api/types/model/edge/#targetposition): +A free (unconnected) endpoint has an empty string in [`source`](/docs/api/types/model/edge/#source) or [`target`](/docs/api/types/model/edge/#target), and its anchor position is stored in [`sourcePosition`](/docs/api/types/model/edge/#sourceposition) or [`targetPosition`](/docs/api/types/model/edge/#targetposition): -A free endpoint never has a port: when an endpoint is detached, its `sourcePort`/`targetPort` is cleared. An edge with both endpoints free is a **dual dangling edge** — it routes between its two anchor positions. +A free endpoint never has a port: when an endpoint is detached, its `sourcePort` or `targetPort` is cleared. An edge with both endpoints free is a **dual dangling edge**. It routes between its two anchor positions. ## Enabling the Feature @@ -34,13 +34,13 @@ Dangling edges are controlled by the [`danglingEdges`](/docs/api/types/configura [`enabled`](/docs/api/types/configuration/features/danglingedgesconfig/#enabled) is the master switch. When true: -- An edge draw that ends on empty canvas keeps the edge as a dangling edge instead of discarding it. +- An edge drawn onto empty canvas is kept as a dangling edge instead of being discarded. - An [edge relink](/docs/guides/edges/relinking) dropped on empty canvas detaches that endpoint. -- [`detachEdge`](/docs/api/services/ngdiagrammodelservice/#detachedge) and [`startLinkingFromPosition`](/docs/api/services/ngdiagramservice/#startlinkingfromposition) become available (with the feature off they are no-ops with a console warning). +- [`detachEdge`](/docs/api/services/ngdiagrammodelservice/#detachedge) and [`startLinkingFromPosition`](/docs/api/services/ngdiagramservice/#startlinkingfromposition) become available. With the feature off, they do nothing and log a console warning. ## Keeping Edges Dropped on Empty Canvas -With `enabled: true`, every edge draw released on empty canvas is kept as a dangling edge, anchored where the pointer was released. A drop over a port the edge cannot connect to (for example a wrong-direction port) is not a drop on empty canvas — the draw is discarded. To decide per edge, provide [`shouldKeepOnDrop`](/docs/api/types/configuration/features/danglingedgesconfig/#shouldkeepondrop): +With `enabled: true`, every edge draw released on empty canvas is kept as a dangling edge, anchored where the pointer was released. A drop on a port that the edge cannot connect to (for example a port with the wrong direction) does not count as a drop on empty canvas, so the draw is discarded. To decide per edge, provide [`shouldKeepOnDrop`](/docs/api/types/configuration/features/danglingedgesconfig/#shouldkeepondrop): ```typescript const config: NgDiagramConfig = { @@ -52,7 +52,7 @@ const config: NgDiagramConfig = { }; ``` -The edge passed in is the fully-built final edge (after [`linking.finalEdgeDataBuilder`](/docs/api/types/configuration/features/linkingconfig/#finaledgedatabuilder)). Returning `false` discards the edge — the default behavior when the feature is off. +The `edge` argument is the final edge, after [`linking.finalEdgeDataBuilder`](/docs/api/types/configuration/features/linkingconfig/#finaledgedatabuilder) has run. Returning `false` discards the edge, which is also what happens when the feature is off. The same callback runs for a [relink](/docs/guides/edges/relinking) dropped on empty canvas. There it receives the edge as it would be after the detach, and returning `false` reverts the relink. ## Detaching Edges on Node Delete @@ -67,7 +67,7 @@ const config: NgDiagramConfig = { }; ``` -For per-edge control, provide [`shouldDetachOnNodeDelete`](/docs/api/types/configuration/features/danglingedgesconfig/#shoulddetachonnodedelete) — it is called once per endpoint losing its node, and returning `false` deletes the edge: +For per-edge control, provide [`shouldDetachOnNodeDelete`](/docs/api/types/configuration/features/danglingedgesconfig/#shoulddetachonnodedelete). It is called once for each endpoint that loses its node, and returning `false` deletes the edge: ```typescript const config: NgDiagramConfig = { @@ -82,22 +82,22 @@ const config: NgDiagramConfig = { The detach rules are: - **An explicit delete always wins.** An edge that is itself part of the deleted selection is deleted, never detached. -- **Hidden wiring never materializes.** An edge that is hidden only because of the node it loses (for example the edges of the collapsed children of a deleted group) is deleted — detaching it would make invisible wiring appear as a visible dangling edge. An edge that stays hidden on its own — through its `hidden` flag, a template binding, or a hidden surviving endpoint — is detached like any other edge and remains hidden. -- **An edge losing both endpoints in one delete is deleted by default.** It becomes a dual dangling edge only when `shouldDetachOnNodeDelete` is defined and returns `true` for both ends. -- Deleting a node with the keyboard Delete key follows the same rules — the detached edges are reported in the [`selectionRemoved`](/docs/api/types/events/selectionremovedevent) event (see [Events](#events) below). +- **Hidden wiring never becomes visible.** An edge that is hidden only because of the node it loses (for example the edges of the collapsed children of a deleted group) is deleted. Detaching it would turn invisible wiring into a visible dangling edge. An edge that is hidden for another reason (its own `hidden` flag, a template binding, or a hidden node at the other end) is detached like any other edge and stays hidden. +- **An edge that loses both endpoints in one delete is deleted by default.** It becomes a dual dangling edge only when `shouldDetachOnNodeDelete` is defined and returns `true` for both ends. +- Deleting a node with the Delete key follows the same rules. The detached edges are reported in the [`selectionRemoved`](/docs/api/types/events/selectionremovedevent) event (see [Events](#events) below). ## Dual Dangling Edges An edge with both endpoints free is fully supported: - **Routing** — the edge routes between its two anchor positions with any routing algorithm, like a connected edge. -- **Persistence** — `toJSON()` serializes the free endpoints' `sourcePosition`/`targetPosition`, and [`initializeModel`](/docs/api/utilities/initializemodel) restores them, so dangling edges survive a save/load round-trip. -- **Copy and paste** — with `danglingEdges.enabled`, copying a node carries along an unselected dangling edge connected to it. Pasting offsets the free endpoints together with the rest of the pasted content, and for manual-routing edges the stored `points` are offset as well. +- **Persistence** — `toJSON()` saves the `sourcePosition`/`targetPosition` of free endpoints, and [`initializeModel`](/docs/api/utilities/initializemodel) restores them, so dangling edges survive a save and load cycle. +- **Copy and paste** — with `danglingEdges.enabled`, copying a node also copies a dangling edge connected to it, even when that edge is not selected. Pasting moves the free endpoints together with the rest of the pasted content, and for manual-routing edges the stored `points` move as well. - **Box selection** — a free endpoint counts by its anchor position, so a box drawn around a dangling end selects the edge. ## Drawing Edges from a Position -[`NgDiagramService.startLinkingFromPosition`](/docs/api/services/ngdiagramservice/#startlinkingfromposition) starts drawing an edge from a point on the canvas instead of from a node — for example from a context-menu action: +[`NgDiagramService.startLinkingFromPosition`](/docs/api/services/ngdiagramservice/#startlinkingfromposition) starts drawing an edge from a point on the canvas instead of from a node, for example from a context menu action: ```typescript private ngDiagramService = inject(NgDiagramService); @@ -109,23 +109,23 @@ startDrawingAt(event: MouseEvent) { } ``` -The free end of the edge follows the pointer until a click finishes the draw — on a port the edge connects to it, on empty canvas both ends stay free (a dual dangling edge), kept when `shouldKeepOnDrop` allows it. An edge drawn from a position has an empty source, i.e. it is a dangling edge by construction, so this method requires `danglingEdges.enabled` — with the feature off the call is ignored with a console warning. For draws started this way, [`linking.validateConnection`](/docs/api/types/configuration/features/linkingconfig/#validateconnection) receives `null` as the source node. +The free end of the edge follows the pointer until a click finishes the draw. A click on a port connects the edge to that port. A click on empty canvas leaves both ends free (a dual dangling edge), and the edge is kept when `shouldKeepOnDrop` allows it. An edge drawn from a position has no source node, so it is a dangling edge from the start. For that reason this method requires `danglingEdges.enabled`; with the feature off, the call is ignored and a console warning is logged. For draws started this way, [`linking.validateConnection`](/docs/api/types/configuration/features/linkingconfig/#validateconnection) receives `null` as the source node. ## Utilities and Service Methods -Type guards and queries are exported from the package: +The package exports these helper functions: - [`isDanglingEdge(edge)`](/docs/api/utilities/isdanglingedge) — whether the edge has at least one free endpoint. - [`hasFreeEndpoint(edge, end?)`](/docs/api/utilities/hasfreeendpoint) — whether the given endpoint (or either, when `end` is omitted) is free. -- [`getDanglingEndpoints(edges)`](/docs/api/utilities/getdanglingendpoints) — collects the free endpoints of the given edges as [`DanglingEndpoint`](/docs/api/types/model/danglingendpoint) entries (a dual dangling edge yields two). +- [`getDanglingEndpoints(edges)`](/docs/api/utilities/getdanglingendpoints) — collects the free endpoints of the given edges as [`DanglingEndpoint`](/docs/api/types/model/danglingendpoint) entries (a dual dangling edge gives two). - [`getNearestDanglingEndpointInRange(edges, point, range)`](/docs/api/utilities/getnearestdanglingendpointinrange) — the free endpoint nearest to a point within a range. -- [`computeDetachAnchor(edge, end, node)`](/docs/api/utilities/computedetachanchor) — the anchor a detached endpoint stays at: the port's current position, the edge's routed endpoint, or the node's center as a last resort. +- [`computeDetachAnchor(edge, end, node)`](/docs/api/utilities/computedetachanchor) — the position where a detached endpoint stays: the port's current position, otherwise the edge's routed endpoint, or the node's center as a last resort. -[`NgDiagramModelService`](/docs/api/services/ngdiagrammodelservice) offers the model-level operations: +[`NgDiagramModelService`](/docs/api/services/ngdiagrammodelservice) provides the model-level operations: -- [`getDanglingEndpoints()`](/docs/api/services/ngdiagrammodelservice/#getdanglingendpoints) and [`getNearestDanglingEndpointInRange(point, range)`](/docs/api/services/ngdiagrammodelservice/#getnearestdanglingendpointinrange) — the queries above over the diagram's committed edges. -- [`detachEdge(edgeId, end, position?)`](/docs/api/services/ngdiagrammodelservice/#detachedge) — detaches one endpoint into a free endpoint. Without `position` the endpoint stays anchored where it is now. Requires `danglingEdges.enabled` (no-op with a console warning otherwise). -- [`attachEdge(edgeId, end, nodeId, portId?)`](/docs/api/services/ngdiagrammodelservice/#attachedge) — the symmetric counterpart: connects a free endpoint to a node/port. It applies the same structural checks as a relink drop and runs [`linking.validateConnection`](/docs/api/types/configuration/features/linkingconfig/#validateconnection) with a context of `reason: 'attach'`, returning whether the connection was valid and applied. +- [`getDanglingEndpoints()`](/docs/api/services/ngdiagrammodelservice/#getdanglingendpoints) and [`getNearestDanglingEndpointInRange(point, range)`](/docs/api/services/ngdiagrammodelservice/#getnearestdanglingendpointinrange) — the same queries, run over all edges in the model. +- [`detachEdge(edgeId, end, position?)`](/docs/api/services/ngdiagrammodelservice/#detachedge) — detaches one endpoint into a free endpoint. Without `position`, the endpoint stays anchored where it is now. Requires `danglingEdges.enabled`; with the feature off, it does nothing and logs a console warning. +- [`attachEdge(edgeId, end, nodeId, portId?)`](/docs/api/services/ngdiagrammodelservice/#attachedge) — the opposite operation: connects a free endpoint to a node and, optionally, a port. It applies the same checks as a relink drop (the node must exist and be visible, the port must exist and have the right direction) and runs [`linking.validateConnection`](/docs/api/types/configuration/features/linkingconfig/#validateconnection) with `reason: 'attach'`. It returns whether the connection was valid and applied. ```typescript private modelService = inject(NgDiagramModelService); @@ -142,9 +142,9 @@ async reconnectLooseEnds() { ## Styling -Dangling edges expose dedicated CSS hooks, so you can style them without inspecting the model: +Dangling edges get dedicated CSS classes, so you can style them without checking the model: -- The edge wrapper element gets the `ng-diagram-edge--dangling` class (temporary draw previews are excluded — they always have a free end). +- The edge wrapper element gets the `ng-diagram-edge--dangling` class. Temporary draw previews do not get it, even though they always have a free end. - [`ng-diagram-base-edge`](/docs/api/components/ngdiagrambaseedgecomponent) gets the `dangling` host class, and its `path` carries the `dangling` class as well. ```scss @@ -157,8 +157,8 @@ ng-diagram-base-edge.dangling { ## Events -- [`edgeDrawEnded`](/docs/api/types/events/edgedrawendedevent) — a canvas drop kept as a dangling edge reports `success: true` with the created `edge` but **no** `target`/`targetPort`. Distinguish a connected draw from a kept dangling draw by checking `event.target`. The deprecated `edgeDrawn` event does not fire for dangling edges — it only reports node-to-node connections. -- [`selectionRemoved`](/docs/api/types/events/selectionremovedevent) — the new `detachedEdges` field lists the edges that were detached instead of deleted (snapshots taken after the detach — the freed endpoints are already empty with their positions set). It is always present, `[]` when nothing was detached. +- [`edgeDrawEnded`](/docs/api/types/events/edgedrawendedevent) — a canvas drop kept as a dangling edge reports `success: true` with the created `edge`, but **no** `target`/`targetPort`. To tell a connected draw from a kept dangling draw, check `event.target`. The deprecated `edgeDrawn` event does not fire for dangling edges; it only reports node-to-node connections. +- [`selectionRemoved`](/docs/api/types/events/selectionremovedevent) — the `detachedEdges` field lists the edges that were detached instead of deleted. The snapshots are taken after the detach, so the freed endpoints already have an empty `source`/`target` and their positions set. The field is always present and is `[]` when nothing was detached. ```typescript onEdgeDrawEnded(event: EdgeDrawEndedEvent) { diff --git a/apps/docs/src/content/docs/guides/edges/edges.mdx b/apps/docs/src/content/docs/guides/edges/edges.mdx index 5626d8776..161ca9014 100644 --- a/apps/docs/src/content/docs/guides/edges/edges.mdx +++ b/apps/docs/src/content/docs/guides/edges/edges.mdx @@ -110,7 +110,7 @@ The base edge component exposes these classes for styling: - `.selected` - Applied when the edge is selected - `.temporary` - Applied when the edge is being drawn (preview state) -- `.dangling` - Applied when the edge has at least one free (unconnected) endpoint — see [Dangling Edges](/docs/guides/edges/dangling-edges); the edge wrapper element also gets the `ng-diagram-edge--dangling` class +- `.dangling` - Applied when the edge has at least one free (unconnected) endpoint (see [Dangling Edges](/docs/guides/edges/dangling-edges)). The edge wrapper element also gets the `ng-diagram-edge--dangling` class See [Edge Selection](/docs/guides/edges/selection) for more details on customizing selection styles. @@ -219,7 +219,7 @@ onEdgeDrawEnded(event: EdgeDrawEndedEvent) { Two opt-in features extend what users can do with existing edges: -- **[Relinking](/docs/guides/edges/relinking)** — with [`linking.defaultRelinkable`](/docs/api/types/configuration/features/linkingconfig/#defaultrelinkable), selected edges show endpoint handles that users can drag to reconnect the edge to another port, unless the edge's own `relinkable` says otherwise. +- **[Relinking](/docs/guides/edges/relinking)** — with [`linking.defaultRelinkable`](/docs/api/types/configuration/features/linkingconfig/#defaultrelinkable), selected edges show endpoint handles that users can drag to reconnect the edge to another port. An edge can override the default with its own `relinkable` property. - **[Dangling edges](/docs/guides/edges/dangling-edges)** — with [`danglingEdges.enabled`](/docs/api/types/configuration/features/danglingedgesconfig/#enabled), edges may have free (unconnected) endpoints: a draw or relink dropped on empty canvas keeps the edge, and deleting a node can detach its edges instead of deleting them. ## Selection During Linking diff --git a/apps/docs/src/content/docs/guides/edges/relinking.mdx b/apps/docs/src/content/docs/guides/edges/relinking.mdx index ffba2b56f..2c8ac8d04 100644 --- a/apps/docs/src/content/docs/guides/edges/relinking.mdx +++ b/apps/docs/src/content/docs/guides/edges/relinking.mdx @@ -11,70 +11,70 @@ import { Aside } from '@astrojs/starlight/components'; import CodeSnippet from '@components/code-snippet/code-snippet.astro'; import RelinkingDemo from '@components/angular/edges/relinking/relinking/relinking.astro'; -The **edge relinking** feature lets users reconnect existing edges by dragging their endpoints. A selected edge shows a grabbable handle at each end that can be relinked; dragging one previews the reconnection live and commits it on drop. +The **edge relinking** feature lets users reconnect existing edges by dragging their endpoints. A selected edge shows a handle at each end that can be relinked. Dragging a handle previews the new connection live and commits it on drop. -Select an edge to see its handles: the first edge can be relinked at both ends, the second only at its target end, the third is locked. Drag a handle to another port to reconnect the edge, or drop it on empty canvas to detach that end. +Select an edge to see its handles: the first edge can be relinked at both ends, the second only at its target end, and the third is locked. Drag a handle to another port to reconnect the edge, or drop it on empty canvas to detach that end. ## Enabling Relinking -Relinking is off by default. [`linking.defaultRelinkable`](/docs/api/types/configuration/features/linkingconfig/#defaultrelinkable) sets the default for every edge: `true` lets users drag both ends, `'source'` or `'target'` only that end. +Relinking is off by default. [`linking.defaultRelinkable`](/docs/api/types/configuration/features/linkingconfig/#defaultrelinkable) sets the default for every edge: `true` lets users drag both ends, while `'source'` or `'target'` lets them drag only that end. -With `defaultRelinkable: true`, every selected committed edge rendered by [`ng-diagram-base-edge`](/docs/api/components/ngdiagrambaseedgecomponent) shows an endpoint handle at each end (temporary draw previews never show handles). +With `defaultRelinkable: true`, every selected edge rendered by [`ng-diagram-base-edge`](/docs/api/components/ngdiagrambaseedgecomponent) shows a handle at each end. Temporary edges (draw previews) never show handles. ## Controlling Which Edges Can Be Relinked -An edge overrides the default with its own [`relinkable`](/docs/api/types/model/edge/#relinkable) property. The resolved value — the edge's `relinkable` when set, otherwise `linking.defaultRelinkable` — decides which ends the user can drag: +An edge can override the default with its own [`relinkable`](/docs/api/types/model/edge/#relinkable) property. The effective value is the edge's `relinkable` when it is set, otherwise `linking.defaultRelinkable`. This value decides which ends the user can drag: -| Resolved value | Source end | Target end | -| -------------- | ---------- | ---------- | -| `true` | yes | yes | -| `'source'` | yes | no | -| `'target'` | no | yes | -| `false` | no | no | +| Effective value | Source end | Target end | +| --------------- | ---------- | ---------- | +| `true` | yes | yes | +| `'source'` | yes | no | +| `'target'` | no | yes | +| `false` | no | no | -Edges in the initial model, edges added with [`addEdges`](/docs/api/services/ngdiagrammodelservice/#addedges) and pasted edges set `relinkable` directly in their data: +Edges in the initial model, edges added with [`addEdges`](/docs/api/services/ngdiagrammodelservice/#addedges), and pasted edges take `relinkable` from their own data: -Edges drawn by the user get it in [`finalEdgeDataBuilder`](/docs/api/types/configuration/features/linkingconfig/#finaledgedatabuilder), the same place where an app assigns their `type`. To make only one edge type relinkable, leave the default at `false` and opt that type in: +Edges drawn by the user get the value from [`finalEdgeDataBuilder`](/docs/api/types/configuration/features/linkingconfig/#finaledgedatabuilder), the same callback where an app assigns their `type`. To make only one edge type relinkable, keep the default at `false` and set `relinkable: true` for that type: ```typescript const config: NgDiagramConfig = { linking: { - // defaultRelinkable stays false: only edges that opt in can be relinked + // defaultRelinkable stays false: only edges with relinkable: true can be relinked finalEdgeDataBuilder: (edge) => ({ ...edge, type: 'draft', relinkable: true }), }, }; ``` -For the opposite — everything relinkable except a few locked edges — set `defaultRelinkable: true` and `relinkable: false` on the locked ones. +For the opposite case, where every edge is relinkable except a few locked ones, set `defaultRelinkable: true` and put `relinkable: false` on the locked edges. -The builder runs only for edges the user draws. Loaded edges rely on the default or on their own `relinkable` property. +The builder runs only for edges that the user draws. Loaded edges use the default or their own `relinkable` property. ## How It Works -- Press a handle and drag it — the edge endpoint follows the pointer as a live preview, snapping to nearby ports like edge drawing does. -- A click on a handle without dragging (past a small ~5px threshold) does nothing — no relink starts and no events fire. -- Pressing Escape (or calling [`cancelActiveInteraction()`](/docs/api/services/ngdiagramservice/#cancelactiveinteraction)) cancels the relink and the edge stays unchanged. +- Press a handle and drag it. The endpoint follows the pointer as a live preview and snaps to nearby ports, the same as when drawing an edge. +- A click on a handle without dragging (less than about 5 px of pointer movement) does nothing: no relink starts and no events fire. +- Pressing Escape (or calling [`cancelActiveInteraction()`](/docs/api/services/ngdiagramservice/#cancelactiveinteraction)) cancels the relink, and the edge stays unchanged. ### Drop Outcomes What happens on release depends on where the endpoint is dropped: - **On a valid port** — the edge is reconnected to that node and port. -- **On empty canvas** — the endpoint is detached into a free (dangling) endpoint anchored at the drop position, but only when [`danglingEdges.enabled`](/docs/api/types/configuration/features/danglingedgesconfig/#enabled) is true and [`shouldKeepOnDrop`](/docs/api/types/configuration/features/danglingedgesconfig/#shouldkeepondrop) keeps the edge. A canvas drop is not a connection, so `validateConnection` is not consulted for it. With dangling edges disabled, the relink is reverted with reason `noTarget`. -- **On an invalid target** — a hidden node, a missing or wrong-direction port, or a connection the validator refuses — the relink is reverted with reason `invalidConnection`. -- **Back on the original port** — a no-op: the model is untouched and the gesture reports `success: false` with reason `cancelled`. +- **On empty canvas** — the endpoint becomes a free (dangling) endpoint anchored at the drop position. This requires [`danglingEdges.enabled`](/docs/api/types/configuration/features/danglingedgesconfig/#enabled) to be true and [`shouldKeepOnDrop`](/docs/api/types/configuration/features/danglingedgesconfig/#shouldkeepondrop) to keep the edge. A canvas drop is not a connection, so `validateConnection` is not called for it. With dangling edges disabled, or when `shouldKeepOnDrop` returns `false`, the relink is reverted with reason `noTarget`. +- **On an invalid target** — for example a hidden node, a missing port, a port with the wrong direction, or a connection that the validator refuses — the relink is reverted with reason `invalidConnection`. +- **Back on the original port** — nothing changes: the model stays as it is, and the gesture reports `success: false` with reason `cancelled`. -A reverted relink never touches the model — the edge simply stays as it was. +A reverted relink never changes the model. The edge stays as it was. ## Validating Reconnections -Connections made by relinking run through the same [`linking.validateConnection`](/docs/api/types/configuration/features/linkingconfig/#validateconnection) callback as edge drawing. The optional fifth argument, a [`ConnectionValidationContext`](/docs/api/types/configuration/features/connectionvalidationcontext), tells you which operation is asking — for relinks it carries `reason: 'relink'`, the `edge` being relinked, and which `end` is being dragged: +Connections made by relinking go through the same [`linking.validateConnection`](/docs/api/types/configuration/features/linkingconfig/#validateconnection) callback as edge drawing. The optional fifth argument, a [`ConnectionValidationContext`](/docs/api/types/configuration/features/connectionvalidationcontext), tells you which operation is being validated. For relinks it carries `reason: 'relink'`, the `edge` being relinked, and the `end` that is being dragged: ```typescript const config: NgDiagramConfig = { @@ -93,16 +93,21 @@ const config: NgDiagramConfig = { }; ``` -`context.reason === 'relink'` is for rules about the drop target, like the same-group rule above. To lock an edge or one of its ends, use [`relinkable`](#controlling-which-edges-can-be-relinked) instead — the validator runs only on drop, so a locked edge would still show handles and let the user drag them. +Use `context.reason === 'relink'` for rules about the drop target, like the same-group rule above. To lock an edge or one of its ends, use [`relinkable`](#controlling-which-edges-can-be-relinked) instead. The validator runs only on drop, so an edge locked through the validator would still show handles and let the user drag them. -Note that `source` can be `null` — when relinking the target end of an edge whose source is dangling (and for draws started with [`startLinkingFromPosition`](/docs/api/services/ngdiagramservice/#startlinkingfromposition)), so guard your validator accordingly. +Note that `source` or `target` can be `null`. This happens when the other end of the relinked edge is free, for example when the user relinks the target end of an edge whose source end is dangling. `source` is also `null` for draws started with [`startLinkingFromPosition`](/docs/api/services/ngdiagramservice/#startlinkingfromposition). Guard against `null` in your validator. ## Events Two events report the gesture: -- [`edgeRelinkStarted`](/docs/api/types/events/edgerelinkstartedevent) — fires when the user starts dragging an endpoint. The payload carries the `edge` (snapshot at gesture start) and which `end` is being dragged. -- [`edgeRelinkEnded`](/docs/api/types/events/edgerelinkendedevent) — fires when the gesture ends, regardless of outcome. The payload carries the `edge` (after the relink, or the unchanged snapshot on failure), the dragged `end`, where the endpoint was before (`previousNode`/`previousPort` when it was connected, `previousPosition` when it was dangling), the `dropPosition`, and `success`. On a reconnect, `target`/`targetPort` name the new connection; on failure, `reason` is one of [`noTarget` | `invalidConnection` | `cancelled`](/docs/api/types/events/edgerelinkcancelreason). +- [`edgeRelinkStarted`](/docs/api/types/events/edgerelinkstartedevent) — fires when the user starts dragging an endpoint. The payload carries the `edge` (a snapshot taken at gesture start) and the `end` that is being dragged. +- [`edgeRelinkEnded`](/docs/api/types/events/edgerelinkendedevent) — fires when the gesture ends, whatever the outcome. The payload carries: + - `edge` — the edge after the relink, or the unchanged snapshot on failure + - `end` — the dragged endpoint + - `previousNode` and `previousPort` when the endpoint was connected before, or `previousPosition` when it was free + - `dropPosition` and `success` + - on a reconnect, `target` and `targetPort` name the new connection; on failure, `reason` is one of [`noTarget` | `invalidConnection` | `cancelled`](/docs/api/types/events/edgerelinkcancelreason) ```html @@ -134,7 +139,7 @@ Here are the CSS variables you can use to style the relink handles globally: --ngd-relink-handle-stroke-width // Stroke width of the handle ``` -You can also override them per edge with the `--edge-relink-handle-*` counterparts, exactly like `--edge-stroke`: +You can also override them per edge with the matching `--edge-relink-handle-*` variables, in the same way as `--edge-stroke`: ```scss ng-diagram-base-edge.my-edge { @@ -146,11 +151,11 @@ ng-diagram-base-edge.my-edge { } ``` -Each visible handle is paired with an enlarged invisible hit circle of roughly 12 screen pixels that is compensated for zoom, so the handles stay touch-friendly at any zoom level without growing visually. Hovering the hit circle highlights the visible handle exactly like hovering the handle itself. +Each visible handle has a larger invisible hit circle of about 12 screen pixels. Its size is adjusted for zoom, so the handles stay easy to touch at any zoom level without growing visually. Hovering over the hit circle highlights the visible handle, the same as hovering over the handle itself. ### Custom Edge Templates -Handles are rendered only by `ng-diagram-base-edge`. A fully custom edge template that does not compose the base edge can make any element a grabbable endpoint handle with the [`RelinkHandleDirective`](/docs/api/directives/relinkhandledirective) (`[ngDiagramRelinkHandle]`): +Handles are rendered only by `ng-diagram-base-edge`. A fully custom edge template that does not use the base edge can turn any element into an endpoint handle with the [`RelinkHandleDirective`](/docs/api/directives/relinkhandledirective) (`[ngDiagramRelinkHandle]`): ```html ``` -A pointerdown on the host element starts the relink gesture for the given `end` of the given `edge`; the gesture itself is driven at document level, so the host element may unmount during the drag. +A pointerdown on the host element starts the relink gesture for the given `end` of the given `edge`. The gesture itself is handled at document level, so it keeps running even when the host element is removed from the DOM during the drag (the original edge is not rendered while its endpoint is being relinked). -The directive refuses the pointerdown on an end that cannot be relinked, so a handle on a locked end never starts a gesture. To hide such a handle as well, resolve the edge's `relinkable` against the config default with [`isEdgeEndRelinkable`](/docs/api/utilities/isedgeendrelinkable): +The directive ignores a pointerdown on an end that cannot be relinked, so a handle on a locked end never starts a gesture. To also hide such a handle, check the effective value with [`isEdgeEndRelinkable`](/docs/api/utilities/isedgeendrelinkable): ```typescript private readonly diagramService = inject(NgDiagramService); @@ -176,12 +181,12 @@ readonly targetHandleVisible = computed(() => ```
## Related Topics diff --git a/apps/docs/src/content/docs/guides/flow-config.mdx b/apps/docs/src/content/docs/guides/flow-config.mdx index 794c463fd..248627d57 100644 --- a/apps/docs/src/content/docs/guides/flow-config.mdx +++ b/apps/docs/src/content/docs/guides/flow-config.mdx @@ -80,7 +80,7 @@ The most important categories include: - [`debugMode`](/docs/api/types/configuration/flowconfig/#debugmode): Enables verbose logging for development and debugging. - [`edgeRouting`](/docs/api/types/configuration/features/edgeroutingconfig): Defines edge routing algorithms and their parameters. - [`grouping`](/docs/api/types/configuration/features/groupingconfig): Enables node grouping and related logic. -- [`linking`](/docs/api/types/configuration/features/linkingconfig): Customizes edge creation and connection validation, and enables edge relinking through `linking.defaultRelinkable` (unless the edge's own `relinkable` says otherwise) — see [Relinking](/docs/guides/edges/relinking). +- [`linking`](/docs/api/types/configuration/features/linkingconfig): Customizes edge creation and connection validation. Also enables edge relinking through `defaultRelinkable` — see [Relinking](/docs/guides/edges/relinking). - [`nodeRotation`](/docs/api/types/configuration/features/noderotationconfig): Enables and customizes node rotation and snapping. - [`resize`](/docs/api/types/configuration/features/resizeconfig): Manages node resizing logic, minimum sizes, and resizability. - [`shortcuts`](/docs/api/types/configuration/shortcuts/shortcutdefinition): Defines keyboard shortcuts for diagram actions. diff --git a/apps/docs/src/content/docs/intro/roadmap.mdx b/apps/docs/src/content/docs/intro/roadmap.mdx index 058cab375..d6cb4a8fb 100644 --- a/apps/docs/src/content/docs/intro/roadmap.mdx +++ b/apps/docs/src/content/docs/intro/roadmap.mdx @@ -61,7 +61,10 @@ ngDiagram is stable and actively developed. Here's what we're working on right n

Dangling Edges

-

Edges with free ends: keep them on canvas drops and node deletes, reconnect them later

+

+ Edges with free ends: keep them when dropped on the canvas or when a node is deleted, and reconnect them + later +

diff --git a/apps/docs/src/content/docs/intro/services.mdx b/apps/docs/src/content/docs/intro/services.mdx index f86278fb8..f3e4cfbe9 100644 --- a/apps/docs/src/content/docs/intro/services.mdx +++ b/apps/docs/src/content/docs/intro/services.mdx @@ -63,7 +63,7 @@ Handles all model-related operations including node and edge updates, spatial qu - Perform spatial queries to find nearby elements - Access nodes, edges, and metadata as reactive signals - Find elements by ID -- Detach and attach edge endpoints — [`detachEdge()`](/docs/api/services/ngdiagrammodelservice#detachedge) (requires [`danglingEdges.enabled`](/docs/api/types/configuration/features/danglingedgesconfig/#enabled)) and [`attachEdge()`](/docs/api/services/ngdiagrammodelservice#attachedge) — and query free endpoints with [`getDanglingEndpoints()`](/docs/api/services/ngdiagrammodelservice#getdanglingendpoints) and [`getNearestDanglingEndpointInRange()`](/docs/api/services/ngdiagrammodelservice#getnearestdanglingendpointinrange) — see [Dangling Edges](/docs/guides/edges/dangling-edges) +- Detach and attach edge endpoints with [`detachEdge()`](/docs/api/services/ngdiagrammodelservice#detachedge) (requires [`danglingEdges.enabled`](/docs/api/types/configuration/features/danglingedgesconfig/#enabled)) and [`attachEdge()`](/docs/api/services/ngdiagrammodelservice#attachedge), and query free endpoints with [`getDanglingEndpoints()`](/docs/api/services/ngdiagrammodelservice#getdanglingendpoints) and [`getNearestDanglingEndpointInRange()`](/docs/api/services/ngdiagrammodelservice#getnearestdanglingendpointinrange) — see [Dangling Edges](/docs/guides/edges/dangling-edges) Every mutating method returns a promise that resolves once the change has been applied to the model, so operations can be sequenced with `await` — see 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 c0b64fafc..e8d97d1e1 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 @@ -86,8 +86,9 @@ export interface DiagramEventMap { /** * Event emitted when an edge relink gesture ends, regardless of outcome. * - * Fires when the dragged endpoint is dropped — reconnected to a port, left - * dangling on empty canvas, or reverted (invalid drop or cancelled gesture). + * Fires when the dragged endpoint is dropped, whether it was reconnected to + * a port, left dangling on empty canvas, or reverted (invalid drop or + * cancelled gesture). * * @since 1.4.0 */ @@ -342,15 +343,17 @@ export interface EdgeDrawEndedEvent { /** * Reason an edge relink gesture ended without changing the edge. * - * - `noTarget` — dropped on empty canvas while dangling edges are disabled, - * or `danglingEdges.shouldKeepOnDrop` declined the detached edge - * - `invalidConnection` — the drop target failed validation: rejected by - * `linking.validateConnection` (context reason `relink`), or structurally - * invalid (hidden node, hidden/missing port, wrong-direction port) + * - `noTarget` — the endpoint was dropped on empty canvas while dangling + * edges are disabled, or `danglingEdges.shouldKeepOnDrop` returned false + * for the detached edge + * - `invalidConnection` — the drop target failed validation: it was rejected + * by `linking.validateConnection` (context reason `relink`), or it is not a + * valid target at all (hidden node, hidden or missing port, port with the + * wrong direction) * - `cancelled` — the gesture was aborted (Esc key, - * {@link NgDiagramService.cancelActiveInteraction}, another gesture claimed - * the pointer) or the endpoint was dropped back on its original node and - * port, which changes nothing + * {@link NgDiagramService.cancelActiveInteraction}, or another gesture took + * over the pointer), or the endpoint was dropped back on its original node + * and port, which changes nothing * * @public * @since 1.4.0 @@ -376,9 +379,9 @@ export interface EdgeRelinkStartedEvent { /** * Event payload emitted when an edge relink gesture ends, regardless of outcome. * - * On success the edge was either reconnected (`target`/`targetPort` populated) - * or left dangling (`edge` has an empty endpoint anchored at `dropPosition`). - * On failure the edge is unchanged and `reason` explains why. + * On success the edge was either reconnected (`target` and `targetPort` are + * set) or left dangling (`edge` has a free endpoint anchored at + * `dropPosition`). On failure the edge is unchanged and `reason` explains why. * * @public * @since 1.4.0 @@ -393,7 +396,7 @@ export interface EdgeRelinkEndedEvent { previousNode?: Node; /** The port the endpoint was connected to before the relink, if any. */ previousPort?: string; - /** The dangling position the endpoint had before the relink, if it was dangling. */ + /** The anchor position of the endpoint before the relink, present only when the endpoint was free (dangling). */ previousPosition?: Point; /** The position where the pointer was released. */ dropPosition: Point; @@ -475,8 +478,9 @@ export interface SelectionRemovedEvent { deletedEdges: Edge[]; /** * Edges that were detached into dangling edges instead of being deleted - * (see `danglingEdges.detachOnNodeDelete`). Snapshots taken after the - * detach — the freed endpoints are already empty with their positions set. + * (see `danglingEdges.detachOnNodeDelete`). The snapshots are taken after + * the detach: the freed endpoints already have an empty `source`/`target` + * and their anchor positions set. Empty when nothing was detached. * * @since 1.4.0 */ 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 c5cbf0075..a11e44cf2 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 @@ -49,17 +49,17 @@ export interface LinkingActionState { /** Reason the linking gesture was cancelled (set by finishLinking on failure paths). */ cancelReason?: EdgeDrawCancelReason; /** - * Present while an existing edge's endpoint is being relinked. The original - * edge is hidden and represented by the temporary edge until the gesture - * ends; `relink.end` is the end of the temporary edge that follows the - * pointer (a normal draw always drags the target end). + * Present while an endpoint of an existing edge is being relinked. Until the + * gesture ends, the original edge is not rendered and the temporary edge + * represents it. `relink.end` is the end of the temporary edge that follows + * the pointer (a normal draw always drags the target end). * * @since 1.4.0 */ relink?: LinkingRelinkContext; /** * Reason the relink gesture ended without changing the edge (set by - * finishRelinking on failure paths). + * `finishRelinking` when the relink fails). * * @since 1.4.0 */ diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/edge.interface.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/edge.interface.ts index 3210212e7..094ed973c 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/edge.interface.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/edge.interface.ts @@ -70,9 +70,9 @@ export interface Edge { */ hidden?: boolean; /** - * Whether the user can relink the edge's endpoints. `true` allows both - * ends, an `EdgeEnd` allows only that end. When absent, - * `linking.defaultRelinkable` applies. Any other value locks both ends. + * Whether the user can relink the ends of this edge. `true` allows both + * ends, `'source'` or `'target'` allows only that end, and `false` allows + * neither. When not set, `linking.defaultRelinkable` applies. * * Set by the user; the library only reads it. * @since 1.4.0 diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts index 412993d66..d69f770c5 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts @@ -51,19 +51,19 @@ export interface LinkingConfig { /** * Validates whether a connection between two nodes and ports is allowed. * - * Called for every connection-producing operation: drawing a new edge, - * relinking an existing edge's endpoint, and `attachEdge`. The optional - * `context` says which operation is being validated (since 1.4.0). + * Called for every operation that creates a connection: drawing a new edge, + * relinking an endpoint of an existing edge, and `attachEdge`. The optional + * `context` tells which operation is being validated (since 1.4.0). * - * `source` can be `null` for draws that start from a position instead of a - * node (`startLinkingFromPosition`) and when relinking the free end of a - * dangling edge. + * `source` is `null` for draws started with `startLinkingFromPosition`. + * When an edge is relinked or attached, the other end of that edge can be + * free (dangling); the `source` or `target` for that end is then `null`. * - * @param source The source node. + * @param source The source node, or `null` when the source end is free. * @param sourcePort The source port. - * @param target The target node. + * @param target The target node, or `null` when the target end is free. * @param targetPort The target port. - * @param context The operation asking for validation (`draw` when omitted). + * @param context The operation being validated (`draw` when omitted). * @returns True if the connection is valid, false otherwise. * @default () => true */ @@ -122,17 +122,17 @@ export interface LinkingConfig { selectNodeOnPortPress: boolean; /** * Default `relinkable` value for edges that do not set their own. `true` - * lets the user drag both ends of an edge to another port, an `EdgeEnd` - * only that end, `false` neither. A selected edge shows a handle at each - * end that can be relinked; dragging a handle previews the reconnection - * live and commits it on drop. Dropping on empty canvas leaves the endpoint - * dangling when `danglingEdges.enabled` is true, otherwise the relink is - * reverted. + * lets the user drag both ends of an edge to another port, `'source'` or + * `'target'` allows only that end, and `false` allows neither. A selected + * edge shows a handle at each end that can be relinked. Dragging a handle + * previews the new connection and commits it on drop. A drop on empty + * canvas detaches the endpoint when `danglingEdges.enabled` is true; + * otherwise the relink is reverted. * - * The gesture shares this section's snap distance, edge panning and - * temporary edge builder. Its drops are validated through - * `validateConnection`, which receives a context with `reason: 'relink'` - * and the edge being relinked. + * Relinking uses the same `portSnapDistance`, edge panning and + * `temporaryEdgeDataBuilder` settings as edge drawing. Each drop is + * validated with `validateConnection`, which receives a context with + * `reason: 'relink'` and the edge being relinked. * * @default false * @since 1.4.0 @@ -141,12 +141,12 @@ export interface LinkingConfig { } /** - * Configuration for dangling edges — edges with one or both endpoints not - * connected to any node (an empty `source`/`target` with the free end anchored - * at `sourcePosition`/`targetPosition`). + * Configuration for dangling edges: edges with one or both endpoints not + * connected to any node. A free endpoint has an empty `source` or `target`, + * and its position is stored in `sourcePosition` or `targetPosition`. * - * Everything here is opt-in. With the defaults, a link dropped on empty - * canvas is discarded and deleting a node deletes its edges. + * The feature is off by default: an edge dropped on empty canvas is + * discarded, and deleting a node deletes its edges. * * @public * @since 1.4.0 @@ -154,59 +154,70 @@ export interface LinkingConfig { */ export interface DanglingEdgesConfig { /** - * Master switch for dangling edges. When true, an edge draw that ends on - * empty canvas keeps the edge as a dangling edge instead of discarding it, - * and an edge relink dropped on empty canvas detaches that endpoint. + * Master switch for dangling edges. When true, an edge drawn onto empty + * canvas is kept as a dangling edge instead of being discarded, and a + * relink dropped on empty canvas detaches that endpoint. It also enables + * `detachEdge` and `startLinkingFromPosition`. * - * A drop over a port the edge cannot connect to (for example a - * wrong-direction port) is not an empty-canvas drop and is discarded. + * A drop on a port that the edge cannot connect to (for example a port + * with the wrong direction) does not count as a drop on empty canvas. Such + * a draw is discarded and such a relink is reverted. * @default false */ enabled: boolean; /** - * Per-edge decision whether a link dropped on empty canvas is kept as a - * dangling edge. Called only when `enabled` is true. The edge passed in is - * the fully-built final edge (after `linking.finalEdgeDataBuilder`). - * Returning false discards the edge (the default behavior when the feature - * is off). + * Decides per edge whether a draw or relink dropped on empty canvas keeps + * the edge as a dangling edge. Called only when `enabled` is true. For a + * draw, `edge` is the final edge, after `linking.finalEdgeDataBuilder` has + * run. For a relink, `edge` is the edge as it would be after the detach. + * Returning false discards the drawn edge or reverts the relink, which is + * also what happens when the feature is off. + * @param edge The edge that would be kept. + * @param dropPosition The position where the pointer was released, in flow coordinates. * @default undefined (keep every edge) */ shouldKeepOnDrop?: (edge: Edge, dropPosition: Point) => boolean; /** - * When true, edges connected to a deleted node are detached into dangling - * edges — anchored where their port was — instead of being deleted. + * When true, deleting a node keeps its edges as dangling edges instead of + * deleting them. Each freed endpoint stays anchored where its port was. * Requires `enabled` to be true. * - * An edge is still deleted, never detached, when: - * - it is itself part of the deleted selection (an explicit delete wins), - * - it is hidden only because of the node it loses — detaching would make - * invisible wiring (e.g. the collapsed children of a deleted group) appear - * as a visible dangling edge; an edge that stays hidden on its own (its - * `hidden` flag, a template binding, or a hidden other endpoint) is - * detached like any other and remains hidden, or - * - it loses BOTH endpoints in the same delete — it becomes a dual dangling - * edge only when {@link shouldDetachOnNodeDelete} is provided and returns - * true for both ends. + * An edge is still deleted, not detached, in these cases: + * - The edge itself is part of the deleted selection. An explicit delete + * always wins. + * - The edge is hidden only because of the node it loses, for example the + * edges of the collapsed children of a deleted group. Detaching it would + * turn invisible wiring into a visible dangling edge. An edge that is + * hidden for another reason (its own `hidden` flag, a template binding, + * or a hidden node at the other end) is detached like any other edge and + * stays hidden. + * - The edge loses both endpoints in the same delete. It becomes a dual + * dangling edge only when {@link shouldDetachOnNodeDelete} is provided + * and returns true for both ends. * @default false */ detachOnNodeDelete: boolean; /** - * Per-edge decision whether a given endpoint is detached (kept dangling) or - * deleted along with the node. Called only when `enabled` and - * `detachOnNodeDelete` are true, once per endpoint losing its node. - * Returning false deletes the edge. For an edge losing both endpoints at - * once, the edge survives as a dual dangling edge only when this callback - * is provided and returns true for both ends. + * Decides per endpoint whether it is detached (kept as a free endpoint) or + * deleted together with the node. Called only when `enabled` and + * `detachOnNodeDelete` are true, once for each endpoint that loses its + * node. Returning false deletes the edge. An edge that loses both + * endpoints at once survives as a dual dangling edge only when this + * callback is provided and returns true for both ends. + * @param edge The edge that loses a node. + * @param deletedNode The node being deleted. + * @param end The endpoint of `edge` that is connected to `deletedNode`. * @default undefined (detach every edge, except edges losing both ends) */ shouldDetachOnNodeDelete?: (edge: Edge, deletedNode: Node, end: EdgeEnd) => boolean; } /** - * Context passed to {@link LinkingConfig.validateConnection} describing the - * operation that asks for validation. + * Context passed to {@link LinkingConfig.validateConnection}. It describes the + * operation that is being validated. * - * - `draw` — a new edge is being drawn (gesture or `startLinking*`). + * - `draw` — a new edge is being drawn, by a pointer gesture or by + * `startLinking` / `startLinkingFromPosition`. * - `relink` — an endpoint of `edge` is being dragged to a new target. * - `attach` — `NgDiagramModelService.attachEdge` connects an endpoint of `edge`. * diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/dangling-edges.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/dangling-edges.ts index 92a8f9e6f..abc5eb052 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/dangling-edges.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/dangling-edges.ts @@ -21,11 +21,14 @@ export interface DanglingEndpoint { /** * Checks whether the given endpoint of an edge is free (not connected to a - * node). A free endpoint is represented by an empty `source`/`target` with the - * position stored in `sourcePosition`/`targetPosition`. + * node). A free endpoint has an empty `source` or `target`, and its position + * is stored in `sourcePosition` or `targetPosition`. * * When `end` is omitted, checks whether either endpoint is free. * + * @param edge The edge to check. + * @param end The endpoint to check, or none to check both. + * @returns `true` when the endpoint is free. * @public * @since 1.4.0 * @category Utilities @@ -41,9 +44,12 @@ export const hasFreeEndpoint = (edge: Edge, end?: EdgeEnd): boolean => { }; /** - * Checks whether an edge is dangling — has at least one endpoint not connected - * to a node. An edge with both endpoints free is a dual dangling edge. + * Checks whether an edge is dangling, that is, whether at least one of its + * endpoints is not connected to a node. An edge with both endpoints free is a + * dual dangling edge. * + * @param edge The edge to check. + * @returns `true` when at least one endpoint is free. * @public * @since 1.4.0 * @category Utilities @@ -51,11 +57,13 @@ export const hasFreeEndpoint = (edge: Edge, end?: EdgeEnd): boolean => { export const isDanglingEdge = (edge: Edge): boolean => hasFreeEndpoint(edge); /** - * Collects the free endpoints of the given edges. A dual dangling edge yields - * two entries. Endpoints whose anchor position is missing are skipped — - * they cannot be rendered or snapped to. Temporary and effectively hidden - * edges are skipped. + * Collects the free endpoints of the given edges. A dual dangling edge gives + * two entries. Endpoints without an anchor position are skipped, because they + * cannot be rendered or snapped to. Temporary and effectively hidden edges are + * skipped as well. * + * @param edges The edges to scan. + * @returns The free endpoints with their edge, end and anchor position. * @public * @since 1.4.0 * @category Utilities @@ -77,11 +85,15 @@ export const getDanglingEndpoints = (edges: readonly Edge[]): DanglingEndpoint[] }; /** - * Computes the anchor a detached endpoint stays at: the port's current flow - * position when the edge was connected to a port, the edge's routed endpoint - * otherwise, the node's center as a last resort. Must run while the node is - * still in the state. + * Computes the position where a detached endpoint stays: the current position + * of the port when the edge was connected to a port, otherwise the routed + * endpoint of the edge, or the center of the node as a last resort. Call it + * while the node still exists in the model. * + * @param edge The edge whose endpoint is being detached. + * @param end The endpoint to detach. + * @param node The node the endpoint is connected to, if it still exists. + * @returns The anchor position, or `null` when none can be computed. * @public * @since 1.4.0 * @category Utilities @@ -126,10 +138,15 @@ export const alignManualPointsPatch = (edge: Edge, end: EdgeEnd, anchor: Point): }; /** - * Finds the free edge endpoint nearest to `point` within `range`, or null when - * none is close enough. Sibling of `getNearestPortInRange` for snapping to - * dangling ends. Temporary and effectively hidden edges are skipped. + * Finds the free edge endpoint nearest to `point` within `range`, or `null` + * when none is close enough. It works like `getNearestPortInRange`, but for + * the free endpoints of dangling edges. Temporary and effectively hidden edges + * are skipped. * + * @param edges The edges to scan. + * @param point The point to measure from. + * @param range The maximum distance from `point`. + * @returns The nearest free endpoint, or `null`. * @public * @since 1.4.0 * @category Utilities diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/relinking.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/relinking.ts index 0524e7b1f..81cf18cca 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/relinking.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/relinking.ts @@ -1,10 +1,15 @@ import type { Edge, EdgeEnd } from '../types/edge.interface'; /** - * Whether the user can relink the given end of the edge: the edge's own - * `relinkable` when set, otherwise `defaultRelinkable` from the linking config. - * `true` allows both ends, an `EdgeEnd` only that end, any other value neither. + * Returns whether the user can relink the given end of an edge. It uses the + * edge's own `relinkable` value when set, otherwise `defaultRelinkable` from + * the linking config. `true` allows both ends, `'source'` or `'target'` allows + * only that end, and any other value allows neither. * + * @param edge The edge to check. + * @param end The endpoint to check. + * @param defaultRelinkable The `linking.defaultRelinkable` config value. + * @returns `true` when the user can relink that end. * @public * @since 1.4.0 * @category Utilities diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/diagram/ng-diagram.component.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/components/diagram/ng-diagram.component.ts index 6633f5381..c33256b95 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/components/diagram/ng-diagram.component.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/diagram/ng-diagram.component.ts @@ -226,7 +226,7 @@ export class NgDiagramComponent implements OnInit, OnDestroy { /** * Event emitted when the user starts dragging an endpoint of an existing - * edge (the relinking gesture, see `linking.defaultRelinkable` config). + * edge (the relinking gesture; see the `linking.defaultRelinkable` config). * * @since 1.4.0 */ @@ -235,8 +235,9 @@ export class NgDiagramComponent implements OnInit, OnDestroy { /** * Event emitted when an edge relink gesture ends, regardless of outcome. * - * Fires when the dragged endpoint is dropped — reconnected to a port, left - * dangling on empty canvas, or reverted (invalid drop or cancelled gesture). + * Fires when the dragged endpoint is dropped, whether it was reconnected to + * a port, left dangling on empty canvas, or reverted (invalid drop or + * cancelled gesture). * * @since 1.4.0 */ diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge.component.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge.component.ts index ca23f666c..0564a553c 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge.component.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge.component.ts @@ -173,8 +173,8 @@ export class NgDiagramBaseEdgeComponent { /** * Whether the edge has at least one free (unconnected) endpoint. Temporary - * edges are excluded — a draw preview always has a free end and must not - * pick up dangling styling. + * edges are excluded: a draw preview always has a free end, but it must not + * get the dangling styling. */ readonly dangling = computed(() => { const edge = this.edge(); @@ -184,8 +184,9 @@ export class NgDiagramBaseEdgeComponent { readonly labels = computed(() => this.edge().measuredLabels ?? []); /** - * Whether the source endpoint handle is rendered: the edge is selected, - * committed, routed, and its source end can be relinked. + * Whether the source endpoint handle is rendered. It is rendered when the + * edge is selected, is not a temporary edge, has routed points, and its + * source end can be relinked. * * @since 1.4.0 */ @@ -213,10 +214,10 @@ export class NgDiagramBaseEdgeComponent { readonly relinkTargetHandle = computed(() => this.points()[this.points().length - 1]); /** - * Radius of the handles' invisible hit circle, in flow units. Kept at - * roughly a finger-friendly constant size on screen by dividing by the - * viewport scale — at zoom 0.5 the visible 5px circle alone would leave a - * 2.5px touch target. + * Radius of the invisible hit circle around each handle, in flow units. The + * radius is divided by the viewport scale, so the hit area keeps a constant, + * finger-friendly size on screen at any zoom level. Without this, at zoom + * 0.5 the visible 5px circle would give only a 2.5px touch target. * * @since 1.4.0 */ diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/relinking/relinking.directive.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/relinking/relinking.directive.ts index e4b179afd..bd8995327 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/relinking/relinking.directive.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/relinking/relinking.directive.ts @@ -7,11 +7,12 @@ import { PointerInputEvent } from '../../../types'; /** * Turns its host element into a grabbable edge-endpoint handle: a pointerdown * starts the relink gesture for the given end of the given edge. The gesture - * itself is driven at document level — the host element unmounts when the - * edge is hidden during the drag. + * itself is handled at document level, so it keeps running after the host + * element is removed from the DOM. This happens on every relink, because the + * original edge is not rendered while its endpoint is being dragged. * - * `ng-diagram-base-edge` renders its own handles with this directive; use it - * directly in fully custom edge templates that do not compose the base edge. + * `ng-diagram-base-edge` renders its own handles with this directive. Use it + * directly in fully custom edge templates that do not use the base edge. * * @public * @since 1.4.0 diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/public-services/ng-diagram-model.service.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/public-services/ng-diagram-model.service.ts index 231c7f7bd..2abb2b5f3 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/public-services/ng-diagram-model.service.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/public-services/ng-diagram-model.service.ts @@ -170,10 +170,10 @@ export class NgDiagramModelService extends NgDiagramBaseService implements OnDes } /** - * Collects the free (unconnected) endpoints of all committed edges — a dual - * dangling edge yields two entries. Temporary and effectively hidden edges + * Returns the free (unconnected) endpoints of all edges in the model. A dual + * dangling edge gives two entries. Temporary and effectively hidden edges * are skipped. - * @returns Dangling endpoints with their edge, end and anchor position. + * @returns The free endpoints with their edge, end and anchor position. * @since 1.4.0 */ getDanglingEndpoints(): DanglingEndpoint[] { @@ -184,12 +184,12 @@ export class NgDiagramModelService extends NgDiagramBaseService implements OnDes } /** - * Gets the free edge endpoint nearest to a point within a range — the - * dangling-edges sibling of {@link getNearestPortInRange}. Temporary and - * effectively hidden edges are skipped. + * Finds the free edge endpoint nearest to a point within a range. It works + * like {@link getNearestPortInRange}, but for the free endpoints of dangling + * edges. Temporary and effectively hidden edges are skipped. * @param point Point to check from. * @param range Range to check in. - * @returns Nearest dangling endpoint in range or null. + * @returns Nearest free endpoint in range, or null. * @since 1.4.0 */ getNearestDanglingEndpointInRange(point: Point, range: number): DanglingEndpoint | null { @@ -200,18 +200,19 @@ export class NgDiagramModelService extends NgDiagramBaseService implements OnDes } /** - * Detaches one endpoint of an edge into a free (dangling) endpoint. + * Detaches one endpoint of an edge, so that it becomes a free (dangling) + * endpoint. * * When `position` is omitted, the endpoint stays anchored where it is now: - * at the port's current position when the edge was connected to a port, at - * the edge's routed endpoint otherwise. + * at the current position of the port when the edge was connected to a + * port, otherwise at the routed endpoint of the edge. * - * Requires `danglingEdges.enabled` — with the feature off this method is a - * no-op, so dangling edges only ever come into existence behind the flag. + * Requires `danglingEdges.enabled`. With the feature off, this method does + * nothing and logs a console warning. * - * @param edgeId The edge to detach. + * @param edgeId ID of the edge to detach. * @param end Which endpoint to detach. - * @param position Optional anchor position for the freed endpoint. + * @param position Optional anchor position for the freed endpoint, in flow coordinates. * @since 1.4.0 */ detachEdge(edgeId: string, end: EdgeEnd, position?: Point): Promise { @@ -254,14 +255,19 @@ export class NgDiagramModelService extends NgDiagramBaseService implements OnDes } /** - * Attaches one endpoint of an edge to a node (and optionally a port), - * running `linking.validateConnection` with the edge's endpoints in their - * proper roles — the symmetric counterpart of {@link detachEdge}. + * Attaches one endpoint of an edge to a node and, optionally, to a port. + * This is the opposite of {@link detachEdge}. * - * @param edgeId The edge to attach. + * The same checks as for a relink drop apply: the node must exist and be + * visible, and the port must exist, be visible and have the right direction. + * The connection is then validated with `linking.validateConnection`, which + * receives the attached node as `source` or `target` according to `end`, + * and a context with `reason: 'attach'`. + * + * @param edgeId ID of the edge to attach. * @param end Which endpoint to attach. - * @param nodeId The node to attach to. - * @param portId The port to attach to. + * @param nodeId ID of the node to attach to. + * @param portId ID of the port to attach to. When omitted, the endpoint is attached to the node without a port. * @returns Whether the connection was valid and applied. * @since 1.4.0 */ 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 32e93ac5c..fb9e50dec 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 @@ -217,15 +217,16 @@ export class NgDiagramService extends NgDiagramBaseService { } /** - * Call this method to start drawing an edge from a position on the canvas - * (no source node) from your custom logic. The free end of the edge follows - * the pointer until a click finishes the draw — on a port the edge connects - * to it; on empty canvas both ends stay free (a dual dangling edge), kept - * when `shouldKeepOnDrop` allows it. + * Starts drawing an edge from a position on the canvas instead of from a + * node, for example from a context menu action. The free end of the edge + * follows the pointer until a click finishes the draw: a click on a port + * connects the edge to that port, and a click on empty canvas leaves both + * ends free (a dual dangling edge), kept when `danglingEdges.shouldKeepOnDrop` + * allows it. * - * Requires `danglingEdges.enabled` — an edge drawn from a position has an - * empty source, i.e. it is a dangling edge by construction. With the - * feature off the call is ignored with a console warning. + * Requires `danglingEdges.enabled`, because an edge drawn from a position + * has no source node and is therefore a dangling edge. With the feature off, + * the call is ignored and a console warning is logged. * * @param position The position in flow (diagram) coordinates where the edge starts. * @since 1.4.0 From 7b5bb8c8a8d72ea867c161e8981fdcae17256deb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Ja=C5=BAwa?= Date: Tue, 22 Sep 2026 00:03:45 +0200 Subject: [PATCH 10/10] Trim the relinking and dangling edges docs, move Hidden Elements to done --- .../dangling-edges/diagram.component.ts | 6 ++++ .../docs/guides/edges/dangling-edges.mdx | 19 +++++++--- .../src/content/docs/guides/edges/edges.mdx | 3 +- .../content/docs/guides/edges/relinking.mdx | 35 ++----------------- .../src/content/docs/guides/flow-config.mdx | 4 +-- apps/docs/src/content/docs/intro/roadmap.mdx | 23 +++++++----- apps/docs/src/content/docs/intro/services.mdx | 4 +-- 7 files changed, 44 insertions(+), 50 deletions(-) diff --git a/apps/docs/src/components/angular/edges/dangling-edges/dangling-edges/diagram.component.ts b/apps/docs/src/components/angular/edges/dangling-edges/dangling-edges/diagram.component.ts index a6483e653..4d7dd5da5 100644 --- a/apps/docs/src/components/angular/edges/dangling-edges/dangling-edges/diagram.component.ts +++ b/apps/docs/src/components/angular/edges/dangling-edges/dangling-edges/diagram.component.ts @@ -46,6 +46,10 @@ export class DiagramComponent { // Deleting a node detaches its edges instead of deleting them detachOnNodeDelete: true, }, + // Optional: selected edges get handles, so users can also detach an end by dragging it onto empty canvas + linking: { + defaultRelinkable: true, + }, } satisfies NgDiagramConfig; // @section-end:config @@ -71,8 +75,10 @@ export class DiagramComponent { // @collapse-end:model-shape { id: 'dangling', + // @mark-start:model-shape source: '', // free endpoint: no node, no port sourcePosition: { x: 200, y: 340 }, // anchored here, in flow coordinates + // @mark-end:model-shape target: 'node-3', targetPort: 'port-left', data: {}, diff --git a/apps/docs/src/content/docs/guides/edges/dangling-edges.mdx b/apps/docs/src/content/docs/guides/edges/dangling-edges.mdx index 8bfe01164..183b85b70 100644 --- a/apps/docs/src/content/docs/guides/edges/dangling-edges.mdx +++ b/apps/docs/src/content/docs/guides/edges/dangling-edges.mdx @@ -14,7 +14,7 @@ Dangling edges are edges with one or both endpoints not connected to any node. T The feature is fully opt-in. With the default settings, an edge dropped on empty canvas is discarded, and deleting a node deletes its edges. -Try it below: draw an edge from a port onto empty canvas to keep it as a dangling edge, or select a node and press Delete to detach its edges instead of deleting them. +Try it below: draw an edge from a port onto empty canvas to keep it as a dangling edge, select a node and press Delete to detach its edges instead of deleting them, or select the connected edge and drag one of its handles onto empty canvas to detach that end. @@ -74,7 +74,7 @@ const config: NgDiagramConfig = { danglingEdges: { enabled: true, detachOnNodeDelete: true, - shouldDetachOnNodeDelete: (edge, deletedNode, end) => edge.data?.['keepOnDelete'] === true, + shouldDetachOnNodeDelete: (edge, deletedNode, end) => edge.data?.keepOnDelete === true, }, }; ``` @@ -92,7 +92,6 @@ An edge with both endpoints free is fully supported: - **Routing** — the edge routes between its two anchor positions with any routing algorithm, like a connected edge. - **Persistence** — `toJSON()` saves the `sourcePosition`/`targetPosition` of free endpoints, and [`initializeModel`](/docs/api/utilities/initializemodel) restores them, so dangling edges survive a save and load cycle. -- **Copy and paste** — with `danglingEdges.enabled`, copying a node also copies a dangling edge connected to it, even when that edge is not selected. Pasting moves the free endpoints together with the rest of the pasted content, and for manual-routing edges the stored `points` move as well. - **Box selection** — a free endpoint counts by its anchor position, so a box drawn around a dangling end selects the edge. ## Drawing Edges from a Position @@ -113,7 +112,19 @@ The free end of the edge follows the pointer until a click finishes the draw. A ## Utilities and Service Methods -The package exports these helper functions: +The package exports these helper functions. Import them from `ng-diagram` like any other symbol: + +```typescript +import { hasFreeEndpoint, isDanglingEdge } from 'ng-diagram'; + +private modelService = inject(NgDiagramModelService); + +readonly danglingEdges = computed(() => this.modelService.edges().filter(isDanglingEdge)); + +isSourceFree(edge: Edge): boolean { + return hasFreeEndpoint(edge, 'source'); +} +``` - [`isDanglingEdge(edge)`](/docs/api/utilities/isdanglingedge) — whether the edge has at least one free endpoint. - [`hasFreeEndpoint(edge, end?)`](/docs/api/utilities/hasfreeendpoint) — whether the given endpoint (or either, when `end` is omitted) is free. diff --git a/apps/docs/src/content/docs/guides/edges/edges.mdx b/apps/docs/src/content/docs/guides/edges/edges.mdx index 161ca9014..266fba40b 100644 --- a/apps/docs/src/content/docs/guides/edges/edges.mdx +++ b/apps/docs/src/content/docs/guides/edges/edges.mdx @@ -5,6 +5,7 @@ sidebar: order: 1 --- +import { Badge } from '@astrojs/starlight/components'; import CodeSnippet from '@components/code-snippet/code-snippet.astro'; import { Image } from 'astro:assets'; @@ -215,7 +216,7 @@ onEdgeDrawEnded(event: EdgeDrawEndedEvent) { } ``` -## Relinking and Dangling Edges +## Relinking and Dangling Edges Two opt-in features extend what users can do with existing edges: diff --git a/apps/docs/src/content/docs/guides/edges/relinking.mdx b/apps/docs/src/content/docs/guides/edges/relinking.mdx index 2c8ac8d04..826f53e19 100644 --- a/apps/docs/src/content/docs/guides/edges/relinking.mdx +++ b/apps/docs/src/content/docs/guides/edges/relinking.mdx @@ -153,40 +153,11 @@ ng-diagram-base-edge.my-edge { Each visible handle has a larger invisible hit circle of about 12 screen pixels. Its size is adjusted for zoom, so the handles stay easy to touch at any zoom level without growing visually. Hovering over the hit circle highlights the visible handle, the same as hovering over the handle itself. -### Custom Edge Templates - -Handles are rendered only by `ng-diagram-base-edge`. A fully custom edge template that does not use the base edge can turn any element into an endpoint handle with the [`RelinkHandleDirective`](/docs/api/directives/relinkhandledirective) (`[ngDiagramRelinkHandle]`): - -```html - -``` - -A pointerdown on the host element starts the relink gesture for the given `end` of the given `edge`. The gesture itself is handled at document level, so it keeps running even when the host element is removed from the DOM during the drag (the original edge is not rendered while its endpoint is being relinked). - -The directive ignores a pointerdown on an end that cannot be relinked, so a handle on a locked end never starts a gesture. To also hide such a handle, check the effective value with [`isEdgeEndRelinkable`](/docs/api/utilities/isedgeendrelinkable): - -```typescript -private readonly diagramService = inject(NgDiagramService); - -readonly targetHandleVisible = computed(() => - isEdgeEndRelinkable(this.edge(), 'target', this.diagramService.config().linking.defaultRelinkable) -); -``` - ## Related Topics diff --git a/apps/docs/src/content/docs/guides/flow-config.mdx b/apps/docs/src/content/docs/guides/flow-config.mdx index 248627d57..11a88cade 100644 --- a/apps/docs/src/content/docs/guides/flow-config.mdx +++ b/apps/docs/src/content/docs/guides/flow-config.mdx @@ -76,11 +76,11 @@ The most important categories include: - [`background`](/docs/api/types/configuration/features/backgroundconfig): Configures background visuals (grid, dot spacing, cell size). - [`boxSelection`](/docs/api/types/configuration/features/boxselectionconfig): Configures box selection behavior. -- [`danglingEdges`](/docs/api/types/configuration/features/danglingedgesconfig): Enables edges with free (unconnected) endpoints — see [Dangling Edges](/docs/guides/edges/dangling-edges). +- [`danglingEdges`](/docs/api/types/configuration/features/danglingedgesconfig): Enables edges with free (unconnected) endpoints. - [`debugMode`](/docs/api/types/configuration/flowconfig/#debugmode): Enables verbose logging for development and debugging. - [`edgeRouting`](/docs/api/types/configuration/features/edgeroutingconfig): Defines edge routing algorithms and their parameters. - [`grouping`](/docs/api/types/configuration/features/groupingconfig): Enables node grouping and related logic. -- [`linking`](/docs/api/types/configuration/features/linkingconfig): Customizes edge creation and connection validation. Also enables edge relinking through `defaultRelinkable` — see [Relinking](/docs/guides/edges/relinking). +- [`linking`](/docs/api/types/configuration/features/linkingconfig): Customizes edge creation, connection validation, and edge relinking. - [`nodeRotation`](/docs/api/types/configuration/features/noderotationconfig): Enables and customizes node rotation and snapping. - [`resize`](/docs/api/types/configuration/features/resizeconfig): Manages node resizing logic, minimum sizes, and resizability. - [`shortcuts`](/docs/api/types/configuration/shortcuts/shortcutdefinition): Defines keyboard shortcuts for diagram actions. diff --git a/apps/docs/src/content/docs/intro/roadmap.mdx b/apps/docs/src/content/docs/intro/roadmap.mdx index d6cb4a8fb..f4832adec 100644 --- a/apps/docs/src/content/docs/intro/roadmap.mdx +++ b/apps/docs/src/content/docs/intro/roadmap.mdx @@ -30,8 +30,8 @@ ngDiagram is stable and actively developed. Here's what we're working on right n
-

Hidden Elements

-

Hide nodes, edges, and ports without breaking measurement or layout

+

Async Event Callbacks

+

Async support in diagram event callbacks

@@ -51,13 +51,7 @@ ngDiagram is stable and actively developed. Here's what we're working on right n
- -
-

Async Event Callbacks

-

Async support in diagram event callbacks

- -
- +

Dangling Edges

@@ -78,6 +72,17 @@ ngDiagram is stable and actively developed. Here's what we're working on right n
+ +
+

Hidden Elements

+

Hide nodes, edges, and ports without breaking measurement or layout

+ +
+ + + + +

Assembly Line Template

diff --git a/apps/docs/src/content/docs/intro/services.mdx b/apps/docs/src/content/docs/intro/services.mdx index f3e4cfbe9..0b116f707 100644 --- a/apps/docs/src/content/docs/intro/services.mdx +++ b/apps/docs/src/content/docs/intro/services.mdx @@ -28,7 +28,7 @@ The main orchestration service that provides access to the action state and midd - Access action state and environment information - Manage edges routing - Manage event listeners -- Start linking programmatically — [`startLinking()`](/docs/api/services/ngdiagramservice#startlinking) from a node or port, and [`startLinkingFromPosition()`](/docs/api/services/ngdiagramservice#startlinkingfromposition) from a point on the canvas (requires [`danglingEdges.enabled`](/docs/api/types/configuration/features/danglingedgesconfig/#enabled)) +- Start linking programmatically from a node, a port, or a point on the canvas - [Invalidate element measurements](/docs/guides/nodes/ports#port-measurement) after CSS-driven position changes **Usage Example:** @@ -63,7 +63,7 @@ Handles all model-related operations including node and edge updates, spatial qu - Perform spatial queries to find nearby elements - Access nodes, edges, and metadata as reactive signals - Find elements by ID -- Detach and attach edge endpoints with [`detachEdge()`](/docs/api/services/ngdiagrammodelservice#detachedge) (requires [`danglingEdges.enabled`](/docs/api/types/configuration/features/danglingedgesconfig/#enabled)) and [`attachEdge()`](/docs/api/services/ngdiagrammodelservice#attachedge), and query free endpoints with [`getDanglingEndpoints()`](/docs/api/services/ngdiagrammodelservice#getdanglingendpoints) and [`getNearestDanglingEndpointInRange()`](/docs/api/services/ngdiagrammodelservice#getnearestdanglingendpointinrange) — see [Dangling Edges](/docs/guides/edges/dangling-edges) +- Detach and attach edge endpoints, and find free (dangling) endpoints Every mutating method returns a promise that resolves once the change has been applied to the model, so operations can be sequenced with `await` — see