Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,8 @@ __tmp__*

# Kiro
.kiro

# Codex
.codex
.agents
AGENTS.md
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- **`EdgeDrawEndedEvent.source` is now optional** — a draw can start from a position instead of a node (`startLinkingFromPosition`). Read it as `event.source?.id`. Breaking only for apps compiled with `strictNullChecks` that read `event.source` without a check ([#810](https://github.com/synergycodes/ng-diagram/pull/810))
- **`edgeDrawEnded` reports success for a canvas drop kept as a dangling edge** — with `danglingEdges.enabled`, such a drop fires with `success: true` and the created `edge`, and without `target`/`targetPort` (before, every canvas drop cancelled with `noTarget`). If your app created its own edge on `reason === 'noTarget'`, remove that code before enabling the flag, or edges are created twice. The deprecated `edgeDrawn` never fires for kept dangling edges ([#810](https://github.com/synergycodes/ng-diagram/pull/810))
- **`linking.validateConnection` receives an optional fifth argument** — a `ConnectionValidationContext` with `reason: 'draw' | 'relink' | 'attach'` and, for relink and attach, the `edge` and `end` being reconnected, so one validator can treat the three cases differently. The `source` node argument can now be `null`: for draws started with `startLinkingFromPosition` and when the other end of a relinked edge is free ([#810](https://github.com/synergycodes/ng-diagram/pull/810))
- **`SelectionRemovedEvent` has a new required `detachedEdges` field** — the edges kept as dangling edges instead of deleted (`danglingEdges.detachOnNodeDelete`), `[]` when none. Breaking only for code that builds this event object by hand ([#810](https://github.com/synergycodes/ng-diagram/pull/810))
- **`ModelActionType` gained `'startRelinking'`, `'finishRelinking'` and `'startLinkingFromPosition'`** — breaking for middlewares with exhaustive switches over the action type ([#810](https://github.com/synergycodes/ng-diagram/pull/810))
- **Box selection counts dangling edges by their free-endpoint anchors** — a box drawn around a free endpoint's `sourcePosition`/`targetPosition` selects the edge ([#810](https://github.com/synergycodes/ng-diagram/pull/810))
- **Paste moves free endpoints** — pasting moves the free endpoints of dangling edges together with the rest of the content (for manual-routing edges also their `points`), and paste at a position centers on nodes and free endpoints alike ([#810](https://github.com/synergycodes/ng-diagram/pull/810))
- **Z-index recomputes when an edge's `source`/`target` changes programmatically** — after `updateEdge` or `attachEdge` changes an endpoint, the edge is layered relative to its new nodes ([#810](https://github.com/synergycodes/ng-diagram/pull/810))
- **`ng-diagram-palette-item-preview` takes no space in the layout** — the preview renders nothing on the page, whatever content it holds and however the palette is laid out. It is a block, so inline content placed next to it inside a palette item now starts on a new line ([#801](https://github.com/synergycodes/ng-diagram/pull/801))
- **`NgDiagramPaletteItemPreviewComponent.preview` is for reading content and size** — the element behind this signal is not rendered in the page flow. Read the preview's natural size or content from it; its position is meaningless ([#801](https://github.com/synergycodes/ng-diagram/pull/801))
- **The drag image zoom is capped at 3x** — at higher zoom levels the image under the cursor stays at 3x, so starting a drag stays smooth; the dropped node still lands at the current zoom ([#801](https://github.com/synergycodes/ng-diagram/pull/801))
- **Copying a group now includes its content** — `copy`/`cut` cascade to all descendants of the copied nodes and to the edges connecting copied nodes, so pasting a group reproduces its children and internal wiring (previously a pasted group was empty). Cutting a collapsed group no longer destroys its hidden children — they travel through the clipboard with it. Effectively hidden _selected_ elements are skipped by copy (consistent with `deleteSelection`), and pasted hidden content is not selected, so pasting cannot create an invisible selection; the paste-at-cursor position is computed from the visible pasted content only ([#799](https://github.com/synergycodes/ng-diagram/pull/799))

### Added

- **Dangling edges** — edges with one or both ends not connected to a node. A free end has `source`/`target` set to the empty string `''` (never `null` or `undefined`), no `sourcePort`/`targetPort` (`undefined`), and its position in `sourcePosition`/`targetPosition`. Opt in with the new `danglingEdges.enabled` config: a draw dropped on empty canvas then keeps the edge (`shouldKeepOnDrop` decides per edge) and a relink drop detaches the endpoint. Dangling edges route between their anchors, are saved and restored with the model, copy and paste together with their node, and box selection picks them by their anchors. New utilities `isDanglingEdge`, `hasFreeEndpoint`, `getDanglingEndpoints`, `getNearestDanglingEndpointInRange` and `computeDetachAnchor`; new `NgDiagramModelService.detachEdge`/`attachEdge` (attach validates through `linking.validateConnection` with `reason: 'attach'`); new CSS hooks `ng-diagram-edge--dangling` on the edge wrapper and `dangling` on `ng-diagram-base-edge` and its path ([#810](https://github.com/synergycodes/ng-diagram/pull/810))
- **Keep edges when deleting a node** — with `danglingEdges.detachOnNodeDelete`, deleting a node keeps its edges as dangling edges anchored where their port was, and `selectionRemoved.detachedEdges` lists them. `shouldDetachOnNodeDelete` decides per endpoint. An edge is still deleted when it is deleted explicitly, when it is hidden only because of the node it loses (an edge that stays hidden on its own is detached like any other), or when it loses both ends at once (unless the callback keeps both) ([#810](https://github.com/synergycodes/ng-diagram/pull/810))
- **Edge relinking** — reconnect an edge by dragging one of its ends. Set `linking.defaultRelinkable` to `true` and a selected edge shows a handle at each end; set it to `'source'` or `'target'` to allow only that end. An edge overrides the default with its own `relinkable` property, so you can lock single edges or open only some; `isEdgeEndRelinkable(edge, end, defaultRelinkable)` resolves the two for custom templates. Dragging a handle previews the new connection and commits it on drop. The drop is validated by `linking.validateConnection` with `reason: 'relink'`. A drop on empty canvas detaches the end when `danglingEdges.enabled` allows it; an invalid drop, a drop back on the same port and Escape leave the edge unchanged. New `edgeRelinkStarted`/`edgeRelinkEnded` events report the gesture and its result (`noTarget`, `invalidConnection` or `cancelled` on failure). The handles have a touch-friendly hit area of about 12 px at any zoom; style them with the `--ngd-relink-handle-size/-fill/-fill-hover/-stroke/-stroke-width` theme variables or per edge with `--edge-relink-handle-*`. Custom edge templates can render their own handles with the new `RelinkHandleDirective` ([#810](https://github.com/synergycodes/ng-diagram/pull/810))
- **`NgDiagramService.startLinkingFromPosition(position)`** — starts drawing an edge from a point on the canvas (flow coordinates) instead of from a node; the free end follows the pointer until a click connects it to a port or leaves a dual dangling edge on empty canvas. Requires `danglingEdges.enabled` (ignored with a console warning otherwise) ([#810](https://github.com/synergycodes/ng-diagram/pull/810))
- **`hidden` flag on nodes and edges** — new `hidden?: boolean` property on `SimpleNode` and `Edge` makes hidden content a first-class concept: hidden elements stay mounted in the DOM as `display: none`, keep their geometry, never block initialization or `waitForMeasurements`, and re-measure automatically when unhidden. Effective visibility is derived — hiding a group hides all of its descendants, and an edge is hidden when either endpoint node is hidden — and exposed as the system-computed `computedHidden` property. Visible nodes, edges and labels never get an inline `display` value, so existing CSS-based workarounds keep working (ports keep managing their own inline `display` as before — hide them via the port's `hidden` input, not CSS). Note for upgrades: the library now owns the `hidden` property name on nodes and edges — pre-existing app-level `hidden: true` data activates these semantics after upgrading ([#799](https://github.com/synergycodes/ng-diagram/pull/799))
- **Hidden elements are excluded from every interactive surface** — effectively hidden nodes and edges are not hit-testable and don't participate in `selectAll`, keyboard move, drag, box selection, linking (starting a link from a hidden node is a no-op with a console warning), `zoomToFit`/`computePartsBounds` bounds, virtualization, edge routing, or the resize constraints of their group (a collapsed group is freely resizable — its hidden children reappear where they were on expand). The minimap mirrors the canvas: effectively hidden nodes are not drawn (neither as default rectangles nor as custom minimap templates) and don't inflate the minimap's diagram bounds, in both rendering modes. Programmatic APIs (`select`, `centerOnNode`, z-order and group commands) deliberately do not filter hidden elements. Hiding does not deselect: a hidden element keeps its `selected` flag but cannot be moved, and `deleteSelection` skips hidden selected elements (descendants of a deleted visible group and edges of deleted nodes are still removed) ([#799](https://github.com/synergycodes/ng-diagram/pull/799))
- **Declarative `hidden` in templates** — `ng-diagram-port` and `ng-diagram-base-edge-label` accept a `hidden` input to hide ports and labels independently of their owner (e.g. ports visible only in an edit mode); a hidden port is not a linking target or a port-snap candidate, and edges attached to it keep its last measured geometry as their anchor. The new `NgDiagramHiddenDirective` (`[ngDiagramHidden]`) hides a node or edge from inside its template — model flag, template binding, or inheritance: any source hides the element. Template-driven visibility changes are coalesced: any number of bindings toggling in one change-detection cycle cost a single recompute pass (bulk hiding through the model `hidden` flag remains the cheapest path). Not supported with virtualization (hiding would unmount the declaring template): the binding is then ignored with a console warning — use the model `hidden` flag instead ([#799](https://github.com/synergycodes/ng-diagram/pull/799))
Expand All @@ -30,6 +42,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- **Arrow keys pan the viewport when the selection cannot move** — with `nodeDraggingEnabled: false` and a node selected, the arrow keys used to do nothing. The same happened when only `draggable: false` nodes were selected. Now the arrow keys pan the viewport whenever the selection would not move; with node dragging enabled, a movable selection still moves as before ([#809](https://github.com/synergycodes/ng-diagram/pull/809))
- **`zoomToFit({ nodeIds })` frames only the requested nodes** — without `edgeIds`, the fit used to include every edge of the diagram, so fitting a few nodes in a connected diagram zoomed out to the whole network, and fitting only hidden nodes framed the whole diagram instead of doing nothing. Now only edges with both ends among the fitted nodes count; passing `edgeIds` works as before ([#808](https://github.com/synergycodes/ng-diagram/pull/808))
- **Pasted manual-routing edges keep their shape at the new location** — an edge with `routingMode: 'manual'` pasted together with its nodes used to be drawn at the original location; its `points` now move with the pasted nodes ([#810](https://github.com/synergycodes/ng-diagram/pull/810))
- **Pasting an edge without its nodes no longer duplicates the original connection** — an edge copied without one or both of its endpoint nodes used to be pasted between the original nodes, adding an identical connection on every paste. The end whose node was not copied is now pasted as a free end that moves with the rest of the pasted content, so you can move it or reconnect it. Cutting an edge on its own and pasting it therefore gives a free edge, not the original connection. Edges copied together with their nodes are pasted between the new nodes, as before ([#807](https://github.com/synergycodes/ng-diagram/pull/807))
- **`deleteNodes` on a group deletes its whole subtree** — `NgDiagramModelService.deleteNodes` now cascades to all descendants of the deleted nodes and removes their edges, matching `deleteSelection`. Previously the children survived with a `groupId` pointing at a node that no longer existed, and children of a hidden group reappeared as orphans. To delete a group but keep its children, call `NgDiagramGroupsService.removeFromGroup` first ([#806](https://github.com/synergycodes/ng-diagram/pull/806))
- **Palette preview no longer bleeds over the palette or stretches the page** — at high zoom levels `ng-diagram-palette-item-preview` no longer paints over the palette items and no longer adds scrollbars to the page, whatever the layout around the palette ([#801](https://github.com/synergycodes/ng-diagram/pull/801))
Expand Down
5 changes: 5 additions & 0 deletions apps/angular-demo/src/app/app.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
(nodeRotateEnded)="onNodeRotateEnded($event)"
(edgeDrawn)="onEdgeDrawn($event)"
(edgeDrawEnded)="onEdgeDrawEnded($event)"
(edgeRelinkStarted)="onEdgeRelinkStarted($event)"
(edgeRelinkEnded)="onEdgeRelinkEnded($event)"
(clipboardPasted)="onClipboardPasted($event)"
(nodeResized)="onNodeResized($event)"
(nodeResizeStarted)="onNodeResizeStarted($event)"
Expand All @@ -34,12 +36,15 @@
<app-awaitable-tests (exit)="exitAwaitableTest()" />
} @else if (hiddenElementsDemoMode()) {
<app-hidden-elements-toolbar (exit)="exitHiddenElementsDemo()" (revealGhosts)="revealHiddenGhosts.set($event)" />
} @else if (relinkingTestMode()) {
<app-relinking-toolbar (exit)="exitRelinkingTest()" (resetScene)="resetRelinkingScene()" />
} @else {
<app-toolbar
(enterBatchTest)="enterBatchTest()"
(measurementTestEnter)="enterMeasurementTest()"
(awaitableTestEnter)="enterAwaitableTest()"
(hiddenElementsDemoEnter)="enterHiddenElementsDemo()"
(relinkingTestEnter)="enterRelinkingTest()"
(testVirtualizationClick)="enableVirtualizationTest()"
(reinitializeModelClick)="onReinitializeModel()"
(saveModelClick)="onSaveModel()"
Expand Down
75 changes: 73 additions & 2 deletions apps/angular-demo/src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
DiagramInitEvent,
EdgeDrawEndedEvent,
EdgeDrawnEvent,
EdgeRelinkEndedEvent,
EdgeRelinkStartedEvent,
GroupMembershipChangedEvent,
initializeModel,
MinimapNodeStyle,
Expand All @@ -17,6 +19,7 @@ import {
NgDiagramModelService,
NgDiagramNodeTemplateMap,
NgDiagramPaletteItem,
NgDiagramService,
NodeDragEndedEvent,
NodeDragStartedEvent,
NodeResizedEvent,
Expand All @@ -43,6 +46,7 @@ import { generateModel } from './data/generate-model';
import { createHiddenElementsModel } from './data/hidden-elements-model';
import { nodeTemplateMap } from './data/node-template';
import { paletteModel } from './data/palette-model';
import { createRelinkingModel } from './data/relinking-model';
import { virtualizationConfigOverrides, virtualizationTestConfig } from './data/virtualization-test.config';
import { ButtonEdgeComponent } from './edge-template/button-edge/button-edge.component';
import { CustomPolylineEdgeComponent } from './edge-template/custom-polyline-edge/custom-polyline-edge.component';
Expand All @@ -55,6 +59,7 @@ import { ImageMinimapNodeComponent } from './minimap-node-template/image-minimap
import { PaletteComponent } from './palette/palette.component';
import { BatchTestToolbarComponent } from './toolbar/batch-test-toolbar.component';
import { HiddenElementsToolbarComponent } from './toolbar/hidden-elements-toolbar.component';
import { RelinkingToolbarComponent } from './toolbar/relinking-toolbar.component';
import { ToolbarComponent } from './toolbar/toolbar.component';

const LOCAL_STORAGE_KEY = 'ng-diagram-demo';
Expand All @@ -67,6 +72,7 @@ const LOCAL_STORAGE_KEY = 'ng-diagram-demo';
ToolbarComponent,
BatchTestToolbarComponent,
HiddenElementsToolbarComponent,
RelinkingToolbarComponent,
MeasurementTestsComponent,
AwaitableTestsComponent,
PaletteComponent,
Expand All @@ -80,6 +86,7 @@ const LOCAL_STORAGE_KEY = 'ng-diagram-demo';
export class AppComponent {
private readonly injector = inject(Injector);
private readonly modelService = inject(NgDiagramModelService);
private readonly ngDiagramService = inject(NgDiagramService);

paletteModel: NgDiagramPaletteItem[] = paletteModel;
nodeTemplateMap: NgDiagramNodeTemplateMap = nodeTemplateMap;
Expand Down Expand Up @@ -124,6 +131,17 @@ export class AppComponent {
},
linking: {
selectNodeOnPortPress: false,
// Every selected edge shows grabbable endpoint handles unless its own
// `relinkable` says otherwise — drag one to reconnect it to another
// port or drop it on empty canvas to detach it.
defaultRelinkable: true,
},
// Dangling edges: a link drawn onto empty canvas is kept (with a free
// endpoint), and deleting a node detaches its edges instead of deleting
// them. Delete an edge explicitly by selecting it.
danglingEdges: {
enabled: true,
detachOnNodeDelete: true,
},
shortcuts: configureShortcuts([
{
Expand Down Expand Up @@ -194,6 +212,40 @@ export class AppComponent {
}
}

relinkingTestMode = signal(false);

enterRelinkingTest(): void {
this.savedModelData = this.modelData();
this.relinkingTestMode.set(true);
this.modelData.set(createRelinkingModel());
}

exitRelinkingTest(): void {
this.relinkingTestMode.set(false);
// The panel writes its own linking/dangling rules into the live config.
// A model swap rebuilds the diagram core from the `[config]` input, which
// already carries these values; this call restores them even when there is
// no saved model to swap back to.
this.ngDiagramService.updateConfig({
linking: { defaultRelinkable: true, validateConnection: () => true },
danglingEdges: {
enabled: true,
detachOnNodeDelete: true,
shouldKeepOnDrop: () => true,
shouldDetachOnNodeDelete: () => true,
},
});
if (this.savedModelData) {
this.modelData.set(this.savedModelData);
this.savedModelData = null;
}
}

/** Rebuilds the relinking scene from scratch, keeping the mode open. */
resetRelinkingScene(): void {
this.modelData.set(createRelinkingModel());
}

enterBatchTest(): void {
this.savedModelData = this.modelData();
this.batchTestMode.set(true);
Expand Down Expand Up @@ -274,18 +326,37 @@ export class AppComponent {
});
}

onEdgeRelinkStarted(event: EdgeRelinkStartedEvent): void {
console.log('Edge Relink Started:', { edge: event.edge.id, end: event.end });
}

onEdgeRelinkEnded(event: EdgeRelinkEndedEvent): void {
console.log('Edge Relink Ended:', {
edge: event.edge.id,
end: event.end,
success: event.success,
previousNode: event.previousNode?.id,
previousPort: event.previousPort,
target: event.target?.id,
targetPort: event.targetPort,
reason: event.reason,
dropPosition: event.dropPosition,
});
}

onEdgeDrawEnded(event: EdgeDrawEndedEvent): void {
if (event.success) {
console.log('Edge Draw Ended (success):', {
edge: event.edge!.id,
source: event.source.id,
// source is undefined for draws started from empty canvas
source: event.source?.id,
target: event.target?.id,
sourcePort: event.sourcePort,
targetPort: event.targetPort,
});
} else {
console.log('Edge Draw Ended (cancelled):', {
source: event.source.id,
source: event.source?.id,
sourcePort: event.sourcePort,
reason: event.reason,
dropPosition: event.dropPosition,
Expand Down
Loading
Loading