import type { Patch } from "../operations/operations.js"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface HistoryOptions { /** Maximum number of undo entries retained. @default 100 */ maxEntries?: number; } export interface HistoryState { /** All recorded patches (forward patches). */ patches: Patch[]; /** Current undo position — index of the next patch to apply for redo. */ position: number; } // --------------------------------------------------------------------------- // History class // --------------------------------------------------------------------------- /** * Linear undo/redo history manager. * * Stores a stack of forward patches. `undo()` moves the position backwards * and returns the patch to revert. `redo()` moves it forward and returns the * patch to re-apply. Pushing a new patch after an undo truncates the redo * history (standard linear model). * * @example * ```ts * import { createHistory } from "@suaveplan/diff/history"; * import { applyPatch, revertPatch } from "@suaveplan/diff/patch"; * * const history = createHistory({ maxEntries: 50 }); * history.push(patch1); * const undoPatch = history.undo(); * ``` */ export class History { protected readonly _maxEntries: number; protected _patches: Patch[]; protected _position: number; protected _listeners: Array<(state: HistoryState) => void>; constructor(options?: HistoryOptions) { this._maxEntries = options?.maxEntries ?? 100; this._patches = []; this._position = 0; this._listeners = []; } /** * Record a forward patch. Truncates any redo entries above the current * position. Evicts the oldest entry when `maxEntries` is exceeded. */ push(patch: Patch): void { // Truncate redo entries this._patches = this._patches.slice(0, this._position); this._patches.push(patch); // Enforce max entries if (this._patches.length > this._maxEntries) { this._patches = this._patches.slice( this._patches.length - this._maxEntries ); } this._position = this._patches.length; this._notify(); } /** * Move position back by one and return the patch at that position (the * forward patch that was most recently applied), so the caller can invert * and apply it. Returns `undefined` if there is nothing to undo. */ undo(): Patch | undefined { if (!this.canUndo()) return undefined; this._position--; const patch = this._patches[this._position]; this._notify(); return patch; } /** * Move position forward by one and return the patch at the new position * (the next forward patch to re-apply). Returns `undefined` if there is * nothing to redo. */ redo(): Patch | undefined { if (!this.canRedo()) return undefined; const patch = this._patches[this._position]; this._position++; this._notify(); return patch; } /** Returns `true` if there is at least one patch that can be undone. */ canUndo(): boolean { return this._position > 0; } /** Returns `true` if there is at least one patch that can be redone. */ canRedo(): boolean { return this._position < this._patches.length; } /** Clear all history and reset position to 0. */ clear(): void { this._patches = []; this._position = 0; this._notify(); } /** Return a serialisable snapshot of the current history state. */ getState(): HistoryState { return { patches: this._patches.map((p) => [...p]), position: this._position, }; } /** * Register a listener that is called whenever the history state changes. * Returns an unsubscribe function. */ onChange(listener: (state: HistoryState) => void): () => void { this._listeners.push(listener); return () => { this._listeners = this._listeners.filter((l) => l !== listener); }; } protected _notify(): void { const state = this.getState(); for (const listener of this._listeners) { listener(state); } } } // --------------------------------------------------------------------------- // Factory // --------------------------------------------------------------------------- /** * Create a new {@link History} instance. * * @example * ```ts * const history = createHistory({ maxEntries: 50 }); * ``` */ export function createHistory(options?: HistoryOptions): History { return new History(options); }