Use an external inspector when your application needs to edit a node without putting every control inside the canvas. The examples below render a fixed-size node and a content-sized node, then let you edit a target by nodeId, add and delete nodes, and undo committed payload changes.
Specs describe intent; live models hold data. Use the mounted DiagramInstance to get the model and engine. Loading and editing are different intents: tracked model setters update live data, while commands put user-facing changes on the history stack.
1. Share the data and inspector
Create inspector.ts in your browser application's source directory. The framework examples in the next step import this file.
The data uses NodeSpec and EdgeSpec. fixed starts at 200 × 80; auto starts at 60 × 36 and fits its longer label through metadata.sizing.auto.
The inspector uses the mounted DiagramEngine. addNode() returns the live NodeModel and executes the shipped AddNodeCommand; removeNode() executes RemoveNodeCommand. You do not need to construct those commands yourself.
For payload commits, execute the shipped SetNodeDataCommand. This is the command surface for an inspector edit: it changes only the supplied data keys and restores those keys on undo.
tsimport { NodeModel, SetNodeDataCommand, type DiagramEngine } from '@grafloria/engine';
import type { NodeSpec, EdgeSpec } from '@grafloria/renderer';
export const initialNodes: NodeSpec[] = [
{
id: 'fixed', position: { x: 80, y: 80 },
size: { width: 200, height: 80 }, label: 'Draft',
data: { note: 'Review pending' },
},
{
id: 'auto', position: { x: 80, y: 230 },
size: { width: 60, height: 36 },
label: 'This node fits its longer label',
metadata: { sizing: { auto: true, padding: 10 } },
data: { note: 'Content-sized' },
},
];
export const initialEdges: EdgeSpec[] = [
{ id: 'connection', source: 'fixed', target: 'auto' },
];
let nextNodeId = 0;
export function mountInspector(
host: HTMLElement,
engine: DiagramEngine,
repaint: () => void,
): () => void {
engine.setInteractionConfig({ enableInPlaceTextEdit: true });
const abort = new AbortController();
const panel = document.createElement('div');
panel.style.cssText = 'display:flex;flex-wrap:wrap;gap:12px;padding:12px;font:14px sans-serif';
host.append(panel);
function field(caption: string, value: string, type = 'text'): HTMLInputElement {
const label = document.createElement('label');
label.append(`${caption} `);
const input = document.createElement('input');
input.type = type;
input.value = value;
input.style.width = type === 'number' ? '70px' : '160px';
label.append(input);
panel.append(label);
return input;
}
const target = field('nodeId', 'fixed');
const label = field('Label (live)', 'Draft');
const note = field('Note', 'Review pending');
const width = field('Width', '200', 'number');
const height = field('Height', '80', 'number');
width.min = height.min = '1';
const status = document.createElement('output');
panel.append(status);
function node(): NodeModel | undefined {
return engine.getDiagram()?.getNode(target.value);
}
function refresh(): void {
const current = node();
if (!current) {
status.textContent = 'Target not found';
return;
}
label.value = current.getLabel() ?? '';
note.value = typeof current.data.note === 'string' ? current.data.note : '';
width.value = String(current.size.width);
height.value = String(current.size.height);
status.textContent = `Current note: ${note.value}`;
}
function button(caption: string, action: () => Promise<void>): void {
const control = document.createElement('button');
control.type = 'button';
control.textContent = caption;
control.addEventListener('click', () => {
void action().then(() => {
repaint();
refresh();
}).catch((error: Error) => { status.textContent = error.message; });
}, { signal: abort.signal });
panel.append(control);
}
target.addEventListener('input', refresh, { signal: abort.signal });
label.addEventListener('input', () => {
node()?.setMetadata('label', label.value);
repaint();
}, { signal: abort.signal });
const resize = () => {
const w = Number(width.value);
const h = Number(height.value);
if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) {
node()?.setSize(w, h);
repaint();
}
};
width.addEventListener('input', resize, { signal: abort.signal });
height.addEventListener('input', resize, { signal: abort.signal });
button('Apply note', async () => {
if (!node()) throw new Error('Choose an existing nodeId');
await engine.commandManager.execute(
new SetNodeDataCommand(target.value, { note: note.value }),
);
});
button('Add', async () => {
let id: string;
do { id = `inspector-node-${++nextNodeId}`; }
while (engine.getDiagram()?.getNode(id));
const added = new NodeModel({
id, type: 'rect',
position: { x: 420, y: 80 }, size: { width: 180, height: 80 },
});
added.setMetadata('label', 'New node');
const live = await engine.addNode(added);
target.value = live.id;
});
button('Delete target', async () => {
if (!node()) throw new Error('Choose an existing nodeId');
await engine.removeNode(target.value);
});
button('Undo', async () => { await engine.undo(); });
button('Redo', async () => { await engine.redo(); });
refresh();
return () => {
abort.abort();
panel.remove();
};
}
The label and dimension fields are live, non-history updates. The Note field is a draft until you press Apply note; that button records one undoable payload edit. Add and delete also enter history. For undoable label editing, use the canvas's in-place editor described below rather than the live label field.
2. Mount the canvas in your framework
Install the packages for your framework in your own project.
JavaScript:
bashnpm install @grafloria/element @grafloria/renderer @grafloria/engine
Angular:
bashnpm install @grafloria/angular @grafloria/engine @grafloria/renderer @grafloria/element @angular/common @angular/core @angular/forms @angular/platform-browser rxjs
Qwik:
bashnpm install @grafloria/qwik @grafloria/engine @grafloria/renderer @grafloria/element @builder.io/qwik
React:
bashnpm install @grafloria/react @grafloria/engine @grafloria/renderer @grafloria/element react react-dom
Vue:
bashnpm install @grafloria/vue @grafloria/engine @grafloria/renderer @grafloria/element vue
JavaScript mounts with render. Angular uses DiagramCanvasComponent and its activeEngine(). React's GrafloriaFlow, Vue's GrafloriaFlow, and Qwik's GrafloriaFlow hand you the instance through their initialization callback or event.
Each sample mounts the same inspector above a 400-pixel canvas. The React, Vue and Qwik samples use uncontrolled defaults so the live instance owns edits. Angular uses two-way node and edge bindings so model changes return to application state.
ts// main.ts — run in the browser; call the returned cleanup when removing this view.
import { render } from '@grafloria/element';
import { initialNodes, initialEdges, mountInspector } from './inspector';
export function mountNodeEditor(parent: HTMLElement): () => void {
const inspector = document.createElement('div');
const canvas = document.createElement('div');
canvas.style.height = '400px';
parent.append(inspector, canvas);
const instance = render({ nodes: initialNodes, edges: initialEdges }, canvas);
const removeInspector = mountInspector(
inspector, instance.getEngine(), () => instance.renderNow(),
);
return () => {
removeInspector();
instance.dispose();
inspector.remove();
canvas.remove();
};
}
const root = document.createElement('div');
document.body.append(root);
export const unmountNodeEditor = mountNodeEditor(root);
ts// node-editor.component.ts
import { AfterViewInit, Component, ElementRef, OnDestroy, viewChild } from '@angular/core';
import { DiagramCanvasComponent } from '@grafloria/angular';
import { initialNodes, initialEdges, mountInspector } from './inspector';
@Component({
selector: 'app-node-editor',
standalone: true,
imports: [DiagramCanvasComponent],
template: `
<div #inspector></div>
<grafloria-diagram-canvas
[(nodes)]="nodes" [(edges)]="edges"
style="display:block;height:400px" />
`,
})
export class NodeEditorComponent implements AfterViewInit, OnDestroy {
nodes: ReturnType<DiagramCanvasComponent['nodes']> = initialNodes;
edges: ReturnType<DiagramCanvasComponent['edges']> = initialEdges;
canvas = viewChild.required(DiagramCanvasComponent);
inspector = viewChild.required<ElementRef<HTMLDivElement>>('inspector');
private removeInspector?: () => void;
ngAfterViewInit(): void {
const canvas = this.canvas();
const engine = canvas.activeEngine();
if (!engine) return;
this.removeInspector = mountInspector(
this.inspector().nativeElement, engine, () => canvas.scheduleRender(),
);
}
ngOnDestroy(): void { this.removeInspector?.(); }
}
tsx// node-editor.tsx
import { component$, $, noSerialize, useSignal, useVisibleTask$, type NoSerialize } from '@builder.io/qwik';
import { GrafloriaFlow } from '@grafloria/qwik';
import type { DiagramInstance } from '@grafloria/renderer';
import { initialNodes, initialEdges, mountInspector } from './inspector';
export default component$(() => {
const instance = useSignal<NoSerialize<DiagramInstance>>();
const inspector = useSignal<HTMLDivElement>();
useVisibleTask$(({ track, cleanup }) => {
const api = track(() => instance.value);
const host = inspector.value;
if (!api || !host) return;
cleanup(mountInspector(host, api.getEngine(), () => api.renderNow()));
});
return <>
<div ref={inspector} />
<div style={{ height: '400px' }}>
<GrafloriaFlow defaultNodes={initialNodes} defaultEdges={initialEdges}
onInit$={$((api: DiagramInstance) => { instance.value = noSerialize(api); })} />
</div>
</>;
});
tsx// NodeEditor.tsx
import { useEffect, useRef } from 'react';
import { GrafloriaFlow } from '@grafloria/react';
import type { DiagramInstance } from '@grafloria/renderer';
import { initialNodes, initialEdges, mountInspector } from './inspector';
export default function NodeEditor() {
const inspector = useRef<HTMLDivElement>(null);
const instance = useRef<DiagramInstance | null>(null);
const removeInspector = useRef<(() => void) | null>(null);
function onInit(api: DiagramInstance): void {
instance.current = api;
removeInspector.current?.();
if (inspector.current) {
removeInspector.current = mountInspector(
inspector.current, api.getEngine(), () => api.renderNow(),
);
}
}
useEffect(() => () => { removeInspector.current?.(); }, []);
return <>
<div ref={inspector} />
<div style={{ height: 400 }}>
<GrafloriaFlow defaultNodes={initialNodes} defaultEdges={initialEdges} onInit={onInit} />
</div>
</>;
}
vue<!-- NodeEditor.vue --> <script setup lang="ts"> import { shallowRef, onUnmounted } from 'vue'; import { GrafloriaFlow } from '@grafloria/vue'; import type { DiagramInstance } from '@grafloria/renderer'; import { initialNodes, initialEdges, mountInspector } from './inspector'; const inspector = shallowRef<HTMLDivElement>(); const instance = shallowRef<DiagramInstance>(); let removeInspector: (() => void) | undefined; function onInit(api: DiagramInstance): void { instance.value = api; removeInspector?.(); if (inspector.value) { removeInspector = mountInspector( inspector.value, api.getEngine(), () => api.renderNow(), ); } } onUnmounted(() => { removeInspector?.(); }); </script> <template> <div ref="inspector"></div> <div style="height:400px"> <GrafloriaFlow :default-nodes="initialNodes" :default-edges="initialEdges" @init="onInit" /> </div> </template>
3. Edit the target and test history
The JavaScript view starts with the inspector targeting Draft and an edge leading to the wider content-sized node.
Angular, Qwik, React and Vue render the same initial nodes and the inspector's Apply note, Add, Delete target, Undo and Redo controls.
- Keep
nodeIdset tofixed. Type in Label (live): the canvas text follows the input. Change Width or Height: the box changes size throughsetSize(). - Change Note and press Apply note. The Current note output shows the committed payload. Press Undo to restore the previous note, then Redo to reapply it. Payload data is separate from the display label; changing
notedoes not replace the node's label. - Press Add. A new node appears to the right, and the inspector targets its generated id. Press Delete target to remove it. Undo restores it. Look up the node again by id after undo rather than retaining a model reference: deletion undo reconstructs models.
- Set
nodeIdtoauto. Lengthen its label: content-aware sizing grows it during rendering when the text needs more room. With this unconstrained node, manually enlarging the box survives subsequent renders; shortening the label does not shrink it.
Removing a node also removes its connected links and descendants; undo restores them. removeNode() rejects if the target does not exist, which the inspector reports instead of silently ignoring it.
Commit labels in place
The shared inspector setup enables enableInPlaceTextEdit through setInteractionConfig(). Double-click a node to open the shipped label editor. Enter or blur commits the rename through a command; Escape cancels it. Undo and Redo then act on that rename. Angular also enables its in-place editing input by default.
For an external Rename action on an instance, call beginLabelEdit({ type: 'node', nodeId: 'fixed' }). It returns whether an editor opens; a missing, non-editable or read-only target returns false. An optional { seed: 'R' } second argument starts with replacement text instead of selecting the existing label.
See the live in-place label editing demo to try Enter, blur and Escape.
Options that matter
| Option | Type | Default | What it does |
|---|---|---|---|
NodeSpec.id | string | Optional | Gives your inspector a stable target for model lookup and commands. |
NodeSpec.label | string | Optional | Supplies metadata.label, the display label. |
NodeSpec.data | Payload record | Optional | Carries application data separately from the label. |
NodeSpec.size | { width: number; height: number } | Optional | Declares the box dimensions. |
metadata.sizing.auto | boolean | Off unless true | Enables content-aware fitting during rendering. |
metadata.sizing.padding | number | 8 | Adds padding around measured label content. |
metadata.sizing.minWidth, minHeight | number | No per-node constraint | Sets lower bounds for content sizing and interactive resizing. |
metadata.sizing.maxWidth, maxHeight | number | No per-node constraint | Sets upper bounds; maxWidth also supplies the label's wrap width during measurement. |
Pitfalls
- Use tracked setters such as
setMetadata(),setData()andsetSize(), not raw assignments to live fields. Setters notify the model's change tracking. They do not themselves create history entries; use a command for a committed inspector action. metadata.labeltakes precedence over a legacydata.label. A payload command that writesdata.labeldoes not rename a node with a canonical label; use the label editor for that task.- With auto-sizing enabled, the renderer grows the current dimensions to accommodate content, subject to sizing constraints. A manual enlargement of the unconstrained
autonode remains; a manual reduction can grow again if the label needs more room. - For controlled inputs, keep the framework's change-event return path. See the React quick start and Vue quick start.
- For engine history, explicit layout after edits, and repainting custom HTML content, see commands and history, lay out a diagram, and JavaScript elements and content.
Live demos and related guides
- Updating nodes: live label, background and width controls. Source.
- Auto-sizing: compare a content-sized node with a fixed-size control.
- Save and restore documents: persist the live document rather than a framework projection.
Was this page helpful?