Use this pattern when you need a mounted editor in which users add workflow steps, connect them, undo edits, and run the graph. The component owns the canvas; the DiagramInstance is the shared imperative facade behind every binding.
What you build
The sample mounts three steps in a 720-pixel-high canvas. Add step inserts a node through the engine, users connect nodes by dragging from one port to another, Undo calls the engine history, and Run marks the nodes in graph order as running and then complete. The browser renders the result on the mounted canvas, not on a detached model.
Give the host a resolved height. A canvas whose host has no height renders blank; see Edit Mermaid diagrams for the sizing rule.
1. Define the workflow data
Use node and edge specs as data. The same document shape works in each binding.
tsimport type { EdgeSpec, NodeSpec } from '@grafloria/renderer';
export const nodes: NodeSpec[] = [
{ id: 'trigger', position: { x: 80, y: 150 }, size: { width: 140, height: 54 }, label: 'Trigger' },
{ id: 'fetch', position: { x: 300, y: 150 }, size: { width: 140, height: 54 }, label: 'Fetch data' },
{ id: 'save', position: { x: 520, y: 150 }, size: { width: 140, height: 54 }, label: 'Save result' },
];
export const edges: EdgeSpec[] = [
{ id: 'trigger-fetch', source: 'trigger', target: 'fetch' },
{ id: 'fetch-save', source: 'fetch', target: 'save' },
];
2. Mount the editor
Each binding emits the mounted instance through its initialization callback. Keep that live object in the framework's stable state, then call getEngine() for history and getModel() for workflow data.
jsimport { render } from '@grafloria/element';
import { nodes, edges } from './workflow-data.js';
const host = document.getElementById('workflow');
if (!host) throw new Error('Missing #workflow');
host.style.height = '720px';
const instance = render(JSON.stringify({ nodes, edges }), host);
const engine = instance.getEngine();
const model = instance.getModel();
const addButton = document.getElementById('add-step');
if (!addButton) throw new Error('Missing #add-step');
addButton.addEventListener('click', async () => {
await engine.addNode({ type: 'basic', position: { x: 740, y: 150 }, size: { width: 140, height: 54 }, data: { label: 'Notify' } });
instance.renderNow();
});
const undoButton = document.getElementById('undo');
if (!undoButton) throw new Error('Missing #undo');
undoButton.addEventListener('click', () => { void engine.undo(); });
const runButton = document.getElementById('run');
if (!runButton) throw new Error('Missing #run');
runButton.addEventListener('click', () => {
for (const node of model.getNodes()) {
node.setState({ status: 'running' });
instance.renderNow();
node.setState({ status: 'completed' });
}
instance.renderNow();
});
tsximport { useRef } from 'react';
import { GrafloriaFlow, type DiagramInstance } from '@grafloria/react';
import { nodes, edges } from './workflow-data';
export default function WorkflowEditor() {
const instance = useRef<DiagramInstance | null>(null);
const onInit = (value: DiagramInstance) => { instance.current = value; };
const addStep = async () => {
const value = instance.current;
if (!value) return;
await value.getEngine().addNode({ type: 'basic', position: { x: 740, y: 150 }, size: { width: 140, height: 54 }, data: { label: 'Notify' } });
value.renderNow();
};
const run = () => {
const value = instance.current;
if (!value) return;
for (const node of value.getModel().getNodes()) { node.setState({ status: 'running' }); value.renderNow(); node.setState({ status: 'completed' }); }
value.renderNow();
};
return <div style={{ height: '720px' }}><button onClick={addStep}>Add step</button><button onClick={() => { void instance.current?.getEngine().undo(); }}>Undo</button><button onClick={run}>Run</button><GrafloriaFlow defaultNodes={nodes} defaultEdges={edges} onInit={onInit} style={{ display: 'block', height: '100%' }} /></div>;
}
vue<script setup lang="ts"> import { ref } from 'vue'; import { GrafloriaFlow, type DiagramInstance } from '@grafloria/vue'; import { nodes, edges } from './workflow-data'; const instance = ref<DiagramInstance | null>(null); const editableNodes = ref(nodes); const editableEdges = ref(edges); function onInit(value: DiagramInstance) { instance.value = value; } async function addStep() { if (!instance.value) return; await instance.value.getEngine().addNode({ type: 'basic', position: { x: 740, y: 150 }, size: { width: 140, height: 54 }, data: { label: 'Notify' } }); instance.value.renderNow(); } function run() { if (!instance.value) return; for (const node of instance.value.getModel().getNodes()) { node.setState({ status: 'running' }); instance.value.renderNow(); node.setState({ status: 'completed' }); } instance.value.renderNow(); } </script> <template><div style="height: 720px"><button @click="addStep">Add step</button><button @click="instance?.getEngine().undo()">Undo</button><button @click="run">Run</button><GrafloriaFlow v-model:nodes="editableNodes" v-model:edges="editableEdges" @init="onInit" style="display:block;height:100%" /></div></template>
tsimport { Component } from '@angular/core';
import { GrafloriaDiagramComponent } from '@grafloria/angular';
import type { DiagramInstance } from '@grafloria/renderer';
import { nodes, edges } from './workflow-data';
@Component({ standalone: true, imports: [GrafloriaDiagramComponent], template: `
<div style="height:720px"><button (click)="addStep()">Add step</button><button (click)="undo()">Undo</button><button (click)="run()">Run</button>
<grafloria-diagram [spec]="spec" [options]="options" (ready)="onReady($event)" style="display:block;height:100%"></grafloria-diagram>
</div>` })
export class WorkflowEditorComponent {
readonly spec = JSON.stringify({ nodes, edges }); readonly options = {}; private instance: DiagramInstance | null = null;
onReady(value: DiagramInstance): void { this.instance = value; }
async addStep(): Promise<void> { if (!this.instance) return; await this.instance.getEngine().addNode({ type: 'basic', position: { x: 740, y: 150 }, size: { width: 140, height: 54 }, data: { label: 'Notify' } }); this.instance.renderNow(); }
undo(): void { void this.instance?.getEngine().undo(); }
run(): void { if (!this.instance) return; for (const node of this.instance.getModel().getNodes()) { node.setState({ status: 'running' }); this.instance.renderNow(); node.setState({ status: 'completed' }); } this.instance.renderNow(); }
}
tsximport { component$, $, noSerialize, useSignal } from '@builder.io/qwik';
import { GrafloriaFlow, type DiagramInstance } from '@grafloria/qwik';
import type { EdgeSpec, NodeSpec } from '@grafloria/renderer';
const nodes: NodeSpec[] = [
{ id: 'trigger', position: { x: 80, y: 150 }, size: { width: 140, height: 54 }, label: 'Trigger' },
{ id: 'fetch', position: { x: 300, y: 150 }, size: { width: 140, height: 54 }, label: 'Fetch data' },
{ id: 'save', position: { x: 520, y: 150 }, size: { width: 140, height: 54 }, label: 'Save result' },
];
const edges: EdgeSpec[] = [
{ id: 'trigger-fetch', source: 'trigger', target: 'fetch' },
{ id: 'fetch-save', source: 'fetch', target: 'save' },
];
export default component$(() => {
const instance = useSignal<DiagramInstance>();
const onInit = $((value: DiagramInstance) => { instance.value = noSerialize(value); });
const addStep = $(async () => {
const value = instance.value;
if (!value) return;
await value.getEngine().addNode({ type: 'basic', position: { x: 740, y: 150 }, size: { width: 140, height: 54 }, data: { label: 'Notify' } });
value.renderNow();
});
const run = $(() => {
const value = instance.value;
if (!value) return;
for (const node of value.getModel().getNodes()) { node.setState({ status: 'running' }); value.renderNow(); node.setState({ status: 'completed' }); }
value.renderNow();
});
return <div style={{ height: '720px' }}><button onClick$={addStep}>Add step</button><button onClick$={$(() => { void instance.value?.getEngine().undo(); })}>Undo</button><button onClick$={run}>Run</button><GrafloriaFlow defaultNodes={nodes} defaultEdges={edges} onInit$={onInit} style={{ display: 'block', height: '100%' }} /></div>;
});
The canvas shows the three connected steps. Add step adds a fourth node, Undo removes that engine command, and Run transitions each current node through running to completed. Connect the new step by dragging from an output port to an input port; the renderer and engine create and validate the link.
3. Add workflow rules
Ports express whether a step can start or receive a connection. Use registerConnectionValidator when a workflow rule applies across all connection gestures:
tsimport { registerConnectionValidator } from '@grafloria/renderer';
const disposeValidator = registerConnectionValidator(() => true);
// Call disposeValidator() when this feature is unloaded.
All registered validators must pass. The registration is process-global, so retain and dispose the returned function; see Ports and connection rules.
Options that matter
| Option | Type | Default | What it does |
|---|---|---|---|
height on the host | CSS size | none | Gives the mounted canvas space to paint. |
defaultNodes | NodeInput[] | empty | Supplies initial nodes to framework bindings. |
defaultEdges | EdgeInput[] | empty | Supplies initial links to framework bindings. |
theme | Theme | light | Selects the visual theme; use LIGHT_THEME or DARK_THEME. |
Pitfalls
undo()belongs toinstance.getEngine(), not the renderer instance. See How Grafloria works.- Keep the instance in a ref, signal, or component field. Do not recreate it during a render.
- Call
renderNow()when you need the repaint before measuring the canvas; ordinary updates are scheduled. - The sample runs a visual status update. A production runner must apply its own asynchronous work and status policy while preserving the graph in the model.
Live demo
Try the workflow automation builder, which adds steps with +, edits them in a panel, keeps page edits in the engine's undo stack, and runs branches from a trigger. Its source is workflow-builder.html.
Related: Ports and connection rules, Edit Mermaid diagrams, and How Grafloria works.
Was this page helpful?