import { Emitter } from "@noya-app/emitter"; import { findAllDefs } from "@noya-app/noya-schemas"; import { GetAtPath, Observable, PathKey, get as getPath, } from "@noya-app/observable"; import { TSchema } from "@sinclair/typebox"; import { Draft, Patches } from "mutative"; import { checkType } from "./checkType"; import { ExtendedPatch, applyExtendedPatch, createExtended, extractItemId, toExtendedPatch, } from "./extended"; import { HistoryEntries } from "./historyEntries"; import { unescapePath } from "./unescapePath"; export type HistoryEntry = { undo: (state: S) => S; redo: (state: S) => S; undoPatches?: ExtendedPatch[]; redoPatches?: ExtendedPatch[]; metadata: M; }; export type ActionHandler = { run: (state: S) => S; undo: (state: S) => S; }; export type HistorySnapshot = { version: number; history: HistoryEntry[]; historyIndex: number; canUndo: boolean; canRedo: boolean; }; export type StateManagerOptions = { /** * By default, the state manager will ignore edits that don't change the state. * Set this to true to allow empty edits. */ allowEmptyEdits?: boolean; /** * Merge two history entries into a single history entry. * Usually this means providing a name, timestamp, or other metadata, and * combining the undo/redo operations based on this metadata. */ mergeHistoryEntries?: ( parameters: { previous: HistoryEntry; next: HistoryEntry; }, stateManager: StateManager ) => HistoryEntry | void; /** * Maximum number of history entries to retain. * When the limit is reached, older entries are discarded. */ maxHistoryEntries?: number; /** * Schema to validate the state against. */ schema?: TSchema; }; export function createEditHistoryEntry( state: S, options: StateManagerOptions, metadata: M, mutator: (draft: Draft) => void ): { state: S; historyEntry: Required> } | undefined { let [updated, patches, inversePatches] = createExtended(state, mutator); patches = HistoryEntries.compactPatches(patches); inversePatches = HistoryEntries.compactPatches(inversePatches); if (options.allowEmptyEdits !== true) { // Ignore edits that don't change the state if (patches.length === 0 && inversePatches.length === 0) return; } const historyEntry = { undo: (afterState: S) => applyExtendedPatch(afterState, inversePatches), redo: (beforeState: S) => applyExtendedPatch(beforeState, patches), undoPatches: inversePatches, redoPatches: patches, metadata, }; return { state: updated, historyEntry }; } export function createPatchHistoryEntry( state: S, options: StateManagerOptions, metadata: M, patches: ExtendedPatch[], inversePatches: ExtendedPatch[] ): { state: S; historyEntry: Required> } | undefined { if (options.allowEmptyEdits !== true) { // Ignore edits that don't change the state if (patches.length === 0 && inversePatches.length === 0) return; } const historyEntry = { undo: (afterState: S) => applyExtendedPatch(afterState, inversePatches), redo: (beforeState: S) => applyExtendedPatch(beforeState, patches), undoPatches: inversePatches, redoPatches: patches, metadata, }; return { state, historyEntry }; } /** * A wrapper class that allows easily creating JSON patches. * * Supports methods for the following operations: * - 'add' * - 'remove' * - 'replace' * - 'move' */ export class OperationManager { constructor(public state: S) {} patches: ExtendedPatch[] = []; inversePatches: ExtendedPatch[] = []; getState = () => this.state; get =

(path: P): GetAtPath => { return getPath(this.state, path); }; _replaceLastDashWithIndex = ( exInv: ExtendedPatch, state: S, value: unknown // eslint-disable-next-line @shopify/prefer-early-return ) => { if (exInv.op === "remove") { const lastSegment = exInv.path[exInv.path.length - 1]; if (lastSegment === "-") { const parentValue = getPath(state, exInv.path.slice(0, -1).join("/")); if (Array.isArray(parentValue)) { const itemId = extractItemId(value); if (itemId !== undefined) { exInv.path[exInv.path.length - 1] = { id: itemId }; } else { exInv.path[exInv.path.length - 1] = parentValue.length; } } } } }; set =

(path: P | PathKey[], value: GetAtPath) => { const existingValue = getPath(this.state, path); const op: Patches[number] = existingValue !== undefined ? { op: "replace", path: unescapePath(path), value } : { op: "add", path: unescapePath(path), value }; const inv: Patches[number] = existingValue !== undefined ? { op: "replace", path: unescapePath(path), value: existingValue } : { op: "remove", path: unescapePath(path) }; const exOp = toExtendedPatch(this.state, op); const exInv = toExtendedPatch(this.state, inv); this._replaceLastDashWithIndex(exInv, this.state, value); this.patches.push(exOp); this.inversePatches.push(exInv); this.state = applyExtendedPatch(this.state, [exOp]); }; /** * Alias for `set` */ replace = this.set; /** * For objects, behaves like `set`. * For arrays, inserts the value instead of replacing an existing value. */ add =

(path: P, value: GetAtPath) => { const op: Patches[number] = { op: "add", path: unescapePath(path), value, }; const inv: Patches[number] = { op: "remove", path: unescapePath(path), }; const exOp = toExtendedPatch(this.state, op); const exInv = toExtendedPatch(this.state, inv); this._replaceLastDashWithIndex(exInv, this.state, value); this.patches.push(exOp); this.inversePatches.push(exInv); this.state = applyExtendedPatch(this.state, [exOp]); }; remove =

(path: P) => { const existingValue = getPath(this.state, path); const op: Patches[number] = { op: "remove", path: unescapePath(path), }; const inv: Patches[number] = { op: "add", path: unescapePath(path), value: existingValue, }; const exOp = toExtendedPatch(this.state, op); const exInv = toExtendedPatch(this.state, inv); this.patches.push(exOp); this.inversePatches.push(exInv); this.state = applyExtendedPatch(this.state, [exOp]); }; /** * Alias for `remove` */ delete = this.remove; static getInverseMoveIndexes({ state, opFrom, opTo, }: { state?: any; opFrom: PathKey[]; opTo: PathKey[]; }) { const invFrom = opTo.slice(); const invTo = opFrom.slice(); for (let i = 0; i < Math.min(invFrom.length, invTo.length); i++) { let fromValue = invFrom[i]; let toValue = invTo[i]; let shouldAdjust = false; if (state) { const fromParentPath = invFrom.slice(0, i); const toParentPath = invTo.slice(0, i); const fromParent = getPath(state, fromParentPath); const toParent = getPath(state, toParentPath); // console.log({ fromParent, toParent, fromParentPath, toParentPath }); shouldAdjust = Array.isArray(fromParent) && Array.isArray(toParent); } else { shouldAdjust = typeof fromValue === "number" && typeof toValue === "number"; } if (shouldAdjust) { fromValue = Number(fromValue); toValue = Number(toValue); if (fromValue <= toValue && opFrom.length > opTo.length) { invTo[i] = toValue + 1; } if (fromValue >= toValue && opFrom.length < opTo.length) { invFrom[i] = fromValue - 1; } } else if (fromValue !== toValue) { break; } } return { invFrom, invTo }; } move =

( from: F, to: P ) => { function isSameParent(state: S, path1: PathKey[], path2: PathKey[]) { return ( getPath(state, path1.slice(0, -1)) === getPath(state, path2.slice(0, -1)) ); } let opFrom = [...unescapePath(from)]; let opTo = [...unescapePath(to)]; const op: Patches[number] = { op: "move", from: opFrom, path: opTo, }; const exOp: ExtendedPatch = toExtendedPatch(this.state, op); this.patches.push(exOp); this.state = applyExtendedPatch(this.state, [exOp]); let invFrom = opTo.slice(); let invTo = opFrom.slice(); // Inverse patch should operate on the updated state to make sure // we're targeting the right value by id in the case of an extended patch if (!isSameParent(this.state, invFrom, invTo)) { // Update the indexes in the inverse patch const result = OperationManager.getInverseMoveIndexes({ state: this.state, opFrom, opTo, }); invFrom = result.invFrom; invTo = result.invTo; } const inv: Patches[number] = { op: "move", from: invFrom, path: invTo, }; const exInv = toExtendedPatch(this.state, inv); this.inversePatches.push(exInv); }; editDraft = (mutator: (draft: Draft) => void) => { const [updated, patches, inversePatches] = createExtended( this.state, mutator ); this.patches.push(...patches); this.inversePatches.unshift(...inversePatches); this.state = updated; }; } export class StateManager extends Emitter { state: S; schema?: TSchema; version = 0; // Note: The history object is mutable, but each entry is immutable history: HistoryEntry[] = []; historyIndex$ = new Observable(0); get historyIndex() { return this.historyIndex$.get(); } set historyIndex(value: number) { this.historyIndex$.set(value); } historyEmittor = new Emitter(); constructor( initialState: S, public options: StateManagerOptions = {} ) { super(); this.state = initialState; this.schema = options.schema; // Bind overloaded methods this.getState = this.getState.bind(this); this.getHistorySnapshot = this._createVersionMemoizer( this.getHistorySnapshot ); } _setState(newState: S) { this.state = newState; this.version++; this.emit(); this.historyEmittor.emit(); } _setStateWithHistoryEntry( newState: S, historyEntry: HistoryEntry, { shouldTypeCheck = true, }: { shouldTypeCheck?: boolean; } = {} ) { if (shouldTypeCheck) { checkType(newState, this.schema, findAllDefs(this.schema)); } this.history.length = this.historyIndex; if (this.history.length > 0 && this.options.mergeHistoryEntries) { const merged = this.options.mergeHistoryEntries( { previous: this.history[this.history.length - 1], next: historyEntry, }, this ); if (merged) { this.history[this.history.length - 1] = merged; this._setState(newState); return; } } this.history.push(historyEntry); this.historyIndex++; // Ensure the history does not exceed the maxHistoryEntries limit if ( this.options.maxHistoryEntries !== undefined && this.history.length > this.options.maxHistoryEntries ) { this.history.shift(); this.historyIndex--; } this._setState(newState); } edit = ( ...args: M extends {} ? [metadata: M, mutator: (draft: Draft) => void] : [mutator: (draft: Draft) => void] ) => { const [metadata, mutator] = args.length === 1 ? [undefined as M, args[0]] : args; const result = createEditHistoryEntry( this.state, this.options, metadata, mutator ); if (!result) return; this._setStateWithHistoryEntry(result.state, result.historyEntry); }; operate = ( ...args: M extends {} ? [metadata: M, operator: (operationManager: OperationManager) => void] : [operator: (operationManager: OperationManager) => void] ) => { const [metadata, operator] = args.length === 1 ? [undefined, args[0]] : args; const operationManager = new OperationManager(this.getState()); operator(operationManager); const patches = HistoryEntries.compactPatches(operationManager.patches); const inversePatches = HistoryEntries.compactPatches( operationManager.inversePatches ); const afterState = applyExtendedPatch(this.state, patches); if (this.options.allowEmptyEdits !== true) { // Ignore edits that don't change the state if (patches.length === 0) return; } this._setStateWithHistoryEntry(afterState, { undo: (afterState: S) => applyExtendedPatch(afterState, inversePatches), redo: (beforeState: S) => applyExtendedPatch(beforeState, patches), undoPatches: inversePatches, redoPatches: patches, metadata: metadata as M, }); return patches; }; performAction = ( ...args: M extends {} ? [metadata: M, handler: ActionHandler] : [handler: ActionHandler] ) => { const [metadata, handler] = args.length === 1 ? [undefined as M, args[0]] : args; this._setStateWithHistoryEntry(handler.run(this.state), { undo: handler.undo, redo: handler.run, metadata, }); }; canUndo = () => { return this.historyIndex > 0; }; canUndo$ = this.historyIndex$.map((index) => index > 0); canRedo = () => { return this.historyIndex < this.history.length; }; canRedo$ = this.historyIndex$.map((index) => index < this.history.length); undo = () => { if (!this.canUndo()) return; const handler = this.history[this.historyIndex - 1]; this.historyIndex--; this._setState(handler.undo(this.state)); }; redo = () => { if (!this.canRedo()) return; const handler = this.history[this.historyIndex]; this.historyIndex++; this._setState(handler.redo(this.state)); }; getState(): S; getState

(path: P): GetAtPath; getState

(path?: P) { if (path === undefined) return this.state; return getPath(this.state, path); } clearHistory = () => { this.history.length = 0; this.historyIndex = 0; this.historyEmittor.emit(); }; replaceState = (newState: S) => { this.clearHistory(); this._setState(newState); }; /** * Replace the initial state of the state manager, effectively rebasing the history. * * Replaces the initial state with the new state then applies each redo up to the * current history index. */ rebase = (newInitialState: S) => { let state = newInitialState; for (let i = 0; i < this.historyIndex; i++) { const handler = this.history[i]; state = handler.redo(state); } this._setState(state); }; getStateAtHistoryIndex = (index: number) => { let state = this.state; // undo all the way to the base state for (let i = this.historyIndex; i > index; i--) { const handler = this.history[i - 1]; state = handler.undo(state); } return state; }; _rewindToIndex = (index: number, f: (state: S) => S) => { let state = this.state; // undo all the way to the base state for (let i = this.historyIndex; i > index; i--) { const handler = this.history[i - 1]; state = handler.undo(state); } state = f(state); // reapply all the history for (let i = 0; i < this.historyIndex; i++) { const handler = this.history[i]; state = handler.redo(state); } this._setState(state); }; /** * Accept a patch to the base state, effectively rebasing the history. */ acceptPatchesAtIndex = ( index: number, patches: ExtendedPatch[], filterHistoryEntry?: (entry: HistoryEntry, index: number) => boolean ) => { let state = this.getStateAtHistoryIndex(index); state = applyExtendedPatch(state, patches); // reapply all the history for (let i = 0; i < this.historyIndex; i++) { if (filterHistoryEntry && !filterHistoryEntry(this.history[i], i)) continue; const handler = this.history[i]; state = handler.redo(state); } this._setState(state); }; goToHistoryIndex = (index: number) => { if (index < 0 || index > this.history.length) return; if (index < this.historyIndex) { while (this.historyIndex > index) { this.undo(); } } else { while (this.historyIndex < index) { this.redo(); } } }; getHistorySnapshot = (): HistorySnapshot => { return { version: this.version, history: this.history.slice(), historyIndex: this.historyIndex, canUndo: this.canUndo(), canRedo: this.canRedo(), }; }; _createVersionMemoizer any>(f: F) { let version: number | undefined; let memoizedValue: ReturnType | undefined; return (...args: Parameters): ReturnType => { if (version !== this.version) { memoizedValue = f(...args); version = this.version; } return memoizedValue!; }; } }