Learn / State, signals & tooling in Angular

State, signals & tooling in Angular

Everything on <grafloria-diagram-canvas> is a signal — input(), model(), output(), zero decorators. This page maps the whole surface: the two-way bindings and what they emit, the methods you reach with viewChild, and the engine underneath for everything else.

Four two-way bindings

<grafloria-diagram-canvas
  [(nodes)]="nodes"        <!-- NodeSpec[] — controlled data -->
  [(edges)]="edges"        <!-- EdgeSpec[] -->
  [(viewport)]="viewport"  <!-- Rectangle — the camera rect -->
  [(zoom)]="zoom"          <!-- number -->
  style="display:block; height:100vh" />

All four are model() signals, which is a statement about who writes: the canvas itself pans, zooms, fits, drags and undoes — so it writes these values back, and model() is what makes the round trip a first-class binding. Bind plain properties or signals on your side; both work. Each model() also gives you the standalone outputs for free: (nodesChange), (edgesChange), (viewportChange), (zoomChange) — each emitting the next value.

nodes/edges left unbound means uncontrolled: pass [engine] and mutate the engine yourself. Bound, the canvas reconciles your arrays against the live model with the same shared reconciler the React wrapper uses — only fields that actually differ are written, so re-handing it an equivalent array is free — and engine-side mutations come back out as fresh arrays. One input tunes this: [skipModelUpdate]="true" suspends the inbound half (your arrays stop being pushed in; outbound emissions continue), and flipping it back re-syncs immediately.

modelChange — the incremental patch

Alongside the next-array outputs the canvas emits (modelChange): a DiagramIncremental describing precisely which nodes, links and groups were added, removed or modified — produced by the engine's IncrementalCapture, so it replays exactly. It fires for engine-originated changes only: a change you pushed in through [nodes]/[edges] is not echoed back at you. That makes it the right hook for persistence — you save what the user did, never your own writes:

<grafloria-diagram-canvas [(nodes)]="nodes" [(edges)]="edges"
  (modelChange)="persist($event)" />
Two viewport outputs exist and they are not the same: (viewportChange) — the model() twin — emits the camera rect that the viewport input is, while the legacy (viewportChanged) emits the VISIBLE world rect (the SVG viewBox) after a pan/zoom. Persist the former; use the latter when you need what is actually on screen.

Component methods via viewChild

The canvas surfaces the everyday verbs as plain methods. Grab the component once and wire your toolbar:

editor.component.ts
import { Component, viewChild } from '@angular/core';
import { DiagramCanvasComponent } from '@grafloria/angular';

export class EditorComponent {
  canvas = viewChild.required(DiagramCanvasComponent);

  // history
  undo() { void this.canvas().undo(); }
  redo() { void this.canvas().redo(); }

  // clipboard — each is ONE undo step
  copy()  { void this.canvas().copySelection(); }
  cut()   { void this.canvas().cutSelection(); }
  paste() { void this.canvas().pasteClipboard(); }   // drops at the cursor
  del()   { void this.canvas().deleteSelection(); }

  // camera
  zoomIn()  { this.canvas().zoomIn(); }
  zoomOut() { this.canvas().zoomOut(); }
  reset()   { this.canvas().resetZoom(); }
  fit()     { this.canvas().fitToContent(40); }
  fitSel()  { this.canvas().zoomToSelection(); }

  // persistence & export
  save() { return this.canvas().snapshot(); }                 // SerializedDiagram | null
  load(s: SerializedDiagram) { this.canvas().loadSnapshot(s); }
  text() { return this.canvas().exportText(); }               // Mermaid-compatible
  parse(t: string) { this.canvas().loadText(t); }
  png()  { return this.canvas().exportDiagram('png', { scale: 2 }); }

  // layout
  relayout() { void this.canvas().applyLayout('elk'); }
}

undo()/redo() and the clipboard methods return the command's promise — await it when a test (or a spinner) needs to observe the result. The keyboard already drives all of them: ⌘Z, ⌘C/V/X, ⌘0, Shift+1 work with zero wiring.

loadSnapshot() does not replace the document — it reconciles into the live diagram (removals included), so the renderer, your event subscriptions and the plugins stay attached to the same model. Saving before any edit and restoring later is exactly two calls.

Live: undo/redo from your own buttons →

Live: snapshot & restore →

The engine escape hatch

Everything the component does not surface lives one level down. activeEngine() is a computed signal returning the engine in use — the one you bound, or the one the canvas created for controlled mode. It is available from ngAfterViewInit onward:

ngAfterViewInit() {
  const engine = this.canvas().activeEngine();
  if (!engine) return;

  engine.canUndo();  engine.canRedo();          // history state — engine-level,
                                                // there is no component canUndo()
  engine.selectNodes(['a', 'b']);               // programmatic selection
  engine.copy();                                // engine clipboard…
  engine.paste({ offset: { x: 60, y: 60 } });   // …and paste with a delta
  engine.setInteractionConfig({ portVisibility: 'always' });
  engine.getDiagram();                          // the live DiagramModel
}

canUndo/canRedo deserve the callout: people look for them on the component, but history state belongs to the engine. Disable your toolbar buttons from there.

Live: engine clipboard round-trip →

Engine events — the interaction stream

The canvas deliberately has no per-gesture outputs; the engine's eventBus is the path. The connection lifecycle, for instance, fires connection:start, connection:update (per move, with live validity), connection:port-enter / connection:port-leave (with the rejection reason when a validator vetoed), then exactly one of connection:complete or connection:cancel:

ngAfterViewInit() {
  const engine = this.canvas().activeEngine();
  engine?.eventBus.on('connection:complete', (p) => {
    console.log('wired', p.sourcePortId, '→', p.targetPortId);
  });
  engine?.eventBus.on('connection:port-enter', (p) => {
    if (!p.isValid) console.log('refused:', p.rejectionReason);
  });
}

The event vocabulary and payloads are catalogued in Events & interaction — the bus is framework-agnostic, so everything there applies verbatim.

Live: the connection lifecycle, logged →

Toolbars

Edges come with a floating toolbar built in: it appears on link hover or selection, glued to a fraction along the rendered route, and defaults to delete + insert-node-on-edge. Three inputs control it — [enableLinkToolbar] (default true), [linkToolbarActions] for your own buttons, and [linkToolbarAnchor] (0.5 = midpoint).

Nodes: build the toolbar as your own overlay and anchor it with viewportController() — the live world↔screen transform the canvas paints with. worldToClient(x, y, hostRect) converts a world point to client pixels, and onChange fires on every pan/zoom so the overlay re-anchors; it is the same controller the minimap and zoom controls drive, so nothing ever drifts:

const viewport = this.canvas().viewportController();
const rect = host.getBoundingClientRect();
const c = viewport.worldToClient(node.position.x + node.size.width / 2, node.position.y, rect);
toolbar.style.left = (c.x - rect.left) + 'px';
toolbar.style.top  = (c.y - rect.top) + 'px';
viewport.onChange(reposition);   // stay glued through pan & zoom

Live: the built-in edge toolbar →

Live: a node toolbar anchored via viewportController →

App-wide configuration

provideGrafloria sets defaults once at bootstrap; explicit inputs on a specific canvas always win:

main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideGrafloria } from '@grafloria/angular';
import { DARK_THEME } from '@grafloria/renderer';

bootstrapApplication(AppComponent, {
  providers: [provideGrafloria({ theme: DARK_THEME })],
});

Theme resolution order: canvas-level [theme] → the provided default → the built-in light theme. More in Theming.

Zoneless, verified

The binding has no NgZone dependency and no EventEmitter — inputs are signals, outputs are output(), everything the template binds is a signal, and the SVG layer is painted imperatively outside change detection entirely. The component's own test suite mounts, paints and interacts under provideZonelessChangeDetection(), so a zoneless app is a supported configuration, not a hopeful one.

Where next