Initial Commit
This commit is contained in:
158
packages/foundation/data/diff/src/history/history.ts
Normal file
158
packages/foundation/data/diff/src/history/history.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user