Command System

Undoable commands and the undo/redo stack.

Overview

All user actions that modify the board should go through the command system to support undo and redo.

Command Interface

interface Command {
  execute(): void;
  undo(): void;
}

A command encapsulates both the forward action and its inverse. When executed via the registry, it is pushed onto the undo stack.

CommandRegistry

interface CommandRegistry {
  execute(command: Command): void;
  undo(): void;
  redo(): void;
  canUndo(): boolean;
  canRedo(): boolean;
  getHistorySize(): number;
  getCursor(): number;
}
MethodDescription
execute(cmd)Calls cmd.execute() and pushes it onto the history.
undo()Calls undo() on the most recent command and moves the cursor back.
redo()Calls execute() on the next command and moves the cursor forward.
canUndo/canRedoCheck if undo/redo is available.

Built-in Shortcuts

The core registers Ctrl+Z for undo and Ctrl+Shift+Z for redo automatically.

Example: Add Shape Command

function createAddShapeCommand(store: BoardStore, shape: ShapeData): Command {
  return {
    execute() {
      store.addShape(shape);
    },
    undo() {
      store.deleteShape(shape.id);
    },
  };
}

// Usage in a tool's onPointerUp:
function onPointerUp(toolCtx: ToolContext) {
  const shape = /* ... built shape ... */;
  toolCtx.commands.execute(createAddShapeCommand(toolCtx.store, shape));
}

Best Practice

Always create shapes through commands rather than directly mutating the store. Direct mutations (store.addShape()) work but won’t appear in the undo history.

A common pattern for drawing tools:

  1. onPointerDown — Create a temporary shape directly on the store (for live preview).
  2. onPointerMove — Update the temporary shape directly.
  3. onPointerUp — Delete the temporary shape, then execute an AddShapeCommand with the final data.

This gives users a live preview while ensuring the final result is undoable.

Undo/Redo in Collaborative (Yjs) Apps

⚠️ CommandRegistry is local / single-user only. It is an in-memory stack scoped to one client. It only tracks commands you explicitly execute() — it does not observe store.onMutation, and it has no notion of remote peers. For a single-user app this is exactly what you want, and the built-in tools (tool-select’s move/resize/delete) undo correctly out of the box.

For a collaborative app backed by Yjs, do not use commands.undo() as your primary undo. It is not collaboration-safe.

When multiple clients edit a shared Yjs document, CommandRegistry breaks down in three ways:

  1. It restores local snapshots. A command’s undo() reverts to the state captured at execute() time. Replaying that over a Yjs document can clobber concurrent edits from other peers — it is not conflict-free.
  2. Remote changes never enter the history. Updates arriving through the Yjs sync mirror don’t go through ctx.commands, so they aren’t on the local stack at all. Undo would skip right past them.
  3. The built-in Ctrl+Z / Ctrl+Shift+Z shortcuts call commands.undo()/redo(). createApp wires these automatically, so in a Yjs app the keyboard undo silently behaves as local-only — easy to miss until two users step on each other.

Yjs ships a CRDT-aware undo/redo built for exactly this: Y.UndoManager. It tracks changes on the shared Y types and, via trackedOrigins, scopes undo to this client’s own writes so undoing never reverts a peer’s concurrent edit.

Minimal recipe — track the shared shapes type, and tag your local writes with a transaction origin so only those are undoable:

import * as Y from "yjs";

// The shared Y type your sync plugin writes shapes into
// (e.g. the Y.Map / Y.Doc behind @edv4h/usketch-plugin-sync-localstorage-yjs
//  or @edv4h/usketch-plugin-sync-ywebsocket).
const yShapes = ydoc.getMap("shapes");

// A stable origin object identifying *local* edits.
const LOCAL_ORIGIN = { local: true };

const undoManager = new Y.UndoManager(yShapes, {
  // Only undo edits that originated locally — never a remote peer's.
  trackedOrigins: new Set([LOCAL_ORIGIN]),
});

// Apply local edits inside a transaction tagged with LOCAL_ORIGIN so the
// UndoManager picks them up (remote edits arrive with a different origin
// and are correctly ignored).
ydoc.transact(() => {
  // ...write/update/delete shapes on yShapes...
}, LOCAL_ORIGIN);

// Wire your own keyboard handlers to these instead of commands.undo():
undoManager.undo();
undoManager.redo();

Disabling the built-in shortcuts

If you drive undo through Y.UndoManager, you’ll want Ctrl+Z / Ctrl+Shift+Z to call your handlers rather than commands.undo(). Re-register those shortcuts after createApp (last registration wins) and point them at the UndoManager:

const app = createApp({ /* ... */ });
app.shortcuts.register("Ctrl+Z", () => undoManager.undo());
app.shortcuts.register("Ctrl+Shift+Z", () => undoManager.redo());
App typeUse for undo/redo
Single-user / localCommandRegistry (ctx.commands) — built-in, works out of the box.
Collaborative (Yjs)Y.UndoManager with trackedOrigins scoped to local writes.