How to Implement Undo/Redo with Clypra's 100-Level History Stack Command Pattern

Clypra implements undo/redo through a command-journal architecture where every mutation is encapsulated as a Command object supporting apply(), invert(), and optional merge() operations, managed by the CommandJournal class in src/core/history/CommandJournal.ts to maintain a linear history stack with a 100-level limit.

Clypra's video editor provides robust undo/redo functionality by treating every timeline modification as a reversible command. Built on an IO-level command pattern, the system stores executed commands in the CommandJournal, which manages up to 100 levels of history through a centralized Zustand store at src/store/historyStore.ts.

Core Architecture Components

The Command Interface

All mutating operations in Clypra implement the Command interface defined within the history system. Each command must provide an apply(state) method to execute the mutation, an invert() method that returns a new Command representing the reverse operation, and optionally a merge(other) method for coalescing rapid successive actions.

CommandJournal Implementation

The CommandJournal class in src/core/history/CommandJournal.ts serves as the central undo/redo engine. It maintains an internal array of executed commands and a _position cursor tracking the current point in the history stack. When the stack exceeds 100 entries, the journal automatically truncates older commands to enforce the limit. The journal handles command execution, inverse application during undo, and reapplication during redo, while supporting transaction grouping and time-based coalescing.

Creating Reversible Commands

Define custom operations by implementing the Command interface in the src/core/history/commands/ directory. Here is an example of adding a clip to the timeline:

// src/core/history/commands/AddClipCommand.ts
import type { Command } from '@/core/history/Command';
import type { Clip, TimelineState } from '@/lib/timeline/types';

export class AddClipCommand implements Command {
  public readonly label = 'Add Clip';
  public readonly undoable = true;
  public readonly timestamp = Date.now();

  constructor(private readonly clip: Clip) {}

  apply(state: TimelineState): TimelineState {
    return {
      ...state,
      clips: [...state.clips, this.clip],
    };
  }

  invert(): Command {
    return new DeleteClipCommand(this.clip.id);
  }
}

Executing and Managing History

Basic Execution Flow

UI handlers execute commands through the historyStore bridge. Call historyStore.execute(command, currentState) to apply the mutation and push it onto the history stack:

import { useHistoryStore } from '@/store/historyStore';
import { AddClipCommand } from '@/core/history/commands/AddClipCommand';
import { getCurrentTimelineState } from '@/lib/timeline/utils';

function onAddClip(newClip: Clip) {
  const history = useHistoryStore.getState();
  const state = getCurrentTimelineState();
  
  history.execute(new AddClipCommand(newClip), state);
}

Transaction Grouping

For complex operations that require multiple commands to function as a single undo step, use the transaction API. The beginTransaction(label) and commitTransaction(state) methods in src/store/historyStore.ts create a composite command entry:

import { useHistoryStore } from '@/store/historyStore';
import { DeleteClipCommand } from '@/core/history/commands/DeleteClipCommand';
import { AddClipCommand } from '@/core/history/commands/AddClipCommand';

function splitClip(originalClip: Clip, leftPart: Clip, rightPart: Clip) {
  const history = useHistoryStore.getState();
  
  history.beginTransaction('Split Clip');
  
  history.execute(new DeleteClipCommand(originalClip.id), getState());
  history.execute(new AddClipCommand(leftPart), getState());
  history.execute(new AddClipCommand(rightPart), getState());
  
  history.commitTransaction(getState());
}

UI Integration and State Management

React Component Integration

The historyStore exposes selectors for canUndo, canRedo, undoLabel, and redoLabel derived from the CommandJournal state. Use these to build responsive toolbar buttons:

import { useHistoryStore } from '@/store/historyStore';

export function UndoRedoToolbar() {
  const { undo, redo, canUndo, canRedo, undoLabel, redoLabel } =
    useHistoryStore((state) => ({
      undo: state.undo,
      redo: state.redo,
      canUndo: state.canUndo,
      canRedo: state.canRedo,
      undoLabel: state.undoLabel,
      redoLabel: state.redoLabel,
    }));

  return (
    <div className="toolbar">
      <button disabled={!canUndo} onClick={() => undo()}>
        ↶ {undoLabel ?? 'Undo'}
      </button>
      <button disabled={!canRedo} onClick={() => redo()}>
        ↷ {redoLabel ?? 'Redo'}
      </button>
    </div>
  );
}

Keyboard Shortcuts

Bind standard undo/redo shortcuts through the useKeyboardShortcuts.ts hook, which connects Ctrl+Z to historyStore.undo() and Ctrl+Y to historyStore.redo():

// src/hooks/useKeyboardShortcuts.ts
// Implements keyboard listeners that call historyStore.undo() and historyStore.redo()

Summary

  • Command Pattern: Every mutation implements the Command interface with apply(), invert(), and optional merge() methods located in src/core/history/commands/.
  • History Management: The CommandJournal in src/core/history/CommandJournal.ts maintains a 100-level linear stack with a _position cursor and automatic truncation.
  • Store Bridge: historyStore.ts wraps the journal in a Zustand interface, exposing execute(), undo(), redo(), and transaction helpers.
  • UI Integration: Components consume state selectors for button states and labels, while useKeyboardShortcuts.ts handles keyboard bindings.
  • Transaction Support: Group related commands using beginTransaction() and commitTransaction() to create single undoable entries.

Frequently Asked Questions

What is the maximum history depth in Clypra?

The CommandJournal enforces a 100-level limit on the history stack. When this limit is exceeded, the oldest commands are automatically removed from the bottom of the stack to maintain memory constraints while preserving recent user actions.

How does command coalescing work in the history stack?

Commands that implement the merge(other: Command): boolean method can be coalesced by the CommandJournal when executed within a configurable time window. This collapses rapid successive operations—such as continuous drag adjustments—into a single history entry, keeping the stack clean and undo behavior intuitive.

Can I implement custom undoable actions for plugins?

Yes. Create a new class in src/core/history/commands/ that implements the Command interface with apply() and invert() methods, then dispatch it through historyStore.execute(). The system automatically integrates custom commands into the 100-level history stack with full undo/redo support.

What happens if an error occurs during a transaction?

Transactions group multiple commands into a single composite entry. If an individual command fails during execution, the specific error handling depends on the command's implementation. However, the transaction boundary ensures that the history cursor remains consistent, preventing partial states from appearing in the undo stack.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →