+ |
|
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 {
+ const { config } = commandHandler.flowCore;
+ if (!config.danglingEdges?.enabled) {
+ return null;
+ }
+
+ // Mirror the connected path's guard — a hidden source must not silently
+ // produce an invisible dangling edge.
+ if (temporaryEdge.source && commandHandler.flowCore.getNodeById(temporaryEdge.source)?.computedHidden) {
+ return null;
+ }
+
+ // A free end has no port — undefined, never ''. createFinalEdge applies the
+ // same normalization to a free source (a draw started from a position).
+ const edge = createFinalEdge(config, temporaryEdge, {
+ target: '',
+ targetPort: undefined,
+ targetPosition: dropPosition,
+ });
+
+ if (config.danglingEdges.shouldKeepOnDrop && !config.danglingEdges.shouldKeepOnDrop(edge, dropPosition)) {
+ return null;
+ }
+
+ return edge;
+};
+
const validateTarget = (
commandHandler: CommandHandler,
targetNodeId: string | undefined,
@@ -58,6 +96,13 @@ export const finishLinking = async (commandHandler: CommandHandler, command: Fin
return;
}
+ // A relink owns this state — finishing it as a draw would ADD a duplicate
+ // edge instead of updating the relinked one (finishRelinking is the only
+ // legal finish for it). A teardown already in progress must not run twice.
+ if (linking.relink || linking._finishing) {
+ return;
+ }
+
// Claims the teardown — a cancelLinking racing this finish must no-op instead
// of overwriting the reason and emitting a second edgeDrawEnded.
linking._finishing = true;
@@ -79,6 +124,21 @@ export const finishLinking = async (commandHandler: CommandHandler, command: Fin
const targetPortId = targetPort || undefined;
if (!targetNodeId) {
+ const { config } = commandHandler.flowCore;
+ // A drop over a port the preview refused (wrong direction, or the source's
+ // own port) is not an empty-canvas drop: no dangling edge is kept and the
+ // draw cancels with 'noTarget' exactly as with the feature off.
+ const droppedOnPort =
+ !!config.danglingEdges?.enabled &&
+ !!commandHandler.flowCore.getNearestPortInRange(linking.dropPosition, config.linking.portSnapDistance);
+
+ const keptEdge = droppedOnPort
+ ? null
+ : buildKeptDanglingEdge(commandHandler, temporaryEdge, linking.dropPosition);
+ if (keptEdge) {
+ await commandHandler.flowCore.applyUpdate({ edgesToAdd: [keptEdge] }, 'finishLinking');
+ return;
+ }
linking.cancelReason = 'noTarget';
await runCancelledFinishPass(commandHandler);
return;
diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/finish-relinking.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/finish-relinking.ts
new file mode 100644
index 000000000..1766ea7c3
--- /dev/null
+++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/finish-relinking.ts
@@ -0,0 +1,153 @@
+import { clearLinkingForGesture } from './linking-gesture';
+import type { CommandHandler, Edge, Point } from '../../../types';
+import type { EdgeRelinkCancelReason } from '../../../event-manager/event-types';
+import type { InternalLinkingActionState } from '../../../types/action-state.interface';
+import { alignManualPointsPatch, getPortFlowPosition } from '../../../utils';
+import { getTargetPortInfo } from './move-temporary-edge';
+import { connectionContextForGesture, isValidEndpointTarget, validateConnection } from './utils';
+
+export interface FinishRelinkingCommand {
+ name: 'finishRelinking';
+ /** The drop position in flow coordinates. */
+ position: Point;
+}
+
+/**
+ * Ends a relink gesture: commits the dragged endpoint to the port under the
+ * drop position, detaches it into a dangling end on an empty-canvas drop
+ * (when the dangling-edges feature allows), or reverts the edge (which is
+ * just clearing the state — the model was never touched during the drag).
+ *
+ * A drop back on the endpoint's original node and port changes nothing and
+ * reports `success: false, reason: 'cancelled'`.
+ */
+export const finishRelinking = async (commandHandler: CommandHandler, command: FinishRelinkingCommand) => {
+ const { flowCore } = commandHandler;
+ const linking = flowCore.actionStateManager.linking as InternalLinkingActionState | undefined;
+
+ // No relink, or a finishRelinking/cancelLinking already owns the teardown —
+ // a second call must not commit twice or emit a second edgeRelinkEnded.
+ if (!linking?.relink || linking._finishing) {
+ return;
+ }
+
+ // Claims the teardown — a cancelLinking racing this finish must no-op.
+ linking._finishing = true;
+ const gestureId = linking._gestureId;
+
+ try {
+ const { edgeId, end } = linking.relink;
+ const temporaryEdge = linking.temporaryEdge;
+ const dropPosition = command.position;
+ linking.dropPosition = dropPosition;
+
+ // An empty 'finishRelinking' pass: the emitter reports the failed relink
+ // and the redraw erases the temporary edge and re-shows the original edge.
+ const runRevertPass = async (reason: EdgeRelinkCancelReason) => {
+ linking.relinkCancelReason = reason;
+ await flowCore.applyUpdate({}, 'finishRelinking');
+ };
+
+ const edge = flowCore.getEdgeById(edgeId);
+ if (!edge || !temporaryEdge) {
+ await runRevertPass('cancelled');
+ return;
+ }
+
+ // Hit-test the drop position itself rather than trusting the preview:
+ // moveTemporaryEdge un-snaps candidates it rejects, so the preview can't
+ // distinguish "no port under the cursor" from "port that cannot take this
+ // end". The raw hit classifies the drop: no port at all is an empty-canvas
+ // drop, a port the end cannot take is a refused connection.
+ const dropPortInfo = getTargetPortInfo(commandHandler, dropPosition, temporaryEdge, end);
+ const { hitPort } = dropPortInfo;
+ const candidateNodeId = dropPortInfo.targetNodeId || undefined;
+ const candidatePortId = dropPortInfo.targetPortId || undefined;
+
+ // A drop back on the original node and port is a no-op, not a reconnect:
+ // the model is untouched and no success is reported (event parity with
+ // edgeRelinkStarted is kept through the cancelled revert pass).
+ const originalNodeId = (end === 'source' ? edge.source : edge.target) || undefined;
+ const originalPortId = (end === 'source' ? edge.sourcePort : edge.targetPort) || undefined;
+ if (hitPort && hitPort.nodeId === originalNodeId && hitPort.id === originalPortId) {
+ await runRevertPass('cancelled');
+ return;
+ }
+
+ // The fixed end can become effectively hidden mid-gesture — neither a
+ // reconnect nor a detach may commit an edge anchored to a hidden node.
+ const fixedEndNodeId = end === 'target' ? edge.source : edge.target;
+ if (fixedEndNodeId && flowCore.getNodeById(fixedEndNodeId)?.computedHidden) {
+ await runRevertPass('cancelled');
+ return;
+ }
+
+ if (!hitPort) {
+ // Dropped on empty canvas — detach the endpoint when dangling edges are
+ // enabled and the per-edge callback keeps the detached edge. This is not
+ // a connection, so the connection validator is not consulted.
+ const { danglingEdges } = flowCore.config;
+ if (danglingEdges?.enabled) {
+ const detachUpdate: Partial & { id: string } =
+ end === 'target'
+ ? { id: edgeId, target: '', targetPort: undefined, targetPosition: dropPosition }
+ : { id: edgeId, source: '', sourcePort: undefined, sourcePosition: dropPosition };
+ const detachedEdge: Edge = { ...edge, ...detachUpdate };
+ if (!danglingEdges.shouldKeepOnDrop || danglingEdges.shouldKeepOnDrop(detachedEdge, dropPosition)) {
+ await flowCore.applyUpdate(
+ { edgesToUpdate: [{ ...detachUpdate, ...alignManualPointsPatch(edge, end, dropPosition) }] },
+ 'finishRelinking'
+ );
+ return;
+ }
+ }
+ await runRevertPass('noTarget');
+ return;
+ }
+
+ // A port that cannot take this end — wrong direction, or the fixed end's
+ // own node/port — is a refused connection, not a detach: the edge reverts
+ // and the app validator is never consulted.
+ if (!candidateNodeId) {
+ await runRevertPass('invalidConnection');
+ return;
+ }
+
+ // The shared structural gate with attachEdge: the node must exist, be
+ // visible, and the port must be present, right-directed and not
+ // template-hidden. The gesture hit-test already guarantees most of it.
+ if (!isValidEndpointTarget(flowCore, end, candidateNodeId, candidatePortId)) {
+ await runRevertPass('invalidConnection');
+ return;
+ }
+ const candidateNode = flowCore.getNodeById(candidateNodeId);
+
+ const isValid = validateConnection(
+ flowCore,
+ end === 'target' ? edge.source || undefined : candidateNodeId,
+ end === 'target' ? edge.sourcePort : candidatePortId,
+ end === 'target' ? candidateNodeId : edge.target || undefined,
+ end === 'target' ? candidatePortId : edge.targetPort,
+ true,
+ connectionContextForGesture(flowCore, linking.relink)
+ );
+ if (!isValid) {
+ await runRevertPass('invalidConnection');
+ return;
+ }
+
+ const reconnectUpdate: Partial & { id: string } =
+ end === 'target'
+ ? { id: edgeId, target: candidateNodeId, targetPort: candidatePortId, targetPosition: undefined }
+ : { id: edgeId, source: candidateNodeId, sourcePort: candidatePortId, sourcePosition: undefined };
+
+ // Manual-routing edges keep their stored points verbatim — move the
+ // reconnected end's point onto the new anchor so the path follows.
+ const newAnchor = candidatePortId && candidateNode ? getPortFlowPosition(candidateNode, candidatePortId) : null;
+ const pointsPatch = newAnchor ? alignManualPointsPatch(edge, end, newAnchor) : {};
+
+ await flowCore.applyUpdate({ edgesToUpdate: [{ ...reconnectUpdate, ...pointsPatch }] }, 'finishRelinking');
+ } finally {
+ clearLinkingForGesture(flowCore.actionStateManager, gestureId);
+ }
+};
diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/index.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/index.ts
index d0a2ad258..9a132a5b9 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/index.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/index.ts
@@ -2,9 +2,11 @@
export * from './cancel-linking';
export * from './finish-linking';
export * from './finish-linking-to-position';
+export * from './finish-relinking';
export * from './move-temporary-edge';
export * from './start-linking';
export * from './start-linking-from-position';
+export * from './start-relinking';
// Utilities
export * from './utils';
diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/move-temporary-edge.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/move-temporary-edge.ts
index 3a7345c93..5f0281f20 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/move-temporary-edge.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/move-temporary-edge.ts
@@ -1,5 +1,13 @@
-import type { CommandHandler, Edge, Point } from '../../../types';
-import { createTemporaryEdge, isProperTargetPort, validateConnection } from './utils';
+import type { CommandHandler, Edge, EdgeEnd, Point, Port } from '../../../types';
+import type { LinkingRelinkContext } from '../../../types/action-state.interface';
+import {
+ connectionContextForGesture,
+ createTemporaryEdge,
+ isProperSourcePort,
+ isProperTargetPort,
+ relinkPreviewBase,
+ validateConnection,
+} from './utils';
export interface MoveTemporaryEdgeCommand {
name: 'moveTemporaryEdge';
@@ -10,23 +18,38 @@ interface TargetPortInfo {
targetNodeId: string;
targetPortId: string;
isValid: boolean;
+ /** The raw hit-test result, before the properness filter — null when the position is over no port at all. */
+ hitPort: Port | null;
}
+/**
+ * Finds the port the dragged end of the temporary edge would snap to at
+ * `position`. For a normal draw (and a target-end relink) the candidate must
+ * be target-capable; while relinking the source end it must be source-capable.
+ * `hitPort` reports what the position is over regardless of properness, so
+ * callers can tell "no port here" from "port this end cannot take".
+ */
export const getTargetPortInfo = (
commandHandler: CommandHandler,
position: Point,
- temporaryEdge: Edge
+ temporaryEdge: Edge,
+ draggedEnd: EdgeEnd = 'target'
): TargetPortInfo => {
- const targetPort = commandHandler.flowCore.getNearestPortInRange(
+ const candidatePort = commandHandler.flowCore.getNearestPortInRange(
position,
commandHandler.flowCore.config.linking.portSnapDistance
);
- const isProperTarget = targetPort && isProperTargetPort(targetPort, temporaryEdge.source, temporaryEdge.sourcePort);
+ const isProperCandidate =
+ candidatePort &&
+ (draggedEnd === 'target'
+ ? isProperTargetPort(candidatePort, temporaryEdge.source, temporaryEdge.sourcePort)
+ : isProperSourcePort(candidatePort, temporaryEdge.target, temporaryEdge.targetPort));
return {
- targetNodeId: isProperTarget ? targetPort.nodeId : '',
- targetPortId: isProperTarget ? targetPort.id : '',
- isValid: !!isProperTarget,
+ targetNodeId: isProperCandidate ? candidatePort.nodeId : '',
+ targetPortId: isProperCandidate ? candidatePort.id : '',
+ isValid: !!isProperCandidate,
+ hitPort: candidatePort ?? null,
};
};
@@ -34,87 +57,123 @@ export const createNewTemporaryEdge = (
commandHandler: CommandHandler,
temporaryEdge: Edge,
targetPortInfo: TargetPortInfo,
- position: Point
+ position: Point,
+ draggedEnd: EdgeEnd = 'target',
+ relink?: LinkingRelinkContext
): Edge => {
- const { targetNodeId, targetPortId } = targetPortInfo;
-
- const source = temporaryEdge.source || '';
- const sourcePort = temporaryEdge.sourcePort || '';
+ const { targetNodeId: candidateNodeId, targetPortId: candidatePortId } = targetPortInfo;
+ const { config } = commandHandler.flowCore;
+
+ // The end that stays anchored while the other follows the pointer.
+ const fixedEndFields: Partial =
+ draggedEnd === 'target'
+ ? {
+ source: temporaryEdge.source || '',
+ sourcePort: temporaryEdge.sourcePort || '',
+ sourcePosition: temporaryEdge.sourcePosition,
+ }
+ : {
+ target: temporaryEdge.target || '',
+ targetPort: temporaryEdge.targetPort || '',
+ targetPosition: temporaryEdge.targetPosition,
+ };
+
+ const buildPreview = (draggedEndFields: Partial): Edge =>
+ createTemporaryEdge(config, {
+ ...(relink ? relinkPreviewBase(relink.originalEdge) : {}),
+ ...fixedEndFields,
+ ...draggedEndFields,
+ });
const createFloatingEdge = () =>
- createTemporaryEdge(commandHandler.flowCore.config, {
- source,
- sourcePort,
- target: '',
- targetPort: '',
- targetPosition: position,
- });
+ buildPreview(
+ draggedEnd === 'target'
+ ? { target: '', targetPort: '', targetPosition: position }
+ : { source: '', sourcePort: '', sourcePosition: position }
+ );
- if (!targetNodeId) {
+ if (!candidateNodeId) {
return createFloatingEdge();
}
- const targetNode = commandHandler.flowCore.getNodeById(targetNodeId);
- if (!targetNode) {
+ const candidateNode = commandHandler.flowCore.getNodeById(candidateNodeId);
+ if (!candidateNode) {
return createFloatingEdge();
}
const isConnectionValid = validateConnection(
commandHandler.flowCore,
- temporaryEdge.source,
- temporaryEdge.sourcePort,
- targetNodeId,
- targetPortId
+ draggedEnd === 'target' ? temporaryEdge.source : candidateNodeId,
+ draggedEnd === 'target' ? temporaryEdge.sourcePort : candidatePortId,
+ draggedEnd === 'target' ? candidateNodeId : temporaryEdge.target,
+ draggedEnd === 'target' ? candidatePortId : temporaryEdge.targetPort,
+ undefined,
+ connectionContextForGesture(commandHandler.flowCore, relink)
);
if (!isConnectionValid) {
return createFloatingEdge();
}
- if (targetPortId && targetNode.measuredPorts?.find((port) => port.id === targetPortId)) {
- return createTemporaryEdge(commandHandler.flowCore.config, {
- source,
- sourcePort,
- target: targetNodeId,
- targetPort: targetPortId,
- targetPosition: position,
- });
- }
-
- return createTemporaryEdge(commandHandler.flowCore.config, {
- source,
- sourcePort,
- target: targetNodeId,
- targetPort: '',
- targetPosition: position,
- });
+ const hasMeasuredCandidatePort =
+ candidatePortId && candidateNode.measuredPorts?.find((port) => port.id === candidatePortId);
+
+ return buildPreview(
+ draggedEnd === 'target'
+ ? {
+ target: candidateNodeId,
+ targetPort: hasMeasuredCandidatePort ? candidatePortId : '',
+ targetPosition: position,
+ }
+ : {
+ source: candidateNodeId,
+ sourcePort: hasMeasuredCandidatePort ? candidatePortId : '',
+ sourcePosition: position,
+ }
+ );
};
-export const isSameTarget = (temporaryEdge: Edge, targetNodeId: string, targetPortId: string): boolean => {
- return targetNodeId === temporaryEdge.target && targetPortId === temporaryEdge.targetPort;
+export const isSameTarget = (
+ temporaryEdge: Edge,
+ targetNodeId: string,
+ targetPortId: string,
+ draggedEnd: EdgeEnd = 'target'
+): boolean => {
+ return draggedEnd === 'target'
+ ? targetNodeId === temporaryEdge.target && targetPortId === temporaryEdge.targetPort
+ : targetNodeId === temporaryEdge.source && targetPortId === temporaryEdge.sourcePort;
};
export const moveTemporaryEdge = async (commandHandler: CommandHandler, command: MoveTemporaryEdgeCommand) => {
const { position } = command;
- const temporaryEdge = commandHandler.flowCore.actionStateManager.linking?.temporaryEdge;
+ const linking = commandHandler.flowCore.actionStateManager.linking;
+ const temporaryEdge = linking?.temporaryEdge;
- if (!commandHandler.flowCore.actionStateManager.linking || !temporaryEdge) {
+ if (!linking || !temporaryEdge) {
return;
}
- const targetPortInfo = getTargetPortInfo(commandHandler, position, temporaryEdge);
+ const draggedEnd = linking.relink?.end ?? 'target';
+ const targetPortInfo = getTargetPortInfo(commandHandler, position, temporaryEdge, draggedEnd);
if (
targetPortInfo.targetNodeId &&
- isSameTarget(temporaryEdge, targetPortInfo.targetNodeId, targetPortInfo.targetPortId)
+ isSameTarget(temporaryEdge, targetPortInfo.targetNodeId, targetPortInfo.targetPortId, draggedEnd)
) {
return;
}
- const newTemporaryEdge = createNewTemporaryEdge(commandHandler, temporaryEdge, targetPortInfo, position);
+ const newTemporaryEdge = createNewTemporaryEdge(
+ commandHandler,
+ temporaryEdge,
+ targetPortInfo,
+ position,
+ draggedEnd,
+ linking.relink
+ );
commandHandler.flowCore.actionStateManager.linking = {
- ...commandHandler.flowCore.actionStateManager.linking,
+ ...linking,
temporaryEdge: newTemporaryEdge,
};
diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/start-linking-from-position.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/start-linking-from-position.ts
index aaa003f49..5988a30f5 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/start-linking-from-position.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/start-linking-from-position.ts
@@ -13,6 +13,12 @@ export const startLinkingFromPosition = async (
) => {
const { position } = command;
+ // A draw or relink already owns the linking state — clobbering it would
+ // strand its gesture (hidden edge, unbalanced started/ended events).
+ if (commandHandler.flowCore.actionStateManager.isLinking()) {
+ return;
+ }
+
const temporaryEdge = createTemporaryEdge(commandHandler.flowCore.config, {
source: '',
sourcePosition: position,
diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/start-relinking.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/start-relinking.ts
new file mode 100644
index 000000000..1954281fe
--- /dev/null
+++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/start-relinking.ts
@@ -0,0 +1,77 @@
+import { createLinkingState } from './linking-gesture';
+import type { CommandHandler, EdgeEnd } from '../../../types';
+import { isEdgeEndRelinkable } from '../../../utils/relinking';
+import { createTemporaryEdge, relinkPreviewBase } from './utils';
+
+export interface StartRelinkingCommand {
+ name: 'startRelinking';
+ edgeId: string;
+ end: EdgeEnd;
+}
+
+/**
+ * Starts dragging an endpoint of an existing edge. The edge is hidden for the
+ * duration of the gesture and represented by a temporary edge that mirrors its
+ * type, data and arrowheads; the dragged end starts at the edge's current
+ * routed endpoint. The model is untouched until finishRelinking commits —
+ * cancelling simply clears the action state.
+ */
+export const startRelinking = async (commandHandler: CommandHandler, command: StartRelinkingCommand) => {
+ const { edgeId, end } = command;
+ const { flowCore } = commandHandler;
+ const { actionStateManager } = flowCore;
+
+ // A draw or another relink already owns the linking state.
+ if (actionStateManager.isLinking()) {
+ return;
+ }
+
+ const edge = flowCore.getEdgeById(edgeId);
+ if (!edge || edge.temporary || edge.computedHidden) {
+ return;
+ }
+ if (!isEdgeEndRelinkable(edge, end, flowCore.config.linking.defaultRelinkable)) {
+ return;
+ }
+
+ // The dragged end starts where the edge currently ends: the routed endpoint
+ // when available, the stored dangling position otherwise.
+ const points = edge.points;
+ const draggedPosition =
+ end === 'source' ? (points?.[0] ?? edge.sourcePosition) : (points?.[points.length - 1] ?? edge.targetPosition);
+ if (!draggedPosition) {
+ return;
+ }
+
+ const previewBase = relinkPreviewBase(edge);
+
+ const temporaryEdge =
+ end === 'target'
+ ? createTemporaryEdge(flowCore.config, {
+ ...previewBase,
+ source: edge.source,
+ sourcePort: edge.sourcePort,
+ sourcePosition: edge.sourcePosition ?? points?.[0],
+ target: '',
+ targetPort: '',
+ targetPosition: draggedPosition,
+ })
+ : createTemporaryEdge(flowCore.config, {
+ ...previewBase,
+ source: '',
+ sourcePort: '',
+ sourcePosition: draggedPosition,
+ target: edge.target,
+ targetPort: edge.targetPort,
+ targetPosition: edge.targetPosition ?? points?.[points.length - 1],
+ });
+
+ actionStateManager.linking = createLinkingState({
+ sourceNodeId: edge.source,
+ sourcePortId: edge.sourcePort ?? '',
+ temporaryEdge,
+ relink: { edgeId, end, originalEdge: edge },
+ });
+
+ await flowCore.applyUpdate({}, 'startRelinking');
+};
diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/utils.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/utils.ts
index f3d86f584..e3f7e7ed3 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/utils.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/linking/utils.ts
@@ -1,5 +1,6 @@
import { FlowCore } from '../../../flow-core';
-import type { Edge, FlowConfig, Port } from '../../../types';
+import type { ConnectionValidationContext, Edge, EdgeEnd, FlowConfig, Port } from '../../../types';
+import type { LinkingRelinkContext } from '../../../types/action-state.interface';
export const createFinalEdge = (config: FlowConfig, temporaryEdge: Edge, partialEdge: Partial): Edge => {
const data = {
@@ -10,6 +11,11 @@ export const createFinalEdge = (config: FlowConfig, temporaryEdge: Edge, partial
return config.linking.finalEdgeDataBuilder({
...data,
id: config.computeEdgeId(),
+ // A temporary edge marks a free end with an empty-string port; the model
+ // shape of a free end is an undefined port, whichever end is free and
+ // whichever drop (canvas, port, draw started from a position) commits it.
+ ...(data.source ? {} : { sourcePort: undefined }),
+ ...(data.target ? {} : { targetPort: undefined }),
});
};
@@ -17,6 +23,11 @@ export const isProperTargetPort = (targetPort: Port, sourceNodeId?: string, sour
if (targetPort.type === 'source') {
return false;
}
+ // A draw with no source at all (started from empty canvas, or relinking an
+ // edge whose other end is dangling) can target any target-capable port.
+ if (!sourceNodeId && !sourcePortId) {
+ return true;
+ }
if (sourceNodeId && targetPort.nodeId !== sourceNodeId) {
return true;
}
@@ -26,13 +37,35 @@ export const isProperTargetPort = (targetPort: Port, sourceNodeId?: string, sour
return false;
};
+/**
+ * Mirror of `isProperTargetPort` for the source end — used while relinking the
+ * source endpoint of an edge: the candidate port becomes the edge's source, so
+ * it must be source-capable and distinct from the fixed target end.
+ */
+export const isProperSourcePort = (sourcePort: Port, targetNodeId?: string, targetPortId?: string) => {
+ if (sourcePort.type === 'target') {
+ return false;
+ }
+ if (!targetNodeId && !targetPortId) {
+ return true;
+ }
+ if (targetNodeId && sourcePort.nodeId !== targetNodeId) {
+ return true;
+ }
+ if (targetPortId && sourcePort.id !== targetPortId) {
+ return true;
+ }
+ return false;
+};
+
export const validateConnection = (
core: FlowCore,
sourceNodeId?: string,
sourcePortId?: string,
targetNodeId?: string,
targetPortId?: string,
- isFinishLinking?: boolean
+ isFinishLinking?: boolean,
+ context?: ConnectionValidationContext
) => {
const sourceNode = sourceNodeId ? core.getNodeById(sourceNodeId) : null;
const targetNode = targetNodeId ? core.getNodeById(targetNodeId) : null;
@@ -43,22 +76,88 @@ export const validateConnection = (
? (targetNode?.measuredPorts?.find((port) => port.id === targetPortId) ?? null)
: null;
- // Dragging temporary edge case without snapping to target port
- if (!isFinishLinking && sourcePort && !targetPort) {
+ // A preview pass has nothing to validate while the dragged end has not
+ // snapped to a port; a source-end relink drags the source, so the roles of
+ // the fixed and the dragged port are swapped.
+ const draggedEnd: EdgeEnd = context?.reason === 'relink' ? (context.end ?? 'target') : 'target';
+ const draggedEndUnsnapped = draggedEnd === 'target' ? sourcePort && !targetPort : targetPort && !sourcePort;
+ if (!isFinishLinking && draggedEndUnsnapped) {
return true;
}
- return core.config.linking.validateConnection(sourceNode, sourcePort, targetNode, targetPort);
+ return core.config.linking.validateConnection(
+ sourceNode,
+ sourcePort,
+ targetNode,
+ targetPort,
+ context ?? { reason: 'draw' }
+ );
};
+/**
+ * Fields of an edge carried into its relink preview (the temporary edge shown
+ * while an endpoint is dragged) so the preview looks like the original edge.
+ * The arrowhead keys are present explicitly (possibly undefined) so
+ * `createTemporaryEdge` doesn't inject its default arrowhead into the preview
+ * of an arrowless edge.
+ */
+export const relinkPreviewBase = (edge: Edge): Partial => ({
+ type: edge.type,
+ data: edge.data,
+ routing: edge.routing,
+ sourceArrowhead: edge.sourceArrowhead,
+ targetArrowhead: edge.targetArrowhead,
+ // Labels are carried over so they stay visible while the endpoint is
+ // dragged; the routing middleware repositions them along the preview path
+ // on every pass.
+ measuredLabels: edge.measuredLabels,
+});
+
export const createTemporaryEdge = (config: FlowConfig, partialEdge: Partial): Edge => {
+ // The default arrowhead applies only when the caller doesn't mention the
+ // property at all — a relink preview passes the original edge's arrowheads
+ // through, including an explicit undefined for "no arrowhead".
return config.linking.temporaryEdgeDataBuilder({
id: 'TEMPORARY_EDGE',
data: {},
temporary: true,
+ targetArrowhead: 'ng-diagram-arrow',
...partialEdge,
source: partialEdge.source || '',
target: partialEdge.target || '',
- targetArrowhead: partialEdge.targetArrowhead || 'ng-diagram-arrow',
});
};
+
+/**
+ * Structural checks for connecting an edge's `end` to `nodeId`/`portId`:
+ * the node must exist and be visible; the port (when given) must exist in
+ * `measuredPorts`, point the right direction for the end, and not be
+ * template-hidden. Shared by the relink drop commit and `attachEdge` so both
+ * accept exactly the same targets.
+ */
+export const isValidEndpointTarget = (core: FlowCore, end: EdgeEnd, nodeId: string, portId?: string): boolean => {
+ const node = core.getNodeById(nodeId);
+ if (!node || node.computedHidden) {
+ return false;
+ }
+ if (portId) {
+ const port = node.measuredPorts?.find((candidate) => candidate.id === portId);
+ const wrongDirection = end === 'target' ? port?.type === 'source' : port?.type === 'target';
+ if (!port || wrongDirection || core.templateVisibilityRegistry?.isPortHidden(nodeId, portId)) {
+ return false;
+ }
+ }
+ return true;
+};
+
+/**
+ * Builds the validation context for a gesture: `reason: 'relink'` with the
+ * live edge while an endpoint is being relinked, `reason: 'draw'` otherwise.
+ */
+export const connectionContextForGesture = (
+ core: FlowCore,
+ relink: LinkingRelinkContext | undefined
+): ConnectionValidationContext =>
+ relink
+ ? { reason: 'relink', edge: core.getEdgeById(relink.edgeId) ?? relink.originalEdge, end: relink.end }
+ : { reason: 'draw' };
diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/event-manager/event-types.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/event-manager/event-types.ts
index c5c17802b..e8d97d1e1 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/core/src/event-manager/event-types.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/event-manager/event-types.ts
@@ -1,4 +1,4 @@
-import type { Edge, GroupNode, Node, Point, Size, Viewport } from '../types';
+import type { Edge, EdgeEnd, GroupNode, Node, Point, Size, Viewport } from '../types';
/**
* Map of all available diagram events and their payload types
@@ -76,6 +76,23 @@ export interface DiagramEventMap {
* @since 1.2.0
*/
edgeDrawEnded: EdgeDrawEndedEvent;
+ /**
+ * Event emitted when the user starts dragging an endpoint of an existing
+ * edge (the relinking gesture).
+ *
+ * @since 1.4.0
+ */
+ edgeRelinkStarted: EdgeRelinkStartedEvent;
+ /**
+ * 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
+ */
+ edgeRelinkEnded: EdgeRelinkEndedEvent;
/**
* Event emitted when clipboard content is pasted into the diagram.
*
@@ -301,8 +318,12 @@ export type GestureCancelReason = 'cancelled';
* @category Types/Events
*/
export interface EdgeDrawEndedEvent {
- /** The source node from which the edge was drawn */
- source: Node;
+ /**
+ * The source node from which the edge was drawn.
+ * Undefined for draws started from empty canvas
+ * (see {@link NgDiagramService.startLinkingFromPosition}).
+ */
+ source?: Node;
/** Source port identifier if connected to a specific port */
sourcePort?: string;
/** The position where the pointer was released */
@@ -319,6 +340,76 @@ export interface EdgeDrawEndedEvent {
reason?: EdgeDrawCancelReason;
}
+/**
+ * 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,
+ * {@link NgDiagramService.cancelActiveInteraction}, or another gesture took
+ * over the pointer), or the endpoint was dropped back on its original node
+ * and port, which changes nothing
+ *
+ * @public
+ * @since 1.4.0
+ * @category Types/Events
+ */
+export type EdgeRelinkCancelReason = 'noTarget' | 'invalidConnection' | 'cancelled';
+
+/**
+ * Event payload emitted when the user starts dragging an endpoint of an
+ * existing edge.
+ *
+ * @public
+ * @since 1.4.0
+ * @category Types/Events
+ */
+export interface EdgeRelinkStartedEvent {
+ /** The edge being relinked (snapshot at gesture start). */
+ edge: Edge;
+ /** Which endpoint is being dragged. */
+ end: EdgeEnd;
+}
+
+/**
+ * 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.
+ *
+ * @public
+ * @since 1.4.0
+ * @category Types/Events
+ */
+export interface EdgeRelinkEndedEvent {
+ /** The edge after the relink (unchanged snapshot when `success` is false). */
+ edge: Edge;
+ /** Which endpoint was dragged. */
+ end: EdgeEnd;
+ /** The node the endpoint was connected to before the relink, if any. */
+ previousNode?: Node;
+ /** The port the endpoint was connected to before the relink, if any. */
+ previousPort?: string;
+ /** The anchor position of the endpoint before the relink, present only when the endpoint was free (dangling). */
+ previousPosition?: Point;
+ /** The position where the pointer was released. */
+ dropPosition: Point;
+ /** Whether the edge was changed (reconnected or left dangling). */
+ success: boolean;
+ /** The node the endpoint was reconnected to (only present on reconnect). */
+ target?: Node;
+ /** The port the endpoint was reconnected to (only present on reconnect). */
+ targetPort?: string;
+ /** The reason the relink was reverted (only present on failure). */
+ reason?: EdgeRelinkCancelReason;
+}
+
/**
* Event payload emitted when clipboard content is pasted into the diagram.
*
@@ -385,6 +476,15 @@ export interface SelectionRemovedEvent {
deletedNodes: Node[];
/** Edges that were deleted from the diagram */
deletedEdges: Edge[];
+ /**
+ * 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
+ */
+ detachedEdges: Edge[];
}
/**
diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/event-manager/index.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/event-manager/index.ts
index f0ec0fc8b..e1760038b 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/core/src/event-manager/index.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/event-manager/index.ts
@@ -6,6 +6,9 @@ export type {
EdgeDrawCancelReason,
EdgeDrawEndedEvent,
EdgeDrawnEvent,
+ EdgeRelinkCancelReason,
+ EdgeRelinkEndedEvent,
+ EdgeRelinkStartedEvent,
EventListener,
GestureCancelReason,
GroupMembershipChangedEvent,
diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-config/default-flow-config.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-config/default-flow-config.ts
index 93ee5b557..03f8e7477 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-config/default-flow-config.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/flow-config/default-flow-config.ts
@@ -4,6 +4,7 @@ import type { Edge } from '../types/edge.interface';
import type {
BackgroundConfig,
BoxSelectionConfig,
+ DanglingEdgesConfig,
EdgeRoutingConfig,
FlowConfig,
GroupingConfig,
@@ -36,6 +37,7 @@ const defaultLinkingConfig: LinkingConfig = {
edgePanningForce: 10,
edgePanningThreshold: 30,
selectNodeOnPortPress: true,
+ defaultRelinkable: false,
validateConnection: (): boolean => {
// Allow connection by default
return true;
@@ -52,6 +54,11 @@ const defaultLinkingConfig: LinkingConfig = {
},
};
+const defaultDanglingEdgesConfig: DanglingEdgesConfig = {
+ enabled: false,
+ detachOnNodeDelete: false,
+};
+
const defaultGroupingConfig: GroupingConfig = {
canGroup: (): boolean => {
// Allow grouping by default
@@ -155,6 +162,7 @@ export const createFlowConfig = (config: DeepPartial, flowCore: Flow
computeEdgeId: () => flowCore.environment.generateId(),
resize: defaultResizeConfig,
linking: defaultLinkingConfig,
+ danglingEdges: defaultDanglingEdgesConfig,
grouping: defaultGroupingConfig,
zoom: defaultZoomConfig,
background: defaultBackgroundConfig,
diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/box-selection/box-selection.handler.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/box-selection/box-selection.handler.test.ts
index fa102bb0c..e726b9d04 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/box-selection/box-selection.handler.test.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/box-selection/box-selection.handler.test.ts
@@ -413,6 +413,73 @@ describe('BoxSelectionEventHandler', () => {
});
});
+ describe('dangling edges', () => {
+ it('should select a dual dangling edge whose free anchors are inside the box even with zero nodes in it', () => {
+ const dualDanglingEdge = {
+ ...mockEdge,
+ id: 'dualDangling',
+ source: '',
+ sourcePosition: { x: 100, y: 100 },
+ target: '',
+ targetPosition: { x: 300, y: 200 },
+ };
+ mockModel.getEdges.mockReturnValue([dualDanglingEdge]);
+ mockSpatialHash.queryIds.mockReturnValue(new Set());
+
+ instance.handle(getSampleBoxSelectionEvent({ phase: 'start', lastInputPoint: { x: 50, y: 50 } }));
+ instance.handle(getSampleBoxSelectionEvent({ phase: 'end', lastInputPoint: { x: 450, y: 450 } }));
+
+ expect(mockCommandHandler.emit).toHaveBeenCalledWith('select', {
+ nodeIds: [],
+ edgeIds: ['dualDangling'],
+ multiSelection: false,
+ });
+ });
+
+ it('should not select a single-dangling edge whose free anchor is outside the box', () => {
+ const danglingEdge = {
+ ...mockEdge,
+ id: 'dangling',
+ source: 'node1',
+ target: '',
+ targetPosition: { x: 900, y: 900 },
+ };
+ mockModel.getEdges.mockReturnValue([danglingEdge]);
+ // The connected node IS inside the box — the free anchor is not.
+ mockSpatialHash.queryIds.mockReturnValue(new Set(['node1']));
+
+ instance.handle(getSampleBoxSelectionEvent({ phase: 'start', lastInputPoint: { x: 50, y: 50 } }));
+ instance.handle(getSampleBoxSelectionEvent({ phase: 'end', lastInputPoint: { x: 450, y: 450 } }));
+
+ expect(mockCommandHandler.emit).toHaveBeenCalledWith('select', {
+ nodeIds: ['node1'],
+ edgeIds: [],
+ multiSelection: false,
+ });
+ });
+
+ it('should select a single-dangling edge when both the node and the free anchor are inside the box', () => {
+ const danglingEdge = {
+ ...mockEdge,
+ id: 'dangling',
+ source: 'node1',
+ target: '',
+ targetPosition: { x: 400, y: 400 },
+ };
+ mockModel.getEdges.mockReturnValue([danglingEdge]);
+ mockSpatialHash.queryIds.mockReturnValue(new Set(['node1']));
+
+ instance.handle(getSampleBoxSelectionEvent({ phase: 'start', lastInputPoint: { x: 50, y: 50 } }));
+ instance.handle(getSampleBoxSelectionEvent({ phase: 'end', lastInputPoint: { x: 450, y: 450 } }));
+
+ expect(mockCommandHandler.emit).toHaveBeenCalledWith('select', {
+ nodeIds: ['node1'],
+ edgeIds: ['dangling'],
+ multiSelection: false,
+ });
+ });
+ });
+
describe('partialInclusion setting', () => {
it('should respect partialInclusion: true', () => {
mockFlowCore.config.boxSelection.partialInclusion = true;
diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/box-selection/box-selection.handler.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/box-selection/box-selection.handler.ts
index 575341f35..409f5c446 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/box-selection/box-selection.handler.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/box-selection/box-selection.handler.ts
@@ -64,10 +64,26 @@ export class BoxSelectionEventHandler extends EventHandler {
getNodesInRect(this.flow, rect, this.flow.config.boxSelection.partialInclusion).map((node) => node.id)
);
+ // An edge is inside the box when each endpoint is: a node inside the box,
+ // or a free (dangling) endpoint whose anchor position lies inside the box.
+ const isPointInRect = (point: Point | undefined): boolean =>
+ !!point &&
+ point.x >= rect.x &&
+ point.x <= rect.x + rect.width &&
+ point.y >= rect.y &&
+ point.y <= rect.y + rect.height;
+ const isEndpointInBox = (nodeId: string, position: Point | undefined): boolean =>
+ nodeId ? nodeIds.has(nodeId) : isPointInRect(position);
+
// Endpoint containment comes from the spatial hash (hidden nodes already
// excluded), but an edge can be hidden on its own — filter it here.
const edgesBetweenIds = edges
- .filter((edge) => !edge.computedHidden && nodeIds.has(edge.source) && nodeIds.has(edge.target))
+ .filter(
+ (edge) =>
+ !edge.computedHidden &&
+ isEndpointInBox(edge.source, edge.sourcePosition) &&
+ isEndpointInBox(edge.target, edge.targetPosition)
+ )
.map((edge) => edge.id);
this.flow.commandHandler.emit('select', {
diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/linking/linking.handler.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/linking/linking.handler.ts
index eb0f2f434..bc941fd76 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/linking/linking.handler.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/linking/linking.handler.ts
@@ -30,6 +30,14 @@ export class LinkingEventHandler extends EventHandler {
throw new Error(LINKING_MISSING_TARGET_ERROR(event));
}
+ // A draw or relink already owns the linking state — clobbering it
+ // would strand its gesture (hidden edge, unbalanced started/ended
+ // events, orphaned document listeners).
+ if (this.flow.actionStateManager.isLinking()) {
+ console.warn('[ngDiagram] startLinking ignored: another linking or relinking gesture is in progress.');
+ break;
+ }
+
this.flow.actionStateManager.linking = createLinkingState({
sourceNodeId,
sourcePortId: event.portId,
diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/relinking/index.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/relinking/index.ts
new file mode 100644
index 000000000..566108690
--- /dev/null
+++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/relinking/index.ts
@@ -0,0 +1 @@
+export * from './relinking.event';
diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/relinking/relinking.event.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/relinking/relinking.event.ts
new file mode 100644
index 000000000..f2b2bdaff
--- /dev/null
+++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/relinking/relinking.event.ts
@@ -0,0 +1,13 @@
+import type { EdgeEnd } from '../../../types/edge.interface';
+import type { Point } from '../../../types/utils';
+import type { BasePointerInputEvent, InputEventPhase } from '../../input-events.interface';
+
+export interface RelinkingInputEvent extends BasePointerInputEvent {
+ name: 'relinking';
+ phase: InputEventPhase;
+ /** Which endpoint of the edge is being dragged. */
+ end: EdgeEnd;
+ panningForce: Point | null;
+ /** Set on an `end` emitted because another gesture claimed the pointer — the relink is cancelled, not finished. */
+ takenOver?: boolean;
+}
diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/relinking/relinking.handler.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/relinking/relinking.handler.ts
new file mode 100644
index 000000000..17007a23d
--- /dev/null
+++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/handlers/relinking/relinking.handler.ts
@@ -0,0 +1,78 @@
+import type { InternalLinkingActionState } from '../../../types/action-state.interface';
+import { EventHandler } from '../event-handler';
+import { RelinkingInputEvent } from './relinking.event';
+
+/** @internal */
+export const RELINKING_MISSING_TARGET_ERROR = (event: RelinkingInputEvent) =>
+ `[ngDiagram] Relinking event missing target edge.
+
+Event details:
+ • Phase: ${event.phase}
+ • End: ${event.end}
+ • Target type: ${event.targetType}
+ • Pointer position: (${event.lastInputPoint.x}, ${event.lastInputPoint.y})
+
+This indicates a programming error. Relinking start events must have a target edge.
+`;
+
+export class RelinkingEventHandler extends EventHandler {
+ handle(event: RelinkingInputEvent): void {
+ if (this.flow.isCancellingInteraction()) {
+ return;
+ }
+
+ const relink = () => (this.flow.actionStateManager.linking as InternalLinkingActionState | undefined)?.relink;
+
+ switch (event.phase) {
+ case 'start': {
+ const edgeId = event.target?.id;
+ if (!edgeId) {
+ throw new Error(RELINKING_MISSING_TARGET_ERROR(event));
+ }
+
+ this.flow.commandHandler.emit('startRelinking', {
+ edgeId,
+ end: event.end,
+ });
+
+ break;
+ }
+ case 'continue': {
+ if (!relink()) break;
+
+ const flowPosition = this.flow.clientToFlowPosition(event.lastInputPoint);
+
+ if (this.flow.config.viewportPanningEnabled && event.panningForce) {
+ this.flow.commandHandler.emit('moveViewportBy', { x: event.panningForce.x, y: event.panningForce.y });
+ }
+
+ this.flow.commandHandler.emit('moveTemporaryEdge', {
+ position: flowPosition,
+ });
+
+ break;
+ }
+ case 'end': {
+ if (!relink()) break;
+
+ // A taken-over end carries another pointer's coordinates — abort the
+ // relink instead of hit-testing them as the drop target.
+ if (event.takenOver) {
+ this.flow.commandHandler.emit('cancelLinking');
+ break;
+ }
+
+ const flowPosition = this.flow.clientToFlowPosition(event.lastInputPoint);
+
+ this.flow.commandHandler.emit('finishRelinking', { position: flowPosition });
+
+ break;
+ }
+ }
+ }
+
+ // No cancel() override: the interaction coordinator's `linking` entry covers
+ // relinks too — `isLinking()` is true during a relink and `cancelLinking`
+ // branches on `linking.relink`, so Escape reaches the right teardown through
+ // the LinkingEventHandler.
+}
diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/input-events.interface.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/input-events.interface.ts
index 962d8cc75..f6a6c5081 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/input-events.interface.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/input-events.interface.ts
@@ -17,6 +17,7 @@ export type InputEventName =
| 'resize'
| 'zoom'
| 'linking'
+ | 'relinking'
| 'rotate'
| 'paletteDrop'
| 'boxSelection'
diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/input-events.router.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/input-events.router.ts
index 345d65167..fb7765e42 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/input-events.router.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/input-events/input-events.router.ts
@@ -8,6 +8,7 @@ import { EventHandler } from './handlers/event-handler';
import { KeyboardMoveSelectionEventHandler } from './handlers/keyboard-move-selection/keyboard-move-selection.handler';
import { KeyboardPanningEventHandler } from './handlers/keyboard-panning/keyboard-panning.handler';
import { LinkingEventHandler } from './handlers/linking/linking.handler';
+import { RelinkingEventHandler } from './handlers/relinking/relinking.handler';
import { PaletteDropEventHandler } from './handlers/palette-drop/palette-drop.handler';
import { panningHandlerFactory } from './handlers/panning/panning-handler-factory';
import { PasteEventHandler } from './handlers/paste/paste.handler';
@@ -53,6 +54,7 @@ export abstract class InputEventsRouter {
this.register('deleteSelection', new DeleteSelectionEventHandler(flow));
this.register('zoom', new ZoomingEventHandler(flow));
this.register('linking', new LinkingEventHandler(flow));
+ this.register('relinking', new RelinkingEventHandler(flow));
this.register('rotate', new RotateEventHandler(flow));
this.register('paletteDrop', new PaletteDropEventHandler(flow));
this.register('cut', new CutEventHandler(flow));
diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/interaction-coordinator/interaction-coordinator.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/interaction-coordinator/interaction-coordinator.ts
index 9ff0ca44e..3e7970cd1 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/core/src/interaction-coordinator/interaction-coordinator.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/interaction-coordinator/interaction-coordinator.ts
@@ -19,6 +19,8 @@ export class InteractionCoordinator {
* adding one entry here.
*/
private readonly cancellableGestures: readonly { event: InputEventName; isActive: () => boolean }[] = [
+ // The `linking` entry covers relinking too: a relink keeps `isLinking()`
+ // true and `cancelLinking` branches on `linking.relink`.
{ event: 'linking', isActive: () => this.flowCore.actionStateManager.isLinking() },
{ event: 'pointerMoveSelection', isActive: () => this.flowCore.actionStateManager.isDragging() },
{ event: 'resize', isActive: () => this.flowCore.actionStateManager.isResizing() },
diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/__tests__/edges-routing-helpers.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/__tests__/edges-routing-helpers.test.ts
index de1f9a374..2dd9aaa8b 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/__tests__/edges-routing-helpers.test.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/__tests__/edges-routing-helpers.test.ts
@@ -806,6 +806,48 @@ describe('Edge Routing Helper Functions', () => {
});
});
+ it('should reposition the labels of a relink preview along the new points', () => {
+ const temporaryEdge: Edge = {
+ ...mockEdge,
+ id: 'temp-edge',
+ temporary: true,
+ routing: 'polyline',
+ measuredLabels: [{ id: 'label-1', positionOnEdge: 0.5, position: { x: 999, y: 999 } }],
+ };
+ mockRoutingManager.computePointOnPath = vi.fn().mockReturnValue({ x: 50, y: 50 });
+
+ const result = createUpdatedTemporaryEdge(
+ temporaryEdge,
+ new Map(),
+ mockRoutingManager as EdgeRoutingManager,
+ 1000
+ );
+
+ expect(mockRoutingManager.computePointOnPath).toHaveBeenCalledWith(
+ 'polyline',
+ [
+ { x: 10, y: 10 },
+ { x: 50, y: 50 },
+ { x: 90, y: 90 },
+ ],
+ 0.5
+ );
+ expect(result.measuredLabels).toEqual([{ id: 'label-1', positionOnEdge: 0.5, position: { x: 50, y: 50 } }]);
+ });
+
+ it('should keep measuredLabels undefined for a preview without labels', () => {
+ const temporaryEdge: Edge = { ...mockEdge, id: 'temp-edge', temporary: true };
+
+ const result = createUpdatedTemporaryEdge(
+ temporaryEdge,
+ new Map(),
+ mockRoutingManager as EdgeRoutingManager,
+ 1000
+ );
+
+ expect(result.measuredLabels).toBeUndefined();
+ });
+
it('should preserve other edge properties', () => {
const temporaryEdge: Edge = {
...mockEdge,
diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/__tests__/get-source-target-positions.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/__tests__/get-source-target-positions.test.ts
index 4ca09ef33..e3f9d3e70 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/__tests__/get-source-target-positions.test.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/__tests__/get-source-target-positions.test.ts
@@ -63,24 +63,10 @@ vi.mock('../../../../utils', () => ({
}),
}));
-vi.mock('../../../../utils/compute-floating-edge-side', () => ({
- computeFloatingEndSide: vi.fn().mockImplementation((node, _portId, cursorPosition) => {
- // Simple mock that returns different sides based on cursor position relative to a fixed point
- if (!node) return 'left';
- const nodeCenter = {
- x: node.position.x + 50,
- y: node.position.y + 25,
- };
- const dx = cursorPosition.x - nodeCenter.x;
- const dy = cursorPosition.y - nodeCenter.y;
-
- if (Math.abs(dx) > Math.abs(dy)) {
- return dx > 0 ? 'left' : 'right';
- } else {
- return dy > 0 ? 'top' : 'bottom';
- }
- }),
-}));
+// The floating-side computation runs REAL geometry (angleBetweenPoints →
+// angleToSide) so the side assertions below pin the actual 45°-segment
+// behavior instead of a lookalike fake.
+vi.mock('../../../../utils/compute-floating-edge-side', async (importOriginal) => await importOriginal());
describe('getSourceTargetPositions', () => {
it('should return source and target positions for edge with nodes', () => {
@@ -330,8 +316,11 @@ describe('getSourceTargetPositions', () => {
const result = getSourceTargetPositions(edge, nodesMap);
- expect(result.source).toEqual({ x: 50, y: 50, side: 'right' });
- expect(result.target).toEqual({ x: 150, y: 150, side: 'left' });
+ // Real geometry: the 45° diagonal falls into the bottom segment
+ // (angleToSide treats [45, 135) as bottom), and the reverse angle (225°)
+ // into the top segment — each free end faces the other.
+ expect(result.source).toEqual({ x: 50, y: 50, side: 'bottom' });
+ expect(result.target).toEqual({ x: 150, y: 150, side: 'top' });
});
});
@@ -406,6 +395,58 @@ describe('getSourceTargetPositions', () => {
expect(result.source?.side).toBe('bottom'); // Dangling side computed dynamically, not the 'right' default
expect(result.target?.side).toBe('left'); // Target side comes from the port
});
+
+ it('should compute both sides facing each other for a committed dual dangling edge', () => {
+ const edge: Edge = {
+ ...mockEdge,
+ source: '',
+ target: '',
+ sourcePosition: { x: 0, y: 0 },
+ targetPosition: { x: 10, y: 200 }, // Mostly below the source
+ };
+
+ const result = getSourceTargetPositions(edge, new Map());
+
+ // Each side faces the other endpoint instead of the right/left defaults.
+ expect(result.source).toEqual({ x: 0, y: 0, side: 'bottom' });
+ expect(result.target).toEqual({ x: 10, y: 200, side: 'top' });
+ });
+
+ it('should compute a dynamic source side for a dangling source even without a target position', () => {
+ const edge: Edge = {
+ ...mockEdge,
+ source: '', // Dangling start
+ target: 'node-2', // Connected end without a stored position
+ targetPort: 'port-2',
+ targetPosition: undefined,
+ sourcePosition: { x: 250, y: -50 }, // Dangling position above the target
+ };
+
+ const nodesMap = new Map([
+ [
+ 'node-2',
+ {
+ ...mockNode,
+ id: 'node-2',
+ position: { x: 200, y: 0 },
+ measuredPorts: [
+ {
+ ...mockPort,
+ id: 'port-2',
+ side: 'left',
+ position: { x: 0, y: 45 },
+ size: { width: 10, height: 10 },
+ },
+ ],
+ },
+ ],
+ ]);
+
+ const result = getSourceTargetPositions(edge, nodesMap);
+
+ expect(result.source?.side).toBe('bottom'); // Dynamic, not the 'right' default
+ expect(result.target?.side).toBe('left'); // Target side comes from the port
+ });
});
});
diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/edges-routing.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/edges-routing.ts
index 4ed2def56..1251f8288 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/edges-routing.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/edges-routing.ts
@@ -200,6 +200,9 @@ export const createUpdatedTemporaryEdge = (
points,
sourcePosition: sourcePoint,
targetPosition: targetPoint,
+ // A relink preview carries the original edge's labels; repositioning them
+ // on every pass keeps them on the path that follows the pointer.
+ measuredLabels: updateLabelPositions(temporaryEdge, points ?? [], routingManager),
computedZIndex: zIndex,
};
};
@@ -231,11 +234,15 @@ export const edgesRoutingMiddleware: Middleware = {
? processEdgesForRouting(edges, nodesMap, edgeRoutingManager, helpers, modelActionTypes)
: [];
- // A temporary edge whose source became effectively hidden mid-gesture is
- // not re-routed — its geometry is stale and the render layer skips it.
- const isTemporarySourceHidden = temporaryEdge ? nodesMap.get(temporaryEdge.source)?.computedHidden : false;
+ // A temporary edge whose anchored end became effectively hidden mid-gesture
+ // is not re-routed — its geometry is stale and the render layer skips it.
+ // The anchored end is the source for draws and target-end relinks, and the
+ // target while a source endpoint is being relinked.
+ const anchoredEndNodeId =
+ actionStateManager.linking?.relink?.end === 'source' ? temporaryEdge?.target : temporaryEdge?.source;
+ const isAnchoredEndHidden = anchoredEndNodeId ? nodesMap.get(anchoredEndNodeId)?.computedHidden : false;
const newTemporaryEdge =
- temporaryEdge && !isTemporarySourceHidden
+ temporaryEdge && !isAnchoredEndHidden
? createUpdatedTemporaryEdge(temporaryEdge, nodesMap, edgeRoutingManager, temporaryEdgeZIndex)
: undefined;
diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/get-source-target-positions.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/get-source-target-positions.ts
index d1f200c41..df5a886a3 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/get-source-target-positions.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/edges-routing/get-source-target-positions.ts
@@ -1,6 +1,6 @@
import type { Edge, Node, Point, PortLocation, PortSide } from '../../../types';
import { getNodeBorderIntersection, getPortFlowPositionSide } from '../../../utils';
-import { computeFloatingEndSide } from '../../../utils/compute-floating-edge-side';
+import { computeFloatingEndSide, computeFloatingStartSide } from '../../../utils/compute-floating-edge-side';
/**
* Computes dynamic sides for floating edge ends — the live end of a temporary
@@ -13,15 +13,27 @@ const getFloatingEndSides = (
edge: Edge,
nodesMap: Map
): { sourceSide?: PortSide; targetSide?: PortSide } => {
- // Floating target (dangling end, or drawing from source)
+ // Both ends free (dual dangling edge, or a draw started from empty canvas):
+ // each side faces the other endpoint so the route runs between them instead
+ // of using the hardcoded right/left defaults.
+ if (!edge.source && !edge.target && edge.sourcePosition && edge.targetPosition) {
+ return {
+ sourceSide: computeFloatingStartSide(edge.sourcePosition, edge.targetPosition),
+ targetSide: computeFloatingStartSide(edge.targetPosition, edge.sourcePosition),
+ };
+ }
+
+ // Floating target (dangling end, a draw in progress, or a target-end relink
+ // preview — a draw always drags the target end)
if (!edge.target && edge.targetPosition) {
const startNode = nodesMap.get(edge.source);
const targetSide = computeFloatingEndSide(startNode, edge.sourcePort, edge.targetPosition);
return { targetSide };
}
- // Floating source (dangling start, or reverse drawing from target)
- if (!edge.source && edge.sourcePosition && edge.targetPosition) {
+ // Floating source (dangling start, or a source-end relink preview — the only
+ // gesture that moves the source end)
+ if (!edge.source && edge.sourcePosition) {
const endNode = nodesMap.get(edge.target);
const sourceSide = computeFloatingEndSide(endNode, edge.targetPort, edge.sourcePosition);
return { sourceSide };
diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/__tests__/edge-draw-ended.emitter.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/__tests__/edge-draw-ended.emitter.test.ts
index bf5ae8c11..c4d40945f 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/__tests__/edge-draw-ended.emitter.test.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/__tests__/edge-draw-ended.emitter.test.ts
@@ -122,6 +122,37 @@ describe('EdgeDrawEndedEmitter', () => {
});
});
+ it('should report a kept dangling edge as a successful draw with no target', () => {
+ helpers.anyEdgesAdded.mockReturnValue(true);
+
+ // A drop over empty canvas kept by the dangling-edges feature: the free
+ // end is normalized to target '' and targetPort undefined (never '').
+ const keptEdge: Edge = {
+ ...mockEdge,
+ id: 'kept-edge',
+ source: 'source-node',
+ sourcePort: 'port-1',
+ target: '',
+ targetPort: undefined,
+ targetPosition: dropPosition,
+ temporary: false,
+ };
+ context.edgesMap.set('kept-edge', keptEdge);
+
+ emitter.emit(context, eventManager);
+
+ expect(emitSpy).toHaveBeenCalledOnce();
+ expect(emitSpy).toHaveBeenCalledWith('edgeDrawEnded', {
+ source: sourceNode,
+ sourcePort: 'port-1',
+ dropPosition,
+ success: true,
+ edge: keptEdge,
+ target: undefined,
+ targetPort: undefined,
+ });
+ });
+
it('should skip existing edges and only emit for newly added edge', () => {
helpers.anyEdgesAdded.mockReturnValue(true);
@@ -209,6 +240,54 @@ describe('EdgeDrawEndedEmitter', () => {
expect(emitSpy.mock.calls[0][1].dropPosition).toEqual({ x: 0, y: 0 });
});
+ it('should not emit for a relink gesture sharing the linking state', () => {
+ helpers.anyEdgesAdded.mockReturnValue(true);
+ const originalEdge: Edge = { ...mockEdge, id: 'edge-1', source: 'source-node', target: 'target-node' };
+ mockActionStateManager.linking = {
+ ...baseLinking,
+ relink: { edgeId: 'edge-1', end: 'target', originalEdge },
+ };
+ context.edgesMap.set('new-edge', { ...mockEdge, id: 'new-edge', source: 'source-node', target: 'target-node' });
+
+ emitter.emit(context, eventManager);
+
+ // Relink gestures report through edgeRelinkEnded instead.
+ expect(emitSpy).not.toHaveBeenCalled();
+ });
+
+ it('should emit success with undefined source for a draw started from a position', () => {
+ helpers.anyEdgesAdded.mockReturnValue(true);
+ mockActionStateManager.linking = {
+ ...baseLinking,
+ sourceNodeId: '',
+ sourcePortId: '',
+ };
+
+ const danglingEdge: Edge = {
+ ...mockEdge,
+ id: 'dangling-edge',
+ source: '',
+ sourcePort: undefined,
+ sourcePosition: { x: 10, y: 20 },
+ target: 'target-node',
+ targetPort: 'port-2',
+ };
+ context.edgesMap.set('dangling-edge', danglingEdge);
+
+ emitter.emit(context, eventManager);
+
+ expect(emitSpy).toHaveBeenCalledOnce();
+ expect(emitSpy).toHaveBeenCalledWith('edgeDrawEnded', {
+ source: undefined,
+ sourcePort: undefined,
+ dropPosition,
+ success: true,
+ edge: danglingEdge,
+ target: targetNode,
+ targetPort: 'port-2',
+ });
+ });
+
it('should emit undefined sourcePort when sourcePortId is empty', () => {
helpers.anyEdgesAdded.mockReturnValue(false);
mockActionStateManager.linking = {
diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/__tests__/edge-relink.emitter.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/__tests__/edge-relink.emitter.test.ts
new file mode 100644
index 000000000..d10705266
--- /dev/null
+++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/__tests__/edge-relink.emitter.test.ts
@@ -0,0 +1,255 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import type { EventManager } from '../../../../../event-manager/event-manager';
+import { mockEdge, mockNode } from '../../../../../test-utils';
+import type { Edge, LinkingActionState, MiddlewareContext, Node } from '../../../../../types';
+import { EdgeRelinkEndedEmitter, EdgeRelinkStartedEmitter } from '../edge-relink.emitter';
+
+describe('EdgeRelink emitters', () => {
+ let eventManager: EventManager;
+ let emitSpy: ReturnType;
+ let mockActionStateManager: { linking: LinkingActionState | undefined };
+ let context: MiddlewareContext;
+
+ const previousNode: Node = { ...mockNode, id: 'node-b' };
+ const newNode: Node = { ...mockNode, id: 'node-c' };
+ const dropPosition = { x: 100, y: 200 };
+
+ const originalEdge: Edge = {
+ ...mockEdge,
+ id: 'edge-1',
+ source: 'node-a',
+ sourcePort: 'out',
+ target: 'node-b',
+ targetPort: 'in',
+ };
+
+ beforeEach(() => {
+ emitSpy = vi.fn();
+ eventManager = { deferredEmit: emitSpy } as unknown as EventManager;
+ mockActionStateManager = { linking: undefined };
+
+ context = {
+ modelActionTypes: ['finishRelinking'],
+ nodesMap: new Map(),
+ edgesMap: new Map(),
+ actionStateManager: mockActionStateManager,
+ } as unknown as MiddlewareContext;
+
+ context.nodesMap.set('node-a', { ...mockNode, id: 'node-a' });
+ context.nodesMap.set('node-b', previousNode);
+ context.nodesMap.set('node-c', newNode);
+ });
+
+ describe('EdgeRelinkStartedEmitter', () => {
+ let emitter: EdgeRelinkStartedEmitter;
+
+ beforeEach(() => {
+ emitter = new EdgeRelinkStartedEmitter();
+ context.modelActionTypes = ['startRelinking'];
+ mockActionStateManager.linking = {
+ sourceNodeId: 'node-a',
+ sourcePortId: 'out',
+ temporaryEdge: null,
+ relink: { edgeId: 'edge-1', end: 'target', originalEdge },
+ };
+ });
+
+ it('should emit edgeRelinkStarted with the original edge and dragged end', () => {
+ emitter.emit(context, eventManager);
+
+ expect(emitSpy).toHaveBeenCalledOnce();
+ expect(emitSpy).toHaveBeenCalledWith('edgeRelinkStarted', {
+ edge: originalEdge,
+ end: 'target',
+ });
+ });
+
+ it('should not emit when the action type is not startRelinking', () => {
+ context.modelActionTypes = ['startLinking'];
+
+ emitter.emit(context, eventManager);
+
+ expect(emitSpy).not.toHaveBeenCalled();
+ });
+
+ it('should not emit without a relink state', () => {
+ mockActionStateManager.linking = {
+ sourceNodeId: 'node-a',
+ sourcePortId: 'out',
+ temporaryEdge: null,
+ };
+
+ emitter.emit(context, eventManager);
+
+ expect(emitSpy).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('EdgeRelinkEndedEmitter', () => {
+ let emitter: EdgeRelinkEndedEmitter;
+
+ beforeEach(() => {
+ emitter = new EdgeRelinkEndedEmitter();
+ });
+
+ it('should not emit when the action type is not finishRelinking', () => {
+ context.modelActionTypes = ['finishLinking'];
+ mockActionStateManager.linking = {
+ sourceNodeId: 'node-a',
+ sourcePortId: 'out',
+ temporaryEdge: null,
+ relink: { edgeId: 'edge-1', end: 'target', originalEdge },
+ };
+
+ emitter.emit(context, eventManager);
+
+ expect(emitSpy).not.toHaveBeenCalled();
+ });
+
+ it('should not emit without a relink state', () => {
+ mockActionStateManager.linking = {
+ sourceNodeId: 'node-a',
+ sourcePortId: 'out',
+ temporaryEdge: null,
+ };
+
+ emitter.emit(context, eventManager);
+
+ expect(emitSpy).not.toHaveBeenCalled();
+ });
+
+ it('should emit a success event with the reconnected edge and resolved nodes', () => {
+ const reconnectedEdge: Edge = { ...originalEdge, target: 'node-c', targetPort: 'in-c' };
+ context.edgesMap.set('edge-1', reconnectedEdge);
+ mockActionStateManager.linking = {
+ sourceNodeId: 'node-a',
+ sourcePortId: 'out',
+ temporaryEdge: null,
+ dropPosition,
+ relink: { edgeId: 'edge-1', end: 'target', originalEdge },
+ };
+
+ emitter.emit(context, eventManager);
+
+ expect(emitSpy).toHaveBeenCalledOnce();
+ expect(emitSpy).toHaveBeenCalledWith('edgeRelinkEnded', {
+ edge: reconnectedEdge,
+ end: 'target',
+ previousNode,
+ previousPort: 'in',
+ previousPosition: undefined,
+ dropPosition,
+ success: true,
+ target: newNode,
+ targetPort: 'in-c',
+ reason: undefined,
+ });
+ });
+
+ it('should emit a failure event with the original edge and the cancel reason', () => {
+ // The failed relink never changed the model — the edge is still there.
+ context.edgesMap.set('edge-1', originalEdge);
+ mockActionStateManager.linking = {
+ sourceNodeId: 'node-a',
+ sourcePortId: 'out',
+ temporaryEdge: null,
+ dropPosition,
+ relinkCancelReason: 'invalidConnection',
+ relink: { edgeId: 'edge-1', end: 'target', originalEdge },
+ };
+
+ emitter.emit(context, eventManager);
+
+ expect(emitSpy).toHaveBeenCalledOnce();
+ expect(emitSpy).toHaveBeenCalledWith('edgeRelinkEnded', {
+ edge: originalEdge,
+ end: 'target',
+ previousNode,
+ previousPort: 'in',
+ previousPosition: undefined,
+ dropPosition,
+ success: false,
+ target: undefined,
+ targetPort: undefined,
+ reason: 'invalidConnection',
+ });
+ });
+
+ it('should include previousPosition only when the endpoint was previously dangling', () => {
+ const danglingOriginal: Edge = {
+ ...originalEdge,
+ target: '',
+ targetPort: undefined,
+ targetPosition: { x: 55, y: 66 },
+ };
+ const reconnectedEdge: Edge = { ...danglingOriginal, target: 'node-c', targetPort: 'in-c' };
+ context.edgesMap.set('edge-1', reconnectedEdge);
+ mockActionStateManager.linking = {
+ sourceNodeId: 'node-a',
+ sourcePortId: 'out',
+ temporaryEdge: null,
+ dropPosition,
+ relink: { edgeId: 'edge-1', end: 'target', originalEdge: danglingOriginal },
+ };
+
+ emitter.emit(context, eventManager);
+
+ expect(emitSpy).toHaveBeenCalledOnce();
+ const event = emitSpy.mock.calls[0][1];
+ expect(event.previousNode).toBeUndefined();
+ expect(event.previousPort).toBeUndefined();
+ expect(event.previousPosition).toEqual({ x: 55, y: 66 });
+ expect(event.success).toBe(true);
+ expect(event.target).toBe(newNode);
+ });
+
+ it('should fall back to the original edge when the reconnected edge is missing from the map', () => {
+ mockActionStateManager.linking = {
+ sourceNodeId: 'node-a',
+ sourcePortId: 'out',
+ temporaryEdge: null,
+ dropPosition,
+ relink: { edgeId: 'edge-1', end: 'target', originalEdge },
+ };
+
+ emitter.emit(context, eventManager);
+
+ expect(emitSpy.mock.calls[0][1].edge).toBe(originalEdge);
+ });
+
+ it('should default the drop position to {x: 0, y: 0}', () => {
+ context.edgesMap.set('edge-1', originalEdge);
+ mockActionStateManager.linking = {
+ sourceNodeId: 'node-a',
+ sourcePortId: 'out',
+ temporaryEdge: null,
+ relinkCancelReason: 'cancelled',
+ relink: { edgeId: 'edge-1', end: 'target', originalEdge },
+ };
+
+ emitter.emit(context, eventManager);
+
+ expect(emitSpy.mock.calls[0][1].dropPosition).toEqual({ x: 0, y: 0 });
+ });
+
+ it('should resolve previous data from the source end when relinking the source', () => {
+ const reconnectedEdge: Edge = { ...originalEdge, source: 'node-c', sourcePort: 'out-c' };
+ context.edgesMap.set('edge-1', reconnectedEdge);
+ mockActionStateManager.linking = {
+ sourceNodeId: 'node-a',
+ sourcePortId: 'out',
+ temporaryEdge: null,
+ dropPosition,
+ relink: { edgeId: 'edge-1', end: 'source', originalEdge },
+ };
+
+ emitter.emit(context, eventManager);
+
+ const event = emitSpy.mock.calls[0][1];
+ expect(event.previousNode).toBe(context.nodesMap.get('node-a'));
+ expect(event.previousPort).toBe('out');
+ expect(event.target).toBe(newNode);
+ expect(event.targetPort).toBe('out-c');
+ });
+ });
+});
diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/__tests__/selection-removed.emitter.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/__tests__/selection-removed.emitter.test.ts
index 06e51f371..cf06b2fa9 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/__tests__/selection-removed.emitter.test.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/__tests__/selection-removed.emitter.test.ts
@@ -21,6 +21,7 @@ describe('SelectionRemovedEmitter', () => {
context = {
modelActionType: 'deleteSelection',
modelActionTypes: ['deleteSelection'],
+ initialUpdate: {},
initialNodesMap: new Map(),
initialEdgesMap: new Map(),
nodesMap: new Map(),
@@ -66,6 +67,7 @@ describe('SelectionRemovedEmitter', () => {
const expectedEvent: SelectionRemovedEvent = {
deletedNodes: [deletedNode],
deletedEdges: [],
+ detachedEdges: [],
};
expect(emitSpy).toHaveBeenCalledOnce();
@@ -85,6 +87,7 @@ describe('SelectionRemovedEmitter', () => {
const expectedEvent: SelectionRemovedEvent = {
deletedNodes: [deletedNode1, deletedNode2],
deletedEdges: [],
+ detachedEdges: [],
};
expect(emitSpy).toHaveBeenCalledOnce();
@@ -104,6 +107,7 @@ describe('SelectionRemovedEmitter', () => {
const expectedEvent: SelectionRemovedEvent = {
deletedNodes: [deletedNode],
deletedEdges: [],
+ detachedEdges: [],
};
expect(emitSpy).toHaveBeenCalledOnce();
@@ -132,6 +136,7 @@ describe('SelectionRemovedEmitter', () => {
const expectedEvent: SelectionRemovedEvent = {
deletedNodes: [],
deletedEdges: [deletedEdge],
+ detachedEdges: [],
};
expect(emitSpy).toHaveBeenCalledOnce();
@@ -165,6 +170,7 @@ describe('SelectionRemovedEmitter', () => {
const expectedEvent: SelectionRemovedEvent = {
deletedNodes: [],
deletedEdges: [deletedEdge1, deletedEdge2],
+ detachedEdges: [],
};
expect(emitSpy).toHaveBeenCalledOnce();
@@ -198,6 +204,7 @@ describe('SelectionRemovedEmitter', () => {
const expectedEvent: SelectionRemovedEvent = {
deletedNodes: [],
deletedEdges: [deletedEdge],
+ detachedEdges: [],
};
expect(emitSpy).toHaveBeenCalledOnce();
@@ -230,6 +237,7 @@ describe('SelectionRemovedEmitter', () => {
const expectedEvent: SelectionRemovedEvent = {
deletedNodes: [deletedNode1, deletedNode2],
deletedEdges: [deletedEdge],
+ detachedEdges: [],
};
expect(emitSpy).toHaveBeenCalledOnce();
@@ -276,6 +284,7 @@ describe('SelectionRemovedEmitter', () => {
const expectedEvent: SelectionRemovedEvent = {
deletedNodes: [deletedNode1, deletedNode2],
deletedEdges: [deletedEdge1],
+ detachedEdges: [],
};
expect(emitSpy).toHaveBeenCalledOnce();
@@ -350,6 +359,7 @@ describe('SelectionRemovedEmitter', () => {
const expectedEvent: SelectionRemovedEvent = {
deletedNodes: [deletedNode],
deletedEdges: [deletedEdge],
+ detachedEdges: [],
};
expect(emitSpy).toHaveBeenCalledOnce();
@@ -359,6 +369,136 @@ describe('SelectionRemovedEmitter', () => {
});
});
+ describe('detached edges', () => {
+ it('should report edges detached to dangling instead of deleted', () => {
+ const deletedNode: Node = { ...mockNode, id: 'node1', position: { x: 100, y: 100 } };
+ const initialEdge: Edge = {
+ id: 'edge1',
+ source: 'node1',
+ target: 'node2',
+ data: {},
+ };
+ const detachedEdge: Edge = {
+ ...initialEdge,
+ source: '',
+ sourcePort: undefined,
+ sourcePosition: { x: 100, y: 100 },
+ };
+
+ context.initialNodesMap.set('node1', deletedNode);
+ context.initialEdgesMap.set('edge1', initialEdge);
+ // The delete command itself issued the detach patch (built by
+ // partitionIncidentEdges) and the edge survived the pass — demoted to
+ // dangling rather than deleted.
+ context.initialUpdate.edgesToUpdate = [
+ { id: 'edge1', source: '', sourcePort: undefined, sourcePosition: { x: 100, y: 100 } },
+ ];
+ context.edgesMap.set('edge1', detachedEdge);
+
+ emitter.emit(context, eventManager);
+
+ const expectedEvent: SelectionRemovedEvent = {
+ deletedNodes: [deletedNode],
+ deletedEdges: [],
+ detachedEdges: [detachedEdge],
+ };
+
+ expect(emitSpy).toHaveBeenCalledOnce();
+ expect(emitSpy).toHaveBeenCalledWith('selectionRemoved', expectedEvent);
+ });
+
+ it('should emit when only detachments happened', () => {
+ const initialEdge: Edge = {
+ id: 'edge1',
+ source: 'node1',
+ target: 'node2',
+ data: {},
+ };
+ const detachedEdge: Edge = {
+ ...initialEdge,
+ target: '',
+ targetPort: undefined,
+ targetPosition: { x: 200, y: 200 },
+ };
+
+ context.initialEdgesMap.set('edge1', initialEdge);
+ context.initialUpdate.edgesToUpdate = [
+ { id: 'edge1', target: '', targetPort: undefined, targetPosition: { x: 200, y: 200 } },
+ ];
+ context.edgesMap.set('edge1', detachedEdge);
+
+ emitter.emit(context, eventManager);
+
+ expect(emitSpy).toHaveBeenCalledOnce();
+ expect(emitSpy.mock.calls[0][1].detachedEdges).toEqual([detachedEdge]);
+ expect(emitSpy.mock.calls[0][1].deletedEdges).toEqual([]);
+ });
+
+ it('should not report an unchanged surviving edge as detached', () => {
+ const deletedNode: Node = { ...mockNode, id: 'node1', position: { x: 100, y: 100 } };
+ const survivingEdge: Edge = {
+ id: 'edge1',
+ source: 'node3',
+ target: 'node4',
+ data: {},
+ };
+
+ context.initialNodesMap.set('node1', deletedNode);
+ context.initialEdgesMap.set('edge1', survivingEdge);
+ context.edgesMap.set('edge1', survivingEdge);
+
+ emitter.emit(context, eventManager);
+
+ expect(emitSpy).toHaveBeenCalledOnce();
+ expect(emitSpy.mock.calls[0][1].detachedEdges).toEqual([]);
+ });
+
+ it('should not report an endpoint change the delete command did not issue as a detach', () => {
+ // A middleware rewrote the endpoint during the pass — the change is NOT
+ // part of the command's own initialUpdate, so it is not a detach.
+ const deletedNode: Node = { ...mockNode, id: 'node1', position: { x: 100, y: 100 } };
+ const initialEdge: Edge = {
+ id: 'edge1',
+ source: 'node3',
+ target: 'node4',
+ data: {},
+ };
+ const rewrittenEdge: Edge = { ...initialEdge, target: 'node5' };
+
+ context.initialNodesMap.set('node1', deletedNode);
+ context.initialEdgesMap.set('edge1', initialEdge);
+ context.edgesMap.set('edge1', rewrittenEdge);
+
+ emitter.emit(context, eventManager);
+
+ expect(emitSpy).toHaveBeenCalledOnce();
+ expect(emitSpy.mock.calls[0][1].detachedEdges).toEqual([]);
+ });
+
+ it('should not report an issued detach whose edge was removed during the pass', () => {
+ // The command issued a detach patch, but a middleware deleted the edge
+ // anyway — it must not be reported as a (surviving) detached edge.
+ const initialEdge: Edge = {
+ id: 'edge1',
+ source: 'node1',
+ target: 'node2',
+ data: {},
+ };
+
+ context.initialEdgesMap.set('edge1', initialEdge);
+ context.initialUpdate.edgesToUpdate = [
+ { id: 'edge1', target: '', targetPort: undefined, targetPosition: { x: 200, y: 200 } },
+ ];
+ // Edge absent from edgesMap — deleted rather than detached.
+
+ emitter.emit(context, eventManager);
+
+ expect(emitSpy).toHaveBeenCalledOnce();
+ expect(emitSpy.mock.calls[0][1].detachedEdges).toEqual([]);
+ expect(emitSpy.mock.calls[0][1].deletedEdges).toEqual([initialEdge]);
+ });
+ });
+
describe('empty deletion scenarios', () => {
it('should not emit event when nothing is deleted', () => {
const node: Node = { ...mockNode, id: 'node1', position: { x: 100, y: 100 } };
@@ -468,6 +608,7 @@ describe('SelectionRemovedEmitter', () => {
const expectedEvent: SelectionRemovedEvent = {
deletedNodes: [node1, node2],
deletedEdges: [edge],
+ detachedEdges: [],
};
expect(emitSpy).toHaveBeenCalledOnce();
@@ -487,6 +628,7 @@ describe('SelectionRemovedEmitter', () => {
const expectedEvent: SelectionRemovedEvent = {
deletedNodes: [node1, node2],
deletedEdges: [],
+ detachedEdges: [],
};
expect(emitSpy).toHaveBeenCalledOnce();
diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/edge-draw-ended.emitter.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/edge-draw-ended.emitter.ts
index 89bb89798..caf1e89c8 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/edge-draw-ended.emitter.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/edge-draw-ended.emitter.ts
@@ -16,8 +16,17 @@ export class EdgeDrawEndedEmitter implements EventEmitter {
return;
}
- const sourceNode = context.nodesMap.get(linking.sourceNodeId);
- if (!sourceNode) {
+ // Relink gestures share the linking action state but report through
+ // edgeRelinkEnded — see EdgeRelinkEndedEmitter.
+ if (linking.relink) {
+ return;
+ }
+
+ // Draws started from empty canvas (startLinkingFromPosition) legitimately
+ // have no source node. A draw that HAD a source node which has since
+ // vanished keeps the pre-existing behavior of not reporting at all.
+ const sourceNode = linking.sourceNodeId ? context.nodesMap.get(linking.sourceNodeId) : undefined;
+ if (linking.sourceNodeId && !sourceNode) {
return;
}
diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/edge-relink.emitter.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/edge-relink.emitter.ts
new file mode 100644
index 000000000..7a04d08c7
--- /dev/null
+++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/edge-relink.emitter.ts
@@ -0,0 +1,67 @@
+import type { EventManager } from '../../../../event-manager/event-manager';
+import type { EdgeRelinkEndedEvent, EdgeRelinkStartedEvent } from '../../../../event-manager/event-types';
+import type { MiddlewareContext } from '../../../../types';
+import type { EventEmitter } from './event-emitter.interface';
+
+export class EdgeRelinkStartedEmitter implements EventEmitter {
+ name = 'EdgeRelinkStartedEmitter';
+
+ emit(context: MiddlewareContext, eventManager: EventManager): void {
+ if (!context.modelActionTypes.includes('startRelinking')) {
+ return;
+ }
+
+ const relink = context.actionStateManager.linking?.relink;
+ if (!relink) {
+ return;
+ }
+
+ const event: EdgeRelinkStartedEvent = {
+ edge: relink.originalEdge,
+ end: relink.end,
+ };
+ eventManager.deferredEmit('edgeRelinkStarted', event);
+ }
+}
+
+export class EdgeRelinkEndedEmitter implements EventEmitter {
+ name = 'EdgeRelinkEndedEmitter';
+
+ emit(context: MiddlewareContext, eventManager: EventManager): void {
+ if (!context.modelActionTypes.includes('finishRelinking')) {
+ return;
+ }
+
+ const linking = context.actionStateManager.linking;
+ const relink = linking?.relink;
+ if (!linking || !relink) {
+ return;
+ }
+
+ const { originalEdge, end } = relink;
+ const success = !linking.relinkCancelReason;
+ const currentEdge = context.edgesMap.get(relink.edgeId);
+ const edge = success ? (currentEdge ?? originalEdge) : originalEdge;
+
+ const previousNodeId = end === 'source' ? originalEdge.source : originalEdge.target;
+ const previousPort = end === 'source' ? originalEdge.sourcePort : originalEdge.targetPort;
+ const previousPosition = end === 'source' ? originalEdge.sourcePosition : originalEdge.targetPosition;
+
+ const newNodeId = success ? (end === 'source' ? edge.source : edge.target) : '';
+
+ const event: EdgeRelinkEndedEvent = {
+ edge,
+ end,
+ previousNode: previousNodeId ? context.nodesMap.get(previousNodeId) : undefined,
+ previousPort: previousPort || undefined,
+ // The previous position only matters when the endpoint was dangling.
+ previousPosition: previousNodeId ? undefined : previousPosition,
+ dropPosition: linking.dropPosition ?? { x: 0, y: 0 },
+ success,
+ target: newNodeId ? context.nodesMap.get(newNodeId) : undefined,
+ targetPort: success ? (end === 'source' ? edge.sourcePort : edge.targetPort) || undefined : undefined,
+ reason: linking.relinkCancelReason,
+ };
+ eventManager.deferredEmit('edgeRelinkEnded', event);
+ }
+}
diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/index.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/index.ts
index 41714f75a..2be37eedc 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/index.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/index.ts
@@ -2,6 +2,7 @@ export { ClipboardPastedEmitter } from './clipboard-paste.emitter';
export { DiagramInitEmitter } from './diagram-init.emitter';
export { EdgeDrawEndedEmitter } from './edge-draw-ended.emitter';
export { EdgeDrawnEmitter } from './edge-drawn.emitter';
+export { EdgeRelinkEndedEmitter, EdgeRelinkStartedEmitter } from './edge-relink.emitter';
export { type EventEmitter } from './event-emitter.interface';
export { NodeDragEndedEmitter, NodeDragStartedEmitter } from './node-drag-lifecycle.emitter';
export { NodeResizeEndedEmitter, NodeResizeStartedEmitter } from './node-resize-lifecycle.emitter';
diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/selection-removed.emitter.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/selection-removed.emitter.ts
index 31a6d2377..ad552b823 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/selection-removed.emitter.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/emitters/selection-removed.emitter.ts
@@ -27,10 +27,22 @@ export class SelectionRemovedEmitter implements EventEmitter {
}
}
- if (nodesToDelete.length > 0 || edgesToDelete.length > 0) {
+ // Detached edges are exactly the edgesToUpdate the delete command itself
+ // issued (built by partitionIncidentEdges) — a middleware that happens to
+ // rewrite an endpoint during the pass must not be misreported as a detach.
+ const detachedEdges: Edge[] = [];
+ for (const update of context.initialUpdate.edgesToUpdate ?? []) {
+ const currentEdge = edgesMap.get(update.id);
+ if (currentEdge && ('source' in update || 'target' in update)) {
+ detachedEdges.push(currentEdge);
+ }
+ }
+
+ if (nodesToDelete.length > 0 || edgesToDelete.length > 0 || detachedEdges.length > 0) {
const event: SelectionRemovedEvent = {
deletedNodes: nodesToDelete,
deletedEdges: edgesToDelete,
+ detachedEdges,
};
eventManager.deferredEmit('selectionRemoved', event);
}
diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/event-emitter.middleware.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/event-emitter.middleware.ts
index 791e5fcb1..da49c13ae 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/event-emitter.middleware.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/event-emitter/event-emitter.middleware.ts
@@ -6,6 +6,8 @@ import {
DiagramInitEmitter,
EdgeDrawEndedEmitter,
EdgeDrawnEmitter,
+ EdgeRelinkEndedEmitter,
+ EdgeRelinkStartedEmitter,
EventEmitter,
GroupMembershipChangedEmitter,
NodeDragEndedEmitter,
@@ -53,6 +55,8 @@ export const createEventEmitterMiddleware = (
new ViewportChangedEmitter(),
new EdgeDrawnEmitter(),
new EdgeDrawEndedEmitter(),
+ new EdgeRelinkStartedEmitter(),
+ new EdgeRelinkEndedEmitter(),
new ClipboardPastedEmitter(),
new NodeResizedEmitter(),
new PaletteItemDroppedEmitter(),
diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/z-index-assignment/__tests__/z-index-assignment.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/z-index-assignment/__tests__/z-index-assignment.test.ts
index 1d686a29f..78cd4dab9 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/z-index-assignment/__tests__/z-index-assignment.test.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/z-index-assignment/__tests__/z-index-assignment.test.ts
@@ -121,6 +121,37 @@ describe('zIndexMiddleware', () => {
});
});
+ describe('edge endpoint change (relink / detach)', () => {
+ it('should recompute the edge z-index from its new endpoints when source/target change', () => {
+ const lowNode = { ...mockNode, id: 'low-node', computedZIndex: 5 };
+ const highNode = { ...mockNode, id: 'high-node', computedZIndex: 9 };
+ // The edge was relinked onto high-node; its stored z still derives from
+ // the old endpoint pair.
+ const relinkedEdge = { ...mockEdge, id: 'edge1', source: 'low-node', target: 'high-node', computedZIndex: 5 };
+ nodesMap.set('low-node', lowNode);
+ nodesMap.set('high-node', highNode);
+ edgesMap.set('edge1', relinkedEdge);
+ context.state.nodes = [lowNode, highNode];
+ context.state.edges = [relinkedEdge];
+
+ (helpers.anyNodesAdded as ReturnType).mockReturnValue(false);
+ (helpers.checkIfAnyNodePropsChanged as ReturnType).mockReturnValue(false);
+ (helpers.checkIfAnyEdgePropsChanged as ReturnType).mockImplementation((props: string[]) =>
+ props.includes('source')
+ );
+ (helpers.getAffectedEdgeIds as ReturnType).mockImplementation((props: string[]) =>
+ props.includes('source') ? ['edge1'] : []
+ );
+
+ executeMiddleware();
+
+ expect(helpers.checkIfAnyEdgePropsChanged).toHaveBeenCalledWith(['source', 'target']);
+ expect(nextMock).toHaveBeenCalledWith({
+ edgesToUpdate: [{ id: 'edge1', computedZIndex: 9 }],
+ });
+ });
+ });
+
describe('node selection z-index assignment', () => {
it('should assign selectedZIndex to newly selected nodes', () => {
const node1 = { ...mockNode, id: 'node1', selected: true, computedZIndex: 0 };
diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/z-index-assignment/z-index-assignment.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/z-index-assignment/z-index-assignment.ts
index 97b2c13ca..cefa6b44e 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/z-index-assignment/z-index-assignment.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/middleware-manager/middlewares/z-index-assignment/z-index-assignment.ts
@@ -41,7 +41,8 @@ function collectDirtyEdgeIds(
initialConnectedEdgesMap: Map,
isEdgeAdded: boolean,
hasEdgeSelectionChange: boolean,
- hasEdgeZOrderChange: boolean
+ hasEdgeZOrderChange: boolean,
+ hasEdgeEndpointChange: boolean
): Set {
const dirtyEdgeIds = new Set();
@@ -54,6 +55,10 @@ function collectDirtyEdgeIds(
if (hasEdgeZOrderChange) {
for (const id of helpers.getAffectedEdgeIds(['zOrder'])) dirtyEdgeIds.add(id);
}
+ if (hasEdgeEndpointChange) {
+ // A relinked or detached edge derives its z from different nodes now.
+ for (const id of helpers.getAffectedEdgeIds(['source', 'target'])) dirtyEdgeIds.add(id);
+ }
if (recomputedNodesMap.size > 0) {
for (const nodeId of recomputedNodesMap.keys()) {
for (const edgeId of initialConnectedEdgesMap.get(nodeId) ?? []) {
@@ -268,6 +273,7 @@ export const zIndexMiddleware: Middleware<'z-index'> = {
const isEdgeAdded = checkIfIsEdgeAdded(modelActionTypes);
const hasEdgeSelectionChange = helpers.checkIfAnyEdgePropsChanged(['selected']);
const hasEdgeZOrderChange = helpers.checkIfAnyEdgePropsChanged(['zOrder']);
+ const hasEdgeEndpointChange = helpers.checkIfAnyEdgePropsChanged(['source', 'target']);
let childrenByGroupId: Map | undefined;
const getChildrenByGroupId = () => {
@@ -285,7 +291,13 @@ export const zIndexMiddleware: Middleware<'z-index'> = {
} else {
const dirtyNodeIds = collectDirtyNodeIds(helpers, nodesMap);
- if (dirtyNodeIds.size === 0 && !isEdgeAdded && !hasEdgeSelectionChange && !hasEdgeZOrderChange) {
+ if (
+ dirtyNodeIds.size === 0 &&
+ !isEdgeAdded &&
+ !hasEdgeSelectionChange &&
+ !hasEdgeZOrderChange &&
+ !hasEdgeEndpointChange
+ ) {
next();
return;
}
@@ -315,7 +327,8 @@ export const zIndexMiddleware: Middleware<'z-index'> = {
initialConnectedEdgesMap,
isEdgeAdded,
hasEdgeSelectionChange,
- hasEdgeZOrderChange
+ hasEdgeZOrderChange,
+ hasEdgeEndpointChange
);
const edgesToUpdate = computeEdgeUpdates(
diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/model-lookup/model-lookup.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/model-lookup/model-lookup.test.ts
index dc8a1aa85..d43ab1ae4 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/core/src/model-lookup/model-lookup.test.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/model-lookup/model-lookup.test.ts
@@ -123,6 +123,61 @@ describe('ModelLookup', () => {
});
});
+ describe('danglingEdges', () => {
+ const danglingEdge: Edge = {
+ id: 'dangling',
+ source: 'node1',
+ target: '',
+ targetPosition: { x: 10, y: 20 },
+ data: {},
+ };
+ const dualDanglingEdge: Edge = {
+ id: 'dual',
+ source: '',
+ sourcePosition: { x: 0, y: 0 },
+ target: '',
+ targetPosition: { x: 5, y: 5 },
+ data: {},
+ };
+
+ it('should reflect only the edges with at least one free endpoint', () => {
+ (mockFlowCore.getState as ReturnType).mockReturnValue({
+ nodes: mockNodes,
+ edges: [...mockEdges, danglingEdge, dualDanglingEdge],
+ metadata: {},
+ });
+
+ expect(modelLookup.danglingEdges.map((edge) => edge.id)).toEqual(['dangling', 'dual']);
+ });
+
+ it('should cache the list and recompute it only after desynchronize()', () => {
+ (mockFlowCore.getState as ReturnType).mockReturnValue({
+ nodes: mockNodes,
+ edges: [...mockEdges, danglingEdge],
+ metadata: {},
+ });
+
+ const first = modelLookup.danglingEdges;
+ // Same reference on a second read — no re-filter of all edges.
+ expect(modelLookup.danglingEdges).toBe(first);
+ expect((mockFlowCore.getState as ReturnType).mock.calls.length).toBe(1);
+
+ // The state changed, but without desynchronize the cache still stands.
+ (mockFlowCore.getState as ReturnType).mockReturnValue({
+ nodes: mockNodes,
+ edges: [...mockEdges, danglingEdge, dualDanglingEdge],
+ metadata: {},
+ });
+ expect(modelLookup.danglingEdges).toBe(first);
+
+ modelLookup.desynchronize();
+
+ const recomputed = modelLookup.danglingEdges;
+ expect(recomputed).not.toBe(first);
+ expect(recomputed.map((edge) => edge.id)).toEqual(['dangling', 'dual']);
+ });
+ });
+
describe('getNodeById', () => {
it('should return node by id', () => {
const node = modelLookup.getNodeById('node1');
diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/model-lookup/model-lookup.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/model-lookup/model-lookup.ts
index 3c424bd6d..4239a084a 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/core/src/model-lookup/model-lookup.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/model-lookup/model-lookup.ts
@@ -31,6 +31,7 @@ export class ModelLookup {
private _directChildrenMap = { map: new Map(), synchronized: false };
private _descendantsCache = { map: new Map(), synchronized: false }; // Cache for all descendants
private _connectedEdgesMap = { map: new Map(), synchronized: false };
+ private _danglingEdges = { edges: [] as Edge[], synchronized: false };
constructor(private readonly flowCore: FlowCore) {}
@@ -43,6 +44,7 @@ export class ModelLookup {
this._directChildrenMap.synchronized = false;
this._descendantsCache.synchronized = false;
this._connectedEdgesMap.synchronized = false;
+ this._danglingEdges.synchronized = false;
}
/**
@@ -101,6 +103,23 @@ export class ModelLookup {
return this._connectedEdgesMap.map;
}
+ /**
+ * Committed edges with at least one free (unconnected) endpoint. Rebuilt
+ * lazily after a state change with one pass over all edges (the same profile
+ * as {@link connectedEdgesMap}), then served from cache until the next
+ * change, so consumers iterate only the dangling subset.
+ * @returns Dangling edges array
+ */
+ get danglingEdges(): Edge[] {
+ if (!this._danglingEdges.synchronized) {
+ this._danglingEdges = {
+ edges: this.flowCore.getState().edges.filter((edge) => !edge.source || !edge.target),
+ synchronized: true,
+ };
+ }
+ return this._danglingEdges.edges;
+ }
+
/**
* Gets the descendants cache if it is not synchronized synchronize it
* @returns Descendants cache
diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/base-render-strategy.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/base-render-strategy.test.ts
index 9175ddf23..00d0f96df 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/base-render-strategy.test.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/base-render-strategy.test.ts
@@ -27,7 +27,7 @@ describe('BaseRenderStrategy temporary edge handling', () => {
let mockFlowCore: {
getState: ReturnType;
getNodeById: ReturnType;
- actionStateManager: { linking: { temporaryEdge: Edge } | null };
+ actionStateManager: { linking: { temporaryEdge: Edge; relink?: { edgeId: string } } | null };
renderer: { draw: ReturnType };
config: { debugMode: boolean };
};
@@ -64,4 +64,37 @@ describe('BaseRenderStrategy temporary edge handling', () => {
// from nothing.
expect(drawnEdges()).not.toContainEqual(temporaryEdge);
});
+
+ it('should hide the relinked edge and show the temporary edge during a relink', () => {
+ const relinkedEdge: Edge = { ...mockEdge, id: 'relinked-edge', source: 'source-node', target: 'other-node' };
+ const otherEdge: Edge = { ...mockEdge, id: 'other-edge', source: 'a', target: 'b' };
+ mockFlowCore.getState.mockReturnValue({
+ nodes: [sourceNode],
+ edges: [relinkedEdge, otherEdge],
+ metadata: { viewport: { x: 0, y: 0, scale: 1 } },
+ });
+ mockFlowCore.actionStateManager = { linking: { temporaryEdge, relink: { edgeId: 'relinked-edge' } } };
+
+ new TestRenderStrategy(mockFlowCore as unknown as FlowCore).init();
+
+ // The temporary edge represents the relinked edge for the duration of the
+ // gesture — rendering both would show the stale original underneath.
+ expect(drawnEdges()).not.toContainEqual(relinkedEdge);
+ expect(drawnEdges()).toContainEqual(otherEdge);
+ expect(drawnEdges()).toContainEqual(temporaryEdge);
+ });
+
+ it('should render the relinked edge again when no relink is in progress', () => {
+ const edge: Edge = { ...mockEdge, id: 'relinked-edge', source: 'source-node', target: 'other-node' };
+ mockFlowCore.getState.mockReturnValue({
+ nodes: [sourceNode],
+ edges: [edge],
+ metadata: { viewport: { x: 0, y: 0, scale: 1 } },
+ });
+ mockFlowCore.actionStateManager = { linking: null };
+
+ new TestRenderStrategy(mockFlowCore as unknown as FlowCore).init();
+
+ expect(drawnEdges()).toContainEqual(edge);
+ });
});
diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/base-render-strategy.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/base-render-strategy.ts
index 009a66920..aa47036b2 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/base-render-strategy.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/base-render-strategy.ts
@@ -18,15 +18,24 @@ export abstract class BaseRenderStrategy implements RenderStrategy {
protected render(): void {
const { nodes, edges, metadata } = this.flowCore.getState();
- const temporaryEdge = this.flowCore.actionStateManager.linking?.temporaryEdge;
+ const linking = this.flowCore.actionStateManager.linking;
+ const temporaryEdge = linking?.temporaryEdge;
- const { nodes: visibleNodes, edges: visibleEdges } = this.process(nodes, edges, metadata.viewport);
+ const { nodes: visibleNodes, edges: processedEdges } = this.process(nodes, edges, metadata.viewport);
+
+ // An edge whose endpoint is being relinked is represented by the temporary
+ // edge for the duration of the gesture — rendering both would show the
+ // stale original underneath the preview.
+ const relinkedEdgeId = linking?.relink?.edgeId;
+ const visibleEdges = relinkedEdgeId ? processedEdges.filter((edge) => edge.id !== relinkedEdgeId) : processedEdges;
// The temporary edge lives in action state, so hidden-computation never
- // stamps it — check its source here, or hiding the source mid-gesture
- // leaves a rubber band dangling from nothing.
+ // stamps it — check its anchored end here, or hiding that node mid-gesture
+ // leaves a rubber band dangling from nothing. During a target-end drag the
+ // anchored end is the source; during a source-end relink it is the target.
+ const anchoredEndNodeId = linking?.relink?.end === 'source' ? temporaryEdge?.target : temporaryEdge?.source;
const isTemporaryEdgeVisible =
- temporaryEdge?.temporary && !this.flowCore.getNodeById(temporaryEdge.source)?.computedHidden;
+ temporaryEdge?.temporary && !(anchoredEndNodeId && this.flowCore.getNodeById(anchoredEndNodeId)?.computedHidden);
const finalEdges = isTemporaryEdgeVisible ? [...visibleEdges, temporaryEdge] : visibleEdges;
this.performanceLogger.withPerformanceLogging(
diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/virtualized-render-strategy.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/virtualized-render-strategy.test.ts
index e1214a36a..e1b10db36 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/virtualized-render-strategy.test.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/virtualized-render-strategy.test.ts
@@ -38,6 +38,7 @@ describe('VirtualizedRenderStrategy', () => {
spatialHash,
modelLookup: {
nodesMap,
+ danglingEdges: [],
getNodeById: vi.fn((id: string) => nodesMap.get(id)),
getConnectedEdges: vi.fn().mockReturnValue([]),
getAllDescendantIds: vi.fn().mockReturnValue([]),
@@ -183,6 +184,99 @@ describe('VirtualizedRenderStrategy', () => {
});
});
+ describe('dangling edges', () => {
+ const setDanglingEdges = (danglingEdges: Edge[]) => {
+ (mockFlowCore.modelLookup as unknown as { danglingEdges: Edge[] }).danglingEdges = danglingEdges;
+ };
+
+ it('should render a dual dangling edge with anchors inside the viewport even with no visible nodes', () => {
+ const dualDangling: Edge = {
+ ...mockEdge,
+ id: 'dual',
+ source: '',
+ sourcePosition: { x: 100, y: 100 },
+ target: '',
+ targetPosition: { x: 300, y: 200 },
+ points: undefined,
+ };
+ setDanglingEdges([dualDangling]);
+ spatialHash.process([]);
+ updateNodesMap([]);
+
+ const result = strategy.process([], [dualDangling], defaultViewport);
+
+ expect(result.nodes).toEqual([]);
+ expect(result.edges.map((e) => e.id)).toContain('dual');
+ });
+
+ it('should render a single-dangling edge and add its off-screen connected node as external', () => {
+ const externalNode: Node = {
+ ...mockNode,
+ id: 'far-node',
+ position: { x: 2000, y: 2000 },
+ size: { width: 50, height: 50 },
+ };
+ const singleDangling: Edge = {
+ ...mockEdge,
+ id: 'single',
+ source: 'far-node',
+ target: '',
+ targetPosition: { x: 150, y: 150 },
+ points: undefined,
+ };
+ setDanglingEdges([singleDangling]);
+ spatialHash.process([externalNode]);
+ updateNodesMap([externalNode]);
+
+ const result = strategy.process([externalNode], [singleDangling], defaultViewport);
+
+ expect(result.edges.map((e) => e.id)).toContain('single');
+ // The connected endpoint renders like the external endpoint of a
+ // node-discovered edge, so the edge is not drawn into nothing.
+ expect(result.nodes.map((n) => n.id)).toContain('far-node');
+ });
+
+ it('should skip a computedHidden dangling edge', () => {
+ const hiddenDangling: Edge = {
+ ...mockEdge,
+ id: 'hidden-dangling',
+ source: '',
+ sourcePosition: { x: 100, y: 100 },
+ target: '',
+ targetPosition: { x: 300, y: 200 },
+ points: undefined,
+ computedHidden: true,
+ };
+ setDanglingEdges([hiddenDangling]);
+ spatialHash.process([]);
+ updateNodesMap([]);
+
+ const result = strategy.process([], [hiddenDangling], defaultViewport);
+
+ expect(result.edges).toEqual([]);
+ });
+
+ it('should not render a dangling edge whose anchors are far outside the viewport', () => {
+ const farDangling: Edge = {
+ ...mockEdge,
+ id: 'far-dangling',
+ source: '',
+ sourcePosition: { x: 5000, y: 5000 },
+ target: '',
+ targetPosition: { x: 5300, y: 5200 },
+ points: undefined,
+ };
+ setDanglingEdges([farDangling]);
+ spatialHash.process([]);
+ updateNodesMap([]);
+
+ const result = strategy.process([], [farDangling], defaultViewport);
+
+ expect(result.edges).toEqual([]);
+ expect(result.nodes).toEqual([]);
+ });
+ });
+
describe('group node handling', () => {
it('should include descendants when group node is visible', () => {
const groupNode = { ...mockGroupNode, id: 'g1', position: { x: 100, y: 100 }, size: { width: 200, height: 200 } };
diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/visible-elements-resolver.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/visible-elements-resolver.ts
index 24972dedb..debea96f9 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/visible-elements-resolver.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/render-strategy/virtualized/visible-elements-resolver.ts
@@ -1,6 +1,6 @@
import type { FlowCore } from '../../flow-core';
import type { Edge, Node, Rect } from '../../types';
-import { isGroup } from '../../utils';
+import { boundingRectOfPoints, doesRectsIntersect, isGroup } from '../../utils';
import type { RenderStrategyResult } from '../render-strategy.interface';
/**
@@ -13,11 +13,70 @@ export class VisibleElementsResolver {
resolve(viewportRect: Rect): RenderStrategyResult {
const primaryVisibleIds = this.getPrimaryVisibleIds(viewportRect);
const { edges, edgeIds, externalNodeIds } = this.collectVisibleEdges(primaryVisibleIds);
+ this.collectVisibleDanglingEdges(viewportRect, primaryVisibleIds, edges, edgeIds, externalNodeIds);
const { nodes, nodeIds } = this.buildNodeList(primaryVisibleIds, externalNodeIds);
return { nodes, edges, nodeIds, edgeIds };
}
+ /**
+ * Adds dangling edges whose visible geometry intersects the viewport.
+ * Edge discovery walks connected edges of visible nodes, which can never
+ * reach an edge through a free ('' ) endpoint — a dual dangling edge is
+ * unreachable that way, and a single-dangling edge disappears the moment its
+ * only node scrolls out even when its free endpoint is still on screen.
+ */
+ private collectVisibleDanglingEdges(
+ viewportRect: Rect,
+ primaryVisibleIds: Set,
+ edges: Edge[],
+ edgeIds: Set,
+ externalNodeIds: Set
+ ): void {
+ // The model lookup rebuilds its dangling-edge list lazily after a state
+ // change with one pass over all edges (like connectedEdgesMap) and serves
+ // it from cache until the next change; this loop iterates only that subset.
+ for (const edge of this.flowCore.modelLookup.danglingEdges) {
+ if (edgeIds.has(edge.id) || edge.computedHidden) {
+ continue;
+ }
+ if (!this.intersectsViewport(edge, viewportRect)) {
+ continue;
+ }
+
+ edges.push(edge);
+ edgeIds.add(edge.id);
+
+ // The connected endpoint (if any) may be off-screen — render it like the
+ // external endpoints of node-discovered edges.
+ const connectedNodeId = edge.source || edge.target;
+ if (connectedNodeId && !primaryVisibleIds.has(connectedNodeId)) {
+ externalNodeIds.add(connectedNodeId);
+ }
+ }
+ }
+
+ /**
+ * Whether the bounding box of the edge's routed points and free-endpoint
+ * anchors intersects the rect. Bounding-box overlap can render an edge whose
+ * path merely skirts the viewport (a benign false positive) but never drops
+ * one that crosses it.
+ */
+ private intersectsViewport(edge: Edge, rect: Rect): boolean {
+ const anchors = [
+ ...(edge.points ?? []),
+ ...(!edge.source && edge.sourcePosition ? [edge.sourcePosition] : []),
+ ...(!edge.target && edge.targetPosition ? [edge.targetPosition] : []),
+ ];
+ if (anchors.length === 0) {
+ return false;
+ }
+
+ // A bounding box that only touches the viewport edge counts as outside,
+ // like every other rect test in the engine.
+ return doesRectsIntersect(boundingRectOfPoints(anchors), rect);
+ }
+
private getPrimaryVisibleIds(viewportRect: Rect): Set {
const primaryVisibleIds = new Set(this.flowCore.spatialHash.queryIds(viewportRect));
diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/action-state.interface.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/action-state.interface.ts
index e2dc7d2d0..a11e44cf2 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/action-state.interface.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/action-state.interface.ts
@@ -1,6 +1,6 @@
-import type { EdgeDrawCancelReason, GestureCancelReason } from '../event-manager/event-types';
+import type { EdgeDrawCancelReason, EdgeRelinkCancelReason, GestureCancelReason } from '../event-manager/event-types';
import type { InputModifiers } from '../input-events/input-events.interface';
-import type { Edge } from './edge.interface';
+import type { Edge, EdgeEnd } from './edge.interface';
import type { Node } from './node.interface';
import type { Point } from './utils';
@@ -48,6 +48,38 @@ export interface LinkingActionState {
dropPosition?: Point;
/** Reason the linking gesture was cancelled (set by finishLinking on failure paths). */
cancelReason?: EdgeDrawCancelReason;
+ /**
+ * Present while an endpoint of an existing edge is being relinked. Until the
+ * gesture ends, the original edge is not rendered and the temporary edge
+ * represents it. `relink.end` is the end of the temporary edge that follows
+ * the pointer (a normal draw always drags the target end).
+ *
+ * @since 1.4.0
+ */
+ relink?: LinkingRelinkContext;
+ /**
+ * Reason the relink gesture ended without changing the edge (set by
+ * `finishRelinking` when the relink fails).
+ *
+ * @since 1.4.0
+ */
+ relinkCancelReason?: EdgeRelinkCancelReason;
+}
+
+/**
+ * Context of an edge relink gesture carried inside the linking action state.
+ *
+ * @public
+ * @since 1.4.0
+ * @category Internals
+ */
+export interface LinkingRelinkContext {
+ /** ID of the edge whose endpoint is being dragged. */
+ edgeId: string;
+ /** Which endpoint of the edge is being dragged. */
+ end: EdgeEnd;
+ /** Snapshot of the edge as it was when the gesture started. */
+ originalEdge: Edge;
}
/**
diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/command-handler.interface.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/command-handler.interface.ts
index 47d87d074..e26d2fbb0 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/command-handler.interface.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/command-handler.interface.ts
@@ -27,9 +27,11 @@ import {
CancelLinkingCommand,
FinishLinkingCommand,
FinishLinkingToPositionCommand,
+ FinishRelinkingCommand,
MoveTemporaryEdgeCommand,
StartLinkingCommand,
StartLinkingFromPositionCommand,
+ StartRelinkingCommand,
} from '../command-handler/commands/linking';
import { MoveNodesByCommand } from '../command-handler/commands/move';
import { MoveNodesStartCommand, MoveNodesStopCommand } from '../command-handler/commands/move-nodes-lifecycle';
@@ -85,6 +87,8 @@ export type Command =
| CancelLinkingCommand
| StartLinkingFromPositionCommand
| FinishLinkingToPositionCommand
+ | StartRelinkingCommand
+ | FinishRelinkingCommand
| ResizeNodeCommand
| ResizeNodeStartCommand
| ResizeNodeStopCommand
diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/edge.interface.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/edge.interface.ts
index 1a941234c..094ed973c 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/edge.interface.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/edge.interface.ts
@@ -10,6 +10,15 @@ import { DataObject, Point, Size } from './utils';
*/
export type RoutingMode = 'manual' | 'auto';
+/**
+ * Identifies one endpoint of an edge.
+ *
+ * @public
+ * @since 1.4.0
+ * @category Types/Model
+ */
+export type EdgeEnd = 'source' | 'target';
+
/**
* Interface representing an edge (connection) between nodes in the flow diagram
*
@@ -60,6 +69,15 @@ export interface Edge {
* @since 1.4.0
*/
hidden?: boolean;
+ /**
+ * 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
+ */
+ relinkable?: boolean | EdgeEnd;
/**
* @readonly
* @remarks ComputedHidden is computed by the system and should not be set manually.
diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts
index cba8d96b0..d69f770c5 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/flow-config.interface.ts
@@ -1,9 +1,9 @@
import { EdgeRoutingName } from '../edge-routing-manager';
-import type { Edge } from './edge.interface';
+import type { Edge, EdgeEnd } from './edge.interface';
import type { Node, Port } from './node.interface';
import type { NgDiagramPanelPosition } from './panel-position.interface';
import type { ShortcutDefinition } from './shortcut.interface';
-import { Size } from './utils';
+import { Point, Size } from './utils';
/**
* Configuration for node resizing behavior.
@@ -50,10 +50,20 @@ export interface LinkingConfig {
portSnapDistance: number;
/**
* Validates whether a connection between two nodes and ports is allowed.
- * @param source The source node.
+ *
+ * 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`.
+ *
+ * @param source The source node, or `null` when the source end is free.
* @param sourcePort The source port.
- * @param target The target node.
+ * @param target The target node, or `null` when the target end is free.
* @param targetPort The target port.
+ * @param context The operation being validated (`draw` when omitted).
* @returns True if the connection is valid, false otherwise.
* @default () => true
*/
@@ -61,7 +71,8 @@ export interface LinkingConfig {
source: Node | null,
sourcePort: Port | null,
target: Node | null,
- targetPort: Port | null
+ targetPort: Port | null,
+ context?: ConnectionValidationContext
) => boolean;
/**
* Allows customization of the temporary edge object shown while the user is dragging to create a new edge.
@@ -109,6 +120,118 @@ export interface LinkingConfig {
* @since 1.2.0
*/
selectNodeOnPortPress: boolean;
+ /**
+ * Default `relinkable` value for edges that do not set their own. `true`
+ * lets the user drag both ends of an edge to another port, `'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 false
+ * @since 1.4.0
+ */
+ defaultRelinkable: boolean | EdgeEnd;
+}
+
+/**
+ * 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.
+ *
+ * @public
+ * @since 1.4.0
+ * @category Types/Configuration/Features
+ */
+export interface DanglingEdgesConfig {
+ /**
+ * 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 false
+ */
+ enabled: 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.
+ * @param edge The edge that would be kept.
+ * @param dropPosition The position where the pointer was released, in flow coordinates.
+ * @default undefined (keep every edge)
+ */
+ shouldKeepOnDrop?: (edge: Edge, dropPosition: Point) => boolean;
+ /**
+ * When true, 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 {@link shouldDetachOnNodeDelete} is provided
+ * and returns true for both ends.
+ * @default false
+ */
+ detachOnNodeDelete: 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.
+ * @param edge The edge that loses a node.
+ * @param deletedNode The node being deleted.
+ * @param end The endpoint of `edge` that is connected to `deletedNode`.
+ * @default undefined (detach every edge, except edges losing both ends)
+ */
+ shouldDetachOnNodeDelete?: (edge: Edge, deletedNode: Node, end: EdgeEnd) => boolean;
+}
+
+/**
+ * Context passed to {@link LinkingConfig.validateConnection}. 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`.
+ *
+ * @public
+ * @since 1.4.0
+ * @category Types/Configuration/Features
+ */
+export interface ConnectionValidationContext {
+ /** The operation being validated. */
+ reason: 'draw' | 'relink' | 'attach';
+ /** The existing edge whose endpoint is being connected (relink and attach only). */
+ edge?: Edge;
+ /** Which endpoint of `edge` is being connected (relink and attach only). */
+ end?: EdgeEnd;
}
/**
@@ -534,6 +657,12 @@ export interface FlowConfig {
*/
linking: LinkingConfig;
+ /**
+ * Configuration for dangling edges (edges with unconnected endpoints).
+ * @since 1.4.0
+ */
+ danglingEdges: DanglingEdgesConfig;
+
/**
* Configuration for node grouping.
*/
diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/middleware.interface.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/middleware.interface.ts
index c6b08c332..2f43894e7 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/core/src/types/middleware.interface.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/types/middleware.interface.ts
@@ -69,8 +69,11 @@ export type ModelActionType =
| 'resizeNodeStop'
| 'cancelResize'
| 'startLinking'
+ | 'startLinkingFromPosition'
| 'moveTemporaryEdge'
| 'finishLinking'
+ | 'startRelinking'
+ | 'finishRelinking'
| 'zoom'
| 'changeZOrder'
| 'rotateNodeTo'
diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/__tests__/dangling-edges.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/__tests__/dangling-edges.test.ts
new file mode 100644
index 000000000..fc2bef09b
--- /dev/null
+++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/__tests__/dangling-edges.test.ts
@@ -0,0 +1,203 @@
+import { describe, expect, it } from 'vitest';
+import { mockEdge } from '../../test-utils';
+import type { Edge } from '../../types';
+import {
+ alignManualPointsPatch,
+ getDanglingEndpoints,
+ getNearestDanglingEndpointInRange,
+ hasFreeEndpoint,
+ isDanglingEdge,
+} from '../dangling-edges';
+
+describe('dangling-edges utils', () => {
+ const connectedEdge: Edge = { ...mockEdge, id: 'connected', source: 'node-a', target: 'node-b' };
+ const sourceDanglingEdge: Edge = {
+ ...mockEdge,
+ id: 'source-dangling',
+ source: '',
+ target: 'node-b',
+ sourcePosition: { x: 10, y: 20 },
+ };
+ const targetDanglingEdge: Edge = {
+ ...mockEdge,
+ id: 'target-dangling',
+ source: 'node-a',
+ target: '',
+ targetPosition: { x: 100, y: 200 },
+ };
+ const dualDanglingEdge: Edge = {
+ ...mockEdge,
+ id: 'dual-dangling',
+ source: '',
+ target: '',
+ sourcePosition: { x: 0, y: 0 },
+ targetPosition: { x: 50, y: 50 },
+ };
+
+ describe('hasFreeEndpoint', () => {
+ it('should report no free endpoint on a fully connected edge', () => {
+ expect(hasFreeEndpoint(connectedEdge)).toBe(false);
+ expect(hasFreeEndpoint(connectedEdge, 'source')).toBe(false);
+ expect(hasFreeEndpoint(connectedEdge, 'target')).toBe(false);
+ });
+
+ it('should report the free source endpoint', () => {
+ expect(hasFreeEndpoint(sourceDanglingEdge)).toBe(true);
+ expect(hasFreeEndpoint(sourceDanglingEdge, 'source')).toBe(true);
+ expect(hasFreeEndpoint(sourceDanglingEdge, 'target')).toBe(false);
+ });
+
+ it('should report the free target endpoint', () => {
+ expect(hasFreeEndpoint(targetDanglingEdge)).toBe(true);
+ expect(hasFreeEndpoint(targetDanglingEdge, 'source')).toBe(false);
+ expect(hasFreeEndpoint(targetDanglingEdge, 'target')).toBe(true);
+ });
+
+ it('should report both free endpoints on a dual dangling edge', () => {
+ expect(hasFreeEndpoint(dualDanglingEdge)).toBe(true);
+ expect(hasFreeEndpoint(dualDanglingEdge, 'source')).toBe(true);
+ expect(hasFreeEndpoint(dualDanglingEdge, 'target')).toBe(true);
+ });
+ });
+
+ describe('isDanglingEdge', () => {
+ it('should classify edges by their free endpoints', () => {
+ expect(isDanglingEdge(connectedEdge)).toBe(false);
+ expect(isDanglingEdge(sourceDanglingEdge)).toBe(true);
+ expect(isDanglingEdge(targetDanglingEdge)).toBe(true);
+ expect(isDanglingEdge(dualDanglingEdge)).toBe(true);
+ });
+ });
+
+ describe('getDanglingEndpoints', () => {
+ it('should collect the free endpoints of the given edges', () => {
+ const endpoints = getDanglingEndpoints([connectedEdge, sourceDanglingEdge, targetDanglingEdge]);
+
+ expect(endpoints).toEqual([
+ { edge: sourceDanglingEdge, end: 'source', position: { x: 10, y: 20 } },
+ { edge: targetDanglingEdge, end: 'target', position: { x: 100, y: 200 } },
+ ]);
+ });
+
+ it('should yield two entries for a dual dangling edge', () => {
+ const endpoints = getDanglingEndpoints([dualDanglingEdge]);
+
+ expect(endpoints).toEqual([
+ { edge: dualDanglingEdge, end: 'source', position: { x: 0, y: 0 } },
+ { edge: dualDanglingEdge, end: 'target', position: { x: 50, y: 50 } },
+ ]);
+ });
+
+ it('should skip temporary edges', () => {
+ const temporaryEdge: Edge = { ...targetDanglingEdge, id: 'temp', temporary: true };
+
+ expect(getDanglingEndpoints([temporaryEdge])).toEqual([]);
+ });
+
+ it('should skip effectively hidden edges', () => {
+ const hidden: Edge = { ...targetDanglingEdge, id: 'hidden', computedHidden: true };
+
+ expect(getDanglingEndpoints([hidden])).toEqual([]);
+ });
+
+ it('should skip free endpoints without a stored position', () => {
+ const positionless: Edge = { ...mockEdge, id: 'positionless', source: '', target: 'node-b' };
+
+ expect(getDanglingEndpoints([positionless])).toEqual([]);
+ });
+ });
+
+ describe('getNearestDanglingEndpointInRange', () => {
+ it('should pick the nearest free endpoint within range', () => {
+ const nearest = getNearestDanglingEndpointInRange([sourceDanglingEdge, targetDanglingEdge], { x: 12, y: 22 }, 10);
+
+ expect(nearest).toEqual({ edge: sourceDanglingEdge, end: 'source', position: { x: 10, y: 20 } });
+ });
+
+ it('should pick the nearest of several endpoints in range', () => {
+ const nearest = getNearestDanglingEndpointInRange([dualDanglingEdge], { x: 40, y: 40 }, 100);
+
+ expect(nearest).toEqual({ edge: dualDanglingEdge, end: 'target', position: { x: 50, y: 50 } });
+ });
+
+ it('should return null when no endpoint is within range', () => {
+ const nearest = getNearestDanglingEndpointInRange(
+ [sourceDanglingEdge, targetDanglingEdge],
+ { x: 500, y: 500 },
+ 5
+ );
+
+ expect(nearest).toBeNull();
+ });
+
+ it('should not pick an effectively hidden edge even when it is the nearest', () => {
+ const hidden: Edge = { ...sourceDanglingEdge, id: 'hidden', computedHidden: true };
+
+ const nearest = getNearestDanglingEndpointInRange([hidden, targetDanglingEdge], { x: 10, y: 20 }, 1000);
+
+ expect(nearest).toEqual({ edge: targetDanglingEdge, end: 'target', position: { x: 100, y: 200 } });
+ });
+
+ it('should return null for connected edges only', () => {
+ expect(getNearestDanglingEndpointInRange([connectedEdge], { x: 0, y: 0 }, 1000)).toBeNull();
+ });
+ });
+
+ describe('alignManualPointsPatch', () => {
+ const manualEdge: Edge = {
+ ...mockEdge,
+ id: 'manual',
+ routingMode: 'manual',
+ points: [
+ { x: 10, y: 20 },
+ { x: 50, y: 60 },
+ { x: 110, y: 120 },
+ ],
+ };
+ const anchor = { x: 300, y: 400 };
+
+ it('should return an empty patch for a non-manual edge', () => {
+ expect(alignManualPointsPatch({ ...manualEdge, routingMode: undefined }, 'target', anchor)).toEqual({});
+ expect(alignManualPointsPatch({ ...manualEdge, routingMode: 'auto' }, 'target', anchor)).toEqual({});
+ });
+
+ it('should return an empty patch for a manual edge without points', () => {
+ expect(alignManualPointsPatch({ ...manualEdge, points: undefined }, 'target', anchor)).toEqual({});
+ expect(alignManualPointsPatch({ ...manualEdge, points: [] }, 'target', anchor)).toEqual({});
+ });
+
+ it('should move the last point to the anchor for the target end', () => {
+ const patch = alignManualPointsPatch(manualEdge, 'target', anchor);
+
+ expect(patch).toEqual({
+ points: [
+ { x: 10, y: 20 },
+ { x: 50, y: 60 },
+ { x: 300, y: 400 },
+ ],
+ });
+ // The stored edge is not mutated — the patch owns fresh point objects.
+ expect(manualEdge.points![2]).toEqual({ x: 110, y: 120 });
+ });
+
+ it('should move the first point to the anchor for the source end', () => {
+ const patch = alignManualPointsPatch(manualEdge, 'source', anchor);
+
+ expect(patch).toEqual({
+ points: [
+ { x: 300, y: 400 },
+ { x: 50, y: 60 },
+ { x: 110, y: 120 },
+ ],
+ });
+ expect(manualEdge.points![0]).toEqual({ x: 10, y: 20 });
+ });
+
+ it('should move the only point of a single-point edge for either end', () => {
+ const singlePointEdge: Edge = { ...manualEdge, points: [{ x: 1, y: 2 }] };
+
+ expect(alignManualPointsPatch(singlePointEdge, 'source', anchor)).toEqual({ points: [{ x: 300, y: 400 }] });
+ expect(alignManualPointsPatch(singlePointEdge, 'target', anchor)).toEqual({ points: [{ x: 300, y: 400 }] });
+ });
+ });
+});
diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/__tests__/relinking.test.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/__tests__/relinking.test.ts
new file mode 100644
index 000000000..dc779080a
--- /dev/null
+++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/__tests__/relinking.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it } from 'vitest';
+import { mockEdge } from '../../test-utils';
+import type { Edge, EdgeEnd } from '../../types';
+import { isEdgeEndRelinkable } from '../relinking';
+
+describe('isEdgeEndRelinkable', () => {
+ const edge: Edge = { ...mockEdge, id: 'edge-1', source: 'node-a', target: 'node-b' };
+
+ describe('resolved values', () => {
+ it.each<[boolean | EdgeEnd, boolean, boolean]>([
+ [true, true, true],
+ ['source', true, false],
+ ['target', false, true],
+ [false, false, false],
+ ])('%j allows source=%s target=%s', (value, source, target) => {
+ expect(isEdgeEndRelinkable({ ...edge, relinkable: value }, 'source', false)).toBe(source);
+ expect(isEdgeEndRelinkable({ ...edge, relinkable: value }, 'target', false)).toBe(target);
+ });
+
+ it('should lock both ends for a value outside the type', () => {
+ const locked: Edge = { ...edge, relinkable: 'both' as never };
+
+ expect(isEdgeEndRelinkable(locked, 'source', true)).toBe(false);
+ expect(isEdgeEndRelinkable(locked, 'target', true)).toBe(false);
+ });
+ });
+
+ describe('fallback to the default', () => {
+ it('should use the default when the edge has no relinkable property', () => {
+ expect(isEdgeEndRelinkable(edge, 'source', true)).toBe(true);
+ expect(isEdgeEndRelinkable(edge, 'target', true)).toBe(true);
+ expect(isEdgeEndRelinkable(edge, 'source', 'target')).toBe(false);
+ expect(isEdgeEndRelinkable(edge, 'target', 'target')).toBe(true);
+ expect(isEdgeEndRelinkable(edge, 'source', false)).toBe(false);
+ expect(isEdgeEndRelinkable(edge, 'target', false)).toBe(false);
+ });
+
+ it('should ignore the default when the edge sets relinkable', () => {
+ expect(isEdgeEndRelinkable({ ...edge, relinkable: false }, 'source', true)).toBe(false);
+ expect(isEdgeEndRelinkable({ ...edge, relinkable: false }, 'target', true)).toBe(false);
+ expect(isEdgeEndRelinkable({ ...edge, relinkable: true }, 'source', false)).toBe(true);
+ expect(isEdgeEndRelinkable({ ...edge, relinkable: 'source' }, 'source', 'target')).toBe(true);
+ expect(isEdgeEndRelinkable({ ...edge, relinkable: 'source' }, 'target', 'target')).toBe(false);
+ });
+ });
+});
diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/dangling-edges.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/dangling-edges.ts
new file mode 100644
index 000000000..abc5eb052
--- /dev/null
+++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/dangling-edges.ts
@@ -0,0 +1,194 @@
+import type { Edge, EdgeEnd } from '../types/edge.interface';
+import type { Node } from '../types/node.interface';
+import type { Point } from '../types/utils';
+import { getPortFlowPosition } from './get-port-flow-position';
+
+/**
+ * One free (unconnected) endpoint of a dangling edge.
+ *
+ * @public
+ * @since 1.4.0
+ * @category Types/Model
+ */
+export interface DanglingEndpoint {
+ /** The dangling edge. */
+ edge: Edge;
+ /** Which endpoint of the edge is free. */
+ end: EdgeEnd;
+ /** The position the free endpoint is anchored at. */
+ position: Point;
+}
+
+/**
+ * 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.
+ *
+ * @param edge The edge to check.
+ * @param end The endpoint to check, or none to check both.
+ * @returns `true` when the endpoint is free.
+ * @public
+ * @since 1.4.0
+ * @category Utilities
+ */
+export const hasFreeEndpoint = (edge: Edge, end?: EdgeEnd): boolean => {
+ if (end === 'source') {
+ return !edge.source;
+ }
+ if (end === 'target') {
+ return !edge.target;
+ }
+ return !edge.source || !edge.target;
+};
+
+/**
+ * Checks whether an edge is dangling, that is, whether at least one of its
+ * endpoints is not connected to a node. An edge with both endpoints free is a
+ * dual dangling edge.
+ *
+ * @param edge The edge to check.
+ * @returns `true` when at least one endpoint is free.
+ * @public
+ * @since 1.4.0
+ * @category Utilities
+ */
+export const isDanglingEdge = (edge: Edge): boolean => hasFreeEndpoint(edge);
+
+/**
+ * Collects the free endpoints of the given edges. A dual dangling edge gives
+ * two entries. Endpoints without an anchor position are skipped, because they
+ * cannot be rendered or snapped to. Temporary and effectively hidden edges are
+ * skipped as well.
+ *
+ * @param edges The edges to scan.
+ * @returns The free endpoints with their edge, end and anchor position.
+ * @public
+ * @since 1.4.0
+ * @category Utilities
+ */
+export const getDanglingEndpoints = (edges: readonly Edge[]): DanglingEndpoint[] => {
+ const endpoints: DanglingEndpoint[] = [];
+ for (const edge of edges) {
+ if (edge.temporary || edge.computedHidden) {
+ continue;
+ }
+ if (!edge.source && edge.sourcePosition) {
+ endpoints.push({ edge, end: 'source', position: edge.sourcePosition });
+ }
+ if (!edge.target && edge.targetPosition) {
+ endpoints.push({ edge, end: 'target', position: edge.targetPosition });
+ }
+ }
+ return endpoints;
+};
+
+/**
+ * Computes the position where a detached endpoint stays: the current position
+ * of the port when the edge was connected to a port, otherwise the routed
+ * endpoint of the edge, or the center of the node as a last resort. Call it
+ * while the node still exists in the model.
+ *
+ * @param edge The edge whose endpoint is being detached.
+ * @param end The endpoint to detach.
+ * @param node The node the endpoint is connected to, if it still exists.
+ * @returns The anchor position, or `null` when none can be computed.
+ * @public
+ * @since 1.4.0
+ * @category Utilities
+ */
+export const computeDetachAnchor = (edge: Edge, end: EdgeEnd, node: Node | null | undefined): Point | null => {
+ const portId = end === 'source' ? edge.sourcePort : edge.targetPort;
+ if (node && portId) {
+ const portPosition = getPortFlowPosition(node, portId);
+ if (portPosition) {
+ return portPosition;
+ }
+ }
+ const points = edge.points;
+ if (points && points.length > 0) {
+ return end === 'source' ? points[0] : points[points.length - 1];
+ }
+ if (node) {
+ return {
+ x: node.position.x + (node.size?.width ?? 0) / 2,
+ y: node.position.y + (node.size?.height ?? 0) / 2,
+ };
+ }
+ return null;
+};
+
+/**
+ * For a manual-routing edge, returns a `points` patch with the given end's
+ * point moved to `anchor`, keeping the stored path aligned with the new
+ * endpoint — the routing middleware keeps manual points verbatim, so without
+ * this the drawn path would still end at the old endpoint.
+ * Returns an empty patch for auto-routed edges (they re-route on their own).
+ *
+ * @internal
+ */
+export const alignManualPointsPatch = (edge: Edge, end: EdgeEnd, anchor: Point): Partial => {
+ if (edge.routingMode !== 'manual' || !edge.points || edge.points.length === 0) {
+ return {};
+ }
+ const points = edge.points.map((point) => ({ ...point }));
+ points[end === 'source' ? 0 : points.length - 1] = { x: anchor.x, y: anchor.y };
+ return { points };
+};
+
+/**
+ * Finds the free edge endpoint nearest to `point` within `range`, or `null`
+ * when none is close enough. It works like `getNearestPortInRange`, but for
+ * the free endpoints of dangling edges. Temporary and effectively hidden edges
+ * are skipped.
+ *
+ * @param edges The edges to scan.
+ * @param point The point to measure from.
+ * @param range The maximum distance from `point`.
+ * @returns The nearest free endpoint, or `null`.
+ * @public
+ * @since 1.4.0
+ * @category Utilities
+ */
+export const getNearestDanglingEndpointInRange = (
+ edges: readonly Edge[],
+ point: Point,
+ range: number
+): DanglingEndpoint | null => {
+ // Single allocation-free scan (this runs in pointermove handlers): only the
+ // winning endpoint materializes an object.
+ let bestEdge: Edge | null = null;
+ let bestEnd: EdgeEnd = 'source';
+ let bestPosition: Point | null = null;
+ let bestDistSq = range * range;
+
+ const consider = (edge: Edge, end: EdgeEnd, position: Point | undefined) => {
+ if (!position) {
+ return;
+ }
+ const dx = position.x - point.x;
+ const dy = position.y - point.y;
+ const distSq = dx * dx + dy * dy;
+ if (distSq <= bestDistSq) {
+ bestDistSq = distSq;
+ bestEdge = edge;
+ bestEnd = end;
+ bestPosition = position;
+ }
+ };
+
+ for (const edge of edges) {
+ if (edge.temporary || edge.computedHidden) {
+ continue;
+ }
+ if (!edge.source) {
+ consider(edge, 'source', edge.sourcePosition);
+ }
+ if (!edge.target) {
+ consider(edge, 'target', edge.targetPosition);
+ }
+ }
+
+ return bestEdge && bestPosition ? { edge: bestEdge, end: bestEnd, position: bestPosition } : null;
+};
diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/index.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/index.ts
index fc06b7c90..4f06fcc7d 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/index.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/index.ts
@@ -1,6 +1,7 @@
export * from './change-generation';
export * from './compute-floating-edge-side';
export * from './constants';
+export * from './dangling-edges';
export * from './deep-merge';
export * from './get-node-border-intersection';
export * from './get-port-flow-position';
@@ -9,6 +10,7 @@ export * from './is-group';
export * from './measurement-validation';
export * from './object-properties';
export * from './rects-points-sizes';
+export * from './relinking';
export * from './semaphore';
export * from './snap-node-position';
export * from './sort-nodes-by-z-index';
diff --git a/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/relinking.ts b/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/relinking.ts
new file mode 100644
index 000000000..81cf18cca
--- /dev/null
+++ b/packages/ng-diagram/projects/ng-diagram/src/core/src/utils/relinking.ts
@@ -0,0 +1,20 @@
+import type { Edge, EdgeEnd } from '../types/edge.interface';
+
+/**
+ * Returns whether the user can relink the given end of an edge. It uses the
+ * edge's own `relinkable` value when set, otherwise `defaultRelinkable` from
+ * the linking config. `true` allows both ends, `'source'` or `'target'` allows
+ * only that end, and any other value allows neither.
+ *
+ * @param edge The edge to check.
+ * @param end The endpoint to check.
+ * @param defaultRelinkable The `linking.defaultRelinkable` config value.
+ * @returns `true` when the user can relink that end.
+ * @public
+ * @since 1.4.0
+ * @category Utilities
+ */
+export const isEdgeEndRelinkable = (edge: Edge, end: EdgeEnd, defaultRelinkable: boolean | EdgeEnd): boolean => {
+ const value = edge.relinkable ?? defaultRelinkable;
+ return value === true || value === end;
+};
diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/diagram/ng-diagram.component.scss b/packages/ng-diagram/projects/ng-diagram/src/lib/components/diagram/ng-diagram.component.scss
index b6b20ecdd..7aa8e3825 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/lib/components/diagram/ng-diagram.component.scss
+++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/diagram/ng-diagram.component.scss
@@ -22,6 +22,13 @@
}
}
+ // While an edge endpoint is being dragged the handle itself is unmounted
+ // (the original edge is hidden), so the grabbing cursor is held at the host.
+ &.relinking,
+ &.relinking * {
+ cursor: grabbing;
+ }
+
.diagram-background-container {
width: 100%;
height: 100%;
diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/diagram/ng-diagram.component.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/components/diagram/ng-diagram.component.ts
index 85291babd..c33256b95 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/lib/components/diagram/ng-diagram.component.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/diagram/ng-diagram.component.ts
@@ -11,6 +11,7 @@ import {
OnDestroy,
OnInit,
Output,
+ signal,
untracked,
} from '@angular/core';
import { Edge, Node } from '../../../core/src';
@@ -20,6 +21,8 @@ import type {
DiagramInitEvent,
EdgeDrawEndedEvent,
EdgeDrawnEvent,
+ EdgeRelinkEndedEvent,
+ EdgeRelinkStartedEvent,
GroupMembershipChangedEvent,
GroupNode,
MiddlewareChain,
@@ -42,6 +45,7 @@ import type {
import { MobileBoxSelectionDirective } from '../../../public-api';
import { DiagramSelectionDirective } from '../../directives';
+import { RelinkingGestureService } from '../../services/input-events/relinking-gesture.service';
import { CursorPositionTrackerDirective } from '../../directives/cursor-position-tracker/cursor-position-tracker.directive';
import { BoxSelectionDirective } from '../../directives/input-events/box-selection/box-selection.directive';
import { KeyboardInputsDirective } from '../../directives/input-events/keyboard-inputs/keyboard-inputs.directive';
@@ -110,6 +114,7 @@ import { NgDiagramWatermarkComponent } from '../watermark/watermark.component';
],
host: {
'[class.pannable]': 'viewportPannable()',
+ '[class.relinking]': 'relinkingActive()',
'[attr.tabindex]': `tabbable() ? '0' : '-1'`,
},
})
@@ -162,6 +167,10 @@ export class NgDiagramComponent implements OnInit, OnDestroy {
/** Whether panning is enabled in the diagram. */
readonly viewportPannable = this.renderer.viewportPannable;
+ /** Whether an edge endpoint is being dragged — holds the grabbing cursor at the host. */
+ protected readonly relinkingActive =
+ inject(RelinkingGestureService, { optional: true })?.active ?? signal(false).asReadonly();
+
/**
* Whether the diagram container takes part in the page's sequential Tab order.
*
@@ -215,6 +224,25 @@ export class NgDiagramComponent implements OnInit, OnDestroy {
*/
@Output() edgeDrawEnded = new EventEmitter();
+ /**
+ * 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
+ */
+ @Output() edgeRelinkStarted = new EventEmitter();
+
+ /**
+ * 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
+ */
+ @Output() edgeRelinkEnded = new EventEmitter();
+
/**
* Event emitted when selected nodes are moved within the diagram.
*
@@ -522,6 +550,8 @@ export class NgDiagramComponent implements OnInit, OnDestroy {
eventManager.on('edgeDrawn', (event) => this.edgeDrawn.emit(event));
eventManager.on('edgeDrawEnded', (event) => this.edgeDrawEnded.emit(event));
+ eventManager.on('edgeRelinkStarted', (event) => this.edgeRelinkStarted.emit(event));
+ eventManager.on('edgeRelinkEnded', (event) => this.edgeRelinkEnded.emit(event));
eventManager.on('selectionMoved', (event) => this.selectionMoved.emit(event));
eventManager.on('selectionChanged', (event) => this.selectionChanged.emit(event));
eventManager.on('selectionGestureEnded', (event) => this.selectionGestureEnded.emit(event));
diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge-component.spec.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge-component.spec.ts
index 38663ea2e..3c0fefe4f 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge-component.spec.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge-component.spec.ts
@@ -1,10 +1,12 @@
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { beforeEach, describe, expect, it, vi } from 'vitest';
-import { Edge, Point } from '../../../../core/src';
+import { Edge, EdgeEnd, Point } from '../../../../core/src';
import { FlowCoreProviderService, RendererService } from '../../../services';
import { InputEventsRouterService } from '../../../services/input-events/input-events-router.service';
+import { RelinkingGestureService } from '../../../services/input-events/relinking-gesture.service';
import { MarkerRegistryService } from '../../../services/marker-registry/marker-registry.service';
+import { NgDiagramService } from '../../../public-services/ng-diagram.service';
import { NgDiagramBaseEdgeLabelComponent } from '../../edge-label/base-edge-label/base-edge-label.component';
import { NgDiagramBaseEdgeComponent } from './base-edge.component';
@@ -21,8 +23,10 @@ describe('NgDiagramBaseEdgeComponent', () => {
let mockEdge: Edge;
let mockFlowCore: any; // eslint-disable-line @typescript-eslint/no-explicit-any
let mockFlowCoreProvider: any; // eslint-disable-line @typescript-eslint/no-explicit-any
+ let defaultRelinkable: boolean | EdgeEnd;
beforeEach(async () => {
+ defaultRelinkable = false;
// Create mock for EdgeRoutingManager
const mockEdgeRoutingManager = {
hasRouting: vi.fn().mockReturnValue(true),
@@ -53,6 +57,8 @@ describe('NgDiagramBaseEdgeComponent', () => {
await TestBed.configureTestingModule({
providers: [
{ provide: FlowCoreProviderService, useValue: mockFlowCoreProvider },
+ { provide: NgDiagramService, useValue: { config: () => ({ linking: { defaultRelinkable } }) } },
+ { provide: RelinkingGestureService, useValue: { beginRelink: vi.fn().mockReturnValue(false) } },
RendererService,
InputEventsRouterService,
MarkerRegistryService,
@@ -279,4 +285,75 @@ describe('NgDiagramBaseEdgeComponent', () => {
const pathElement = fixture.nativeElement.querySelector('path');
expect(pathElement.getAttribute('stroke-dasharray')).toBe('var(--edge-stroke-dasharray, none)');
});
+
+ describe('relink handles', () => {
+ const handles = () => ({
+ source: component.relinkSourceHandleVisible(),
+ target: component.relinkTargetHandleVisible(),
+ });
+
+ const renderedHandles = () => ({
+ source: fixture.nativeElement.querySelectorAll('[data-relink-handle="source"]').length,
+ target: fixture.nativeElement.querySelectorAll('[data-relink-handle="target"]').length,
+ });
+
+ it.each<[boolean | EdgeEnd, boolean, boolean]>([
+ [true, true, true],
+ ['source', true, false],
+ ['target', false, true],
+ [false, false, false],
+ ])('relinkable %j on a selected edge shows source=%s target=%s', (relinkable, source, target) => {
+ fixture.componentRef.setInput('edge', { ...mockEdge, selected: true, relinkable });
+ fixture.detectChanges();
+
+ expect(handles()).toEqual({ source, target });
+ expect(renderedHandles()).toEqual({ source: source ? 1 : 0, target: target ? 1 : 0 });
+ });
+
+ it.each<[boolean | EdgeEnd, boolean, boolean]>([
+ [true, true, true],
+ ['source', true, false],
+ ['target', false, true],
+ [false, false, false],
+ ])('defaultRelinkable %j applies to an edge without relinkable: source=%s target=%s', (value, source, target) => {
+ defaultRelinkable = value;
+ fixture.componentRef.setInput('edge', { ...mockEdge, selected: true });
+ fixture.detectChanges();
+
+ expect(handles()).toEqual({ source, target });
+ });
+
+ it('should let the edge override the default', () => {
+ defaultRelinkable = true;
+ fixture.componentRef.setInput('edge', { ...mockEdge, selected: true, relinkable: false });
+ fixture.detectChanges();
+
+ expect(handles()).toEqual({ source: false, target: false });
+ });
+
+ it('should hide both handles when the edge is not selected', () => {
+ defaultRelinkable = true;
+ fixture.componentRef.setInput('edge', { ...mockEdge, selected: false });
+ fixture.detectChanges();
+
+ expect(handles()).toEqual({ source: false, target: false });
+ expect(renderedHandles()).toEqual({ source: 0, target: 0 });
+ });
+
+ it('should hide both handles on a temporary edge', () => {
+ defaultRelinkable = true;
+ fixture.componentRef.setInput('edge', { ...mockEdge, selected: true, temporary: true });
+ fixture.detectChanges();
+
+ expect(handles()).toEqual({ source: false, target: false });
+ });
+
+ it('should hide both handles on an edge without points', () => {
+ defaultRelinkable = true;
+ fixture.componentRef.setInput('edge', { ...mockEdge, selected: true, points: [] });
+ fixture.detectChanges();
+
+ expect(handles()).toEqual({ source: false, target: false });
+ });
+ });
});
diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge.component.html b/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge.component.html
index 9269be0ce..99c697699 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge.component.html
+++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge.component.html
@@ -21,6 +21,60 @@
[attr.stroke-opacity]="strokeOpacity() || 'var(--edge-stroke-opacity, 1)'"
/>
+
+
+ @if (relinkSourceHandleVisible()) {
+
+ }
+ @if (relinkTargetHandleVisible()) {
+
+ }
+ @if (relinkSourceHandleVisible()) {
+
+ }
+ @if (relinkTargetHandleVisible()) {
+
+ }
diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge.component.scss b/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge.component.scss
index b69e34e9f..6e6c2ecdd 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge.component.scss
+++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge.component.scss
@@ -12,4 +12,45 @@ svg {
cursor: pointer;
transition: var(--edge-stroke-transition, none);
}
+
+ // Theme through --ngd-relink-handle-* (declared in styles.css, palette
+ // defaults); override per edge through --edge-relink-handle-* like
+ // --edge-stroke.
+ .ng-diagram-edge__relink-handle {
+ pointer-events: all;
+ cursor: grab;
+ touch-action: none;
+ r: calc(var(--edge-relink-handle-size, var(--ngd-relink-handle-size, 0.625rem)) / 2);
+ fill: var(--edge-relink-handle-fill, var(--ngd-relink-handle-fill, #fff));
+ stroke: var(--edge-relink-handle-stroke, var(--ngd-relink-handle-stroke, currentColor));
+ stroke-width: var(--edge-relink-handle-stroke-width, var(--ngd-relink-handle-stroke-width, 1.5px));
+
+ &:active {
+ cursor: grabbing;
+ }
+ }
+
+ // Enlarged invisible grab area for the handles (touch, low zoom). It sits
+ // under the visible circle, so the pointer is over exactly one of the two.
+ .ng-diagram-edge__relink-handle-hit {
+ pointer-events: all;
+ cursor: grab;
+ touch-action: none;
+ fill: transparent;
+ stroke: none;
+
+ &:active {
+ cursor: grabbing;
+ }
+ }
+
+ // The visible circle highlights while the pointer is over it or over the
+ // hit area of the same end (an earlier sibling in the template).
+ .ng-diagram-edge__relink-handle:hover,
+ .ng-diagram-edge__relink-handle-hit[data-relink-handle-hit='source']:hover
+ ~ .ng-diagram-edge__relink-handle[data-relink-handle='source'],
+ .ng-diagram-edge__relink-handle-hit[data-relink-handle-hit='target']:hover
+ ~ .ng-diagram-edge__relink-handle[data-relink-handle='target'] {
+ fill: var(--edge-relink-handle-fill-hover, var(--ngd-relink-handle-fill-hover, currentColor));
+ }
}
diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge.component.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge.component.ts
index 5cbbdb99f..0564a553c 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge.component.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/base-edge/base-edge.component.ts
@@ -1,9 +1,20 @@
import { ChangeDetectionStrategy, Component, computed, effect, inject, input } from '@angular/core';
-import { Edge, equalPointsArrays, Point, RoutingMode } from '../../../../core/src';
+import {
+ Edge,
+ EdgeEnd,
+ equalPointsArrays,
+ isDanglingEdge,
+ isEdgeEndRelinkable,
+ Point,
+ RoutingMode,
+} from '../../../../core/src';
import { isValidPosition } from '../../../../core/src/utils/measurement-validation';
import { EdgeSelectionDirective, InlineMarkersDirective, ZIndexDirective } from '../../../directives';
+import { RelinkHandleDirective } from '../../../directives/input-events/relinking/relinking.directive';
import { FlowCoreProviderService } from '../../../services';
import { MarkerRegistryService } from '../../../services/marker-registry/marker-registry.service';
+import { RendererService } from '../../../services/renderer/renderer.service';
+import { NgDiagramService } from '../../../public-services/ng-diagram.service';
const INVALID_EDGE_COORDINATES_ERROR = (
edgeId: string,
@@ -21,6 +32,9 @@ Edge details:
Documentation: https://www.ngdiagram.dev/docs/guides/edges/edges/
`;
+/** Screen-pixel radius of the relink handles' invisible hit area. */
+const RELINK_HANDLE_HIT_RADIUS_PX = 12;
+
/**
* Base edge component that handles edge rendering.
* It can be extended or used directly to render edges in the diagram.
@@ -32,7 +46,7 @@ Documentation: https://www.ngdiagram.dev/docs/guides/edges/edges/
@Component({
selector: 'ng-diagram-base-edge',
standalone: true,
- imports: [InlineMarkersDirective],
+ imports: [InlineMarkersDirective, RelinkHandleDirective],
templateUrl: './base-edge.component.html',
styleUrl: './base-edge.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -43,11 +57,16 @@ Documentation: https://www.ngdiagram.dev/docs/guides/edges/edges/
host: {
'[class.selected]': 'selected()',
'[class.temporary]': 'temporary()',
+ '[class.dangling]': 'dangling()',
},
})
export class NgDiagramBaseEdgeComponent {
private readonly flowCoreProvider = inject(FlowCoreProviderService);
private readonly markerRegistry = inject(MarkerRegistryService);
+ // Optional so consumer TestBeds that mount the component without
+ // provideNgDiagram() keep working — without it the relink handles stay off.
+ private readonly diagramService = inject(NgDiagramService, { optional: true });
+ private readonly renderer = inject(RendererService, { optional: true });
/**
* Whether to use inline markers (Safari fallback).
@@ -152,8 +171,61 @@ export class NgDiagramBaseEdgeComponent {
readonly selected = computed(() => this.edge().selected);
readonly temporary = computed(() => this.edge().temporary);
+ /**
+ * 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.
+ */
+ readonly dangling = computed(() => {
+ const edge = this.edge();
+ return isDanglingEdge(edge) && !edge.temporary;
+ });
+
readonly labels = computed(() => this.edge().measuredLabels ?? []);
+ /**
+ * 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
+ */
+ readonly relinkSourceHandleVisible = computed(() => this.relinkHandleVisible('source'));
+
+ /**
+ * Same as {@link relinkSourceHandleVisible} for the target end.
+ *
+ * @since 1.4.0
+ */
+ readonly relinkTargetHandleVisible = computed(() => this.relinkHandleVisible('target'));
+
+ /**
+ * Position of the source endpoint handle (the first routed point).
+ *
+ * @since 1.4.0
+ */
+ readonly relinkSourceHandle = computed(() => this.points()[0]);
+
+ /**
+ * Position of the target endpoint handle (the last routed point).
+ *
+ * @since 1.4.0
+ */
+ readonly relinkTargetHandle = computed(() => this.points()[this.points().length - 1]);
+
+ /**
+ * Radius of the 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
+ */
+ readonly relinkHandleHitRadius = computed(() => {
+ const scale = this.renderer?.viewport().scale || 1;
+ return RELINK_HANDLE_HIT_RADIUS_PX / scale;
+ });
+
readonly class = computed(() => {
const classArray = ['ng-diagram-edge__path'];
@@ -165,6 +237,10 @@ export class NgDiagramBaseEdgeComponent {
classArray.push('temporary');
}
+ if (this.dangling()) {
+ classArray.push('dangling');
+ }
+
return classArray.join(' ');
});
@@ -172,6 +248,14 @@ export class NgDiagramBaseEdgeComponent {
private prevRoutingMode: RoutingMode | undefined;
private prevPoints: Point[] | undefined;
+ private relinkHandleVisible(end: EdgeEnd): boolean {
+ if (!this.selected() || this.temporary() || this.points().length === 0) {
+ return false;
+ }
+ const defaultRelinkable = this.diagramService?.config().linking?.defaultRelinkable ?? false;
+ return isEdgeEndRelinkable(this.edge(), end, defaultRelinkable);
+ }
+
constructor() {
// Sync edge properties from custom components back to the model
effect(() => this.syncEdgePropertiesToModel());
diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/ng-diagram-edge.component.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/ng-diagram-edge.component.ts
index b48be6a65..d23c7796c 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/ng-diagram-edge.component.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/edge/ng-diagram-edge.component.ts
@@ -1,5 +1,5 @@
-import { ChangeDetectionStrategy, Component, input } from '@angular/core';
-import { Edge } from '../../../core/src';
+import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
+import { Edge, isDanglingEdge } from '../../../core/src';
@Component({
selector: 'ng-diagram-edge',
@@ -19,8 +19,16 @@ import { Edge } from '../../../core/src';
// re-measures labels through the existing ResizeObserver path. Visible
// edges get no inline display value — user CSS stays in charge.
'[style.display]': 'edge().computedHidden ? "none" : null',
+ // Styling hook for edges with a free (unconnected) endpoint — lets apps
+ // style dangling edges without inspecting the model.
+ '[class.ng-diagram-edge--dangling]': 'dangling()',
},
})
export class NgDiagramEdgeComponent {
edge = input.required();
+
+ protected readonly dangling = computed(() => {
+ const edge = this.edge();
+ return isDanglingEdge(edge) && !edge.temporary;
+ });
}
diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/object-selection/object-selection.directive.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/object-selection/object-selection.directive.ts
index 64a3b8128..d7686f0fa 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/object-selection/object-selection.directive.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/object-selection/object-selection.directive.ts
@@ -64,6 +64,12 @@ abstract class ObjectSelectionDirective implements OnDestroy {
if (event.boxSelectionHandled || event.selectHandled) {
return false;
}
+ // A pointerdown on an edge endpoint handle starts a relink — never a
+ // selection change (the edge is already selected; handles only render on
+ // selected edges).
+ if (event.relinkHandled) {
+ return false;
+ }
if (event.linkingHandled && !this.flowCoreProvider.provide().config.linking.selectNodeOnPortPress) {
return false;
}
diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/panning/panning.directive.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/panning/panning.directive.ts
index 48b20bbdb..948be7f1e 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/panning/panning.directive.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/panning/panning.directive.ts
@@ -165,6 +165,7 @@ export class PanningDirective implements OnDestroy {
event.moveSelectionHandled ||
event.zoomingHandled ||
event.linkingHandled ||
+ event.relinkHandled ||
event.rotateHandled ||
event.boxSelectionHandled
);
diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/relinking/relinking.directive.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/relinking/relinking.directive.ts
new file mode 100644
index 000000000..bd8995327
--- /dev/null
+++ b/packages/ng-diagram/projects/ng-diagram/src/lib/directives/input-events/relinking/relinking.directive.ts
@@ -0,0 +1,47 @@
+import { Directive, inject, input } from '@angular/core';
+import { Edge, EdgeEnd } from '../../../../core/src';
+import { RelinkingGestureService } from '../../../services/input-events/relinking-gesture.service';
+import { InputEventsRouterService } from '../../../services/input-events/input-events-router.service';
+import { PointerInputEvent } from '../../../types';
+
+/**
+ * Turns its host element into a grabbable edge-endpoint handle: a pointerdown
+ * starts the relink gesture for the given end of the given edge. The gesture
+ * itself is 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.
+ *
+ * @public
+ * @since 1.4.0
+ * @category Directives
+ */
+@Directive({
+ selector: '[ngDiagramRelinkHandle]',
+ standalone: true,
+ host: {
+ '(pointerdown)': 'onPointerDown($event)',
+ },
+})
+export class RelinkHandleDirective {
+ private readonly relinkingGesture = inject(RelinkingGestureService);
+ private readonly inputEventsRouter = inject(InputEventsRouterService);
+
+ edge = input.required();
+ end = input.required();
+
+ onPointerDown($event: PointerInputEvent) {
+ if (!this.inputEventsRouter.eventGuards.withPrimaryButton($event)) {
+ return;
+ }
+
+ // Mark the event only when the gesture is actually claimed — a refused
+ // relink (another gesture owns the pointer, the end is not relinkable) must not
+ // turn the pointerdown into a dead click that neither selects nor pans.
+ if (this.relinkingGesture.beginRelink($event, this.edge(), this.end())) {
+ $event.relinkHandled = true;
+ }
+ }
+}
diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/model/signal-model-adapter.spec.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/model/signal-model-adapter.spec.ts
index 3a1e42112..3f04da264 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/lib/model/signal-model-adapter.spec.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/lib/model/signal-model-adapter.spec.ts
@@ -106,6 +106,24 @@ describe('SignalModelAdapter', () => {
expect(edges[0].targetPosition).toEqual({ x: 300, y: 150 });
});
+ it('should keep both free-endpoint positions of a dual dangling edge', () => {
+ service.updateEdges([
+ {
+ id: 'dual-dangling',
+ source: '',
+ target: '',
+ data: {},
+ sourcePosition: { x: 100, y: 100 },
+ targetPosition: { x: 300, y: 150 },
+ },
+ ]);
+
+ const { edges } = JSON.parse(service.toJSON());
+
+ expect(edges[0].sourcePosition).toEqual({ x: 100, y: 100 });
+ expect(edges[0].targetPosition).toEqual({ x: 300, y: 150 });
+ });
+
it('should use overridden strip functions', () => {
service.stripNodeRuntimeProperties = (node) => node;
service.stripEdgeRuntimeProperties = (edge) => edge;
diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/providers/ng-diagram.providers.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/providers/ng-diagram.providers.ts
index f277ee99d..25374d4c8 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/lib/providers/ng-diagram.providers.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/lib/providers/ng-diagram.providers.ts
@@ -14,6 +14,8 @@ import { FlowResizeBatchProcessorService } from '../services/flow-resize-observe
import { InputEventsRouterService } from '../services/input-events/input-events-router.service';
import { LinkingEventService } from '../services/input-events/linking-event.service';
import { ManualLinkingService } from '../services/input-events/manual-linking.service';
+import { RelinkingEventService } from '../services/input-events/relinking-event.service';
+import { RelinkingGestureService } from '../services/input-events/relinking-gesture.service';
import { MarkerRegistryService } from '../services/marker-registry/marker-registry.service';
import { PanelRegistryService } from '../services/panel-registry/panel-registry.service';
import { PaletteService } from '../services/palette/palette.service';
@@ -70,6 +72,8 @@ export function provideNgDiagram(): Provider[] {
NgDiagramGroupsService,
LinkingEventService,
ManualLinkingService,
+ RelinkingEventService,
+ RelinkingGestureService,
TemplateProviderService,
MarkerRegistryService,
PanelRegistryService,
diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/public-services/ng-diagram-model.service.dangling.spec.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/public-services/ng-diagram-model.service.dangling.spec.ts
new file mode 100644
index 000000000..b471c6b08
--- /dev/null
+++ b/packages/ng-diagram/projects/ng-diagram/src/lib/public-services/ng-diagram-model.service.dangling.spec.ts
@@ -0,0 +1,309 @@
+import { TestBed } from '@angular/core/testing';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { Edge, Node } from '../../core/src';
+import { FlowCoreProviderService } from '../services/flow-core-provider/flow-core-provider.service';
+import { NgDiagramModelService } from './ng-diagram-model.service';
+import { NgDiagramService } from './ng-diagram.service';
+
+describe('NgDiagramModelService dangling edges (attachEdge / detachEdge)', () => {
+ let service: NgDiagramModelService;
+ let mockEmit: ReturnType;
+ let getEdgeById: ReturnType;
+ let getNodeById: ReturnType;
+ let validateConnection: ReturnType;
+ let danglingEdgesConfig: { enabled: boolean };
+
+ const sourceNode: Node = {
+ id: 'node-s',
+ position: { x: 0, y: 0 },
+ data: {},
+ measuredPorts: [
+ {
+ id: 'out',
+ type: 'source',
+ side: 'right',
+ position: { x: 90, y: 45 },
+ size: { width: 10, height: 10 },
+ nodeId: 'node-s',
+ },
+ ],
+ };
+
+ // Port 'in': node position (200, 100) + port position (0, 20), side left
+ // → flow position (200, 125) with the 10x10 port size.
+ const targetNode: Node = {
+ id: 'node-t',
+ position: { x: 200, y: 100 },
+ data: {},
+ measuredPorts: [
+ {
+ id: 'in',
+ type: 'target',
+ side: 'left',
+ position: { x: 0, y: 20 },
+ size: { width: 10, height: 10 },
+ nodeId: 'node-t',
+ },
+ ],
+ };
+
+ const danglingEdge: Edge = {
+ id: 'edge-1',
+ source: 'node-s',
+ sourcePort: 'out',
+ target: '',
+ targetPort: undefined,
+ targetPosition: { x: 400, y: 500 },
+ data: {},
+ };
+
+ const nodes: Record = { 'node-s': sourceNode, 'node-t': targetNode };
+
+ beforeEach(() => {
+ mockEmit = vi.fn().mockResolvedValue(undefined);
+ getEdgeById = vi.fn().mockReturnValue(danglingEdge);
+ getNodeById = vi.fn((id: string) => nodes[id] ?? null);
+ validateConnection = vi.fn().mockReturnValue(true);
+ danglingEdgesConfig = { enabled: true };
+
+ const mockFlowCore = {
+ commandHandler: { emit: mockEmit },
+ transactionManager: { isActive: vi.fn().mockReturnValue(false) },
+ getEdgeById,
+ getNodeById,
+ get config() {
+ return {
+ danglingEdges: danglingEdgesConfig,
+ linking: { validateConnection },
+ };
+ },
+ };
+
+ TestBed.configureTestingModule({
+ providers: [
+ NgDiagramModelService,
+ { provide: NgDiagramService, useValue: { isInitialized: () => false } },
+ {
+ provide: FlowCoreProviderService,
+ useValue: { provide: () => mockFlowCore, isInitialized: () => false },
+ },
+ ],
+ });
+
+ service = TestBed.inject(NgDiagramModelService);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ describe('attachEdge', () => {
+ it('should return false without updating when the port does not exist on the node', async () => {
+ const result = await service.attachEdge('edge-1', 'target', 'node-t', 'bogus-port');
+
+ expect(result).toBe(false);
+ expect(mockEmit).not.toHaveBeenCalled();
+ expect(validateConnection).not.toHaveBeenCalled();
+ });
+
+ it('should return false without updating when the node is effectively hidden', async () => {
+ getNodeById.mockImplementation((id: string) =>
+ id === 'node-t' ? { ...targetNode, computedHidden: true } : (nodes[id] ?? null)
+ );
+
+ const result = await service.attachEdge('edge-1', 'target', 'node-t', 'in');
+
+ expect(result).toBe(false);
+ expect(mockEmit).not.toHaveBeenCalled();
+ });
+
+ it('should return false when validateConnection rejects, passing the attach context', async () => {
+ validateConnection.mockReturnValue(false);
+
+ const result = await service.attachEdge('edge-1', 'target', 'node-t', 'in');
+
+ expect(result).toBe(false);
+ expect(validateConnection).toHaveBeenCalledWith(
+ sourceNode,
+ sourceNode.measuredPorts![0],
+ targetNode,
+ targetNode.measuredPorts![0],
+ {
+ reason: 'attach',
+ edge: danglingEdge,
+ end: 'target',
+ }
+ );
+ expect(mockEmit).not.toHaveBeenCalled();
+ });
+
+ it('should attach a valid port and update the edge', async () => {
+ const result = await service.attachEdge('edge-1', 'target', 'node-t', 'in');
+
+ expect(result).toBe(true);
+ expect(mockEmit).toHaveBeenCalledWith('updateEdge', {
+ id: 'edge-1',
+ edgeChanges: { target: 'node-t', targetPort: 'in', targetPosition: undefined },
+ });
+ });
+
+ it('should align the manual points of an attached manual-routing edge to the port anchor', async () => {
+ const manualEdge: Edge = {
+ ...danglingEdge,
+ routingMode: 'manual',
+ points: [
+ { x: 10, y: 20 },
+ { x: 400, y: 500 },
+ ],
+ };
+ getEdgeById.mockReturnValue(manualEdge);
+
+ const result = await service.attachEdge('edge-1', 'target', 'node-t', 'in');
+
+ expect(result).toBe(true);
+ expect(mockEmit).toHaveBeenCalledWith('updateEdge', {
+ id: 'edge-1',
+ edgeChanges: {
+ target: 'node-t',
+ targetPort: 'in',
+ targetPosition: undefined,
+ points: [
+ { x: 10, y: 20 },
+ { x: 200, y: 125 },
+ ],
+ },
+ });
+ });
+
+ it('should return false when the edge does not exist', async () => {
+ getEdgeById.mockReturnValue(null);
+
+ const result = await service.attachEdge('missing', 'target', 'node-t', 'in');
+
+ expect(result).toBe(false);
+ expect(mockEmit).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('detachEdge', () => {
+ const connectedEdge: Edge = {
+ id: 'edge-2',
+ source: 'node-s',
+ target: 'node-t',
+ targetPort: 'in',
+ data: {},
+ };
+
+ it('should warn and no-op when dangling edges are disabled', async () => {
+ danglingEdgesConfig.enabled = false;
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
+ getEdgeById.mockReturnValue(connectedEdge);
+
+ await service.detachEdge('edge-2', 'target');
+
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('detachEdge ignored'));
+ expect(mockEmit).not.toHaveBeenCalled();
+ });
+
+ it('should anchor the freed endpoint at the port position when the edge was connected to a port', async () => {
+ getEdgeById.mockReturnValue(connectedEdge);
+
+ await service.detachEdge('edge-2', 'target');
+
+ expect(mockEmit).toHaveBeenCalledWith('updateEdge', {
+ id: 'edge-2',
+ edgeChanges: { target: '', targetPort: undefined, targetPosition: { x: 200, y: 125 } },
+ });
+ });
+
+ it('should fall back to the routed endpoint when there is no port', async () => {
+ getEdgeById.mockReturnValue({
+ ...connectedEdge,
+ targetPort: undefined,
+ points: [
+ { x: 1, y: 2 },
+ { x: 300, y: 400 },
+ ],
+ });
+
+ await service.detachEdge('edge-2', 'target');
+
+ expect(mockEmit).toHaveBeenCalledWith('updateEdge', {
+ id: 'edge-2',
+ edgeChanges: { target: '', targetPort: undefined, targetPosition: { x: 300, y: 400 } },
+ });
+ });
+
+ it('should fall back to the node center when there is no port and no routed points', async () => {
+ getEdgeById.mockReturnValue({ ...connectedEdge, targetPort: undefined, points: undefined });
+ getNodeById.mockImplementation((id: string) =>
+ id === 'node-t' ? { ...targetNode, size: { width: 80, height: 40 } } : (nodes[id] ?? null)
+ );
+
+ await service.detachEdge('edge-2', 'target');
+
+ expect(mockEmit).toHaveBeenCalledWith('updateEdge', {
+ id: 'edge-2',
+ edgeChanges: { target: '', targetPort: undefined, targetPosition: { x: 240, y: 120 } },
+ });
+ });
+
+ it('should use the explicit position and align manual points', async () => {
+ getEdgeById.mockReturnValue({
+ ...connectedEdge,
+ routingMode: 'manual',
+ points: [
+ { x: 1, y: 2 },
+ { x: 300, y: 400 },
+ ],
+ });
+
+ await service.detachEdge('edge-2', 'target', { x: 600, y: 700 });
+
+ expect(mockEmit).toHaveBeenCalledWith('updateEdge', {
+ id: 'edge-2',
+ edgeChanges: {
+ target: '',
+ targetPort: undefined,
+ targetPosition: { x: 600, y: 700 },
+ points: [
+ { x: 1, y: 2 },
+ { x: 600, y: 700 },
+ ],
+ },
+ });
+ });
+
+ it('should apply the manual points patch when moving an already-free endpoint', async () => {
+ // The end is already dangling — only the anchor moves, and the stored
+ // manual path must follow it in this branch too.
+ getEdgeById.mockReturnValue({
+ ...danglingEdge,
+ routingMode: 'manual',
+ points: [
+ { x: 10, y: 20 },
+ { x: 400, y: 500 },
+ ],
+ });
+
+ await service.detachEdge('edge-1', 'target', { x: 42, y: 43 });
+
+ expect(mockEmit).toHaveBeenCalledWith('updateEdge', {
+ id: 'edge-1',
+ edgeChanges: {
+ targetPosition: { x: 42, y: 43 },
+ points: [
+ { x: 10, y: 20 },
+ { x: 42, y: 43 },
+ ],
+ },
+ });
+ });
+
+ it('should no-op on an already-free endpoint when no position is given', async () => {
+ await service.detachEdge('edge-1', 'target');
+
+ expect(mockEmit).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/public-services/ng-diagram-model.service.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/public-services/ng-diagram-model.service.ts
index a2d83720b..2abb2b5f3 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/lib/public-services/ng-diagram-model.service.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/lib/public-services/ng-diagram-model.service.ts
@@ -1,5 +1,22 @@
import { effect, inject, Injectable, OnDestroy, signal, untracked } from '@angular/core';
-import { DataObject, Edge, GroupNode, Metadata, Node, Point, Port, Rect } from '../../core/src';
+import {
+ alignManualPointsPatch,
+ computeDetachAnchor,
+ DanglingEndpoint,
+ DataObject,
+ Edge,
+ EdgeEnd,
+ getDanglingEndpoints,
+ getNearestDanglingEndpointInRange,
+ getPortFlowPosition,
+ GroupNode,
+ Metadata,
+ Node,
+ Point,
+ Port,
+ Rect,
+} from '../../core/src';
+import { isValidEndpointTarget, validateConnection } from '../../core/src/command-handler/commands/linking/utils';
import { calculatePartsBounds } from '../../core/src/utils/dimensions';
import { emitWithMeasurementOption } from './emit-with-measurement-option';
import { NgDiagramBaseService } from './ng-diagram-base.service';
@@ -152,6 +169,149 @@ export class NgDiagramModelService extends NgDiagramBaseService implements OnDes
return this.flowCore.getNearestPortInRange(point, range) || null;
}
+ /**
+ * 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 The free endpoints with their edge, end and anchor position.
+ * @since 1.4.0
+ */
+ getDanglingEndpoints(): DanglingEndpoint[] {
+ // The model lookup rebuilds its dangling-edge list lazily after a state
+ // change with one pass over all edges, then serves it from cache until the
+ // next change; this walks only the dangling subset.
+ return getDanglingEndpoints(this.flowCore.modelLookup.danglingEdges);
+ }
+
+ /**
+ * Finds the free edge endpoint nearest to a point within a range. It works
+ * like {@link getNearestPortInRange}, but for the free endpoints of dangling
+ * edges. Temporary and effectively hidden edges are skipped.
+ * @param point Point to check from.
+ * @param range Range to check in.
+ * @returns Nearest free endpoint in range, or null.
+ * @since 1.4.0
+ */
+ getNearestDanglingEndpointInRange(point: Point, range: number): DanglingEndpoint | null {
+ // The model lookup rebuilds its dangling-edge list lazily after a state
+ // change with one pass over all edges, then serves it from cache until the
+ // next change; each call walks only the dangling subset.
+ return getNearestDanglingEndpointInRange(this.flowCore.modelLookup.danglingEdges, point, range);
+ }
+
+ /**
+ * 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.
+ *
+ * @param edgeId ID of the edge to detach.
+ * @param end Which endpoint to detach.
+ * @param position Optional anchor position for the freed endpoint, in flow coordinates.
+ * @since 1.4.0
+ */
+ detachEdge(edgeId: string, end: EdgeEnd, position?: Point): Promise {
+ if (!this.flowCore.config.danglingEdges.enabled) {
+ console.warn(
+ '[ngDiagram] detachEdge ignored: dangling edges are disabled. ' +
+ 'Set config.danglingEdges.enabled = true to detach edge endpoints.'
+ );
+ return Promise.resolve();
+ }
+
+ const edge = this.getEdgeById(edgeId);
+ if (!edge) {
+ return Promise.resolve();
+ }
+
+ const nodeId = end === 'source' ? edge.source : edge.target;
+ if (!nodeId) {
+ // Already dangling on this end — only move the anchor if asked to.
+ if (!position) {
+ return Promise.resolve();
+ }
+ return this.updateEdge(edgeId, {
+ ...(end === 'source' ? { sourcePosition: position } : { targetPosition: position }),
+ ...alignManualPointsPatch(edge, end, position),
+ });
+ }
+
+ const anchor = position ?? computeDetachAnchor(edge, end, this.getNodeById(nodeId));
+ if (!anchor) {
+ return Promise.resolve();
+ }
+
+ return this.updateEdge(edgeId, {
+ ...(end === 'source'
+ ? { source: '', sourcePort: undefined, sourcePosition: anchor }
+ : { target: '', targetPort: undefined, targetPosition: anchor }),
+ ...alignManualPointsPatch(edge, end, anchor),
+ });
+ }
+
+ /**
+ * Attaches one endpoint of an edge to a node and, optionally, to a port.
+ * This is the opposite of {@link 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'`.
+ *
+ * @param edgeId ID of the edge to attach.
+ * @param end Which endpoint to attach.
+ * @param nodeId ID of the node to attach to.
+ * @param portId ID of the port to attach to. When omitted, the endpoint is attached to the node without a port.
+ * @returns Whether the connection was valid and applied.
+ * @since 1.4.0
+ */
+ async attachEdge(edgeId: string, end: EdgeEnd, nodeId: string, portId?: string): Promise {
+ const edge = this.getEdgeById(edgeId);
+ if (!edge) {
+ return false;
+ }
+
+ // Same structural rules as a relink drop: the node must exist and be
+ // visible, the port must exist, point the right direction and not be
+ // template-hidden — otherwise the edge would reference a port that
+ // routing cannot resolve.
+ if (!isValidEndpointTarget(this.flowCore, end, nodeId, portId)) {
+ return false;
+ }
+
+ const sourceNodeId = end === 'source' ? nodeId : edge.source || undefined;
+ const sourcePortId = end === 'source' ? portId : edge.sourcePort;
+ const targetNodeId = end === 'target' ? nodeId : edge.target || undefined;
+ const targetPortId = end === 'target' ? portId : edge.targetPort;
+
+ if (
+ !validateConnection(this.flowCore, sourceNodeId, sourcePortId, targetNodeId, targetPortId, true, {
+ reason: 'attach',
+ edge,
+ end,
+ })
+ ) {
+ return false;
+ }
+
+ const node = this.getNodeById(nodeId);
+ const newAnchor = node && portId ? getPortFlowPosition(node, portId) : null;
+
+ await this.updateEdge(edgeId, {
+ ...(end === 'source'
+ ? { source: nodeId, sourcePort: portId, sourcePosition: undefined }
+ : { target: nodeId, targetPort: portId, targetPosition: undefined }),
+ ...(newAnchor ? alignManualPointsPatch(edge, end, newAnchor) : {}),
+ });
+ return true;
+ }
+
/**
* Gets a node by id.
* @typeParam T - The type of the node's `data` property. Defaults to `DataObject`.
diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/public-services/ng-diagram.service.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/public-services/ng-diagram.service.ts
index 89307de15..fb9e50dec 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/lib/public-services/ng-diagram.service.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/lib/public-services/ng-diagram.service.ts
@@ -7,6 +7,7 @@ import {
EventListener,
Middleware,
Node,
+ Point,
TransactionCallback,
TransactionOptions,
TransactionResult,
@@ -215,6 +216,25 @@ export class NgDiagramService extends NgDiagramBaseService {
this.manualLinkingService.startLinking(node, portId);
}
+ /**
+ * 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.
+ *
+ * @param position The position in flow (diagram) coordinates where the edge starts.
+ * @since 1.4.0
+ */
+ startLinkingFromPosition(position: Point) {
+ this.manualLinkingService.startLinkingFromPosition(position);
+ }
+
// ==============================
// Interaction Control
// ==============================
diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/manual-linking.service.spec.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/manual-linking.service.spec.ts
index 294906f61..498da4869 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/manual-linking.service.spec.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/manual-linking.service.spec.ts
@@ -41,7 +41,16 @@ describe('ManualLinkingService', () => {
},
{
provide: FlowCoreProviderService,
- useValue: { isInitialized: () => true, provide: () => ({ registerInteractionCleanup, getNodeById }) },
+ useValue: {
+ isInitialized: () => true,
+ provide: () => ({
+ registerInteractionCleanup,
+ getNodeById,
+ actionStateManager: { isLinking: () => false },
+ config: { danglingEdges: { enabled: true } },
+ commandHandler: { emit: vi.fn() },
+ }),
+ },
},
],
});
diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/manual-linking.service.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/manual-linking.service.ts
index defe7d0a6..43cdec188 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/manual-linking.service.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/manual-linking.service.ts
@@ -1,5 +1,5 @@
import { inject, Injectable } from '@angular/core';
-import { Node } from '../../../core/src';
+import { Node, Point } from '../../../core/src';
import { PointerInputEvent } from '../../types';
import { CursorPositionTrackerService } from '../cursor-position-tracker/cursor-position-tracker.service';
import { FlowCoreProviderService } from '../flow-core-provider/flow-core-provider.service';
@@ -19,7 +19,12 @@ export class ManualLinkingService {
// Validate BEFORE attaching document listeners or emitting — an
// effectively hidden source is refused by the startLinking command, and
// listeners attached here would be orphaned until the next click.
- const currentNode = this.flowCoreProvider.provide().getNodeById(node.id);
+ const flowCore = this.flowCoreProvider.provide();
+ if (flowCore.actionStateManager.isLinking()) {
+ console.warn('[ngDiagram] startLinking ignored: another linking or relinking gesture is in progress.');
+ return;
+ }
+ const currentNode = flowCore.getNodeById(node.id);
if (!currentNode || currentNode.computedHidden) {
console.warn(`[ngDiagram] startLinking ignored: source node "${node.id}" is missing or effectively hidden.`);
return;
@@ -48,6 +53,49 @@ export class ManualLinkingService {
.registerInteractionCleanup(() => this.removeListeners());
}
+ /**
+ * Call this method to start linking from a position on the canvas (no source
+ * node) from your custom logic. The edge follows the pointer until a click
+ * finishes it.
+ *
+ * Requires `danglingEdges.enabled` — an edge drawn from a position has an
+ * empty source, i.e. it is a dangling edge by construction.
+ */
+ startLinkingFromPosition(position: Point) {
+ const flowCore = this.flowCoreProvider.provide();
+ // Validate BEFORE attaching document listeners — a refused command would
+ // leave the click-capture listener swallowing the next click.
+ if (!flowCore.config.danglingEdges.enabled) {
+ console.warn(
+ '[ngDiagram] startLinkingFromPosition ignored: dangling edges are disabled. ' +
+ 'Set config.danglingEdges.enabled = true to draw edges from a position.'
+ );
+ return;
+ }
+ if (flowCore.actionStateManager.isLinking()) {
+ console.warn(
+ '[ngDiagram] startLinkingFromPosition ignored: another linking or relinking gesture is in progress.'
+ );
+ return;
+ }
+
+ // Defensive: a stale set of listeners (previous gesture torn down without
+ // reaching removeListeners) must not double-drive the new draw.
+ this.removeListeners();
+ this.node = undefined;
+ this.portId = undefined;
+
+ flowCore.commandHandler.emit('startLinkingFromPosition', { position });
+
+ document.addEventListener('pointermove', this.onPointerMove);
+ document.addEventListener('click', this.onDocumentClick, true);
+ document.addEventListener('touchmove', this.onTouchMove, { passive: false });
+ document.addEventListener('touchend', this.onTouchEnd, { passive: false });
+ this.unregisterInteractionCleanup = this.flowCoreProvider
+ .provide()
+ .registerInteractionCleanup(() => this.removeListeners());
+ }
+
private onPointerMove = (event: PointerEvent) => {
if (event.pointerType === 'touch') {
return;
diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/relinking-event.service.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/relinking-event.service.ts
new file mode 100644
index 000000000..5457ae820
--- /dev/null
+++ b/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/relinking-event.service.ts
@@ -0,0 +1,52 @@
+import { inject, Injectable } from '@angular/core';
+import { Edge, EdgeEnd, Point } from '../../../core/src';
+import { PointerInputEvent } from '../../types';
+import { InputEventsRouterService } from './input-events-router.service';
+
+@Injectable()
+export class RelinkingEventService {
+ private readonly inputEventsRouter = inject(InputEventsRouterService);
+
+ emitStart(event: PointerInputEvent, target: Edge | undefined, end: EdgeEnd) {
+ const baseEvent = this.inputEventsRouter.getBaseEvent(event);
+ this.inputEventsRouter.emit({
+ ...baseEvent,
+ name: 'relinking',
+ phase: 'start',
+ target,
+ targetType: 'edge',
+ end,
+ lastInputPoint: { x: event.clientX, y: event.clientY },
+ panningForce: null,
+ });
+ }
+
+ emitContinue(event: PointerInputEvent, target: Edge | undefined, end: EdgeEnd, panningForce: Point | null = null) {
+ const baseEvent = this.inputEventsRouter.getBaseEvent(event);
+ this.inputEventsRouter.emit({
+ ...baseEvent,
+ name: 'relinking',
+ phase: 'continue',
+ target,
+ targetType: 'edge',
+ end,
+ lastInputPoint: { x: event.clientX, y: event.clientY },
+ panningForce,
+ });
+ }
+
+ emitEnd(event: PointerInputEvent, target: Edge | undefined, end: EdgeEnd, takenOver = false) {
+ const baseEvent = this.inputEventsRouter.getBaseEvent(event);
+ this.inputEventsRouter.emit({
+ ...baseEvent,
+ name: 'relinking',
+ phase: 'end',
+ target,
+ targetType: 'edge',
+ end,
+ lastInputPoint: { x: event.clientX, y: event.clientY },
+ panningForce: null,
+ takenOver,
+ });
+ }
+}
diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/relinking-gesture.service.spec.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/relinking-gesture.service.spec.ts
new file mode 100644
index 000000000..b83b0dc1c
--- /dev/null
+++ b/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/relinking-gesture.service.spec.ts
@@ -0,0 +1,288 @@
+import { TestBed } from '@angular/core/testing';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { Edge, EdgeEnd } from '../../../core/src';
+import { PointerInputEvent } from '../../types';
+import { FlowCoreProviderService } from '../flow-core-provider/flow-core-provider.service';
+import { TouchEventsStateService } from '../touch-events-state-service/touch-events-state-service.service';
+import { RelinkingEventService } from './relinking-event.service';
+import { RelinkingGestureService } from './relinking-gesture.service';
+
+describe('RelinkingGestureService', () => {
+ let service: RelinkingGestureService;
+ let emitStart: ReturnType;
+ let emitContinue: ReturnType;
+ let emitEnd: ReturnType;
+ let cancelActiveInteraction: ReturnType;
+ let registerInteractionCleanup: ReturnType;
+ let isLinking: ReturnType;
+ let isCancellingInteraction: ReturnType;
+ let panningHandled: ReturnType;
+ let zoomingHandled: ReturnType;
+ let defaultRelinkable: boolean | EdgeEnd;
+
+ const edge: Edge = { id: 'edge-1', source: 'node-a', target: 'node-b', data: {} };
+
+ const pointerDownEvent = (overrides: Partial = {}): PointerInputEvent =>
+ ({ pointerId: 1, clientX: 0, clientY: 0, button: 0, ...overrides }) as PointerInputEvent;
+
+ /** Dispatches a document-level pointer event (jsdom-safe plain Event with pointer fields). */
+ const dispatch = (
+ type: 'pointermove' | 'pointerup' | 'pointercancel',
+ init: { pointerId?: number; clientX?: number; clientY?: number; button?: number } = {}
+ ) => {
+ const event = new Event(type);
+ Object.assign(event, { pointerId: 1, clientX: 0, clientY: 0, button: 0, ...init });
+ document.dispatchEvent(event);
+ };
+
+ beforeEach(() => {
+ emitStart = vi.fn();
+ emitContinue = vi.fn();
+ emitEnd = vi.fn();
+ cancelActiveInteraction = vi.fn();
+ registerInteractionCleanup = vi.fn().mockReturnValue(vi.fn());
+ isLinking = vi.fn().mockReturnValue(false);
+ isCancellingInteraction = vi.fn().mockReturnValue(false);
+ panningHandled = vi.fn().mockReturnValue(false);
+ zoomingHandled = vi.fn().mockReturnValue(false);
+ defaultRelinkable = true;
+
+ const mockFlowCore = {
+ actionStateManager: { isLinking },
+ isCancellingInteraction,
+ registerInteractionCleanup,
+ cancelActiveInteraction,
+ get config() {
+ return {
+ linking: {
+ defaultRelinkable,
+ edgePanningEnabled: false,
+ edgePanningThreshold: 0,
+ edgePanningForce: 0,
+ },
+ };
+ },
+ };
+
+ TestBed.configureTestingModule({
+ providers: [
+ RelinkingGestureService,
+ { provide: RelinkingEventService, useValue: { emitStart, emitContinue, emitEnd } },
+ {
+ provide: FlowCoreProviderService,
+ useValue: { isInitialized: () => true, provide: () => mockFlowCore },
+ },
+ {
+ provide: TouchEventsStateService,
+ useValue: {
+ panningHandled,
+ zoomingHandled,
+ currentEvent: { set: vi.fn() },
+ clearCurrentEvent: vi.fn(),
+ },
+ },
+ ],
+ });
+
+ service = TestBed.inject(RelinkingGestureService);
+ });
+
+ afterEach(() => {
+ // Make sure no document listeners leak between tests.
+ service.ngOnDestroy();
+ });
+
+ describe('beginRelink refusals', () => {
+ it('should return false when neither the edge nor the default allows relinking', () => {
+ defaultRelinkable = false;
+
+ expect(service.beginRelink(pointerDownEvent(), edge, 'target')).toBe(false);
+
+ dispatch('pointermove', { clientX: 100 });
+ expect(emitStart).not.toHaveBeenCalled();
+ });
+
+ it('should return false and register no document listeners for a locked end', () => {
+ const addEventListener = vi.spyOn(document, 'addEventListener');
+
+ expect(service.beginRelink(pointerDownEvent(), { ...edge, relinkable: 'target' }, 'source')).toBe(false);
+
+ expect(addEventListener).not.toHaveBeenCalled();
+ expect(registerInteractionCleanup).not.toHaveBeenCalled();
+ dispatch('pointermove', { clientX: 100 });
+ expect(emitStart).not.toHaveBeenCalled();
+ addEventListener.mockRestore();
+ });
+
+ it('should claim the gesture for an end the edge allows although the default is false', () => {
+ defaultRelinkable = false;
+
+ expect(service.beginRelink(pointerDownEvent(), { ...edge, relinkable: 'target' }, 'target')).toBe(true);
+ });
+
+ it('should return false when a gesture is already active', () => {
+ expect(service.beginRelink(pointerDownEvent(), edge, 'target')).toBe(true);
+ expect(service.beginRelink(pointerDownEvent({ pointerId: 2 }), edge, 'target')).toBe(false);
+ });
+
+ it('should return false when a linking gesture is already in progress', () => {
+ isLinking.mockReturnValue(true);
+
+ expect(service.beginRelink(pointerDownEvent(), edge, 'target')).toBe(false);
+ });
+
+ it('should return false when the pointerdown was claimed by box selection', () => {
+ expect(service.beginRelink(pointerDownEvent({ boxSelectionHandled: true }), edge, 'target')).toBe(false);
+ });
+
+ it('should return false while an interaction cancel is rolling back', () => {
+ isCancellingInteraction.mockReturnValue(true);
+
+ expect(service.beginRelink(pointerDownEvent(), edge, 'target')).toBe(false);
+ });
+ });
+
+ describe('movement threshold', () => {
+ it('should emit nothing on pointerdown alone', () => {
+ service.beginRelink(pointerDownEvent(), edge, 'target');
+
+ expect(emitStart).not.toHaveBeenCalled();
+ expect(emitContinue).not.toHaveBeenCalled();
+ expect(service.active()).toBe(false);
+ });
+
+ it('should emit nothing and remove listeners on a pointerup within the threshold', () => {
+ service.beginRelink(pointerDownEvent(), edge, 'target');
+
+ dispatch('pointermove', { clientX: 2 });
+ dispatch('pointerup');
+
+ // A plain click on an endpoint handle is not a relink.
+ expect(emitStart).not.toHaveBeenCalled();
+ expect(emitContinue).not.toHaveBeenCalled();
+ expect(emitEnd).not.toHaveBeenCalled();
+
+ // Listeners are gone — later pointer traffic emits nothing.
+ dispatch('pointermove', { clientX: 100 });
+ expect(emitStart).not.toHaveBeenCalled();
+
+ // The gesture slot is released — a new relink can be claimed.
+ expect(service.beginRelink(pointerDownEvent(), edge, 'target')).toBe(true);
+ });
+
+ it('should emit start once, then continue, after the pointer travels beyond the threshold', () => {
+ service.beginRelink(pointerDownEvent(), edge, 'target');
+
+ dispatch('pointermove', { clientX: 10 });
+
+ expect(emitStart).toHaveBeenCalledTimes(1);
+ expect(emitStart).toHaveBeenCalledWith(expect.anything(), edge, 'target');
+ expect(emitContinue).toHaveBeenCalledTimes(1);
+
+ dispatch('pointermove', { clientX: 20 });
+
+ expect(emitStart).toHaveBeenCalledTimes(1);
+ expect(emitContinue).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ describe('pointerup filtering', () => {
+ beforeEach(() => {
+ service.beginRelink(pointerDownEvent(), edge, 'target');
+ dispatch('pointermove', { clientX: 10 });
+ });
+
+ it('should ignore a pointerup from a different pointer', () => {
+ dispatch('pointerup', { pointerId: 2 });
+
+ expect(emitEnd).not.toHaveBeenCalled();
+ expect(service.active()).toBe(true);
+ });
+
+ it('should ignore a pointerup of a non-primary button', () => {
+ dispatch('pointerup', { button: 2 });
+
+ expect(emitEnd).not.toHaveBeenCalled();
+ expect(service.active()).toBe(true);
+ });
+
+ it('should finish the relink on the gesture pointer releasing the primary button', () => {
+ dispatch('pointerup', { clientX: 10 });
+
+ expect(emitEnd).toHaveBeenCalledTimes(1);
+ expect(emitEnd).toHaveBeenCalledWith(expect.anything(), edge, 'target', false);
+ expect(service.active()).toBe(false);
+ });
+ });
+
+ it('should abort with takenOver on pointercancel after the gesture started', () => {
+ service.beginRelink(pointerDownEvent(), edge, 'target');
+ dispatch('pointermove', { clientX: 10 });
+
+ dispatch('pointercancel');
+
+ expect(emitEnd).toHaveBeenCalledTimes(1);
+ expect(emitEnd).toHaveBeenCalledWith(expect.anything(), edge, 'target', true);
+ expect(service.active()).toBe(false);
+ });
+
+ it('should not emit end on pointercancel before the threshold was crossed', () => {
+ service.beginRelink(pointerDownEvent(), edge, 'target');
+
+ dispatch('pointercancel');
+
+ expect(emitEnd).not.toHaveBeenCalled();
+ });
+
+ describe('ngOnDestroy', () => {
+ it('should remove the document listeners so later pointer traffic emits nothing', () => {
+ service.beginRelink(pointerDownEvent(), edge, 'target');
+ dispatch('pointermove', { clientX: 10 });
+ expect(emitStart).toHaveBeenCalledTimes(1);
+
+ service.ngOnDestroy();
+
+ dispatch('pointermove', { clientX: 30 });
+ dispatch('pointerup');
+ expect(emitContinue).toHaveBeenCalledTimes(1);
+ expect(emitEnd).not.toHaveBeenCalled();
+ });
+
+ it('should cancel the active interaction when destroyed mid-gesture', () => {
+ service.beginRelink(pointerDownEvent(), edge, 'target');
+ dispatch('pointermove', { clientX: 10 });
+
+ service.ngOnDestroy();
+
+ // The pointerup will never be routed — the core linking state must not
+ // stay claimed.
+ expect(cancelActiveInteraction).toHaveBeenCalledTimes(1);
+ });
+
+ it('should not cancel anything when destroyed before the gesture started', () => {
+ service.beginRelink(pointerDownEvent(), edge, 'target');
+
+ service.ngOnDestroy();
+
+ expect(cancelActiveInteraction).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('active signal', () => {
+ it('should be true only between crossing the threshold and the pointerup', () => {
+ expect(service.active()).toBe(false);
+
+ service.beginRelink(pointerDownEvent(), edge, 'target');
+ expect(service.active()).toBe(false);
+
+ dispatch('pointermove', { clientX: 2 });
+ expect(service.active()).toBe(false);
+
+ dispatch('pointermove', { clientX: 10 });
+ expect(service.active()).toBe(true);
+
+ dispatch('pointerup', { clientX: 10 });
+ expect(service.active()).toBe(false);
+ });
+ });
+});
diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/relinking-gesture.service.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/relinking-gesture.service.ts
new file mode 100644
index 000000000..55cbde579
--- /dev/null
+++ b/packages/ng-diagram/projects/ng-diagram/src/lib/services/input-events/relinking-gesture.service.ts
@@ -0,0 +1,219 @@
+import { Injectable, OnDestroy, inject, signal } from '@angular/core';
+import { Edge, EdgeEnd, FPS_60, isEdgeEndRelinkable, NgDiagramMath, Point } from '../../../core/src';
+import { MOVE_THRESHOLD } from '../../../core/src/input-events/handlers/pointer-move-selection/pointer-move-selection.handler';
+import { DiagramEventName, PointerInputEvent } from '../../types';
+import { FlowCoreProviderService } from '../flow-core-provider/flow-core-provider.service';
+import { TouchEventsStateService } from '../touch-events-state-service/touch-events-state-service.service';
+import { RelinkingEventService } from './relinking-event.service';
+
+/**
+ * Drives an edge relink gesture from document-level listeners.
+ *
+ * The gesture cannot live on the endpoint handle element: starting a relink
+ * hides the original edge from rendering, which unmounts the handle that
+ * received the pointerdown. This diagram-scoped service outlives it.
+ *
+ * A pointerdown only registers the candidate; the relink starts once the
+ * pointer travels beyond {@link MOVE_THRESHOLD} — a plain click on a handle
+ * must neither detach the edge nor emit relink events.
+ */
+@Injectable()
+export class RelinkingGestureService implements OnDestroy {
+ private readonly relinkingEventService = inject(RelinkingEventService);
+ private readonly flowCoreProvider = inject(FlowCoreProviderService);
+ private readonly touchEventsStateService = inject(TouchEventsStateService);
+
+ private edge: Edge | undefined;
+ private end: EdgeEnd = 'target';
+ private pointerId: number | null = null;
+ private startClientPoint: Point | null = null;
+ private started = false;
+ private gestureActive = false;
+ private edgePanningInterval: number | null = null;
+ private lastPanningEvent: PointerInputEvent | null = null;
+ private lastPanningForce: Point | null = null;
+ private unregisterInteractionCleanup: (() => void) | null = null;
+
+ /**
+ * True while an endpoint is actually being dragged (the movement threshold
+ * was crossed). Drives the diagram host's `relinking` class so the grabbing
+ * cursor survives the handle unmounting mid-gesture.
+ */
+ readonly active = signal(false);
+
+ ngOnDestroy(): void {
+ const wasMidGesture = this.started;
+ this.removeListeners();
+ // Destroyed mid-gesture (the diagram itself was torn down): the pointerup
+ // will never be routed and finishRelinking will never run. Run the full
+ // cancel flow so the core linking state does not stay claimed.
+ if (wasMidGesture && this.flowCoreProvider.isInitialized()) {
+ void this.flowCoreProvider.provide().cancelActiveInteraction();
+ }
+ }
+
+ /**
+ * Registers a pointerdown on an endpoint handle as a relink candidate.
+ * @returns Whether the gesture was claimed.
+ */
+ beginRelink(event: PointerInputEvent, edge: Edge, end: EdgeEnd): boolean {
+ const flowCore = this.flowCoreProvider.provide();
+
+ // Re-entry guard — a second pointerdown mid-gesture would orphan the
+ // previous interaction-cleanup registration.
+ if (this.gestureActive || flowCore.actionStateManager.isLinking()) {
+ return false;
+ }
+ if (!isEdgeEndRelinkable(edge, end, flowCore.config.linking.defaultRelinkable)) {
+ return false;
+ }
+ // An Escape-triggered cancel can still be rolling back state.
+ if (flowCore.isCancellingInteraction()) {
+ return false;
+ }
+ // Another gesture claimed this pointerdown (box selection runs in the
+ // capture phase; panning/zooming are touch takeovers).
+ if (
+ event.boxSelectionHandled ||
+ this.touchEventsStateService.panningHandled() ||
+ this.touchEventsStateService.zoomingHandled()
+ ) {
+ return false;
+ }
+
+ this.edge = edge;
+ this.end = end;
+ this.pointerId = event.pointerId;
+ this.startClientPoint = { x: event.clientX, y: event.clientY };
+ this.started = false;
+ this.gestureActive = true;
+ this.touchEventsStateService.currentEvent.set(DiagramEventName.Linking);
+
+ document.addEventListener('pointermove', this.onPointerMove);
+ document.addEventListener('pointerup', this.onPointerUp);
+ document.addEventListener('pointercancel', this.onPointerCancel);
+ this.unregisterInteractionCleanup = flowCore.registerInteractionCleanup(() => this.removeListeners());
+
+ return true;
+ }
+
+ private onPointerMove = ($event: PointerInputEvent) => {
+ if ($event.pointerId !== this.pointerId) {
+ return;
+ }
+ if (this.touchEventsStateService.panningHandled() || this.touchEventsStateService.zoomingHandled()) {
+ // Another touch gesture claimed the pointer — this move may come from the
+ // other finger, so the relink is cancelled instead of finished at its point.
+ if (this.started) {
+ this.relinkingEventService.emitEnd($event, this.edge, this.end, true);
+ }
+ this.removeListeners();
+ return;
+ }
+
+ if (!this.started) {
+ const start = this.startClientPoint;
+ const travelled = start
+ ? NgDiagramMath.distanceBetweenPoints(start, { x: $event.clientX, y: $event.clientY })
+ : 0;
+ if (travelled < MOVE_THRESHOLD) {
+ return;
+ }
+ this.started = true;
+ this.active.set(true);
+ this.relinkingEventService.emitStart($event, this.edge, this.end);
+ }
+
+ const flowCore = this.flowCoreProvider.provide();
+ const { edgePanningThreshold, edgePanningEnabled, edgePanningForce } = flowCore.config.linking;
+
+ let panningForce: Point | null = null;
+ if (edgePanningEnabled) {
+ const { width, height } = flowCore.getViewport();
+ const { x, y } = flowCore.getFlowOffset();
+ const boundingRect = { x, y, width: width ?? 0, height: height ?? 0 };
+ panningForce = NgDiagramMath.calculateEdgePanningForce(
+ boundingRect,
+ { x: $event.clientX, y: $event.clientY },
+ edgePanningThreshold,
+ edgePanningForce
+ );
+ // The interval reads these fields on every tick — updating them here
+ // keeps auto-pan following the live pointer position and direction
+ // instead of replaying the coordinates captured when panning started.
+ this.lastPanningEvent = $event;
+ this.lastPanningForce = panningForce;
+ if (panningForce) {
+ this.startEdgePanning();
+ } else {
+ this.stopEdgePanning();
+ }
+ }
+
+ this.relinkingEventService.emitContinue($event, this.edge, this.end, panningForce);
+ };
+
+ private onPointerUp = ($event: PointerInputEvent) => {
+ // Only the gesture's own pointer releasing its primary button finishes the
+ // relink — a second finger's tap or a right-button release must not commit
+ // at its coordinates.
+ if ($event.pointerId !== this.pointerId || $event.button !== 0) {
+ return;
+ }
+ const takenOver = this.touchEventsStateService.panningHandled() || this.touchEventsStateService.zoomingHandled();
+ if (this.started) {
+ this.relinkingEventService.emitEnd($event, this.edge, this.end, takenOver);
+ }
+ this.removeListeners();
+ };
+
+ private onPointerCancel = ($event: PointerInputEvent) => {
+ if ($event.pointerId !== this.pointerId) {
+ return;
+ }
+ // A cancelled pointer carries no usable coordinates — abort the relink
+ // instead of committing anything.
+ if (this.started) {
+ this.relinkingEventService.emitEnd($event, this.edge, this.end, true);
+ }
+ this.removeListeners();
+ };
+
+ private removeListeners() {
+ this.unregisterInteractionCleanup?.();
+ this.unregisterInteractionCleanup = null;
+ if (this.gestureActive) {
+ this.gestureActive = false;
+ this.touchEventsStateService.clearCurrentEvent();
+ }
+ this.started = false;
+ this.active.set(false);
+ this.edge = undefined;
+ this.pointerId = null;
+ this.startClientPoint = null;
+ document.removeEventListener('pointermove', this.onPointerMove);
+ document.removeEventListener('pointerup', this.onPointerUp);
+ document.removeEventListener('pointercancel', this.onPointerCancel);
+ this.stopEdgePanning();
+ }
+
+ private startEdgePanning(): void {
+ if (this.edgePanningInterval !== null) {
+ return;
+ }
+ this.edgePanningInterval = window.setInterval(() => {
+ if (this.lastPanningEvent && this.lastPanningForce) {
+ this.relinkingEventService.emitContinue(this.lastPanningEvent, this.edge, this.end, this.lastPanningForce);
+ }
+ }, FPS_60);
+ }
+
+ private stopEdgePanning(): void {
+ if (this.edgePanningInterval !== null) {
+ clearInterval(this.edgePanningInterval);
+ this.edgePanningInterval = null;
+ }
+ this.lastPanningEvent = null;
+ this.lastPanningForce = null;
+ }
+}
diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/styles/styles.css b/packages/ng-diagram/projects/ng-diagram/src/lib/styles/styles.css
index 1522cbed2..3858f93cf 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/lib/styles/styles.css
+++ b/packages/ng-diagram/projects/ng-diagram/src/lib/styles/styles.css
@@ -37,6 +37,12 @@
--ngd-resize-handle-border-color: var(--ngd-transform-box);
--ngd-resize-handle-border-radius: 0;
+ --ngd-relink-handle-size: 0.625rem;
+ --ngd-relink-handle-fill: var(--ngd-ui-bg-primary-default);
+ --ngd-relink-handle-fill-hover: var(--ngd-transform-box);
+ --ngd-relink-handle-stroke: var(--ngd-transform-box);
+ --ngd-relink-handle-stroke-width: 0.09375rem;
+
--ngd-port-size: 0.25rem;
--ngd-port-background-color: var(--ngd-node-port-fill-default);
--ngd-port-border-color: var(--ngd-node-port-stroke-default);
diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/types/pointer-event.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/types/pointer-event.ts
index a96a03bcc..bd1ea69fc 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/lib/types/pointer-event.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/lib/types/pointer-event.ts
@@ -23,6 +23,7 @@ export interface PointerInputEvent extends PointerEvent {
moveSelectionHandled?: boolean;
zoomingHandled?: boolean;
linkingHandled?: boolean;
+ relinkHandled?: boolean;
rotateHandled?: boolean;
selectHandled?: boolean;
boxSelectionHandled?: boolean;
diff --git a/packages/ng-diagram/projects/ng-diagram/src/public-api.ts b/packages/ng-diagram/projects/ng-diagram/src/public-api.ts
index 4f4b1ae21..77d57d0e9 100644
--- a/packages/ng-diagram/projects/ng-diagram/src/public-api.ts
+++ b/packages/ng-diagram/projects/ng-diagram/src/public-api.ts
@@ -26,6 +26,7 @@ export { BoxSelectionDirective } from './lib/directives/input-events/box-selecti
export { MobileBoxSelectionDirective } from './lib/directives/input-events/box-selection/mobile-box-selection.directive';
export { KeyboardInputsDirective } from './lib/directives/input-events/keyboard-inputs/keyboard-inputs.directive';
export { LinkingInputDirective } from './lib/directives/input-events/linking/linking.directive';
+export { RelinkHandleDirective } from './lib/directives/input-events/relinking/relinking.directive';
export {
DiagramSelectionDirective,
EdgeSelectionDirective,
@@ -59,6 +60,16 @@ export { NgDiagramService } from './lib/public-services/ng-diagram.service';
// Configuration helpers
export { configureShortcuts } from './core/src';
+
+// Dangling-edge and relinking utilities
+export {
+ computeDetachAnchor,
+ getDanglingEndpoints,
+ getNearestDanglingEndpointInRange,
+ hasFreeEndpoint,
+ isDanglingEdge,
+ isEdgeEndRelinkable,
+} from './core/src';
export { NgDiagramMinimapNodeTemplateMap } from './lib/components/minimap/ng-diagram-minimap.types';
export { initializeModel, initializeModelAdapter, type InitializeModelOptions } from './lib/model/initialize-model';
export {
@@ -95,7 +106,10 @@ export type {
BackgroundConfig,
BoxSelectionConfig,
ClipboardPastedEvent,
+ ConnectionValidationContext,
CopyPasteActionState,
+ DanglingEdgesConfig,
+ DanglingEndpoint,
DataObject,
DefaultNodeTemplateConfig,
DiagramEventMap,
@@ -105,8 +119,12 @@ export type {
EdgeDrawCancelReason,
EdgeDrawEndedEvent,
EdgeDrawnEvent,
+ EdgeEnd,
EdgeLabel,
EdgeLabelPosition,
+ EdgeRelinkCancelReason,
+ EdgeRelinkEndedEvent,
+ EdgeRelinkStartedEvent,
EdgeRouting,
EdgeRoutingConfig,
EdgeRoutingContext,
@@ -130,6 +148,7 @@ export type {
KeyboardZoomAction,
LinkingActionState,
LinkingConfig,
+ LinkingRelinkContext,
loggerMiddleware,
Metadata,
Middleware,
|