/** * Undo/redo history for an editor whose state can be represented as JSON. * * The history owns snapshots, stack limits and the asynchronous commit boundary. * It does not know how a document is rendered or restored: those concerns are * supplied by the editor through the constructor callbacks. */ export class CmapHistory { /** * goal : Create a history coordinator for one editor state. * pre : snapshot and restore are functions for the same serialized state. * post : The coordinator is ready to track state after reset() is called. * result : A CmapHistory instance with empty undo and redo stacks. * internals : The callbacks keep this controller independent of the editor's * model and view; the stacks contain only serialized snapshots. * * @param {object} options History callbacks and configuration. * @param {Function} options.snapshot Returns the current serialized state. * @param {Function} options.restore Restores one serialized state. * @param {Function} [options.onChange] Receives undo/redo availability changes. * @param {number} [options.limit=100] Maximum number of undo snapshots. */ constructor({ snapshot, restore, onChange = null, limit = 100 }) { if (typeof snapshot !== "function") throw new TypeError("A snapshot function is required"); if (typeof restore !== "function") throw new TypeError("A restore function is required"); this.snapshot = snapshot; this.restoreDocument = restore; this.onChange = onChange; this.limit = Math.max(1, Number(limit) || 100); this.undoStack = []; this.redoStack = []; this.currentSnapshot = null; this.timer = null; this.ready = false; this.isRestoring = false; } /** Notify the host about the current availability of undo and redo. */ notify() { if (this.onChange) { this.onChange({ canUndo: this.canUndo(), canRedo: this.canRedo() }); } } /** * goal : Start a new history session at the current editor state. * pre : The snapshot callback returns the current serialized state. * post : Both stacks are empty and the current state is the history baseline. * result : Undefined; the host is notified of the empty stacks. * internals : A pending timer is cancelled before the baseline is captured. */ reset() { this.cancelScheduledCommit(); this.undoStack = []; this.redoStack = []; this.ready = true; this.currentSnapshot = this.snapshot(); this.notify(); } /** Cancel a pending asynchronous history commit. */ cancelScheduledCommit() { if (this.timer !== null) { window.clearTimeout(this.timer); this.timer = null; } } /** * Schedule one commit for the current mutation transaction. * The zero-delay timer groups synchronous editor changes into one undo step. */ scheduleCommit() { if (!this.ready || this.isRestoring) return; this.cancelScheduledCommit(); this.timer = window.setTimeout(() => { this.timer = null; this.commit(); }, 0); } /** Update the baseline after renderer-only normalization. */ refreshSnapshot() { if (!this.ready || this.isRestoring || this.timer !== null) return; this.currentSnapshot = this.snapshot(); } /** * Commit the current state when it differs from the baseline. * @returns {boolean} Whether a new undo step was recorded. */ commit() { if (!this.ready || this.isRestoring) return false; this.cancelScheduledCommit(); const nextSnapshot = this.snapshot(); if (nextSnapshot === this.currentSnapshot) return false; if (this.currentSnapshot !== null) { this.undoStack.push(this.currentSnapshot); if (this.undoStack.length > this.limit) this.undoStack.shift(); } this.currentSnapshot = nextSnapshot; this.redoStack = []; this.notify(); return true; } /** Return whether an undo operation is available. */ canUndo() { return this.undoStack.length > 0; } /** Return whether a redo operation is available. */ canRedo() { return this.redoStack.length > 0; } get undoCount() { return this.undoStack.length; } get redoCount() { return this.redoStack.length; } /** * Restore one snapshot while suppressing history commits caused by loading. * The editor callback performs the actual model and view reconstruction. */ restoreSnapshot(snapshot) { this.isRestoring = true; try { this.restoreDocument(snapshot); } finally { this.isRestoring = false; } this.currentSnapshot = snapshot; this.notify(); } /** Restore the previous committed state, if one exists. */ undo() { this.commit(); if (!this.canUndo()) return false; this.redoStack.push(this.currentSnapshot); const snapshot = this.undoStack.pop(); this.restoreSnapshot(snapshot); return true; } /** Restore the most recently undone state, if one exists. */ redo() { this.commit(); if (!this.canRedo()) return false; this.undoStack.push(this.currentSnapshot); const snapshot = this.redoStack.pop(); this.restoreSnapshot(snapshot); return true; } /** * goal : Replace the current document as one undoable operation. * pre : replaceDocument performs the complete document replacement. * post : The replacement is current and redo history has been discarded. * result : Undefined; the host receives the new undo/redo availability. * internals : The old baseline is pushed before the callback runs, while * isRestoring prevents loading callbacks from creating nested history steps. */ replace(replaceDocument) { this.commit(); const previousSnapshot = this.currentSnapshot || this.snapshot(); this.isRestoring = true; try { replaceDocument(); } finally { this.isRestoring = false; } const nextSnapshot = this.snapshot(); if (previousSnapshot !== nextSnapshot) { this.undoStack.push(previousSnapshot); if (this.undoStack.length > this.limit) this.undoStack.shift(); } this.currentSnapshot = nextSnapshot; this.redoStack = []; this.notify(); } /** Release the timer when the owning editor is destroyed. */ destroy() { this.cancelScheduledCommit(); } }