UNPKG

@thi.ng/atom

Version:

Mutable wrappers for nested immutable values with optional undo/redo history and transaction support

253 lines (252 loc) 7.45 kB
var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __decorateClass = (decorators, target, key, kind) => { var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target; for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result; if (kind && result) __defProp(target, key, result); return result; }; import { INotifyMixin } from "@thi.ng/api/mixins/inotify"; import { equiv } from "@thi.ng/equiv"; import { defGetterUnsafe } from "@thi.ng/paths/getter"; import { setInUnsafe } from "@thi.ng/paths/set-in"; import { updateInUnsafe } from "@thi.ng/paths/update-in"; import { EVENT_RECORD, EVENT_REDO, EVENT_UNDO } from "./api.js"; const defHistory = (state, maxLen, changed) => new History(state, maxLen, changed); let History = class { state; maxLen; changed; history; future; /** * @param state - parent state * @param maxLen - max size of undo stack * @param changed - predicate to determine changed values (default `!equiv(a,b)`) */ constructor(state, maxLen = 100, changed) { this.state = state; this.maxLen = maxLen; this.changed = changed || ((a, b) => !equiv(a, b)); this.clear(); } get value() { return this.deref(); } set value(val) { this.reset(val); } canUndo() { return this.history.length > 0; } canRedo() { return this.future.length > 0; } /** * Clears history & future stacks */ clear() { this.history = []; this.future = []; } /** * Attempts to re-apply most recent historical value to atom and * returns it if successful (i.e. there's a history). * * @remarks * Before the switch, first records the atom's current value into * the future stack (to enable {@link History.redo} feature). * Returns `undefined` if there's no history. * * If undo was possible, the `History.EVENT_UNDO` event is emitted * after the restoration with both the `prev` and `curr` (restored) * states provided as event value (and object with these two keys). * This allows for additional state handling to be executed, e.g. * application of the "Command pattern". See * {@link History.addListener} for registering event listeners. */ undo() { if (this.history.length) { const prev = this.state.deref(); this.future.push(prev); const curr = this.state.reset(this.history.pop()); this.notify({ id: EVENT_UNDO, value: { prev, curr } }); return curr; } } /** * Attempts to re-apply most recent value from future stack to atom * and returns it if successful (i.e. there's a future). * * @remarks * Before the switch, first records the atom's current value into * the history stack (to enable {@link History.undo} feature). * Returns `undefined` if there's no future (so sad!). * * If redo was possible, the `History.EVENT_REDO` event is emitted * after the restoration with both the `prev` and `curr` (restored) * states provided as event value (and object with these two keys). * This allows for additional state handling to be executed, e.g. * application of the "Command pattern". See * {@link History.addListener} for registering event listeners. */ redo() { if (this.future.length) { const prev = this.state.deref(); this.history.push(prev); const curr = this.state.reset(this.future.pop()); this.notify({ id: EVENT_REDO, value: { prev, curr } }); return curr; } } /** * `IReset.reset()` implementation. Delegates to wrapped * atom/cursor, but too applies `changed` predicate to determine if * there was a change and if the previous value should be recorded. * * @param val - replacement value */ reset(val) { const prev = this.state.deref(); this.state.reset(val); const changed = this.changed(prev, this.state.deref()); if (changed) { this.record(prev); } return val; } resetIn(path, val) { const prev = this.state.deref(); const get = defGetterUnsafe(path); const prevV = get(prev); const curr = setInUnsafe(prev, path, val); this.state.reset(curr); this.changed(prevV, get(curr)) && this.record(prev); return curr; } resetInUnsafe(path, val) { return this.resetIn(path, val); } /** * `ISwap.swap()` implementation. Delegates to wrapped atom/cursor, * but too applies `changed` predicate to determine if there was a * change and if the previous value should be recorded. * * @param fn - update function * @param args - additional args passed to `fn` */ swap(fn, ...args) { return this.reset(fn(this.state.deref(), ...args)); } swapIn(path, fn, ...args) { const prev = this.state.deref(); const get = defGetterUnsafe(path); const prevV = get(prev); const curr = updateInUnsafe(this.state.deref(), path, fn, ...args); this.state.reset(curr); this.changed(prevV, get(curr)) && this.record(prev); return curr; } swapInUnsafe(path, fn, ...args) { return this.swapIn(path, fn, ...args); } /** * Records given state in history. This method is only needed when * manually managing snapshots, i.e. when applying multiple swaps on * the wrapped atom directly, but not wanting to create an history * entry for each change. * * @remarks * **DO NOT call this explicitly if using {@link History.reset} / * {@link History.swap} etc.** * * If no `state` is given, uses the wrapped atom's current state * value (user code SHOULD always call without arg). * * If recording succeeded, the `History.EVENT_RECORD` event is * emitted with the recorded state provided as event value. * * @param state - state to record */ record(state) { const history = this.history; const n = history.length; let ok = true; if (!arguments.length) { state = this.state.deref(); ok = !n || this.changed(history[n - 1], state); } if (ok) { if (n >= this.maxLen) { history.shift(); } history.push(state); this.notify({ id: EVENT_RECORD, value: state }); this.future.length = 0; } } /** * Returns wrapped atom's **current** value. */ deref() { return this.state.deref(); } /** * `IWatch.addWatch()` implementation. Delegates to wrapped * atom/cursor. * * @param id - watch ID * @param fn - watch function */ addWatch(id, fn) { return this.state.addWatch(id, fn); } /** * `IWatch.removeWatch()` implementation. Delegates to wrapped * atom/cursor. * * @param id - watch iD */ removeWatch(id) { return this.state.removeWatch(id); } /** * `IWatch.notifyWatches()` implementation. Delegates to wrapped * atom/cursor. * * @param oldState - * @param newState - */ notifyWatches(oldState, newState) { return this.state.notifyWatches(oldState, newState); } release() { this.state.release(); delete this.state; return true; } // @ts-ignore: mixin // prettier-ignore addListener(id, fn, scope) { } // @ts-ignore: mixin // prettier-ignore removeListener(id, fn, scope) { } // @ts-ignore: mixin notify(e) { } }; History = __decorateClass([ INotifyMixin ], History); export { History, defHistory };