Skip to main content
OpenCut implements a robust undo/redo system using the command pattern. Every modifying operation is wrapped in a command that can be executed, undone, and redone.

Why commands?

The command pattern provides:

Undo/redo support

Every operation can be reversed and re-applied.

State encapsulation

Commands save necessary state for reversal.

Batch operations

Multiple commands can be executed as a single undoable unit.

History tracking

Complete history of all operations for debugging and analysis.

Command interface

All commands extend the base Command class:
apps/web/src/lib/commands/base-command.ts
export abstract class Command {
  abstract execute(): void;

  undo(): void {
    throw new Error("Undo not implemented for this command");
  }

  redo(): void {
    this.execute();
  }
}

CommandManager API

The CommandManager handles command execution and history:
apps/web/src/core/managers/commands.ts
export class CommandManager {
  private history: Command[] = [];
  private redoStack: Command[] = [];

  execute({ command }: { command: Command }): Command {
    command.execute();
    this.history.push(command);
    this.redoStack = [];
    return command;
  }

  push({ command }: { command: Command }): void {
    this.history.push(command);
    this.redoStack = [];
  }

  undo(): void {
    if (this.history.length === 0) return;
    const command = this.history.pop();
    command?.undo();
    if (command) {
      this.redoStack.push(command);
    }
  }

  redo(): void {
    if (this.redoStack.length === 0) return;
    const command = this.redoStack.pop();
    command?.redo();
    if (command) {
      this.history.push(command);
    }
  }

  canUndo(): boolean {
    return this.history.length > 0;
  }

  canRedo(): boolean {
    return this.redoStack.length > 0;
  }

  clear(): void {
    this.history = [];
    this.redoStack = [];
  }
}

Using commands

Basic usage

import { EditorCore } from '@/core';
import { SplitElementsCommand } from '@/lib/commands/timeline';

const editor = EditorCore.getInstance();

// Execute a command
const command = new SplitElementsCommand(
  [{ trackId: 'track-1', elementId: 'element-1' }],
  5.5,
  'both'
);
editor.command.execute({ command });

// Undo the command
editor.command.undo();

// Redo the command
editor.command.redo();

// Check if undo/redo is available
if (editor.command.canUndo()) {
  editor.command.undo();
}

Through manager methods

Most operations are wrapped by manager methods:
import { useEditor } from '@/hooks/use-editor';

function MyComponent() {
  const editor = useEditor();

  const handleSplit = () => {
    // Automatically creates and executes a command
    editor.timeline.splitElements({
      elements: [{ trackId: 'track-1', elementId: 'element-1' }],
      splitTime: 5.5,
      retainSide: 'both'
    });
  };

  return <button onClick={handleSplit}>Split</button>;
}
Under the hood, timeline.splitElements() does:
apps/web/src/core/managers/timeline-manager.ts
splitElements({
  elements,
  splitTime,
  retainSide = "both",
}: {
  elements: { trackId: string; elementId: string }[];
  splitTime: number;
  retainSide?: "both" | "left" | "right";
}): { trackId: string; elementId: string }[] {
  const command = new SplitElementsCommand(elements, splitTime, retainSide);
  this.editor.command.execute({ command });
  return command.getRightSideElements();
}

Creating a command

Here’s a complete example of implementing a command:
import { Command } from '@/lib/commands/base-command';
import { EditorCore } from '@/core';
import type { TimelineTrack } from '@/types/timeline';

export class DeleteElementsCommand extends Command {
  private savedState: TimelineTrack[] | null = null;
  private deletedElements: Array<{
    trackId: string;
    elementId: string;
  }> = [];

  constructor(
    private elements: Array<{
      trackId: string;
      elementId: string;
    }>
  ) {
    super();
  }

  execute(): void {
    const editor = EditorCore.getInstance();
    
    // Save current state for undo
    this.savedState = editor.timeline.getTracks();
    this.deletedElements = [...this.elements];

    // Perform the deletion
    const updatedTracks = this.savedState.map((track) => {
      return {
        ...track,
        elements: track.elements.filter((element) => {
          return !this.deletedElements.some(
            (del) => del.trackId === track.id && del.elementId === element.id
          );
        }),
      };
    });

    editor.timeline.updateTracks(updatedTracks);
  }

