Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View 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);
}