Skip to content

Dangling Edges

since v1.4.0

Dangling edges are edges with one or both endpoints not connected to any node. They are useful when connections are sketched before both nodes exist, when deleting a node should keep its connections, or when free-floating lines (for example annotations) are part of your diagram.

The feature is fully opt-in. With the default settings, an edge dropped on empty canvas is discarded, and deleting a node deletes its edges.

Try it below: draw an edge from a port onto empty canvas to keep it as a dangling edge, select a node and press Delete to detach its edges instead of deleting them, or select the connected edge and drag one of its handles onto empty canvas to detach that end.

A free (unconnected) endpoint has an empty string in source or target, and its anchor position is stored in sourcePosition or targetPosition:

model = initializeModel({
5 collapsed lines
nodes: [
{ id: 'node-1', position: { x: 100, y: 100 }, data: { label: 'Node 1' } },
{ id: 'node-2', position: { x: 500, y: 100 }, data: { label: 'Node 2' } },
{ id: 'node-3', position: { x: 500, y: 300 }, data: { label: 'Node 3' } },
],
edges: [
8 collapsed lines
{
id: 'connected',
source: 'node-1',
sourcePort: 'port-right',
target: 'node-2',
targetPort: 'port-left',
data: {},
},
{
id: 'dangling',
source: '', // free endpoint: no node, no port
sourcePosition: { x: 200, y: 340 }, // anchored here, in flow coordinates
target: 'node-3',
targetPort: 'port-left',
data: {},
},
],
});

A free endpoint never has a port: when an endpoint is detached, its sourcePort or targetPort is cleared. An edge with both endpoints free is a dual dangling edge. It routes between its two anchor positions.

Dangling edges are controlled by the danglingEdges configuration:

config = {
6 collapsed lines
zoom: {
zoomToFit: {
onInit: true,
padding: 80,
},
},
danglingEdges: {
// Keep edges dropped on empty canvas
enabled: true,
// Deleting a node detaches its edges instead of deleting them
detachOnNodeDelete: true,
},
// Optional: selected edges get handles, so users can also detach an end by dragging it onto empty canvas
linking: {
defaultRelinkable: true,
},
} satisfies NgDiagramConfig;

enabled is the master switch. When true:

  • An edge drawn onto empty canvas is kept as a dangling edge instead of being discarded.
  • An edge relink dropped on empty canvas detaches that endpoint.
  • detachEdge and startLinkingFromPosition become available. With the feature off, they do nothing and log a console warning.

With enabled: true, every edge draw released on empty canvas is kept as a dangling edge, anchored where the pointer was released. A drop on a port that the edge cannot connect to (for example a port with the wrong direction) does not count as a drop on empty canvas, so the draw is discarded. To decide per edge, provide shouldKeepOnDrop:

const config: NgDiagramConfig = {
danglingEdges: {
enabled: true,
// Keep only edges drawn from "output" ports as dangling
shouldKeepOnDrop: (edge, dropPosition) => edge.sourcePort?.startsWith('output') ?? false,
},
};

The edge argument is the final edge, after linking.finalEdgeDataBuilder has run. Returning false discards the edge, which is also what happens when the feature is off. The same callback runs for a relink dropped on empty canvas. There it receives the edge as it would be after the detach, and returning false reverts the relink.

By default, deleting a node deletes all of its edges. Set detachOnNodeDelete to keep them as dangling edges instead, anchored where their port was:

const config: NgDiagramConfig = {
danglingEdges: {
enabled: true,
detachOnNodeDelete: true,
},
};

For per-edge control, provide shouldDetachOnNodeDelete. It is called once for each endpoint that loses its node, and returning false deletes the edge:

const config: NgDiagramConfig = {
danglingEdges: {
enabled: true,
detachOnNodeDelete: true,
shouldDetachOnNodeDelete: (edge, deletedNode, end) => edge.data?.keepOnDelete === true,
},
};

