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 optionalmerge()methods located insrc/core/history/commands/. - History Management: The
CommandJournalinsrc/core/history/CommandJournal.tsmaintains a 100-level linear stack with a_positioncursor and automatic truncation. - Store Bridge:
historyStore.tswraps the journal in a Zustand interface, exposingexecute(),undo(),redo(), and transaction helpers. - UI Integration: Components consume state selectors for button states and labels, while
useKeyboardShortcuts.tshandles keyboard bindings. - Transaction Support: Group related commands using
beginTransaction()andcommitTransaction()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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →