Definition
This is a keyboard-handling pattern where, inside an editable region (contenteditable — an HTML region that lets the user edit text directly) using a rich-text engine (an editor library that handles bold, lists, etc., such as ProseMirror or TipTap), when the user presses Ctrl+Z (undo) but the editor's own undo is exhausted, the next undo is handed off to a document-wide undo.
Key background: while focused (the state where the input caret is inside), such an editor always calls preventDefault (a call that cancels the browser's default action for an event) on Mod-Z/Mod-Y (undo/redo shortcuts — Cmd on Mac, Ctrl on Windows) so that the browser's native undo cannot cut in. So even when the editor's undo stack (the list of undoable edit steps) is empty, the key event is already marked as "handled." Since it can no longer simply bubble (an event propagating up to parent elements) to the parent code, you must explicitly call a document-level undo handler to hand it off.
Why it matters
When designing dual history (in-editor undo vs. document-wide undo), there is a common wrong assumption: "if the editor can't handle it, the event will bubble up to the parent unprevented."
In real browsers, the editor calls preventDefault on Mod-Z even when there is nothing to undo, in order to block the browser's native undo. So if the upper handler is written only like if (event.defaultPrevented) return (ignore already-handled events), that condition is always true and the document-level undo never runs. To prevent this, don't rely on "does it bubble to the parent" — instead call the document undo directly the moment the editor undo fails.
How it works
- Guest keymap:
Mod-z→chainCommands(editorUndo, delegateDocumentUndo). - If
editorUndoreturns true, the inline history is consumed. - If false,
delegateDocumentUndoruns a document history step. - Before delegating, guest commit + retire to prevent text loss / stale commits.
- Document history may be a separate stack (e.g., sidebar move vs. body).
Practical application
ProseMirror example:
Do regression tests with Playwright: focus block → structural command (indent/move) → Mod-Z → assert DOM order is restored. jsdom does not reproduce PM's protective preventDefault, so it yields false negatives/positives.
When debugging, reject hypotheses (focus loss, phantom history) by instrumenting document.activeElement, PM eventCount, and defaultPrevented before guessing at a fix.
Trade-offs
- The explicit chain is more verbose than a
defaultPreventedheuristic but consistent across browsers. - Commit before delegating adds latency — design it together with the IME composing gate.
- Multi-layer history (3+) requires documenting keymap priority.
When not to use
- When there is only a single monolithic document PM and no document-level history — PM undo alone is enough.
- When a global capture listener intercepts Mod-Z and competes with PM — the priority conflict needs a separate fix.
Common mistakes
- A blanket
defaultPreventedguard in the selection keyboard handler. - Judging "undo OK" from a green jsdom run.
- delegate without commit — text loss after a block-level undo.
- Mixing inline/document history in one stack, so even a sidebar move gets rolled back by Ctrl+Z.
Related concepts
- two-stack-inverse-undo — the inverse stack model
- focused-guest-editor — guest/host history layers
- ime-composition-contenteditable — another input path during focus