The detach rules are:

  • An explicit delete always wins. An edge that is itself part of the deleted selection is deleted, never detached.
  • Hidden wiring never becomes visible. An edge that is hidden only because of the node it loses (for example the edges of the collapsed children of a deleted group) is deleted. Detaching it would turn invisible wiring into a visible dangling edge. An edge that is hidden for another reason (its own hidden flag, a template binding, or a hidden node at the other end) is detached like any other edge and stays hidden.
  • An edge that loses both endpoints in one delete is deleted by default. It becomes a dual dangling edge only when shouldDetachOnNodeDelete is defined and returns true for both ends.
  • Deleting a node with the Delete key follows the same rules. The detached edges are reported in the selectionRemoved event (see Events below).

An edge with both endpoints free is fully supported:

  • Routing — the edge routes between its two anchor positions with any routing algorithm, like a connected edge.
  • Persistence — toJSON() saves the sourcePosition/targetPosition of free endpoints, and initializeModel restores them, so dangling edges survive a save and load cycle.
  • Box selection — a free endpoint counts by its anchor position, so a box drawn around a dangling end selects the edge.

NgDiagramService.startLinkingFromPosition starts drawing an edge from a point on the canvas instead of from a node, for example from a context menu action:

private ngDiagramService = inject(NgDiagramService);
private viewportService = inject(NgDiagramViewportService);
startDrawingAt(event: MouseEvent) {
const position = this.viewportService.clientToFlowPosition({ x: event.clientX, y: event.clientY });
this.ngDiagramService.startLinkingFromPosition(position);
}

The free end of the edge follows the pointer until a click finishes the draw. A click on a port connects the edge to that port. A click on empty canvas leaves both ends free (a dual dangling edge), and the edge is kept when shouldKeepOnDrop allows it. An edge drawn from a position has no source node, so it is a dangling edge from the start. For that reason this method requires danglingEdges.enabled; with the feature off, the call is ignored and a console warning is logged. For draws started this way, linking.validateConnection receives null as the source node.

The package exports these helper functions. Import them from ng-diagram like any other symbol:

import { hasFreeEndpoint, isDanglingEdge } from 'ng-diagram';
private modelService = inject(NgDiagramModelService);
readonly danglingEdges = computed(() => this.modelService.edges().filter(isDanglingEdge));
isSourceFree(edge: Edge): boolean {
return hasFreeEndpoint(edge, 'source');
}

NgDiagramModelService provides the model-level operations:

private modelService = inject(NgDiagramModelService);
async reconnectLooseEnds() {
for (const { edge, end, position } of this.modelService.getDanglingEndpoints()) {
const port = this.modelService.getNearestPortInRange(position, 50);
if (port) {
await this.modelService.attachEdge(edge.id, end, port.nodeId, port.id);
}
}
}

Dangling edges get dedicated CSS classes, so you can style them without checking the model:

  • The edge wrapper element gets the ng-diagram-edge--dangling class. Temporary draw previews do not get it, even though they always have a free end.
  • ng-diagram-base-edge gets the dangling host class, and its path carries the dangling class as well.
// Dashed rendering for any edge with a free endpoint
ng-diagram-base-edge.dangling {
--edge-stroke-dasharray: 6 4;
--edge-stroke-opacity: 0.7;
}
  • edgeDrawEnded — a canvas drop kept as a dangling edge reports success: true with the created edge, but no target/targetPort. To tell a connected draw from a kept dangling draw, check event.target. The deprecated edgeDrawn event does not fire for dangling edges; it only reports node-to-node connections.
  • selectionRemoved — the detachedEdges field lists the edges that were detached instead of deleted. The snapshots are taken after the detach, so the freed endpoints already have an empty source/target and their positions set. The field is always present and is [] when nothing was detached.
onEdgeDrawEnded(event: EdgeDrawEndedEvent) {
if (event.success && !event.target) {
console.log('Kept as dangling edge:', event.edge!.id);
}
}

Edge Relinking → | Floating Edges → | Configuration →