/** * @factory/editor — undo/redo history (TASK-0058). * * Block-operation history, in-memory, **100 steps** (the durable history is * page/block versions — this is the session buffer). The model is * settle-point snapshots of the block forest: one `step` per user-visible * change group (a keypress burst coalesces into one step via `commit`; * Tiptap's own transaction history feeds `commit` calls from the canvas). * * `History` is pure (no tiptap, no timers) and fully unit-testable: * `record(before, after)` → `undo()` / `redo()` return the snapshot to * restore, with the 100-step cap and branch truncation (a new edit after * an undo discards the redo branch — standard editor semantics). */ import type { BlockDto, HistoryStep } from "./types.js"; /** Max undo steps (PRODUCT-SPEC / functional-requirements §4.3: 100). */ export const HISTORY_LIMIT = 100; export interface HistorySnapshot { readonly label: string; readonly blocks: readonly BlockDto[]; } /** * The undo/redo buffer. `undo()` pops the top step and returns the * pre-state to restore; `redo()` re-applies the next stashed step. */ export class History { private undoStack: HistoryStep[] = []; private redoStack: HistoryStep[] = []; /** * Coalesced snapshot of the current state (the state BEFORE the most * recent committed step, or the initial state before any step). Used by * the canvas to know what `record` should diff against. */ private baseline: readonly BlockDto[]; constructor(initial?: readonly BlockDto[]) { this.baseline = [...(initial ?? [])]; } get canUndo(): boolean { return this.undoStack.length > 0; } get canRedo(): boolean { return this.redoStack.length > 0; } /** Number of undo steps currently available. */ get depth(): number { return this.undoStack.length; } /** * Commit one settle point. `after` must differ from `before` (or the * label may coalesce with the previous step — a continuation of the same * burst, e.g. typing; the previous step's `after` is replaced so undo * jumps to before the whole burst). */ record(before: readonly BlockDto[], after: readonly BlockDto[], label: string): void { const last = this.undoStack[this.undoStack.length - 1]; if (last !== undefined && last.label === label && this.redoStack.length === 0) { // coalesce: extend the previous step (typing burst) this.undoStack[this.undoStack.length - 1] = { label, before: last.before, after: [...after] }; this.baseline = [...after]; return; } this.undoStack.push({ label, before: [...before], after: [...after] }); if (this.undoStack.length > HISTORY_LIMIT) { this.undoStack.shift(); } this.redoStack = []; this.baseline = [...after]; } /** Undo one step; returns the pre-state to restore, or null if none. */ undo(): HistorySnapshot | null { const step = this.undoStack.pop(); if (step === undefined) return null; this.redoStack.push(step); this.baseline = [...step.before]; return { label: step.label, blocks: step.before }; } /** Redo one step; returns the post-state to restore, or null if none. */ redo(): HistorySnapshot | null { const step = this.redoStack.pop(); if (step === undefined) return null; this.undoStack.push(step); this.baseline = [...step.after]; return { label: step.label, blocks: step.after }; } /** The state the next `record` should treat as `before`. */ current(): readonly BlockDto[] { return this.baseline; } /** Drop all history (page switch / reload). */ reset(initial?: readonly BlockDto[]): void { this.undoStack = []; this.redoStack = []; this.baseline = [...(initial ?? [])]; } }