Files
racket-wiki/static/js/cmap/controller/cmap-history.js
T

199 lines
6.2 KiB
JavaScript

/**
* 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;
}
hasPendingCommit() {
return this.timer !== null;
}
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();
}
}