Import it from @grafloria/angular.
DiagramCanvasComponent
Standalone, OnPush, signal-based Angular canvas over the framework-agnostic
SVGRenderer + engine. A thin shell on purpose: every decision it makes is
delegated down into @grafloria/renderer (InteractionController, SVGRenderer,
applyNodes/applyEdges) or @grafloria/engine (commands, IncrementalCapture).
Inputs are input() / model() signals, outputs are output(): no NgZone
dependency, no EventEmitter, so the component runs under
provideZonelessChangeDetection(). Everything the template binds is a signal
(marquee, htmlNodes, htmlLayerTransform, linkToolbarTarget) — that is
what lets the zoneless scheduler see a change with no zone tick. The SVG layer
is painted imperatively (VNode → DOM patcher) and never went through change
detection at all.
viewport and zoom are model() signals because the canvas WRITES them (pan,
cursor-anchored zoom, fit-to-content), so [(zoom)] / [(viewport)] round-trip. zoomChanged / viewportChanged are kept alongside for backwards compatibility
(viewportChanged emits the VISIBLE world rect — the viewBox — whereas the
model's viewportChange emits the camera rect the viewport input IS).
Two modes, both supported:
- Uncontrolled (legacy): bind
[engine]and mutate the engine yourself. Unchanged behaviour. - Controlled: bind
[(nodes)]/[(edges)]— the sameNodeSpec/EdgeSpecdata the React wrapper and<grafloria-flow>take. They are reconciled against the live model byapplyNodes/applyEdgesfrom@grafloria/renderer(the shared reconciler — Angular does not get a second diff algorithm), and model mutations come back out asnodesChange/edgesChange(the next array — Angular's two-way contract) plusmodelChange(aDiagramIncremental: precisely which entities were added / removed / modified — GoJS'sIncrementalData).[skipModelUpdate]="true"suspends the inbound half (GoJS'sskipsDiagramUpdate).
ts@Component({
selector: 'grafloria-diagram-canvas',
imports: [CommonModule, HtmlNodeRendererDirective, GrafloriaHandleDirective, LinkToolbarComponent],
templateUrl: './diagram-canvas.component.html',
styleUrls: ['./diagram-canvas.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class DiagramCanvasComponent implements AfterViewInit, OnDestroy
Use it as <grafloria-diagram-canvas> in a template.
Inputs
| Name | Type | Default | Description |
|---|---|---|---|
engine? | DiagramEngine | undefined | Diagram engine instance. Optional: in controlled mode (nodes/edges bound) | |
nodes? | readonly (NodeSpec | NodeModel)[] | undefined | Controlled node data — the shared NodeSpec shape. undefined = Two-way: also emits its change. | |
edges? | readonly (EdgeSpec | LinkModel)[] | undefined | Controlled edge data. Two-way: [(edges)]. Two-way: also emits its change. | |
skipModelUpdate? | Suspend the INBOUND half of the controlled binding (GoJS's | ||
viewport? | Rectangle | Camera rectangle. Two-way: [(viewport)] (the canvas pans/zooms it). Two-way: also emits its change. | |
zoom? | Zoom level. Two-way: [(zoom)] (the canvas writes it on wheel/fit/keys). Two-way: also emits its change. | ||
theme? | Theme | undefined | Theme configuration. | |
collab? | GrafloriaCollabOptions | undefined | Real-time collaboration: a transport (BroadcastChannelTransport, | |
comments? | boolean | CommentStore | undefined | Anchored comment threads. true creates a store (viewer 'local'); pass a | |
plugins? | boolean | CanvasPluginOptions | undefined | [plugins]="true" mounts minimap + zoom/fit controls + background grid | |
layout? | string | GrafloriaLayoutRequest | undefined | Declarative auto-layout: [layout]="'elk'" or | |
colorMode? | ColorMode | undefined | Card "colorMode". | |
themes? | ThemeSet | undefined | The themes colorMode chooses between. Defaults to the built-in set. | |
tokenBridge? | TokenBridge | undefined | Card "design-token bridge". | |
rendererConfig? | Partial<SVGRendererConfig> | Extra SVGRenderer options (e.g. smartConnectionPoints, linkHitAreaWidth). Merged over the component defaults; changing it recreates the renderer. | |
enableMouseWheelZoom? | Enable ctrl/⌘ + wheel zoom. | ||
enablePan? | Enable pan (middle-drag, space-drag, wheel-scroll). | ||
zoomSensitivity? | Relative zoom step per wheel notch / keyboard zoom. | ||
minZoom? | Minimum zoom level. | ||
maxZoom? | Maximum zoom level. | ||
enableLinkToolbar? | Show the floating edge toolbar on link hover/selection. | ||
linkToolbarActions? | LinkToolbarAction[] | undefined | Buttons on the edge toolbar. Defaults to delete + insert-node-on-edge. | |
linkToolbarAnchor? | Fraction along the link the toolbar is glued to (0.5 = midpoint). | ||
enableSelectionTools? | Resize/rotate handles, Halo, link endpoint + vertex tools. | ||
enableSnapping? | Alignment snaplines, equal spacing, grid snap, keep-in-bounds. | ||
enableProximityConnect? | Drop a node near a compatible port → auto-link it. | ||
enableKeyboardNavigation? | Tab/arrow focus, nudge, keyboard connect, ARIA announcements. | ||
canvasBounds? | Rectangle | null | Keep-in-bounds for dragging. A signal input, not | |
enableInPlaceEditing? | Double-click a node to edit its label in place. | ||
highlighterConfig? | boolean | Partial<HighlighterConfig> | Hover / selection / validation / connect-target overlay decorations (the |
Outputs
nodesChangeedgesChangeviewportChangezoomChangecollabReady— The live SyncAdapter, right afterjoin().layoutDone— Fires after each declarative or imperative layout completes.viewportChanged— The VISIBLE world rect (the SVG viewBox) after a pan/zoom. NOT the same as thezoomChanged— Zoom after a pan/zoom gesture. (zoomChangeis the two-way twin.)modelChange— The incremental patch describing what the MODEL just changed — added /
Properties
| Name | Type | Default | Description |
|---|---|---|---|
engine | Diagram engine instance. Optional: in controlled mode (nodes/edges bound) the canvas creates and owns one if you do not supply it. | ||
nodes | Controlled node data — the shared NodeSpec shape. undefined = uncontrolled: the engine's node set is left alone. Two-way: [(nodes)]. | ||
edges | Controlled edge data. Two-way: [(edges)]. | ||
skipModelUpdate | Suspend the INBOUND half of the controlled binding (GoJS's skipsDiagramUpdate): incoming nodes/edges are not pushed into the model while this is true. Flipping it back to false re-syncs immediately. Outbound emissions are unaffected. | ||
viewport | Camera rectangle. Two-way: [(viewport)] (the canvas pans/zooms it). | ||
zoom | Zoom level. Two-way: [(zoom)] (the canvas writes it on wheel/fit/keys). | ||
theme | Theme configuration. Ignored as a SOURCE once colorMode is set — the mode plus the OS's preferences then decide which of themes is active. Resolution order: explicit [theme] binding → app-wide provideGrafloria({ theme }) → built-in light theme. | ||
effectiveTheme | The theme the canvas actually renders with (see theme for precedence). | ||
nodeDefMap | type → template; '' is the wildcard fallback for HTML-layer nodes. | ||
collab | Real-time collaboration: a transport (BroadcastChannelTransport, WebSocketTransport, MemoryTransport, …) + actor id — the canvas joins a CRDT sync session once the diagram exists and leaves on destroy. Fixed for the life of the canvas. | ||
collabReady | The live SyncAdapter, right after join(). | ||
comments | Anchored comment threads. true creates a store (viewer 'local'); pass a CommentStore to share one. Pins render inside the SVG via the overlay. | ||
plugins | [plugins]="true" mounts minimap + zoom/fit controls + background grid with defaults; an object picks and configures them. The plugins drive and follow the SAME camera as [(zoom)]/[(viewport)] via a persistent two-way-synced ViewportController. | ||
layout | Declarative auto-layout: [layout]="'elk'" or [layout]="{ name: 'auto', options: { spacing: 60 } }" — any name in the engine's layout registry (elk, dagre, force, tree, grid, auto, …). Runs when the binding changes (and once the engine exists). It deliberately does NOT re-run when node data changes — a drag round-trips through [(nodes)] and must not be fought by a relayout. Call `applyLayout( | ||
layoutDone | Fires after each declarative or imperative layout completes. | ||
colorMode | Card "colorMode". 'light' | 'dark' | 'system'. 'system' follows prefers-color-scheme and re-themes LIVE when the OS flips, by rebinding this diagram's CSS variables. A prefers-contrast: more / forced-colors preference upgrades to the high-contrast theme on top of whichever mode is in force. | ||
themes | The themes colorMode chooses between. Defaults to the built-in set. | ||
tokenBridge | Card "design-token bridge". Map the host design system's tokens onto Grafloria's CSS variables: [tokenBridge]="shadcnBridge()" — and the whole diagram adopts the app's palette, live, with no node template touched. | ||
rendererConfig | Extra SVGRenderer options (e.g. smartConnectionPoints, linkHitAreaWidth). Merged over the component defaults; changing it recreates the renderer. | ||
enableMouseWheelZoom | Enable ctrl/⌘ + wheel zoom. | ||
enablePan | Enable pan (middle-drag, space-drag, wheel-scroll). | ||
zoomSensitivity | Relative zoom step per wheel notch / keyboard zoom. | ||
minZoom | Minimum zoom level. | ||
maxZoom | Maximum zoom level. | ||
viewportChanged | The VISIBLE world rect (the SVG viewBox) after a pan/zoom. NOT the same as the viewport model's viewportChange, which emits the camera rect. | ||
zoomChanged | Zoom after a pan/zoom gesture. (zoomChange is the two-way twin.) | ||
modelChange | The incremental patch describing what the MODEL just changed — added / removed / modified nodes, links and groups (GoJS IncrementalData). Produced by the engine's IncrementalCapture, so it replays exactly. Emitted for engine-originated changes only: a change you pushed in through [nodes] / [edges] is not echoed back at you. | ||
activeEngine | The engine actually in use: the bound one, else the one we own. | ||
enableLinkToolbar | Show the floating edge toolbar on link hover/selection. | ||
linkToolbarActions | Buttons on the edge toolbar. Defaults to delete + insert-node-on-edge. | ||
linkToolbarAnchor | Fraction along the link the toolbar is glued to (0.5 = midpoint). | ||
linkToolbarTarget | Link the edge toolbar is currently attached to (null = no toolbar). | ||
enableSelectionTools | Resize/rotate handles, Halo, link endpoint + vertex tools. | ||
enableSnapping | Alignment snaplines, equal spacing, grid snap, keep-in-bounds. | ||
enableProximityConnect | Drop a node near a compatible port → auto-link it. | ||
enableKeyboardNavigation | Tab/arrow focus, nudge, keyboard connect, ARIA announcements. | ||
canvasBounds | Keep-in-bounds for dragging. A signal input, not | ||
enableInPlaceEditing | Double-click a node to edit its label in place. | ||
highlighterConfig | Hover / selection / validation / connect-target overlay decorations (the .grafloria-highlighter-* layer). false hides all kinds — no host CSS required. true (default) shows all. Pass a partial {@link HighlighterConfig} to toggle individual kinds or padding. | ||
toolLayer | SelectionToolLayer | Live tool layer for the current selection (template-bound). | |
highlighters | Highlighter[] | [] | Hover / selection / validation / drop-target decorations (template-bound). |
alignmentGuides | AlignmentGuide[] | [] | Live alignment snaplines + equal-spacing guides during a drag/resize. |
spacingGuides | SpacingGuide[] | [] | |
focusRing | FocusRing | null | null | The visible focus ring (keyboard focus ≠ selection). |
liveMessage | '' | Text of the ARIA live region. | |
livePoliteness | 'polite' | 'assertive' | 'polite' | |
marquee | Live marquee overlay rectangle in SCREEN px (relative to the container), or null when no marquee is active. Bound by the template's SVG overlay. | ||
htmlLayerTransform | HTML layer transform Synced with viewport to keep HTML nodes aligned with SVG | ||
htmlNodes | HTML nodes to render (DECLARATIVE APPROACH - React Flow style) Exposed as a public property for template binding |
Methods
nodeTemplateFor(node: any): TemplateRef<GrafloriaNodeTemplateContext> | nullnodeTemplateContext(node: any): GrafloriaNodeTemplateContextgetCommentStore(): CommentStore | null— The live comment store, when[comments]is enabled.viewportController(): ViewportController | undefined— The live viewport controller — the world↔screen transform this canvas is painting with. Use it to anchor HTML overlays (floating toolbars, badges, callouts) to world coordinates:viewportController().worldToClient(x, y, hostRect)returns client pixels, andonChangefires on every pan/zoom so the overlay can re-anchor. Same instance the minimap/controls plugins drive, so overlays and plugins stay in lockstep. Returns undefined only before the view initialises.async applyLayout(request?: string | GrafloriaLayoutRequest): Promise<unknown | undefined>— Re-run the bound layout, or run any registry layout imperatively.exportDiagram(format: 'svg' | 'png' | 'jpeg' | 'webp' | 'pdf' = 'svg', options: any = {}): Promise<string>— Async export — the full pipeline, including async custom-node capture.exportSvg(options: any = {}): any— Synchronous SVG string export.exportPdf(options: any = {}): any— Synchronous vector-PDF export.snapshot(): SerializedDiagram | null— Serialize the current diagram — feed the result back toloadSnapshot.loadSnapshot(data: SerializedDiagram): void— Restore asnapshot()-ed document by reconciling INTO the live diagram —applyNodes/applyEdgesare full reconcilers, so removals happen and the renderer, listeners, and plugins stay attached to the same model.exportText(options?: unknown): string— Mermaid-compatible text export (lossless sidecar by default).loadText(text: string, options?: unknown): unknown— Parse Mermaid-compatible text (sidecar-aware) and reconcile it into the live diagram — same mechanics asloadSnapshot.get effectiveLinkToolbarActions(): LinkToolbarAction[]onLinkToolbarPointerOver(isOver: boolean): void@ViewChild('container', { static: true }) containerRef!: ElementRef<HTMLDivElement>— Main container reference@ViewChild('svgLayer', { static: true }) svgLayerRef!: ElementRef<HTMLDivElement>— SVG layer reference@ViewChild('htmlLayer', { static: true }) htmlLayerRef!: ElementRef<HTMLDivElement>— HTML layer referenceconstructor()ngAfterViewInit(): voidngOnDestroy(): voidflushModelChange(): void— Force the pending outbound emission to happen NOW (tests, imperative hosts).get overlayViewBox(): string— viewBox for the world-space overlayget overlayStroke(): number— Stroke width that stays 1 CSS px in a world-space overlay.handleSide(handle: ToolHandle): number— Square side of a tool handle in world units (constant on screen).get overlayFontSize(): number— Font size for overlay glyphs/labels, constant on screen.toolGlyph(handle: ToolHandle): string— Glyph drawn inside a click-tool button.get toolFrameTransform(): string | null— SVG transform that rotates the selection frame with a rotated node.highlighterTransform(h: Highlighter): string | null— Rotation transform for one highlighter box (rotated nodes).pointsAttr(points?: Point[]): string— Polylinepointsattribute for a link highlighter / focus ring.spacingLabelX(segment: { x1: number; x2: number }): number— Midpoint of a spacing segment (where its distance label is drawn).spacingLabelY(segment: { y1: number; y2: number }): numberundo(): Promise<void>— Undo the last command (Ctrl/Cmd+Z).redo(): Promise<void>— Redo the last undone command (Ctrl/Cmd+Shift+Z or Ctrl+Y).copySelection(): Promise<void>— Copy the selection to the clipboard (Ctrl/Cmd+C).cutSelection(): Promise<void>— Cut the selection: clipboard + delete, as ONE undo step (Ctrl/Cmd+X).deleteSelection(): Promise<void>— Delete the selection as ONE undo step (Delete / Backspace).pasteClipboard(): Promise<void>— Paste the clipboard, dropping it under the cursor (Ctrl/Cmd+V).
PasteCommand's offset is a DELTA added to every pasted node's stored
position, so "paste at the cursor" = cursor − centre of the copied bbox. With
no known cursor (never moved over the canvas) we fall back to a small nudge so
repeated pastes still stack visibly instead of landing on top of the source.
The pasted link endpoints stay valid because PasteCommand re-ids every port via remapNodePortIds() and remaps the links through that map (verified by CutCommand.spec + ClipboardCommands.spec).
scheduleRender(): void— The ONE coalescing entry point for every re-render.
Marks the canvas dirty and queues a SINGLE requestAnimationFrame. Any number of scheduleRender() calls in the same tick collapse into one frame, so a burst of engine events (node
×N, a drag's mousemoves, severalgetPerformanceMetrics(): { fps: number; frameTime: number; droppedFrames: number; sampleCount: number; }— Real render-loop metrics, computed from the ring buffers (replaces any hardcoded/estimated FPS).- fps: rolling frames-per-second across the last N painted frames
- frameTime: average render duration (ms) over the same window
- droppedFrames: cumulative frames whose render blew the ~60fps budget
- sampleCount: number of frames currently in the window
@HostListener('window:keyup', ['$event']) onKeyUp(event: KeyboardEvent): void— Handle keyup to exit pan mode (Space key)@HostListener('wheel', ['$event']) onWheel(event: WheelEvent): void— Wheel: ctrl/⌘ (and trackpad pinch, which the browser reports as ctrl+wheel) ZOOMS at the cursor; a plain wheel SCROLLS the canvas (shift → horizontal). This is the Figma/Miro/VS Code convention — the previous behaviour zoomed on every wheel event around the viewport CENTRE, which is the "feels wrong" competitors were compared against.zoomAtClient(targetZoom: number, clientX: number, clientY: number): void— Zoom totargetZoomkeeping the world point under (clientX, clientY) pinned to that same screen pixel.
DERIVATION against the center-anchored convention (see the block above
calculateActualViewport). With s the cursor's screen offset from the canvas'
LEFT edge, W the canvas px width and c = viewport.x + W/2 the world-space
centre (which is what the viewBox is anchored on):
world(s) = c + (s − W/2) / zoom
Pinning world(s) across z0 → z1 means solving for the new centre c₁:
c₁ = world − (s − W/2) / z₁ ⇒ viewport.x₁ = world − (s − W/2)/z₁ − W/2
(W does not change with zoom, so the centre is the only free variable.)
Zooming at the exact canvas centre leaves viewport.x/y untouched — which is
precisely the old behaviour, now a special case rather than the only one.
-
zoomBy(factor: number): void— Zoom keeping the canvas centre fixed (keyboard zoom, toolbar buttons). -
zoomIn(): void— Ctrl/⌘ + '=' -
zoomOut(): void— Ctrl/⌘ + '-' -
resetZoom(): void— Ctrl/⌘ + '0' — back to 100%, canvas centre unchanged. -
fitToContent(padding = 40): void— Fit every node in the diagram into view (Shift+1). Picks the largest zoom (within [minZoom, maxZoom]) at which the content's bounding box fits inside the canvas withpaddingscreen px to spare, then centres the viewport on that box. -
zoomToSelection(padding = 40): void— Fit the CURRENT SELECTION into view (Shift+2); falls back to everything. -
@HostListener('pointerdown', ['$event']) onPointerDown(event: PointerEvent): void— Pointer events — the primary pipeline (mouse, pen AND touch), mirroring DomEventBinder.onPointerDown/Move/Up/Cancel. Touch forks to the shared gesture controller; mouse/pen falls through to the existing ladder (a PointerEvent IS a MouseEvent, so the methods take it as-is). -
@HostListener('pointermove', ['$event']) onPointerMove(event: PointerEvent): void -
@HostListener('pointerup', ['$event']) onPointerUp(event: PointerEvent): void -
@HostListener('pointercancel', ['$event']) onPointerCancel(event: PointerEvent): void -
@HostListener('contextmenu', ['$event']) onContextMenu(event: MouseEvent): void— The native context menu: on touch the long-press already produced our own gesture, so the OS menu would sit on top of the canvas mid-interaction. -
@HostListener('mousedown', ['$event']) onCompatMouseDown(event: MouseEvent): void -
@HostListener('mousemove', ['$event']) onCompatMouseMove(event: MouseEvent): void -
@HostListener('mouseup', ['$event']) onCompatMouseUp(event: MouseEvent): void -
onMouseDown(event: MouseEvent): void— Handle mouse down for panning and node selection Supports: -
Left click: Select/drag nodes
-
Ctrl + Left click: Multi-select
-
Middle mouse button: Pan
-
Space + Left click: Pan
-
onMouseMove(event: MouseEvent): void— Handle mouse move for panning, node dragging, and hover -
onMouseUp(event: MouseEvent): void— Handle mouse up to stop panning, node dragging, and connections -
@HostListener('mouseleave') onCompatMouseLeave(): void— mouseleave stays UN-gated onsawPointerEvent(mirrors DomEventBinder: its cleanup is idempotent and there is no pointerleave twin wired), but it must never abort a live TOUCH gesture — the touch resize path drives the SAME SelectionToolsController this handler cancels. -
onMouseLeave(): void— Handle mouse leave to stop panning and node dragging -
@HostListener('dblclick', ['$event']) onDoubleClick(event: MouseEvent): void— Double-click on a link. -
On a label: open an inline text editor in place.
-
On the link body: insert a waypoint at the double-clicked point.
-
worldToScreen(worldX: number, worldY: number): { screenX: number; screenY: number }— Inverse of {@link clientToWorld}: world → canvas-local screen px. Exposed (public) because it is the invariant the cursor-anchored zoom is defined by, and the zoom tests assert on it. -
@HostListener('window:keydown', ['$event']) onKeyDown(event: KeyboardEvent): void— Handle keyboard events (Option 1: Node Interaction) -
Space: Pan mode cursor
-
Delete/Backspace: Delete selection (undoable)
-
Escape: Clear selection
-
Ctrl+A: Select all
-
Ctrl/⌘+Z undo, Ctrl/⌘+Shift+Z or Ctrl+Y redo
-
Ctrl/⌘+X / +C / +V cut / copy / paste-at-cursor
-
Ctrl/⌘ +'=' / '-' / '0' zoom in / out / reset; Shift+1 fit, Shift+2 fit selection
-
getAbsoluteX(node: any): number— Get absolute X position for a node (including parent offset and transforms) CRITICAL FIX: Use getWorldPosition() for simple cases, getGlobalPosition() for transforms This properly handles rotation, scale, and nested hierarchies -
getAbsoluteY(node: any): number— Get absolute Y position for a node (including parent offset and transforms) CRITICAL FIX: Use getWorldPosition() for simple cases, getGlobalPosition() for transforms This properly handles rotation, scale, and nested hierarchies -
getNodeX(node: any): number— Get node X position for HTML rendering — in WORLD units.
The HTML layer's transform is
translate(−viewBox.origin·zoom) scale(zoom), so a child positioned at its
world coordinate lands at (world − origin)·zoom — byte-for-byte the SVG map. The old / zoom cancelled the layer's scale, which pinned HTML nodes to a
zoom-independent offset while the SVG around them scaled: the desync.
getNodeY(node: any): number— Get node Y position for HTML rendering — in WORLD units (see getNodeX).shouldRenderPort(port: PortModel, node: NodeModel): boolean— Check if a port should be rendered as an HTML handle Respects port visibility settings and template configurationgetPortPosition(port: PortModel, node: NodeModel, axis: 'top' | 'left'): string— Get port position CSS value for top or left CRITICAL FIX: Use shape-aware positioning from getPortPositionForShape() This ensures HTML ports align with SVG ports for all shape types
Example
html<!-- uncontrolled -->
<grafloria-diagram-canvas [engine]="engine" [(zoom)]="zoom" />
<!-- controlled -->
<grafloria-diagram-canvas
[(nodes)]="nodes"
[(edges)]="edges"
(modelChange)="persist($event)" />
Was this page helpful?