@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
JavaScript
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
};