History
Undo and redo as explicit committed entries.
Undo has to agree with what the user saw. If two keystrokes visibly produced one word, one undo should take the word back. If a paste produced five blocks, one undo should take all five. That is a property of the commit log, not of the DOM.
SpineEditor builds history as an explicit list of committed entries. Each entry pairs the committed Transaction with the full EditorState before and after it. undo() restores stateBefore; redo() restores stateAfter. Because the engine's document is persistent, storing those references is cheap — a one-character insert shares memory with its predecessor.
Worked example
const first = engine.dispatch({ kind: "typeText", text: "a" });
const second = engine.dispatch({ kind: "typeText", text: "b" });
const beforeUndo = engine.history.done.length;
const undoResult = engine.undo();
const afterUndo = engine.state;
engine.dispatch({ kind: "typeText", text: "c" });
Adjacent typing may coalesce through the grouping policy, so beforeUndo is not guaranteed to be 2. The important invariant is that undo restores the entry's stateBefore, redo restores stateAfter, and a fresh edit clears pending redo entries.
Fields
From packages/core/src/history.ts:
type HistoryEntryKind = "edit" | "undo" | "redo";
interface HistoryEntry {
readonly id: HistoryEntryId;
readonly kind: HistoryEntryKind;
readonly transaction: Transaction;
readonly stateBefore: EditorState;
readonly stateAfter: EditorState;
}
interface HistoryState {
readonly done: readonly HistoryEntry[];
readonly undone: readonly HistoryEntry[];
}
done is chronological — oldest first. undone is ordered so the most recently undone entry is at the end, ready to redo.
Trace events
The engine emits a history trace event on every push, undo, and redo:
history
action: "push"
history
action: "undo"
history
action: "redo"
Why explicit entries
- Compact. Persistent documents share structure, so
stateBeforeandstateAftercost references, not deep copies. - Inspectable. Every
HistoryEntrycarries theTransactionthat produced it. Tooling can walk history and see exactly which steps fired. - Portable. Each entry carries explicit
transaction,stateBefore, andstateAfterartifacts, so tooling can persist or inspect history without depending on DOM snapshots.
API
createHistoryState(): HistoryState;
canUndo(history: HistoryState): boolean;
canRedo(history: HistoryState): boolean;
createHistoryEntry(input): HistoryEntry;
pushHistoryEntry(history, entry): HistoryState;
undoHistory(history): { history, entry, state };
redoHistory(history): { history, entry, state };
clearHistory(): HistoryState;
The engine wraps these in engine.undo() / engine.redo() / engine.canUndo() / engine.canRedo() / engine.clearHistory().