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)" />
(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:
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 →
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:
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
- Custom nodes in Angular — the
ng-templateidiom in depth. - Dashboards in Angular —
<grafloria-dashboard>and widget templates. - Commands & undo — what a command is and why one gesture is one step.
- Events & interaction — the full
event vocabulary behind the
eventBus.