Editor Undo History
The editor’s undo history is a stack of immutable presentation snapshots. Because state updates always create new objects (structural sharing), a snapshot is just a reference — entries are cheap.
The commit seam
Section titled “The commit seam”Every presentation mutation flows through one function:
EditorActions.commitPresentation(newPresentation, intent);The HistoryIntent makes each mutation declare what undo should record:
type HistoryIntent = 'RECORD' | 'TRANSIENT' | { mergeId: string };'RECORD'— the commit becomes its own undo step. The default for helpers likeupdateSelectedSlideandupdateSelectedShapes.'TRANSIENT'— no undo step. Mid-gesture frames (drag move/resize previews) and cosmetic rewrites (the style simplification when a text box closes). The next recorded commit captures the result.{ mergeId }— recorded, but consecutive commits sharing the samemergeIdcollapse into a single undo step. Used for typing bursts and slider drags.
When adding a new mutation, pick the intent explicitly — grep for commitPresentation to see every existing decision.
No-op detection
Section titled “No-op detection”updateHistory skips commits whose content equals the current entry (presentationsHaveEqualContent), so touched-but-unchanged states never create dead undo steps or truncate redo.
Two kinds of hashes are involved, and the distinction matters for performance:
slide.hashis a cheap random identity token reassigned whenever a slide is touched. It drives render and thumbnail change detection and is recomputed on every commit — including per-frame drag updates — so it must stay cheap.- Content hashes answer “did the content actually change”. They are memoized per slide object and only computed inside
updateHistoryfor recorded commits; with structural sharing, only the one slide that was replaced gets hashed. Transient commits (drag frames) never touch them.
Typing bursts
Section titled “Typing bursts”Keystrokes don’t each become an undo step. The typingBurstTracker (state/actions/helpers/typing-burst.ts) groups them into bursts by handing out mergeIds: each keystroke registers the target (text shape or table cell) and the new plain text, and a new burst starts the moment one of the NEW_BURST_RULES detects a boundary:
- a different target is being typed into
- the user paused typing
- a paragraph break was added or removed (gets its own step)
- the change switched between inserting and deleting
- a new word began
Each rule is a small named predicate — add new boundaries by appending to the list.
Restoring entries
Section titled “Restoring entries”undo/redo move the history index and delegate to applyHistoryEntry, which combines:
- a selection policy — undo keeps as much of the user’s current selection as still exists (
getUndoSelection); redo jumps to where the redone change happened (getRedoSelection) - caret restoration — each entry stores the text caret at the time of its last change; entries created outside text editing fall back to a caret at the end of the text (never select-all)
Store-level tests in text-typing-history.test.ts and table-typing-history.test.ts cover the end-to-end behavior: burst grouping, undo/redo increments, caret restoration, and no-op skipping.