Undo and redo diagram edits
Use the diagram's command history when users need to reverse a gesture or when your feature edits the graph on their behalf. A drag is one undoable step; your own toolbar action can use that same history.
Add undo and redo controls
The canvas already handles Ctrl+Z / ⌘Z for undo and Ctrl+Y / ⌘⇧Z for redo. To add buttons, keep the live DiagramInstance and call undo() or redo() on its engine. In Angular, DiagramCanvasComponent exposes those operations directly.
Each sample starts with two nodes. Drag either node and undo to return it to the start of the drag; redo reapplies the move. In JavaScript, Angular, Vue, and React, Add node adds a rectangle through the engine; Undo removes it and Redo restores it. The Qwik sample demonstrates drag undo with the built-in keyboard shortcuts rather than a custom Add node control.
Live drag-and-undo demo. Its canvas starts with two nodes; drag one before trying undo.
JavaScript, Angular, Qwik, Vue, and React
Each example is a mounted canvas with a real height. In JavaScript, mount with render; use getEngine().addNode() for a programmatic addition. The typed sample data uses NodeSpec and EdgeSpec; Angular controlled arrays can also carry live NodeModel and LinkModel instances. For React use GrafloriaFlow; for Vue use GrafloriaFlow; for Qwik use GrafloriaFlow. React and Vue acquire the instance through their component callback. Angular obtains the canvas with viewChild.required() and calls activeEngine().addNode() for programmatic edits, while its undo and redo buttons call the canvas methods directly. The Qwik sample uses the default-data component and the built-in keyboard shortcuts.
jsimport { render } from '@grafloria/element';
/** @type {import('@grafloria/renderer').NodeSpec[]} */
const nodes = [
{ id: 'a', position: { x: 80, y: 80 }, size: { width: 160, height: 70 }, label: 'Drag, then undo' },
{ id: 'b', position: { x: 360, y: 180 }, size: { width: 160, height: 70 }, label: 'Every step counts' },
];
/** @type {import('@grafloria/renderer').EdgeSpec[]} */
const edges = [{ id: 'e1', source: 'a', target: 'b' }];
const wrapper = document.createElement('div');
wrapper.style.height = '460px';
document.body.append(wrapper);
const toolbar = document.createElement('div');
wrapper.append(toolbar);
const host = document.createElement('div');
host.style.height = '420px';
wrapper.append(host);
const instance = render({ nodes, edges }, host);
let nextSuggestion = 1;
async function addSuggestion() {
const number = nextSuggestion++;
await instance.getEngine().addNode({
type: 'rect',
position: { x: 220 + (number - 1) * 24, y: 300 },
size: { width: 160, height: 70 },
});
}
/** @param {string} label @param {() => void} action */
function addButton(label, action) {
const button = document.createElement('button');
button.textContent = label;
button.addEventListener('click', action);
toolbar.append(button);
}
addButton('Add node', () => { void addSuggestion(); });
addButton('Undo', () => { void instance.getEngine().undo(); });
addButton('Redo', () => { void instance.getEngine().redo(); });
tsimport { Component, viewChild } from '@angular/core';
import { DiagramCanvasComponent } from '@grafloria/angular';
import type { LinkModel, NodeModel } from '@grafloria/engine';
import type { EdgeSpec, NodeSpec } from '@grafloria/renderer';
@Component({
selector: 'app-undo-redo',
standalone: true,
imports: [DiagramCanvasComponent],
template: `
<div style="height:460px">
<div>
<button type="button" (click)="addSuggestion()">Add node</button>
<button type="button" (click)="undo()">Undo</button>
<button type="button" (click)="redo()">Redo</button>
</div>
<grafloria-diagram-canvas [(nodes)]="nodes" [(edges)]="edges"
style="display:block;height:420px" />
</div>
`,
})
export class UndoRedoComponent {
readonly canvas = viewChild.required(DiagramCanvasComponent);
nodes: readonly (NodeSpec | NodeModel)[] | undefined = [
{ id: 'a', position: { x: 80, y: 80 }, size: { width: 160, height: 70 }, label: 'Drag, then undo' },
{ id: 'b', position: { x: 360, y: 180 }, size: { width: 160, height: 70 }, label: 'Every step counts' },
];
edges: readonly (EdgeSpec | LinkModel)[] | undefined = [{ id: 'e1', source: 'a', target: 'b' }];
private nextSuggestion = 1;
addSuggestion(): void {
const number = this.nextSuggestion++;
const engine = this.canvas().activeEngine();
if (engine) void engine.addNode({
type: 'rect',
position: { x: 220 + (number - 1) * 24, y: 300 },
size: { width: 160, height: 70 },
});
}
undo(): void { void this.canvas().undo(); }
redo(): void { void this.canvas().redo(); }
}
tsximport { component$ } from '@builder.io/qwik';
import { GrafloriaFlow } from '@grafloria/qwik';
import type { EdgeSpec, NodeSpec } from '@grafloria/renderer';
const nodes: NodeSpec[] = [
{ id: 'a', position: { x: 80, y: 80 }, size: { width: 160, height: 70 }, label: 'Drag, then undo' },
{ id: 'b', position: { x: 360, y: 180 }, size: { width: 160, height: 70 }, label: 'Every step counts' },
];
const edges: EdgeSpec[] = [];
export default component$(() => (
<div style={{ height: '460px' }}>
<GrafloriaFlow defaultNodes={nodes} defaultEdges={edges} />
</div>
));
vue<script setup lang="ts"> import { GrafloriaFlow, type DiagramInstance } from '@grafloria/vue'; import type { EdgeSpec, NodeSpec } from '@grafloria/renderer'; const nodes: NodeSpec[] = [ { id: 'a', position: { x: 80, y: 80 }, size: { width: 160, height: 70 }, label: 'Drag, then undo' }, { id: 'b', position: { x: 360, y: 180 }, size: { width: 160, height: 70 }, label: 'Every step counts' }, ]; const edges: EdgeSpec[] = [{ id: 'e1', source: 'a', target: 'b' }]; let instance: DiagramInstance | null = null; let nextSuggestion = 1; function onInit(diagramInstance: DiagramInstance): void { instance = diagramInstance; } function addSuggestion(): void { const number = nextSuggestion++; const engine = instance?.getEngine(); if (engine) void engine.addNode({ type: 'rect', position: { x: 220 + (number - 1) * 24, y: 300 }, size: { width: 160, height: 70 }, }); } function undo(): void { const engine = instance?.getEngine(); if (engine) void engine.undo(); } function redo(): void { const engine = instance?.getEngine(); if (engine) void engine.redo(); } </script> <template> <div style="height:460px"> <div> <button type="button" @click="addSuggestion">Add node</button> <button type="button" @click="undo">Undo</button> <button type="button" @click="redo">Redo</button> </div> <div style="height:420px"> <GrafloriaFlow :default-nodes="nodes" :default-edges="edges" @init="onInit" /> </div> </div> </template>
tsximport { useRef } from 'react';
import { GrafloriaFlow, type DiagramInstance } from '@grafloria/react';
import type { EdgeSpec, NodeSpec } from '@grafloria/renderer';
const nodes: NodeSpec[] = [
{ id: 'a', position: { x: 80, y: 80 }, size: { width: 160, height: 70 }, label: 'Drag, then undo' },
{ id: 'b', position: { x: 360, y: 180 }, size: { width: 160, height: 70 }, label: 'Every step counts' },
];
const edges: EdgeSpec[] = [{ id: 'e1', source: 'a', target: 'b' }];
export default function UndoRedoExample() {
const instance = useRef<DiagramInstance | null>(null);
const nextSuggestion = useRef(1);
function addSuggestion(): void {
const number = nextSuggestion.current++;
const engine = instance.current?.getEngine();
if (engine) void engine.addNode({
type: 'rect',
position: { x: 220 + (number - 1) * 24, y: 300 },
size: { width: 160, height: 70 },
});
}
return (
<div style={{ height: '460px' }}>
<div>
<button type="button" onClick={addSuggestion}>Add node</button>
<button type="button" onClick={() => { const engine = instance.current?.getEngine(); if (engine) void engine.undo(); }}>Undo</button>
<button type="button" onClick={() => { const engine = instance.current?.getEngine(); if (engine) void engine.redo(); }}>Redo</button>
</div>
<div style={{ height: '420px' }}>
<GrafloriaFlow
defaultNodes={nodes}
defaultEdges={edges}
onInit={(diagramInstance) => { instance.current = diagramInstance; }}
/>
</div>
</div>
);
}
What the calls change
undo() and redo() return promises because a command can do asynchronous work. Await them when the next step depends on the history operation finishing. After an undo, the engine's model contains the reverted state. Controlled React, Angular, and Vue bindings reflect model changes back to application data; these React and Vue examples use initial defaultNodes and defaultEdges instead.
For programmatic edits, use command-backed operations when users must be able to undo them; setup and restoration stay outside history. See How Grafloria works for the instance, engine, and model roles.
| Input | Type | Default | Effect |
|---|---|---|---|
defaultNodes | NodeSpec [] | Not stated | Initial nodes for React and Vue. |
defaultEdges | EdgeSpec [] | Not stated | Initial edges for React and Vue. |
nodes | readonly (NodeSpec | NodeModel)[] | undefined | undefined | Angular's controlled node data; two-way binding reflects canvas edits. |
edges | readonly (EdgeSpec | LinkModel)[] | undefined | undefined | Angular's controlled edge data; two-way binding reflects canvas edits. |
Pitfalls
- Do not use model-level mutations for a feature action that users need to undo. Model operations used to set up, load, or synchronize a diagram stay out of the history.
- Await engine operations before starting a dependent operation. The command methods are asynchronous.
- A drag is one history step, not one step per pointer movement. Undo returns the node to the position where that drag started.
Related
- Command history and edits explains commands and the shared history in more depth.
- Synchronize diagrams covers collaboration between diagrams.
- React quick start, Vue quick start, Angular quick start, and Qwik quick start show the surrounding framework setup.
- Collaboration-aware undo demo shows how a peer's undo applies to that peer's own edits.
Was this page helpful?