Skip to content
D
Documentation

Commands and history

concept
3 min readUpdated

A command represents an undoable edit, so gestures and application actions can share the engine's history instead of maintaining separate undo stacks.

Loading and editing are different intents: setup mutates the live model directly; user-facing edits execute commands. The framework binding renders that model, while the engine owns execution and history.

Two intents, one live model

Use DiagramModel for setup and DiagramEngine for editing:

IntentAPIResult
Seed a documentdiagram.addNode(node)Adds the node without recording a history entry.
Add a node on the user's behalfawait engine.addNode(node)Executes the shipped add-node command and returns the live node.
Execute an explicit editawait engine.commandManager.execute(command)Executes and validates the command before recording an undoable entry.
Reverse or replay an editawait engine.undo() / await engine.redo()Changes the same model the canvas renders.
mermaid
flowchart LR
  Setup["Setup / import"] --> Model["Live diagram model"]
  Gesture["Canvas gesture"] --> Commands["Engine commands"]
  Action["Application action"] --> Commands
  Commands --> Model
  Commands --> History["One history stack"]
  History --> Reverse["Undo / redo"]
  Reverse --> Model
  Model --> Binding["Framework binding"]
  Binding --> Canvas["Rendered canvas"]
  Binding --> State["Controlled application state"]

Direct model additions are not recorded in history. Use an engine editing method or execute a command when a toolbar action must be undoable.

DiagramInstance has no undo() or redo(): reach history through instance.getEngine().undo() and instance.getEngine().redo(). For a flat instance facade, createDiagramApi exposes asynchronous execute(), undo() and redo() and requests a repaint after each call.

Bundle an application action into one undo step

Use the shipped AddNodeCommand rather than implementing node addition yourself. Wrap independent additions in BatchCommand when they form one user action. The batch executes its children in order and undoes them in reverse order.

This browser example mounts Angular's DiagramCanvasComponent with an engine, following its uncontrolled binding. It starts with a single node labelled Loaded. Select Add pair to add Review and Ship together; select Undo to remove both in one step. The loaded node remains. Drag a node, then undo: the drag joins that same history.

Install the packages in your Angular project:

bash
npm install @grafloria/angular @angular/common @angular/core @angular/forms @angular/platform-browser @grafloria/engine @grafloria/renderer rxjs @grafloria/element

Construct the data with NodeModel. Replace your root component with this file:

ts
import { Component, OnDestroy, signal } from '@angular/core';
import { DiagramCanvasComponent } from '@grafloria/angular';
import { AddNodeCommand, BatchCommand, DiagramEngine, NodeModel } from '@grafloria/engine';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [DiagramCanvasComponent],
  template: `
    <button [disabled]="busy()" (click)="addPair()">Add pair</button>
    <button [disabled]="busy()" (click)="undo(canvas)">Undo</button>
    <button [disabled]="busy()" (click)="redo(canvas)">Redo</button>
    <p role="status">{{ status() }}</p>
    <grafloria-diagram-canvas #canvas [engine]="engine"
      style="display:block; height:400px" />
  `,
})
export class AppComponent implements OnDestroy {
  readonly engine = new DiagramEngine();
  readonly busy = signal(false);
  readonly status = signal('Loaded is setup data, not an undo step.');
  private nextPair = 0;

  constructor() {
    const diagram = this.engine.createDiagram('History example');
    const loaded = this.makeNode('loaded', 'Loaded', 60, 60);
    diagram.addNode(loaded);
  }

  private makeNode(id: string, label: string, x: number, y: number): NodeModel {
    const node = new NodeModel({
      id,
      type: 'default',
      position: { x, y },
      size: { width: 140, height: 50 },
    });
    node.setData('label', label);
    return node;
  }

  async addPair(): Promise<void> {
    const pair = ++this.nextPair;
    const y = 140 + (pair - 1) * 70;
    const review = this.makeNode(`review-${pair}`, 'Review', 60, y);
    const ship = this.makeNode(`ship-${pair}`, 'Ship', 260, y);
    await this.run(() => this.engine.commandManager.execute(
      new BatchCommand('Add review and ship', [
        new AddNodeCommand(review),
        new AddNodeCommand(ship),
      ]),
    ));
  }

  async undo(canvas: DiagramCanvasComponent): Promise<void> {
    await this.run(() => canvas.undo());
  }

  async redo(canvas: DiagramCanvasComponent): Promise<void> {
    await this.run(() => canvas.redo());
  }

  private async run(action: () => Promise<void>): Promise<void> {
    if (this.busy()) return;
    this.busy.set(true);
    try {
      await action();
      this.status.set(
        `Can undo: ${this.engine.canUndo()}; can redo: ${this.engine.canRedo()}`,
      );
    } catch (error) {
      this.status.set(error instanceof Error ? error.message : 'Command failed.');
    } finally {
      this.busy.set(false);
    }
  }

  ngOnDestroy(): void {
    this.engine.dispose();
  }
}

The canvas's undo() and redo() methods delegate to its active engine and schedule rendering. The status text reports history availability after a toolbar operation; it is not a subscription to every gesture. Use engine.canUndo() and engine.canRedo() to drive availability in your own controls.

Batching history is different from batching rendering. instance.batchUpdate() applies model mutations as one frame; it does not turn direct model writes into undoable commands.

Await execution and handle refusal

Command permits both synchronous and asynchronous execute() and undo() implementations. Its default redo() re-executes the command. The command manager awaits these operations, so await execution before reading the resulting model or issuing a dependent edit. execute(), undo() and redo() on the manager return Promise<void>, not a success flag.

History contains only successfully executed, validated commands that declare themselves undoable through isUndoable(). Refusal leaves no new entry:

  • If canExecute(context) returns false, execution rejects before applying the command. For example, AddNodeCommand refuses an id already in the diagram. Catch the rejection and report it to the user.
  • With real-time validation enabled and strict validation configured, an invalid result is undone and execution rejects. The failed command does not enter history.
  • A read-only document refuses execution without throwing; the manager emits command:refused and returns. Promise resolution alone therefore does not prove that an edit occurred.

A batch checks every child's canExecute() against the pre-batch state. Do not put an action in a batch if it becomes executable only after an earlier child runs. The pair above works because both node ids are absent before execution.

Keep application state on the same history

An undo changes the engine's live models, not a separate framework snapshot. In controlled mode, the binding returns those changes to your application: Angular writes through [(nodes)] and [(edges)]; Vue updates v-model:nodes; React calls onNodesChange.

Keep that return path connected rather than maintaining another undo stack over your specs. See State and event flow for controlled bindings and Instance and lifecycle for instance ownership.

For a gesture-driven example, open the live interaction demos, drag a node, then press ⌘Z or Ctrl+Z. One drag is one undo step, not one step per position update.

Was this page helpful?