195 lines
6.2 KiB
JavaScript
195 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;
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|