Skip to content
D
Documentation

DiagramCanvasComponent

reference
12 min readUpdated

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 same NodeSpec / EdgeSpec data the React wrapper and <grafloria-flow> take. They are reconciled against the live model by applyNodes / applyEdges from @grafloria/renderer (the shared reconciler — Angular does not get a second diff algorithm), and model mutations come back out as nodesChange / edgesChange (the next array — Angular's two-way contract) plus modelChange (a DiagramIncremental: precisely which entities were added / removed / modified — GoJS's IncrementalData). [skipModelUpdate]="true" suspends the inbound half (GoJS's skipsDiagramUpdate).
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

NameTypeDefaultDescription
engine?DiagramEngine | undefinedDiagram engine instance. Optional: in controlled mode (nodes/edges bound)
nodes?readonly (NodeSpec | NodeModel)[] | undefinedControlled node data — the shared NodeSpec shape. undefined = Two-way: also emits its change.
edges?readonly (EdgeSpec | LinkModel)[] | undefinedControlled edge data. Two-way: [(edges)]. Two-way: also emits its change.
skipModelUpdate?Suspend the INBOUND half of the controlled binding (GoJS's
viewport?RectangleCamera 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 | undefinedTheme configuration.
collab?GrafloriaCollabOptions | undefinedReal-time collaboration: a transport (BroadcastChannelTransport,
comments?boolean | CommentStore | undefinedAnchored 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 | undefinedDeclarative auto-layout: [layout]="'elk'" or
colorMode?ColorMode | undefinedCard "colorMode".
themes?ThemeSet | undefinedThe themes colorMode chooses between. Defaults to the built-in set.
tokenBridge?TokenBridge | undefinedCard "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[] | undefinedButtons 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 | nullKeep-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

  • nodesChange
  • edgesChange
  • viewportChange
  • zoomChange
  • collabReady — The live SyncAdapter, right after join().
  • 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 the
  • zoomChanged — Zoom after a pan/zoom gesture. (zoomChange is the two-way twin.)
  • modelChange — The incremental patch describing what the MODEL just changed — added /
  • selectionChange — The selection changed: the selected nodes and edges AFTER the change. Same

Properties

NameTypeDefaultDescription
engineDiagram engine instance. Optional: in controlled mode (nodes/edges bound) the canvas creates and owns one if you do not supply it.
nodesControlled node data — the shared NodeSpec shape. undefined = uncontrolled: the engine's node set is left alone. Two-way: [(nodes)].
edgesControlled edge data. Two-way: [(edges)].
skipModelUpdateSuspend 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.
viewportCamera rectangle. Two-way: [(viewport)] (the canvas pans/zooms it).
zoomZoom level. Two-way: [(zoom)] (the canvas writes it on wheel/fit/keys).
themeTheme configuration.
effectiveThemeThe theme the canvas actually renders with (see theme for precedence).
nodeDefMaptype → template; '' is the wildcard fallback for HTML-layer nodes.
collabReal-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.
collabReadyThe live SyncAdapter, right after join().
commentsAnchored 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.
layoutDeclarative 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, …).
layoutDoneFires after each declarative or imperative layout completes.
colorModeCard "colorMode".
themesThe themes colorMode chooses between. Defaults to the built-in set.
tokenBridgeCard "design-token bridge".
rendererConfigExtra SVGRenderer options (e.g. smartConnectionPoints, linkHitAreaWidth). Merged over the component defaults; changing it recreates the renderer.
enableMouseWheelZoomEnable ctrl/⌘ + wheel zoom.
enablePanEnable pan (middle-drag, space-drag, wheel-scroll).
zoomSensitivityRelative zoom step per wheel notch / keyboard zoom.
minZoomMinimum zoom level.
maxZoomMaximum zoom level.
viewportChangedThe VISIBLE world rect (the SVG viewBox) after a pan/zoom. NOT the same as the viewport model's viewportChange, which emits the camera rect.
zoomChangedZoom after a pan/zoom gesture. (zoomChange is the two-way twin.)
modelChangeThe 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.
selectionChangeThe selection changed: the selected nodes and edges AFTER the change. Same name and payload as React's onSelectionChange, Vue's selectionChange and Qwik's onSelectionChange$.
activeEngineThe engine actually in use: the bound one, else the one we own.
enableLinkToolbarShow the floating edge toolbar on link hover/selection.
linkToolbarActionsButtons on the edge toolbar. Defaults to delete + insert-node-on-edge.
linkToolbarAnchorFraction along the link the toolbar is glued to (0.5 = midpoint).
linkToolbarTargetLink the edge toolbar is currently attached to (null = no toolbar).
enableSelectionToolsResize/rotate handles, Halo, link endpoint + vertex tools.
enableSnappingAlignment snaplines, equal spacing, grid snap, keep-in-bounds.
enableProximityConnectDrop a node near a compatible port → auto-link it.
enableKeyboardNavigationTab/arrow focus, nudge, keyboard connect, ARIA announcements.
canvasBoundsKeep-in-bounds for dragging. A signal input, not
enableInPlaceEditingDouble-click a node to edit its label in place.
highlighterConfigHover / 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.
toolLayerSelectionToolLayerLive tool layer for the current selection (template-bound).
highlightersHighlighter[][]Hover / selection / validation / drop-target decorations (template-bound).
alignmentGuidesAlignmentGuide[][]Live alignment snaplines + equal-spacing guides during a drag/resize.
spacingGuidesSpacingGuide[][]
focusRingFocusRing | nullnullThe visible focus ring (keyboard focus ≠ selection).
liveMessage''Text of the ARIA live region.
livePoliteness'polite' | 'assertive''polite'
containerRefElementRef<HTMLDivElement>Main container reference
svgLayerRefElementRef<HTMLDivElement>SVG layer reference
htmlLayerRefElementRef<HTMLDivElement>HTML layer reference
marqueeLive marquee overlay rectangle in SCREEN px (relative to the container), or null when no marquee is active. Bound by the template's SVG overlay.
htmlLayerTransformHTML layer transform Synced with viewport to keep HTML nodes aligned with SVG
htmlNodesHTML nodes to render (DECLARATIVE APPROACH - React Flow style) Exposed as a public property for template binding

Methods

  • nodeTemplateFor(node: any): TemplateRef<GrafloriaNodeTemplateContext> | null
  • nodeTemplateContext(node: any): GrafloriaNodeTemplateContext
  • getCommentStore(): 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, and onChange fires 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: ExportOptions = {} ): Promise<string> — Async export — the full pipeline, the same one createDiagram().export() runs.
  • exportSvg(options: ExportOptions = {}): SvgExportResult — Synchronous SVG export. Custom nodes are captured from the HTML node layer as they stand now; NOTHING is fetched, so an external image stays a URL (with a warning) — use await exportDiagram('svg') to embed it, or pass options.resolvedAssets.
  • exportPdf(options: ExportOptions = {}): PdfExportResult — Synchronous vector-PDF export. Custom nodes are captured as they stand now; nothing is fetched, so an external image is MISSING from the PDF (and reported in warnings) — use await exportDiagram('pdf') to embed it.
  • snapshot(): SerializedDiagram | null — Serialize the current diagram — feed the result back to loadSnapshot.
  • loadSnapshot(data: SerializedDiagram): void — Restore a snapshot()-ed document by reconciling INTO the live diagram — applyNodes/applyEdges are 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 as loadSnapshot.
  • get effectiveLinkToolbarActions(): LinkToolbarAction[]
  • onLinkToolbarPointerOver(isOver: boolean): void
  • constructor()
  • ngAfterViewInit(): void
  • ngOnDestroy(): void
  • flushModelChange(): void — Force the pending outbound emission to happen NOW (tests, imperative hosts).
  • get overlayViewBox(): string — viewBox for the world-space overlay — identical to the renderer's.
  • get 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 — Polyline points attribute 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 }): number
  • undo(): 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).
  • scheduleRender(): void — The ONE coalescing entry point for every re-render.
  • getPerformanceMetrics(): { 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 to targetZoom keeping the world point under (clientX, clientY) pinned to that same screen pixel.
  • 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) — and every group and lane FRAME, captions included: a frame reaches past its members (a pool's title strip and empty lanes), and fitting to node boxes alone clipped it. Picks the largest zoom (within [minZoom, maxZoom]) at which the content's bounding box fits inside the canvas with padding screen 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 on sawPointerEvent (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
  • 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.
  • 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 configuration
  • getPortPosition(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?

DiagramCanvasComponent — Grafloria