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 e0695baa5..e09d5d7d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +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`). 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)) @@ -21,6 +29,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. 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)) - **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)) @@ -30,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/angular-demo/src/app/app.component.html b/apps/angular-demo/src/app/app.component.html index 8e4fae78e..e0a7ffe6e 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)" @@ -34,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); @@ -274,18 +326,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..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: [ { @@ -201,6 +218,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', @@ -358,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/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 465edc2bc..6928b6a57 100644 --- a/apps/angular-demo/src/app/toolbar/toolbar.component.html +++ b/apps/angular-demo/src/app/toolbar/toolbar.component.html @@ -1,6 +1,6 @@
- + @@ -24,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 f679da116..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(); 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..4d7dd5da5 --- /dev/null +++ b/apps/docs/src/components/angular/edges/dangling-edges/dangling-edges/diagram.component.ts @@ -0,0 +1,89 @@ +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, + }, + // 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 + + // @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', + // @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: {}, + }, + ], + }); + // @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..52d5c72f4 --- /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 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 the relink + 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 e5fd6090a..1763dd438 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, but it must not +get the dangling styling. + +*** + ### edge > **edge**: `InputSignal`\<[`Edge`](/docs/api/types/model/edge/)\<`object`\>\> @@ -19,6 +29,71 @@ Edge data model *** +### relinkHandleHitRadius + +> `readonly` **relinkHandleHitRadius**: `Signal`\<`number`\> + +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 + +*** + +### relinkSourceHandle + +> `readonly` **relinkSourceHandle**: `Signal`\<[`Point`](/docs/api/types/geometry/point/)\> + +Position of the source endpoint handle (the first routed point). + +#### Since + +1.4.0 + +*** + +### relinkSourceHandleVisible + +> `readonly` **relinkSourceHandleVisible**: `Signal`\<`boolean`\> + +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 + +*** + +### relinkTargetHandle + +> `readonly` **relinkTargetHandle**: `Signal`\<[`Point`](/docs/api/types/geometry/point/)\> + +Position of the target endpoint handle (the last routed point). + +#### Since + +1.4.0 + +*** + +### 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 60c52733a..de32283f4 100644 --- a/apps/docs/src/content/docs/api/Components/NgDiagramComponent.md +++ b/apps/docs/src/content/docs/api/Components/NgDiagramComponent.md @@ -72,6 +72,35 @@ 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, whether it was 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 the `linking.defaultRelinkable` config). + +#### Since + +1.4.0 + +*** + ### edgeTemplateMap > **edgeTemplateMap**: `InputSignal`\<[`NgDiagramEdgeTemplateMap`](/docs/api/types/templates/ngdiagramedgetemplatemap/)\> diff --git a/apps/docs/src/content/docs/api/Directives/RelinkHandleDirective.md b/apps/docs/src/content/docs/api/Directives/RelinkHandleDirective.md new file mode 100644 index 000000000..b9fe54de4 --- /dev/null +++ b/apps/docs/src/content/docs/api/Directives/RelinkHandleDirective.md @@ -0,0 +1,16 @@ +--- +version: "since v1.4.0" +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 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 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 5dd5738d4..9f42cbaf7 100644 --- a/apps/docs/src/content/docs/api/Internals/LinkingActionState.md +++ b/apps/docs/src/content/docs/api/Internals/LinkingActionState.md @@ -26,6 +26,34 @@ Position where the pointer was released. *** +### relink? + +> `optional` **relink**: [`LinkingRelinkContext`](/docs/api/internals/linkingrelinkcontext/) + +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 + +*** + +### relinkCancelReason? + +> `optional` **relinkCancelReason**: [`EdgeRelinkCancelReason`](/docs/api/types/events/edgerelinkcancelreason/) + +Reason the relink gesture ended without changing the edge (set by +`finishRelinking` when the relink fails). + +#### Since + +1.4.0 + +*** + ### 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/Services/NgDiagramModelService.md b/apps/docs/src/content/docs/api/Services/NgDiagramModelService.md index e6c2d3bd6..56ca78b83 100644 --- a/apps/docs/src/content/docs/api/Services/NgDiagramModelService.md +++ b/apps/docs/src/content/docs/api/Services/NgDiagramModelService.md @@ -113,6 +113,57 @@ 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, 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 + +##### edgeId + +`string` + +ID of the edge to attach. + +##### end + +[`EdgeEnd`](/docs/api/types/model/edgeend/) + +Which endpoint to attach. + +##### nodeId + +`string` + +ID of the node to attach to. + +##### portId? + +`string` + +ID of the port to attach to. When omitted, the endpoint is attached to the node without a port. + +#### Returns + +`Promise`\<`boolean`\> + +Whether the connection was valid and applied. + +#### Since + +1.4.0 + +*** + ### computePartsBounds() > **computePartsBounds**(`nodes`, `edges`): [`Rect`](/docs/api/types/geometry/rect/) @@ -206,6 +257,50 @@ 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, so that it becomes a free (dangling) +endpoint. + +When `position` is omitted, the endpoint stays anchored where it is now: +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 does +nothing and logs a console warning. + +#### Parameters + +##### edgeId + +`string` + +ID of 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, in flow coordinates. + +#### Returns + +`Promise`\<`void`\> + +#### Since + +1.4.0 + +*** + ### getChildren() > **getChildren**\<`T`\>(`groupId`): [`Node`](/docs/api/types/model/node/)\<`T`\>[] @@ -326,6 +421,26 @@ Array of nodes connected to the given node *** +### getDanglingEndpoints() + +> **getDanglingEndpoints**(): [`DanglingEndpoint`](/docs/api/types/model/danglingendpoint/)[] + +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/)[] + +The free endpoints with their edge, end and anchor position. + +#### Since + +1.4.0 + +*** + ### getEdgeById() > **getEdgeById**\<`T`\>(`edgeId`): `null` \| [`Edge`](/docs/api/types/model/edge/)\<`T`\> @@ -369,6 +484,40 @@ Returns null if flowCore is not initialized. *** +### getNearestDanglingEndpointInRange() + +> **getNearestDanglingEndpointInRange**(`point`, `range`): `null` \| [`DanglingEndpoint`](/docs/api/types/model/danglingendpoint/) + +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 + +##### 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 free 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..9d6924da9 100644 --- a/apps/docs/src/content/docs/api/Services/NgDiagramService.md +++ b/apps/docs/src/content/docs/api/Services/NgDiagramService.md @@ -521,6 +521,39 @@ The port ID from which the linking starts. Creates a floating edge when undefine *** +### startLinkingFromPosition() + +> **startLinkingFromPosition**(`position`): `void` + +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 + +##### 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/ConnectionValidationContext.md b/apps/docs/src/content/docs/api/Types/Configuration/Features/ConnectionValidationContext.md new file mode 100644 index 000000000..757505675 --- /dev/null +++ b/apps/docs/src/content/docs/api/Types/Configuration/Features/ConnectionValidationContext.md @@ -0,0 +1,39 @@ +--- +version: "since v1.4.0" +editUrl: false +next: false +prev: false +title: "ConnectionValidationContext" +--- + +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, 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`. + +## Properties + +### edge? + +> `optional` **edge**: [`Edge`](/docs/api/types/model/edge/)\<`object`\> + +The existing edge whose endpoint is being connected (relink and attach only). + +*** + +### end? + +> `optional` **end**: [`EdgeEnd`](/docs/api/types/model/edgeend/) + +Which endpoint of `edge` is being connected (relink and attach only). + +*** + +### reason + +> **reason**: `"draw"` \| `"relink"` \| `"attach"` + +The operation being validated. 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..e129bc742 --- /dev/null +++ b/apps/docs/src/content/docs/api/Types/Configuration/Features/DanglingEdgesConfig.md @@ -0,0 +1,144 @@ +--- +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. A free endpoint has an empty `source` or `target`, +and its position is stored in `sourcePosition` or `targetPosition`. + +The feature is off by default: an edge dropped on empty canvas is +discarded, and deleting a node deletes its edges. + +## Properties + +### detachOnNodeDelete + +> **detachOnNodeDelete**: `boolean` + +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, 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 + +```ts +false +``` + +*** + +### enabled + +> **enabled**: `boolean` + +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 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 + +```ts +false +``` + +*** + +### shouldDetachOnNodeDelete()? + +> `optional` **shouldDetachOnNodeDelete**: (`edge`, `deletedNode`, `end`) => `boolean` + +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 + +##### edge + +[`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` + +#### Default + +```ts +undefined (detach every edge, except edges losing both ends) +``` + +*** + +### shouldKeepOnDrop()? + +> `optional` **shouldKeepOnDrop**: (`edge`, `dropPosition`) => `boolean` + +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 + +##### edge + +[`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` + +#### Default + +```ts +undefined (keep every edge) +``` 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 1fee41810..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 @@ -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, `'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 + +```ts +false +``` + +#### Since + +1.4.0 + +*** + ### edgePanningEnabled > **edgePanningEnabled**: `boolean` @@ -149,15 +178,23 @@ The Edge object to use for the temporary edge. ### validateConnection() -> **validateConnection**: (`source`, `sourcePort`, `target`, `targetPort`) => `boolean` +> **validateConnection**: (`source`, `sourcePort`, `target`, `targetPort`, `context?`) => `boolean` Validates whether a connection between two nodes and ports is allowed. +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` 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/) @@ -169,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/) @@ -179,6 +216,12 @@ The target port. `null` | [`Port`](/docs/api/types/model/port/) +##### context? + +[`ConnectionValidationContext`](/docs/api/types/configuration/features/connectionvalidationcontext/) + +The operation being validated (`draw` when omitted). + #### Returns `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 747b48896..87ca6f4ab 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` 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..c194ee90f 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,35 @@ 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, whether it was 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..bb1af5a9a --- /dev/null +++ b/apps/docs/src/content/docs/api/Types/Events/EdgeRelinkCancelReason.md @@ -0,0 +1,23 @@ +--- +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` — 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), 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 new file mode 100644 index 000000000..cf5f0afb5 --- /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` 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 + +### 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 anchor position of the endpoint before the relink, present only when the endpoint was free (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..3a19c7761 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,18 @@ 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`). 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/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/Edge.md b/apps/docs/src/content/docs/api/Types/Model/Edge.md index 0cf49f01c..be283641e 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 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 + +*** + ### routing? > `optional` **routing**: [`EdgeRoutingName`](/docs/api/types/routing/edgeroutingname/) 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/computeDetachAnchor.md b/apps/docs/src/content/docs/api/Utilities/computeDetachAnchor.md new file mode 100644 index 000000000..1326da17a --- /dev/null +++ b/apps/docs/src/content/docs/api/Utilities/computeDetachAnchor.md @@ -0,0 +1,40 @@ +--- +version: "since v1.4.0" +editUrl: false +next: false +prev: false +title: "computeDetachAnchor" +--- + +> **computeDetachAnchor**(`edge`, `end`, `node`): `null` \| [`Point`](/docs/api/types/geometry/point/) + +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 + +### edge + +[`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 new file mode 100644 index 000000000..ee1513b03 --- /dev/null +++ b/apps/docs/src/content/docs/api/Utilities/getDanglingEndpoints.md @@ -0,0 +1,28 @@ +--- +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 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 + +### edges + +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 new file mode 100644 index 000000000..6d03c813e --- /dev/null +++ b/apps/docs/src/content/docs/api/Utilities/getNearestDanglingEndpointInRange.md @@ -0,0 +1,40 @@ +--- +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. It works like `getNearestPortInRange`, but for +the free endpoints of dangling edges. Temporary and effectively hidden edges +are skipped. + +## Parameters + +### edges + +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 new file mode 100644 index 000000000..21e02ab1e --- /dev/null +++ b/apps/docs/src/content/docs/api/Utilities/hasFreeEndpoint.md @@ -0,0 +1,35 @@ +--- +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 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. + +## Parameters + +### edge + +[`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 new file mode 100644 index 000000000..46dafa571 --- /dev/null +++ b/apps/docs/src/content/docs/api/Utilities/isDanglingEdge.md @@ -0,0 +1,27 @@ +--- +version: "since v1.4.0" +editUrl: false +next: false +prev: false +title: "isDanglingEdge" +--- + +> **isDanglingEdge**(`edge`): `boolean` + +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 + +### 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 new file mode 100644 index 000000000..03f482ac3 --- /dev/null +++ b/apps/docs/src/content/docs/api/Utilities/isEdgeEndRelinkable.md @@ -0,0 +1,40 @@ +--- +version: "since v1.4.0" +editUrl: false +next: false +prev: false +title: "isEdgeEndRelinkable" +--- + +> **isEdgeEndRelinkable**(`edge`, `end`, `defaultRelinkable`): `boolean` + +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 + +### edge + +[`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/api/_readme.md b/apps/docs/src/content/docs/api/_readme.md index d7e10f91e..63e4c199f 100644 --- a/apps/docs/src/content/docs/api/_readme.md +++ b/apps/docs/src/content/docs/api/_readme.md @@ -27,6 +27,7 @@ title: "ng-diagram" - [NgDiagramHiddenDirective](/docs/api/directives/ngdiagramhiddendirective/) - [NgDiagramMinimapNavigationDirective](/docs/api/directives/ngdiagramminimapnavigationdirective/) - [NgDiagramNodeSelectedDirective](/docs/api/directives/ngdiagramnodeselecteddirective/) +- [RelinkHandleDirective](/docs/api/directives/relinkhandledirective/) ## Internals @@ -38,6 +39,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/) @@ -71,6 +73,8 @@ title: "ng-diagram" - [BackgroundConfig](/docs/api/types/configuration/features/backgroundconfig/) - [BoxSelectionConfig](/docs/api/types/configuration/features/boxselectionconfig/) +- [ConnectionValidationContext](/docs/api/types/configuration/features/connectionvalidationcontext/) +- [DanglingEdgesConfig](/docs/api/types/configuration/features/danglingedgesconfig/) - [DefaultNodeTemplateConfig](/docs/api/types/configuration/features/defaultnodetemplateconfig/) - [EdgeRoutingConfig](/docs/api/types/configuration/features/edgeroutingconfig/) - [GroupingConfig](/docs/api/types/configuration/features/groupingconfig/) @@ -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/) @@ -200,10 +209,16 @@ title: "ng-diagram" ## Utilities +- [computeDetachAnchor](/docs/api/utilities/computedetachanchor/) - [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/) +- [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 783b2bfb1..753d1de98 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. 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)) - **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 new file mode 100644 index 000000000..183b85b70 --- /dev/null +++ b/apps/docs/src/content/docs/guides/edges/dangling-edges.mdx @@ -0,0 +1,184 @@ +--- +version: 'since v1.4.0' +title: Dangling Edges +description: Understanding how dangling edges work in ngDiagram +sidebar: + order: 6 + badge: New +--- + +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 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. + +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, 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. + + + +## Model Shape + +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` 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 + +Dangling edges are controlled by the [`danglingEdges`](/docs/api/types/configuration/features/danglingedgesconfig) configuration: + + + +[`enabled`](/docs/api/types/configuration/features/danglingedgesconfig/#enabled) is the master switch. When true: + +- 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 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 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 = { + danglingEdges: { + enabled: true, + // Keep only edges drawn from "output" ports as dangling + shouldKeepOnDrop: (edge, dropPosition) => edge.sourcePort?.startsWith('output') ?? false, + }, +}; +``` + +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 + +By default, deleting a node deletes all of its edges. Set [`detachOnNodeDelete`](/docs/api/types/configuration/features/danglingedgesconfig/#detachonnodedelete) to keep them as dangling edges instead, anchored where their port was: + +```typescript +const config: NgDiagramConfig = { + danglingEdges: { + enabled: true, + detachOnNodeDelete: true, + }, +}; +``` + +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 = { + danglingEdges: { + enabled: true, + detachOnNodeDelete: true, + shouldDetachOnNodeDelete: (edge, deletedNode, end) => edge.data?.keepOnDelete === true, + }, +}; +``` + +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 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()` saves the `sourcePosition`/`targetPosition` of free endpoints, and [`initializeModel`](/docs/api/utilities/initializemodel) restores them, so dangling edges survive a save and load cycle. +- **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: + +```typescript +private ngDiagramService = inject(NgDiagramService); +private viewportService = inject(NgDiagramViewportService); + +startDrawingAt(event: MouseEvent) { + const position = this.viewportService.clientToFlowPosition({ x: event.clientX, y: event.clientY }); + this.ngDiagramService.startLinkingFromPosition(position); +} +``` + +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 + +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. +- [`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 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) provides the model-level operations: + +- [`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); + +async reconnectLooseEnds() { + for (const { edge, end, position } of this.modelService.getDanglingEndpoints()) { + const port = this.modelService.getNearestPortInRange(position, 50); + if (port) { + await this.modelService.attachEdge(edge.id, end, port.nodeId, port.id); + } + } +} +``` + +## Styling + +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 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 +// Dashed rendering for any edge with a free endpoint +ng-diagram-base-edge.dangling { + --edge-stroke-dasharray: 6 4; + --edge-stroke-opacity: 0.7; +} +``` + +## 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`. 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) { + if (event.success && !event.target) { + console.log('Kept as dangling edge:', event.edge!.id); + } +} +``` + +## 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 5291785e7..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'; @@ -110,6 +111,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 See [Edge Selection](/docs/guides/edges/selection) for more details on customizing selection styles. @@ -214,6 +216,13 @@ onEdgeDrawEnded(event: EdgeDrawEndedEvent) { } ``` +## Relinking and Dangling Edges + +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. 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 By default, pressing a port to start linking also selects the parent node. To decouple selection from linking gestures, set [`selectNodeOnPortPress`](/docs/api/types/configuration/features/linkingconfig/#selectnodeonportpress) to `false`: @@ -242,3 +251,5 @@ See [Conditional Visibility](/docs/guides/conditional-visibility) for the full s [Labels →](/docs/guides/edges/labels) [Floating Edges →](/docs/guides/edges/floating-edges) +[Dangling Edges →](/docs/guides/edges/dangling-edges) +[Relinking →](/docs/guides/edges/relinking) diff --git a/apps/docs/src/content/docs/guides/edges/relinking.mdx b/apps/docs/src/content/docs/guides/edges/relinking.mdx new file mode 100644 index 000000000..826f53e19 --- /dev/null +++ b/apps/docs/src/content/docs/guides/edges/relinking.mdx @@ -0,0 +1,165 @@ +--- +version: 'since v1.4.0' +title: Relinking +description: Understanding how edge relinking works in ngDiagram +sidebar: + order: 7 + badge: New +--- + +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 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, 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, while `'source'` or `'target'` lets them drag only that end. + + + +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 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: + +| 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 take `relinkable` from their own data: + + + +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 with relinkable: true can be relinked + finalEdgeDataBuilder: (edge) => ({ ...edge, type: 'draft', relinkable: true }), + }, +}; +``` + +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 that the user draws. Loaded edges use the default or their own `relinkable` property. + +## How It Works + +- 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 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 changes the model. The edge stays as it was. + +## Validating Reconnections + +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 = { + linking: { + validateConnection: (source, sourcePort, target, targetPort, context) => { + if (source?.id === target?.id) { + return false; // no self-connections + } + if (context?.reason === 'relink') { + // A relinked end stays inside the edge's current group + return source?.groupId === target?.groupId; + } + return true; + }, + }, +}; +``` + +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` 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` (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 + +``` + +```typescript +onRelinkEnded(event: EdgeRelinkEndedEvent) { + if (event.success && event.target) { + console.log(`Reconnected ${event.end} of ${event.edge.id} from ${event.previousNode?.id} to ${event.target.id}`); + } else if (event.success) { + console.log(`Detached ${event.end} of ${event.edge.id} at`, event.dropPosition); + } else { + console.log('Relink reverted:', event.reason); + } +} +``` + +## Customization + +### Relink Handles + +Here are the CSS variables you can use to style the relink handles globally: + +``` + --ngd-relink-handle-size // Diameter of the visible handle circle + --ngd-relink-handle-fill // Fill color of the handle + --ngd-relink-handle-fill-hover // Fill color on hover + --ngd-relink-handle-stroke // Stroke (outline) color of the handle + --ngd-relink-handle-stroke-width // Stroke width of the handle +``` + +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 { + --edge-relink-handle-size: 0.875rem; + --edge-relink-handle-fill: #fff; + --edge-relink-handle-fill-hover: #3b82f6; + --edge-relink-handle-stroke: #3b82f6; + --edge-relink-handle-stroke-width: 2px; +} +``` + +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. + + + +## Related Topics + +[Dangling Edges →](/docs/guides/edges/dangling-edges) | [Edges Overview →](/docs/guides/edges/edges) | [Edge Selection →](/docs/guides/edges/selection) diff --git a/apps/docs/src/content/docs/guides/flow-config.mdx b/apps/docs/src/content/docs/guides/flow-config.mdx index 91c132707..11a88cade 100644 --- a/apps/docs/src/content/docs/guides/flow-config.mdx +++ b/apps/docs/src/content/docs/guides/flow-config.mdx @@ -76,10 +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. - [`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. +- [`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/overview.mdx b/apps/docs/src/content/docs/intro/overview.mdx index 42be50343..9a830216e 100644 --- a/apps/docs/src/content/docs/intro/overview.mdx +++ b/apps/docs/src/content/docs/intro/overview.mdx @@ -231,7 +231,7 @@ ngDiagram provides injectable services for all diagram operations. Services are - Config, events, transactions - Middleware registration - Routing management ([`registerRouting()`](/docs/api/services/ngdiagramservice#registerrouting), [`unregisterRouting()`](/docs/api/services/ngdiagramservice#unregisterrouting), [`getRegisteredRoutings()`](/docs/api/services/ngdiagramservice#getregisteredroutings)) - - Programmatic linking ([`startLinking()`](/docs/api/services/ngdiagramservice#startlinking)) + - Programmatic linking ([`startLinking()`](/docs/api/services/ngdiagramservice#startlinking), [`startLinkingFromPosition()`](/docs/api/services/ngdiagramservice#startlinkingfromposition) — the latter requires [`danglingEdges.enabled`](/docs/api/types/configuration/features/danglingedgesconfig/#enabled)) - Environment info ([`getEnvironment()`](/docs/api/services/ngdiagramservice#getenvironment)) - Measurement invalidation ([`invalidateMeasurements()`](/docs/api/services/ngdiagramservice#invalidatemeasurements)) - [**NgDiagramModelService**](/docs/api/services/ngdiagrammodelservice) - CRUD operations: diff --git a/apps/docs/src/content/docs/intro/roadmap.mdx b/apps/docs/src/content/docs/intro/roadmap.mdx index cd29c5425..f4832adec 100644 --- a/apps/docs/src/content/docs/intro/roadmap.mdx +++ b/apps/docs/src/content/docs/intro/roadmap.mdx @@ -30,16 +30,16 @@ 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

