Skip to content
D
Documentation

Commands and undo

concept
3 min readUpdated

Grafloria turns an edit into a command so the same history can execute it, undo it, redo it, and—when collaboration is enabled—represent the edit as an operation for other peers.

One mutation path

The DiagramEngine owns the live diagram and its command history. A command has two sides: execute(context) applies the change and undo(context) reverses it. redo(context) re-executes the command unless the command overrides that behavior.

mermaid
flowchart LR
  A[User gesture or feature] --> B[Command]
  B --> C[DiagramEngine.commandManager]
  C --> D[DiagramModel]
  C --> E[Undo stack]
  E --> F[undo / redo]

This makes built-in gestures and your own user-facing features share one history. The engine exposes undo(), redo(), canUndo(), and canRedo() for controls; the calls are asynchronous because a command can do asynchronous work.

Setup is not an edit

Use the diagram model directly when you load or build document state. That setup does not become an undo step. Use CommandManager.execute() when a toolbar action, assistant, or other feature edits a document for the user.

ts
import {
  AddNodeCommand,
  DiagramEngine,
  NodeModel,
} from '@grafloria/engine';

(async () => {
  const engine = new DiagramEngine();
  const diagram = engine.createDiagram('Shipping');

  const ship = new NodeModel({
    id: 'ship',
    type: 'task',
    position: { x: 480, y: 60 },
    size: { width: 120, height: 48 },
  });

  diagram.addNode(ship); // setup: not an undo step

  const review = new NodeModel({
    id: 'review',
    type: 'task',
    position: { x: 480, y: 160 },
    size: { width: 120, height: 48 },
  });

  await engine.commandManager.execute(new AddNodeCommand(review));
  console.log(engine.canUndo()); // true

  await engine.undo();
  console.log(diagram.getNode('review')); // undefined

  await engine.redo();
  console.log(diagram.getNode('review')?.id); // review
})();

The first node is present as initial document state. The second node is a history entry: undo removes it and redo adds it again. AddNodeCommand stores the node's serialized data, so redo reconstructs the node rather than relying on the object that was originally passed to the constructor.

Commands are reversible contracts

Extend Command when a domain action needs to participate in the same history. Implement execute(), undo(), and serialize(). Use canExecute() to refuse an action before it mutates the diagram. Use canUndo() for a state check at undo time. isUndoable() answers a different question: whether the command belongs on the undo stack at all. A non-mutating command such as copy can return false from isUndoable().

After a successful execution, the command manager records the command and emits the command-executed event. If execution throws, the command is not recorded. With strict real-time validation enabled, an invalid result is reverted and the command is not recorded.

Commands can merge when their canMergeWith() returns true and they arrive within the manager's merging window. This compresses repeated changes, such as a drag, into one history entry. A user therefore undoes the gesture as one action rather than undoing every intermediate position.

Use batch mode when one feature performs several changes that must undo together. beginBatch() queues commands; endBatch(name) commits them as one BatchCommand. Its undo runs the contained commands in reverse order. cancelBatch() discards the queued commands without changing the document.

ts
import {
  AddNodeCommand,
  DiagramEngine,
  NodeModel,
} from '@grafloria/engine';

const engine = new DiagramEngine();
const diagram = engine.createDiagram('Batch example');

(async () => {
  const first = new NodeModel({
    id: 'first',
    type: 'task',
    position: { x: 40, y: 40 },
    size: { width: 120, height: 48 },
  });
  const second = new NodeModel({
    id: 'second',
    type: 'task',
    position: { x: 220, y: 40 },
    size: { width: 120, height: 48 },
  });

  engine.commandManager.beginBatch();
  await engine.commandManager.execute(new AddNodeCommand(first));
  await engine.commandManager.execute(new AddNodeCommand(second));
  await engine.commandManager.endBatch('Add shipping steps');

  await engine.undo();
  console.log(diagram.getNode('first')); // undefined
  console.log(diagram.getNode('second')); // undefined
})();

The two additions appear as one undo step. The batch checks its queued commands before applying them and passes through the same validation and history rules as an individual command.

Collaboration keeps undo local

Replica observes local changes to its diagram, appends them to its log, and sends each local operation to onLocalOp. Pass operations from another peer to receive(). Remote operations are de-duplicated, applied, and not echoed back.

Replica undo is deliberately different from “undo the most recent operation in the room”: replica.undo() undoes that replica's last edit. replica.redo() reapplies that replica's undone edit. Remote edits remain in the document, so one peer can undo its own change without removing a colleague's newer change.

For an in-memory demonstration, MemoryHub provides one shared room. Connect one transport per actor and deliver messages through the hub; the hub delivers to every peer except the sender. A production transport can replace the hub while the replica interface remains receive() and onLocalOp.

This gives two complementary histories: CommandManager records commands for one engine's ordinary editing surface, while Replica records document operations for a collaborative document and maintains a per-peer undo stack. Do not treat a remote operation as the local user's undo target.

Where this leaves the framework binding

The engine's models change first. Bindings then reconcile their state from those models, so an undo updates the diagram data exposed by the binding instead of creating a second, competing history. Keep user edits on the engine's command path; use direct model writes for setup or for the collaboration path that a Replica captures.

See make edits undoable for an implementation-focused recipe, model and document for the data being changed, and collaborate on a diagram for transport wiring.

Was this page helpful?