OpenVideo

Undo & Redo

History management and time travel in OpenVideo.

OpenVideo Core has built-in undo/redo support through the command system. Every command generates inverse patches that can be applied to reverse changes.

How It Works

When you execute a command:

  1. Patches are generated describing what changed
  2. Inverse patches are computed to reverse those changes
  3. Both are stored in the history stack
  4. Undo applies the inverse patches; redo re-applies the original patches
Execute: Add Clip
├─ Patches: [{ op: "add", path: "/clips", value: {...} }]
└─ Inverse: [{ op: "remove", path: "/clips/clip_123" }]

History Stack: [Add Clip]

Undo: Apply inverse patches (clip removed)
Redo: Re-apply original patches (clip restored)

Basic Usage

Undo

core.undo();

Redo

core.redo();

Check State

const state = core.store.getState();

// Can we undo?
const canUndo = state.history.length > 0;

// Can we redo?
const canRedo = state.future.length > 0;

// How many actions in history?
const actionCount = state.history.length;

History Stack

Structure

interface HistoryEntry {
  command: Command; // The original command
  patches: Patch[]; // Changes made
  inversePatches: Patch[]; // How to reverse
}

// In the store
interface ProjectState {
  history: HistoryEntry[]; // Past actions (can undo)
  future: HistoryEntry[]; // Undone actions (can redo)
}

Inspect History

const state = core.store.getState();

// See recent commands
const recent = state.history.slice(-5).map((entry) => ({
  type: entry.command.type,
  id: entry.command.id,
  timestamp: entry.command.meta?.timestamp,
}));

console.log("Recent actions:", recent);
// [{ type: "clip.move", id: "cmd_1" }, { type: "clip.add", id: "cmd_2" }]

Batching and History

Batch Commands = Single History Entry

When you batch multiple commands, they're treated as a single undoable action:

import { nanoid } from "@openvideo/core";

// All 3 commands become ONE history entry
core.batch([
  { id: nanoid(), type: "clip.add", payload: { ... } },
  { id: nanoid(), type: "clip.add", payload: { ... } },
  { id: nanoid(), type: "track.add", payload: { ... } },
]);

// One undo removes all 3 changes
core.undo();

When to Batch

// Multi-selection move - batch them
core.batch(
  selectedIds.map((id) => ({
    id: nanoid(),
    type: "clip.move",
    payload: { clipId: id, newPosition: ... },
  }))
);

// Import with multiple clips - batch them
core.batch(
  importedClips.map((clip) => ({
    id: nanoid(),
    type: "clip.add",
    payload: { trackId: "main", clip },
  }))
);

History Limits

Setting a Maximum History Size

Prevent memory issues in long editing sessions:

// Custom middleware to limit history
function limitHistory(maxEntries: number) {
  const state = core.store.getState();

  if (state.history.length > maxEntries) {
    // Remove oldest entries
    core.store.setState((s) => ({
      ...s,
      history: s.history.slice(-maxEntries),
    }));
  }
}

// Call after commands
core.store.subscribe(() => limitHistory(100));

Clearing History

// Clear redo stack (e.g., after significant action)
core.store.setState((state) => ({ ...state, future: [] }));

// Clear all history (e.g., after save)
core.store.setState((state) => ({
  ...state,
  history: [],
  future: [],
}));

UI Patterns

Undo/Redo Buttons

function HistoryControls() {
  const { canUndo, canRedo } = useStore(
    useShallow((state) => ({
      canUndo: state.history.length > 0,
      canRedo: state.future.length > 0,
    })),
  );

  return (
    <div className="history-controls">
      <button onClick={() => core.undo()} disabled={!canUndo}>
        <UndoIcon /> Undo
      </button>
      <button onClick={() => core.redo()} disabled={!canRedo}>
        <RedoIcon /> Redo
      </button>
    </div>
  );
}

History Panel

function HistoryPanel() {
  const history = useStore((state) => state.history);
  const future = useStore((state) => state.future);
  const currentIndex = history.length - 1;

  return (
    <div className="history-panel">
      <h3>History</h3>
      <ul>
        {history.map((entry, index) => (
          <li key={entry.command.id} className={index === currentIndex ? "active" : ""}>
            {entry.command.type}
          </li>
        ))}
        {future.map((entry) => (
          <li key={entry.command.id} className="future">
            {entry.command.type}
          </li>
        ))}
      </ul>
    </div>
  );
}

Keyboard Shortcuts

document.addEventListener("keydown", (e) => {
  const isMac = navigator.platform.includes("Mac");
  const modifier = isMac ? e.metaKey : e.ctrlKey;

  if (modifier && e.key === "z") {
    e.preventDefault();
    if (e.shiftKey) {
      core.redo();
    } else {
      core.undo();
    }
  }

  if (modifier && (e.key === "y" || (e.shiftKey && e.key === "z"))) {
    e.preventDefault();
    core.redo();
  }
});

Advanced: Selective History

Commands Without History

Some commands shouldn't be undoable (e.g., selection changes):

// These update state directly - no history
core.store.getState().select("clip_123");
core.store.getState().setScale({ zoom: 0.5 });

// These go through execute() - added to history
core.execute({ id: nanoid(), type: "clip.add", ... });

Use the meta field to group actions:

// All these are part of the same user gesture
core.batch([
  {
    id: nanoid(),
    type: "clip.add",
    payload: { ... },
    meta: { source: "user", gesture: "import-batch" },
  },
  {
    id: nanoid(),
    type: "clip.add",
    payload: { ... },
    meta: { source: "user", gesture: "import-batch" },
  },
]);

Best Practices

1. Always Use nanoid() for Command IDs

import { nanoid } from "@openvideo/core";

core.execute({
  id: nanoid(), // Required for history tracking
  type: "clip.move",
  payload: { ... },
});
// BAD: Each drag event creates a history entry
core.execute({ id: nanoid(), type: "clip.move", ... }); // while dragging

// GOOD: Only save final position
core.execute({ id: nanoid(), type: "clip.move", ... }); // on drag end only

3. Clear Future on New Actions

After undoing, any new command should clear the redo stack:

// This happens automatically in Core
// history: [A, B, C], future: [D, E]
// Undo -> history: [A, B], future: [C, D, E]
// New command F -> history: [A, B, F], future: []

4. Show Feedback

// Toast notification after undo
function undoWithFeedback() {
  const prevEntry = core.store.getState().history.at(-1);
  core.undo();
  toast.info(`Undid: ${prevEntry?.command.type}`);
}

Debugging History

Log All Changes

core.store.subscribe((state, prevState) => {
  if (state.history.length > prevState.history.length) {
    const latest = state.history.at(-1);
    console.log("New action:", latest?.command.type, latest?.patches);
  }
});

Export History

function exportHistory() {
  const state = core.store.getState();
  return {
    past: state.history.map((h) => h.command),
    future: state.future.map((h) => h.command),
  };
}

Next Steps

On this page