-

Assembly Line Template

-

Demo app for assembly line diagrams

- +

Edge Relinking

+

Reconnect existing edges to other nodes and ports

+
@@ -51,46 +51,70 @@ 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

+

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

+ +
-

Awaitable Service Methods

-

Every mutating service call returns a promise you can await

+

Edge Reshaping

+

Interactive editing of edge paths and waypoints

- - +
-

Edge Reshaping

-

Interactive editing of edge paths and waypoints

+

Hidden Elements

+

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

+ + +
-

Resize Snap Offset

-

Snapping offset for node resize - a community request

- +

Assembly Line Template

+

Demo app for assembly line diagrams

+
+ +
-

Edge Relinking

-

Reconnect existing edges to other nodes and ports

+

Awaitable Service Methods

+

Every mutating service call returns a promise you can await

+ + + + + +
+

Resize Snap Offset

+

Snapping offset for node resize - a community request

+ +
+ + + +
diff --git a/apps/docs/src/content/docs/intro/services.mdx b/apps/docs/src/content/docs/intro/services.mdx index b2b5feefa..0b116f707 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 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:** @@ -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, 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 diff --git a/apps/e2e/tests/dangling-relinking.spec.ts b/apps/e2e/tests/dangling-relinking.spec.ts new file mode 100644 index 000000000..2bb236d08 --- /dev/null +++ b/apps/e2e/tests/dangling-relinking.spec.ts @@ -0,0 +1,723 @@ +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 `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. */ +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[]); +} + +/** 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}`); + 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('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 }); + + 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 } }, + }); + + // 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(); + + 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: '' }); + 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 }) => { + 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 = { linking: { defaultRelinkable: 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); + }); + + /** 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); + 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(); + + await expect + .poll(() => relinkEnded(diagram)) + .toEqual([ + { + edge: 'edge-ab', + end: 'target', + success: false, + reason: 'noTarget', + previousNode: 'node-b', + target: null, + 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 }) => { + 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('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']); + + 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('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('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', () => { + test.use({ hasTouch: true }); + + const relinkOn = { linking: { defaultRelinkable: 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 }) => { + // 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 }); + + 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); + // 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 }) => { + 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('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 e11c8953c..2382ca3b7 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[]; @@ -144,6 +154,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 +184,8 @@ export interface DiagramEventMap { edgeDrawEnded: EdgeDrawEndedEvent; // @deprecated edgeDrawn: EdgeDrawnEvent; + edgeRelinkEnded: EdgeRelinkEndedEvent; + edgeRelinkStarted: EdgeRelinkStartedEvent; groupMembershipChanged: GroupMembershipChangedEvent; nodeDragEnded: NodeDragEndedEvent; nodeDragStarted: NodeDragStartedEvent; @@ -216,6 +243,7 @@ export interface Edge { // (undocumented) readonly measuredLabels?: EdgeLabel[]; points?: Point[]; + relinkable?: boolean | EdgeEnd; routing?: EdgeRoutingName; routingMode?: RoutingMode; selected?: boolean; @@ -240,7 +268,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 +284,9 @@ export interface EdgeDrawnEvent { targetPort?: string; } +// @public +export type EdgeEnd = 'source' | 'target'; + // @public export interface EdgeLabel { id: string; @@ -267,6 +298,29 @@ 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 EdgeRelinkStartedEvent { + edge: Edge; + end: EdgeEnd; +} + // @public export interface EdgeRouting { computePointAtDistance?(points: Point[], distancePx: number): Point; @@ -347,6 +401,7 @@ export interface FlowConfig { boxSelection: BoxSelectionConfig; computeEdgeId: () => string; computeNodeId: () => string; + danglingEdges: DanglingEdgesConfig; debugMode: boolean; defaultNode?: DefaultNodeTemplateConfig; edgeRouting: EdgeRoutingConfig; @@ -397,6 +452,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 +481,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 +519,12 @@ 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; @@ -498,6 +568,8 @@ export type KeyboardZoomAction = 'keyboardZoomIn' | 'keyboardZoomOut'; export interface LinkingActionState { cancelReason?: EdgeDrawCancelReason; dropPosition?: Point; + relink?: LinkingRelinkContext; + relinkCancelReason?: EdgeRelinkCancelReason; sourceNodeId: string; sourcePortId: string; temporaryEdge: Edge | null; @@ -505,6 +577,7 @@ export interface LinkingActionState { // @public export interface LinkingConfig { + defaultRelinkable: boolean | EdgeEnd; edgePanningEnabled: boolean; edgePanningForce: number; edgePanningThreshold: number; @@ -512,7 +585,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) @@ -535,6 +608,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 +752,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 +812,7 @@ export class NgDiagramBaseEdgeComponent { constructor(); // (undocumented) readonly class: Signal; + readonly dangling: Signal; edge: InputSignal>; // (undocumented) readonly labels: Signal; @@ -743,6 +824,11 @@ export class NgDiagramBaseEdgeComponent { readonly path: Signal; // (undocumented) readonly points: Signal; + readonly relinkHandleHitRadius: Signal; + readonly relinkSourceHandle: Signal; + readonly relinkSourceHandleVisible: Signal; + readonly relinkTargetHandle: Signal; + readonly relinkTargetHandleVisible: Signal; routing: InputSignal; // (undocumented) readonly selected: Signal; @@ -836,6 +922,8 @@ export class NgDiagramComponent implements OnInit, OnDestroy { edgeDrawEnded: EventEmitter; // @deprecated edgeDrawn: EventEmitter; + edgeRelinkEnded: EventEmitter; + edgeRelinkStarted: EventEmitter; // (undocumented) readonly edges: WritableSignal[]>; edgeTemplateMap: InputSignal; @@ -864,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; @@ -879,7 +968,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 +1137,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 +1392,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 +1634,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 +1713,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__/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 6fb5a2c40..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 @@ -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: [ @@ -1041,6 +1131,171 @@ 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 }, + // Manual routing: the stored path is the truth, so paste must + // shift it along with the free endpoint (auto edges re-route). + routingMode: 'manual' as const, + 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 }, + routingMode: 'manual' as const, + 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 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 }, + { 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__/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 new file mode 100644 index 000000000..ef964f483 --- /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,433 @@ +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; + templateVisibilityRegistry?: { isEdgeHidden: (edgeId: string) => boolean }; + }; + + 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 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([ + { + id: 'edge-ab', + source: '', + sourcePort: undefined, + sourcePosition: portAnchor, + target: '', + targetPort: undefined, + targetPosition: centerAnchor, + }, + ]); + }); + + 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 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(), [hiddenEdge], new Set(['node-a'])); + + expect(result.edgesToRemove).toEqual(['edge-ab']); + expect(result.edgesToUpdate).toEqual([]); + }); + + 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([]); + 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', () => { + // 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 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'])); + + 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 c16f25db5..52b0fc803 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)); @@ -79,21 +80,17 @@ export const deleteNodes = async (commandHandler: CommandHandler, command: Delet const { edges } = commandHandler.flowCore.getState(); const { modelLookup } = commandHandler.flowCore; const { ids } = command; - const edgesToDeleteIds = new Set(); // Deleting a group cascades to its whole subtree (matching deleteSelection) — // children left behind would keep a dangling groupId, and since a missing // parent counts as visible, effectively hidden children would reappear as // orphans on the computedHidden re-stamp. const nodesToDeleteIds = new Set(ids.flatMap((id) => [id, ...modelLookup.getAllDescendantIds(id)])); - 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 af88e3cda..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 @@ -200,6 +200,14 @@ const createPastedEdges = ( continue; } + // 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 + ? edge.points.map((point) => ({ x: point.x + offset.x, y: point.y + offset.y })) + : edge.points; + pastedEdges.push({ ...edge, id: config.computeEdgeId(), @@ -209,6 +217,7 @@ const createPastedEdges = ( target: target.nodeId, targetPort: target.port, targetPosition: target.position, + points, // See createPastedNodes — hidden pasted edges stay deselected. selected: !isEdgeEffectivelyHidden(edge, hiddenCopiedNodeIds), }); @@ -264,12 +273,22 @@ export const copy = async (commandHandler: CommandHandler) => { const copiedNodes = nodes.filter((node) => copiedNodeIds.has(node.id)); + // "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) { + 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); + }; + // 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)) - ); + const copiedEdges = edges.filter((edge) => (edge.selected && !edge.computedHidden) || isInsideCopiedSet(edge)); commandHandler.flowCore.actionStateManager.copyPaste = { copiedNodes, 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..1cbbb049d --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/detach-on-node-delete.ts @@ -0,0 +1,139 @@ +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 + * with the nodes (the default), or detached into dangling edges. + */ +export interface IncidentEdgesPartition { + edgesToRemove: string[]; + edgesToUpdate: (Partial & { id: string })[]; +} + +/** + * 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 + * - 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 + * 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, + 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: string })[] = []; + + const mayDetach = (edge: Edge, end: EdgeEnd, nodeId: string): boolean => { + const node = flowCore.getNodeById(nodeId); + if (!node) { + return false; + } + if (!danglingEdges?.shouldDetachOnNodeDelete) { + return true; + } + 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); + if (!sourceLost && !targetLost) { + 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) || + wouldBecomeVisible(edge, sourceLost, targetLost) || + dualWithoutOptIn + ) { + edgesToRemove.push(edge.id); + continue; + } + + // 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; + } + + 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 = { + ...update, + source: '', + sourcePort: undefined, + sourcePosition: anchor, + ...alignManualPointsPatch(edge, 'source', anchor), + }; + } + if (targetLost) { + const anchor = computeDetachAnchor(edge, 'target', flowCore.getNodeById(edge.target)); + if (!anchor) { + edgesToRemove.push(edge.id); + continue; + } + // Built on the patch above so an edge losing both ends keeps points[0]. + update = { + ...update, + target: '', + targetPort: undefined, + targetPosition: anchor, + ...alignManualPointsPatch({ ...edge, ...update }, 'target', 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-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..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 @@ -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(), @@ -108,7 +112,7 @@ describe('finishLinkingToPosition', () => { expect(mockCreateFinalEdge).toHaveBeenCalledWith(mockFlowCore.config, mockTemporaryEdge, { target: '', - targetPort: '', + targetPort: undefined, targetPosition: position, }); @@ -142,7 +146,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,13 +204,31 @@ 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); }); + 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: {} }; @@ -229,6 +291,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 bebb0bf28..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 @@ -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: { @@ -101,6 +103,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', @@ -434,6 +473,231 @@ 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, + 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 }, + linking: { portSnapDistance: 10 }, + }; + 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 } }); + + // A free end carries no port — targetPort is undefined, never ''. + expect(mockCreateFinalEdge).toHaveBeenCalledWith(mockFlowCore.config, temporaryEdgeNoTarget, { + target: '', + targetPort: undefined, + targetPosition: { x: 50, y: 60 }, + }); + 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 () => { + 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', + }; + + 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(() => { + callOrder.push('createFinalEdge'); + return keptEdge; + }); + const shouldKeepOnDrop = vi.fn(() => { + callOrder.push('shouldKeepOnDrop'); + return true; + }); + mockFlowCore.config = { danglingEdges: { enabled: true, shouldKeepOnDrop }, linking: { portSnapDistance: 10 } }; + + 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 }, + linking: { portSnapDistance: 10 }, + }; + + 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 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 + ); + + 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..3b05f3983 --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/finish-relinking.test.ts @@ -0,0 +1,533 @@ +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, Port } 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; + getNearestPortInRange: ReturnType; + applyUpdate: ReturnType; + templateVisibilityRegistry?: { isPortHidden: (nodeId: string, portId: string) => boolean }; + config: { + danglingEdges: { + enabled: boolean; + shouldKeepOnDrop?: ReturnType; + }; + linking: { + defaultRelinkable: boolean; + portSnapDistance: number; + 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', + position: { x: 200, y: 100 }, + measuredPorts: [ + { ...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, + 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]), + getNearestPortInRange: vi.fn().mockReturnValue(null), + applyUpdate: vi.fn().mockResolvedValue(undefined), + config: { + danglingEdges: { enabled: false }, + linking: { + defaultRelinkable: true, + portSnapDistance: 12, + 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 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' + ); + expect(mockFlowCore.actionStateManager.clearLinking).toHaveBeenCalled(); + }); + + 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 } }); + + expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith( + { edgesToUpdate: [{ id: 'edge-1', source: 'node-c', sourcePort: 'out-c', sourcePosition: undefined }] }, + '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' }); + + 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', { 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 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' }); + + 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' }); + + 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 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' }); + + await finishRelinking(mockCommandHandler, { name: 'finishRelinking', position: { x: 0, y: 0 } }); + + expect(mockFlowCore.config.linking.validateConnection).toHaveBeenCalledWith( + sourceNode, + sourceNode.measuredPorts![0], + candidateNode, + candidateNode.measuredPorts![0], + { reason: 'relink', edge: originalEdge, end: 'target' } + ); + }); + + 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 } }); + + expect(mockFlowCore.config.linking.validateConnection).toHaveBeenCalledWith( + candidateNode, + candidateNode.measuredPorts![1], + targetNode, + targetNode.measuredPorts![0], + { reason: 'relink', edge: originalEdge, end: 'source' } + ); + }); + + it('should revert when validateConnection rejects the candidate', async () => { + mockFlowCore.config.linking.validateConnection.mockReturnValue(false); + 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] + ); + 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 } }); + + expect(linking.relinkCancelReason).toBe('invalidConnection'); + expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith({}, 'finishRelinking'); + }); + + 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 } }); + + expect(linking.relinkCancelReason).toBe('invalidConnection'); + expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith({}, 'finishRelinking'); + expect(mockFlowCore.config.linking.validateConnection).not.toHaveBeenCalled(); + }); + + 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' }); + + 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] + ); + 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('cancelled'); + expect(mockFlowCore.applyUpdate).toHaveBeenCalledWith({}, 'finishRelinking'); + }); + + it('should record the drop position on the linking state', async () => { + 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 } }); + + 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..2dc2e5cef --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/linking-utils.test.ts @@ -0,0 +1,332 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { FlowCore } from '../../../../flow-core'; +import { mockEdge, mockNode, mockPort } from '../../../../test-utils'; +import type { Edge } from '../../../../types'; +import { + connectionContextForGesture, + createFinalEdge, + createTemporaryEdge, + isProperSourcePort, + isProperTargetPort, + validateConnection, +} 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('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(); + }); + + 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', () => { + 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: { + linking: { validateConnection: ReturnType }; + }; + }; + + beforeEach(() => { + core = { + getEdgeById: vi.fn().mockReturnValue(mockEdge), + getNodeById: vi.fn().mockReturnValue(candidateNode), + config: { + linking: { validateConnection: vi.fn().mockReturnValue(true) }, + }, + }; + }); + + 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 }; + + const result = validateConnection( + core as unknown as FlowCore, + undefined, + undefined, + 'node-c', + 'in-c', + true, + context + ); + + expect(result).toBe(true); + expect(core.config.linking.validateConnection).toHaveBeenCalledWith( + null, + null, + candidateNode, + candidateNode.measuredPorts[0], + context + ); + }); + + 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(core.config.linking.validateConnection).toHaveBeenCalledWith( + null, + null, + candidateNode, + 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 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' }); + }); + + it('should build a draw context outside a relink', () => { + const core = { getEdgeById: vi.fn() }; + + 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 4ca7e7aeb..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 @@ -8,12 +8,16 @@ import { moveTemporaryEdge, MoveTemporaryEdgeCommand } from '../move-temporary-e vi.mock('../utils', () => ({ createTemporaryEdge: vi.fn(), isProperTargetPort: vi.fn(), + isProperSourcePort: vi.fn(), validateConnection: vi.fn(), + connectionContextForGesture: vi.fn().mockReturnValue({ reason: 'draw' }), + relinkPreviewBase: vi.fn().mockReturnValue({}), })); -import { createTemporaryEdge, isProperTargetPort, validateConnection } from '../utils'; +import { createTemporaryEdge, isProperSourcePort, isProperTargetPort, validateConnection } from '../utils'; const mockCreateTemporaryEdge = vi.mocked(createTemporaryEdge); const mockIsProperTargetPort = vi.mocked(isProperTargetPort); +const mockIsProperSourcePort = vi.mocked(isProperSourcePort); const mockValidateConnection = vi.mocked(validateConnection); describe('moveTemporaryEdge', () => { @@ -210,7 +214,9 @@ describe('moveTemporaryEdge', () => { mockTemporaryEdge.source, mockTemporaryEdge.sourcePort, 'target-node', - 'target-port' + 'target-port', + undefined, + { reason: 'draw' } ); expect(mockCreateTemporaryEdge).toHaveBeenCalledWith(mockFlowCore.config, { @@ -245,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 b11e4e5cb..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 @@ -21,6 +21,7 @@ describe('startLinkingFromPosition', () => { }; actionStateManager: { linking: LinkingActionState | null; + isLinking: ReturnType; }; }; @@ -36,6 +37,7 @@ describe('startLinkingFromPosition', () => { }, actionStateManager: { linking: null, + isLinking: vi.fn().mockReturnValue(false), }, }; @@ -46,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/__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..c941aa103 --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/__tests__/start-relinking.test.ts @@ -0,0 +1,258 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { FlowCore } from '../../../../flow-core'; +import { mockEdge } from '../../../../test-utils'; +import type { CommandHandler, Edge, EdgeEnd, 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: { + linking: { defaultRelinkable: boolean | EdgeEnd; 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: { + linking: { + defaultRelinkable: true, + // 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 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' }); + + expect(mockFlowCore.actionStateManager.linking).toBeNull(); + 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', + 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('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' }); + + 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