Skip to content
D
Documentation

DiagramCanvasComponent

reference
6 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 /

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. 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.
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, …). 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(
layoutDoneFires after each declarative or imperative layout completes.
colorModeCard "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.
themesThe themes colorMode chooses between. Defaults to the built-in set.
tokenBridgeCard "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.
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.
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'
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: 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 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
  • @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 reference
  • 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).

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, several

  • 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.

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 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

  • 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 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?