  undo(): void {
    if (this.savedState) {
      const editor = EditorCore.getInstance();
      editor.timeline.updateTracks(this.savedState);
    }
  }
}

Batch commands

Combine multiple commands into a single undoable operation:
apps/web/src/lib/commands/batch-command.ts
import { Command } from "./base-command";

export class BatchCommand extends Command {
  constructor(private commands: Command[]) {
    super();
  }

  execute(): void {
    for (const command of this.commands) {
      command.execute();
    }
  }

  undo(): void {
    // Undo in reverse order
    for (const command of [...this.commands].reverse()) {
      command.undo();
    }
  }

  redo(): void {
    for (const command of this.commands) {
      command.execute();
    }
  }
}

Using batch commands

import { BatchCommand } from '@/lib/commands';
import { UpdateElementCommand } from '@/lib/commands/timeline';

const editor = EditorCore.getInstance();

// Create multiple update commands
const commands = [
  new UpdateElementCommand('track-1', 'element-1', { opacity: 0.5 }),
  new UpdateElementCommand('track-1', 'element-2', { opacity: 0.7 }),
  new UpdateElementCommand('track-2', 'element-3', { opacity: 0.3 }),
];

// Execute as single undoable operation
const batchCommand = new BatchCommand(commands);
editor.command.execute({ command: batchCommand });

// Single undo reverts all changes
editor.command.undo();

Command organization

Commands are organized by domain in the codebase:
apps/web/src/lib/commands/
├── base-command.ts          # Base Command class
├── batch-command.ts         # BatchCommand for grouping
├── index.ts                 # Exports
├── timeline/
│   ├── track/
│   │   ├── add-track.ts
│   │   ├── remove-track.ts
│   │   ├── toggle-track-mute.ts
│   │   └── toggle-track-visibility.ts
│   ├── element/
│   │   ├── insert-element.ts
│   │   ├── delete-elements.ts
│   │   ├── split-elements.ts
│   │   ├── update-element.ts
│   │   ├── move-elements.ts
│   │   └── duplicate-elements.ts
│   └── clipboard/
│       └── paste.ts
├── scene/
│   ├── create-scene.ts
│   ├── delete-scene.ts
│   ├── rename-scene.ts
│   └── toggle-bookmark.ts
├── media/
│   ├── add-media-asset.ts
│   └── remove-media-asset.ts
└── project/
    └── update-project-settings.ts

Execute vs push

The CommandManager has two methods for adding commands to history:
Use when the command hasn’t been executed yet:
const command = new SplitElementsCommand(elements, time, side);
editor.command.execute({ command });
// Calls command.execute() then adds to history
This is the most common case.

Preview operations

Some operations support a preview mode before committing:
import { useEditor } from '@/hooks/use-editor';

function ElementDrag() {
  const editor = useEditor();

  const handleDragStart = () => {
    // Start preview mode
    editor.timeline.previewElements({
      updates: [{
        trackId: 'track-1',
        elementId: 'element-1',
        updates: { transform: { x: 100, y: 50 } }
      }]
    });
  };

  const handleDragEnd = (commit: boolean) => {
    if (commit) {
      // Commit the preview to history
      editor.timeline.commitPreview();
    } else {
      // Discard the preview
      editor.timeline.discardPreview();
    }
  };

  return <div>...</div>;
}

Command best practices

Save minimal state

Only save the state necessary to undo. Don’t save the entire editor state.

Immutable operations

Use immutable updates. Don’t mutate saved state directly.

Use batch for groups

Combine related operations into a BatchCommand for single undo.

Test undo/redo

Always test that undo properly reverses the operation.

Relationship with actions

Actions and commands work together:
Actions are “what triggered this” (user intent), while commands are “how to do it and undo it” (implementation).
  • EditorCore - The CommandManager is accessed via EditorCore
  • Actions - Actions trigger commands for user operations
  • Timeline - Timeline operations are implemented as commands
  • Scenes - Scene operations are implemented as commands

Build docs developers (and LLMs) love