History
Undo / redo via withHistory - a snapshot timeline that wraps the reducer. Every action is undoable by default.
The history layer is a thin snapshot timeline on top of the reducer. Every mutation flows through withHistory(reducer), which captures the previous state before applying each action. Users get the standard Cmd+Z / Cmd+Shift+Z experience without any per-action wiring.
The timeline
import { reducer, withHistory } from '@docmosaic/core';
const wrapped = withHistory(reducer);
let state = wrapped.init(initialDocument);
state = wrapped.reduce(state, { type: 'ADD_SECTION' /* ... */ });
state = wrapped.reduce(state, { type: 'UNDO' });
state = wrapped.reduce(state, { type: 'REDO' });state is { past: Document[], present: Document, future: Document[] }. The past and future arrays are bounded - the timeline caps at the configured depth (defaults to 50) to keep memory predictable for image-heavy documents.
What's undoable
Every reducer action is undoable by default. There's no opt-out flag and no "transient" action - if it mutates the document, it lands on the timeline.
ADD_SECTION/UPDATE_SECTION/DELETE_SECTIONADD_PAGE/DELETE_PAGE/REORDER_PAGESBRING_TO_FRONT/SEND_TO_BACK/MOVE_FORWARD/MOVE_BACKWARDSET_DOCUMENT_NAME/SET_PAGE_SIZE/SET_ORIENTATIONSET_HIDDEN/SET_LOCKEDADD_GUIDE/REMOVE_GUIDESET_PAGE_BACKGROUND
UI-only state (the selection, the active hover, the marquee rectangle) lives outside the document and isn't part of the timeline.
Coalescing
Continuous gestures coalesce into a single timeline entry. A drag that fires 60 UPDATE_SECTION calls becomes one undo step, not sixty. The hook detects same-target same-action sequences within a debounce window and merges them.
Coalescing keeps the timeline tractable for image-heavy documents and matches user intent - one drag, one undo.
Reading the timeline state
The headless hook surfaces two booleans:
const { canUndo, canRedo, actions } = useDocumentState();
<button disabled={!canUndo} onClick={actions.undo}>Undo</button>
<button disabled={!canRedo} onClick={actions.redo}>Redo</button>canUndo is past.length > 0; canRedo is future.length > 0. Editing dispatch clears future - the standard browser-style "redo doesn't survive a new edit" behavior.
Controlled mode disables it
Editor.Root document={...} onDocumentChange={...} (controlled) skips the timeline entirely - the parent owns state, so undo/redo would need to be implemented in the parent's own state container. canUndo / canRedo are always false and actions.undo / actions.redo are no-ops.
If you want undo/redo in controlled mode, wrap your own state in withHistory and dispatch through it.
Reset / replace
There's no "clear timeline" action - to reset, swap the whole Document via the root:
const [doc, setDoc] = useState(initialDoc);
function resetEditor() {
setDoc(createDocument()); // remount-equivalent; timeline restarts
}
<Editor.Root document={doc} onDocumentChange={setDoc}>
{/* ... */}
</Editor.Root>;In uncontrolled mode, change key on Editor.Root to force a remount and discard the timeline.
See also
- Document model - what each timeline snapshot contains
- Keybindings - the default
mod+z/mod+shift+zbindings - Controlled vs uncontrolled - when the timeline is active