import { observable, action, runInAction } from 'mobx'; import { Field } from '../../fields/Doc'; import { RichTextField } from '../../fields/RichTextField'; import { Without } from '../../Utils'; function getBatchName(target: any, key: string | symbol): string { const keyName = key.toString(); if (target?.constructor?.name) { return `${target.constructor.name}.${keyName}`; } return keyName; } function propertyDecorator(target: any, key: string | symbol) { Object.defineProperty(target, key, { configurable: true, enumerable: false, get: function () { return 5; }, set: function (value: any) { Object.defineProperty(this, key, { enumerable: false, writable: true, configurable: true, value: function (...args: any[]) { const batch = UndoManager.StartBatch(getBatchName(target, key)); try { return value.apply(this, args); } finally { batch.end(); } }, }); }, }); } export function undoable(fn: (...args: any[]) => any, batchName: string): (...args: any[]) => any { return function () { const batch = UndoManager.StartBatch(batchName); try { return fn.apply(undefined, arguments as any); } finally { batch.end(); } }; } export function undoBatch(target: any, key: string | symbol, descriptor?: TypedPropertyDescriptor): any; export function undoBatch(fn: (...args: any[]) => any): (...args: any[]) => any; export function undoBatch(target: any, key?: string | symbol, descriptor?: TypedPropertyDescriptor): any { if (!key) { return function () { const batch = UndoManager.StartBatch(''); try { return target.apply(undefined, arguments); } finally { batch.end(); } }; } if (!descriptor) { propertyDecorator(target, key); return; } const oldFunction = descriptor.value; descriptor.value = function (...args: any[]) { const batch = UndoManager.StartBatch(getBatchName(target, key)); try { return oldFunction.apply(this, args); } finally { batch.end(); } }; return descriptor; } export namespace UndoManager { export interface UndoEvent { undo: () => void; redo: () => void; prop: string; } type UndoBatch = UndoEvent[]; export let undoStackNames: string[] = observable([]); export let redoStackNames: string[] = observable([]); export let undoStack: UndoBatch[] = observable([]); export let redoStack: UndoBatch[] = observable([]); let currentBatch: UndoBatch | undefined; export let batchCounter = observable.box(0); let undoing = false; export let tempEvents: UndoEvent[] | undefined = undefined; export function AddEvent(event: UndoEvent, value?: any): void { if (currentBatch && batchCounter.get() && !undoing) { console.log( ' '.slice(0, batchCounter.get()) + 'UndoEvent : ' + event.prop + ' = ' + (value instanceof RichTextField ? value.Text : value instanceof Array ? value.map(val => Field.toScriptString(val)).join(',') : Field.toScriptString(value)) ); currentBatch.push(event); tempEvents?.push(event); } } export function CanUndo(): boolean { return undoStack.length > 0; } export function CanRedo(): boolean { return redoStack.length > 0; } export function PrintBatches(): void { console.log('Open Undo Batches:'); GetOpenBatches().forEach(batch => console.log(batch.batchName)); } const openBatches: Batch[] = []; export function GetOpenBatches(): Without[] { return openBatches; } export function FilterBatches(fieldTypes: string[]) { const fieldCounts: { [key: string]: number } = {}; const lastStack = UndoManager.undoStack.slice(-1)[0]; //.lastElement(); if (lastStack) { lastStack.forEach(ev => fieldTypes.includes(ev.prop) && (fieldCounts[ev.prop] = (fieldCounts[ev.prop] || 0) + 1)); const fieldCount2: { [key: string]: number } = {}; runInAction( () => (UndoManager.undoStack[UndoManager.undoStack.length - 1] = lastStack.filter(ev => { if (fieldTypes.includes(ev.prop)) { fieldCount2[ev.prop] = (fieldCount2[ev.prop] || 0) + 1; if (fieldCount2[ev.prop] === 1 || fieldCount2[ev.prop] === fieldCounts[ev.prop]) return true; return false; } return true; })) ); } } export function TraceOpenBatches() { console.log(`Open batches:\n\t${openBatches.map(batch => batch.batchName).join('\n\t')}\n`); } export class Batch { private disposed: boolean = false; constructor(readonly batchName: string) { openBatches.push(this); } private dispose = (cancel: boolean) => { if (this.disposed) { console.log('WARNING: undo batch already disposed'); return false; } else { this.disposed = true; openBatches.splice(openBatches.indexOf(this)); return EndBatch(this.batchName, cancel); } }; end = () => this.dispose(false); cancel = () => this.dispose(true); } export function StartBatch(batchName: string): Batch { console.log(' '.slice(0, batchCounter.get()) + 'Start ' + batchCounter + ' ' + batchName); runInAction(() => batchCounter.set(batchCounter.get() + 1)); if (currentBatch === undefined) { currentBatch = []; } return new Batch(batchName); } const EndBatch = action((batchName: string, cancel: boolean = false) => { runInAction(() => batchCounter.set(batchCounter.get() - 1)); console.log(' '.slice(0, batchCounter.get()) + 'End ' + batchName + ' (' + currentBatch?.length + ')'); if (batchCounter.get() === 0 && currentBatch?.length) { if (!cancel) { undoStack.push(currentBatch); undoStackNames.push(batchName ?? '???'); } redoStackNames.length = 0; redoStack.length = 0; currentBatch = undefined; return true; } return false; }); export function StartTempBatch() { tempEvents = []; } export function EndTempBatch(success: boolean) { UndoManager.UndoTempBatch(success); } //TODO Make this return the return value export function RunInBatch(fn: () => T, batchName: string) { const batch = StartBatch(batchName); try { return runInAction(fn); } finally { batch.end(); } } export const UndoTempBatch = action((success: any) => { if (tempEvents && !success) { undoing = true; for (let i = tempEvents.length - 1; i >= 0; i--) { currentBatch?.includes(tempEvents[i]) && currentBatch.splice(currentBatch.indexOf(tempEvents[i])); tempEvents[i].undo(); } undoing = false; } tempEvents = undefined; }); export const Undo = action(() => { if (undoStack.length === 0) { return; } const names = undoStackNames.pop(); const commands = undoStack.pop(); if (!commands) { return; } undoing = true; for (let i = commands.length - 1; i >= 0; i--) { commands[i].undo(); } undoing = false; redoStackNames.push(names ?? '???'); redoStack.push(commands); }); export const Redo = action(() => { if (redoStack.length === 0) { return; } const names = redoStackNames.pop(); const commands = redoStack.pop(); if (!commands) { return; } undoing = true; for (const command of commands) { command.redo(); } undoing = false; undoStackNames.push(names ?? '???'); undoStack.push(commands); }); }