Skip to content

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.

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 like updateSelectedSlide and updateSelectedShapes.
  • '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 same mergeId collapse 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.

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.hash is 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 updateHistory for recorded commits; with structural sharing, only the one slide that was replaced gets hashed. Transient commits (drag frames) never touch them.

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.

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.