159 lines
4.7 KiB
TypeScript
159 lines
4.7 KiB
TypeScript
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);
|
|
}
|