UNPKG

json-joy

Version:

Collection of libraries for building collaborative editing apps.

985 lines 30.6 kB
import { printTree } from 'tree-dump/lib/printTree'; import { get } from '@jsonjoy.com/json-pointer/lib/get'; import { toPath } from '@jsonjoy.com/json-pointer/lib/util'; import { find } from './find'; import { Timestamp } from '../../../json-crdt-patch/clock'; import { ObjNode, ArrNode, BinNode, ConNode, VecNode, ValNode, StrNode, RootNode } from '../../nodes'; import { NodeEvents } from './NodeEvents'; import { FanOut } from 'thingies/lib/fanout'; import { PatchBuilder } from '../../../json-crdt-patch/PatchBuilder'; import { MergeFanOut, MicrotaskBufferFanOut } from './fanout'; import { ExtNode } from '../../extensions/ExtNode'; import * as diff from '../../../json-crdt-diff'; import { proxy$ } from './proxy'; const breakPath = (path) => { if (!path) return [void 0, '']; if (typeof path === 'number') return [void 0, path]; if (typeof path === 'string') path = toPath(path); switch (path.length) { case 0: return [void 0, '']; case 1: return [void 0, path[0]]; default: { const key = path[path.length - 1]; const parent = path.slice(0, -1); return [parent, key]; } } }; /** * A generic local changes API for a JSON CRDT node. * * @category Local API */ export class NodeApi { node; api; constructor(node, api) { this.node = node; this.api = api; } /** @ignore */ ev = undefined; /** * Event target for listening to node changes. You can subscribe to `"view"` * events, which are triggered every time the node's view changes. * * ```ts * node.events.on('view', () => { * // do something... * }); * ``` */ get events() { const et = this.ev; return et || (this.ev = new NodeEvents(this)); } /** * Find a child node at the given path starting from this node. * * @param path Path to the child node to find. * @returns JSON CRDT node at the given path. */ find(path) { let node = this.node; /** * @todo Remove this .child() loop, and remove the `.child()` method from JsonNode interface. */ if (path === undefined) { if (typeof node.child === 'function') { const child = node.child(); if (!child) { if (node instanceof RootNode) return node; throw new Error('NO_CHILD'); } return child; } throw new Error('CANNOT_IN'); } if (typeof path === 'string' && !!path && path[0] !== '/') path = '/' + path; if (typeof path === 'number') path = [path]; while (node instanceof ValNode) node = node.child(); return find(node, path); } /** * Find a child node at the given path starting from this node and wrap it in * a local changes API. * * @param path Path to the child node to find. * @returns Local changes API for the child node at the given path. */ in(path) { const node = this.find(path); return this.api.wrap(node); } asVal() { if (this.node instanceof ValNode) return this.api.wrap(this.node); throw new Error('NOT_VAL'); } asStr() { if (this.node instanceof StrNode) return this.api.wrap(this.node); throw new Error('NOT_STR'); } asBin() { if (this.node instanceof BinNode) return this.api.wrap(this.node); throw new Error('NOT_BIN'); } asArr() { if (this.node instanceof ArrNode) return this.api.wrap(this.node); throw new Error('NOT_ARR'); } asVec() { if (this.node instanceof VecNode) return this.api.wrap(this.node); throw new Error('NOT_VEC'); } asObj() { if (this.node instanceof ObjNode) return this.api.wrap(this.node); throw new Error('NOT_OBJ'); } asCon() { if (this.node instanceof ConNode) return this.api.wrap(this.node); throw new Error('NOT_CON'); } asExt(ext) { let extNode; const node = this.node; if (node instanceof ExtNode) extNode = node; if (node instanceof VecNode) extNode = node.ext(); if (!extNode) throw new Error('NOT_EXT'); const api = this.api.wrap(extNode); if (!ext) return api; if (api instanceof ext.Api) return api; throw new Error('NOT_EXT'); } val(path) { return this.in(path).asVal(); } str(path) { return this.in(path).asStr(); } bin(path) { return this.in(path).asBin(); } arr(path) { return this.in(path).asArr(); } vec(path) { return this.in(path).asVec(); } obj(path) { return this.in(path).asObj(); } con(path) { return this.in(path).asCon(); } view() { return this.node.view(); } select(path, leaf) { try { let node = path !== void 0 ? this.find(path) : this.node; if (leaf) while (node instanceof ValNode) node = node.child(); return this.api.wrap(node); } catch (_e) { return; } } read(path) { const view = this.view(); if (Array.isArray(path)) return get(view, path); if (!path) return view; let path2 = path + ''; if (path && path2[0] !== '/') path2 = '/' + path2; return get(view, toPath(path2)); } add(path, value) { const [parent, key] = breakPath(path); ADD: try { const node = this.select(parent, true); if (node instanceof ObjApi) { node.set({ [key]: value }); } else if (node instanceof ArrApi || node instanceof StrApi || node instanceof BinApi) { const length = node.length(); let index = 0; if (typeof key === 'number') index = key; else if (key === '-') index = length; else { index = ~~key; if (index + '' !== key) break ADD; } if (index !== index) break ADD; if (index < 0) index = 0; if (index > length) index = length; if (node instanceof ArrApi) node.ins(index, Array.isArray(value) ? value : [value]); else if (node instanceof StrApi) node.ins(index, value + ''); else if (node instanceof BinApi) { if (!(value instanceof Uint8Array)) break ADD; node.ins(index, value); } } else if (node instanceof VecApi) { node.set([[~~key, value]]); } else break ADD; return true; } catch { } return false; } replace(path, value) { const [parent, key] = breakPath(path); REPLACE: try { const node = this.select(parent, true); if (node instanceof ObjApi) { const keyStr = key + ''; if (!node.has(keyStr)) break REPLACE; node.set({ [key]: value }); } else if (node instanceof ArrApi) { const length = node.length(); let index = 0; if (typeof key === 'number') index = key; else { index = ~~key; if (index + '' !== key) break REPLACE; } if (index !== index || index < 0 || index > length) break REPLACE; if (index === length) node.ins(index, [value]); else node.upd(index, value); } else if (node instanceof VecApi) node.set([[~~key, value]]); else break REPLACE; return true; } catch { } return false; } remove(path, length = 1) { const [parent, key] = breakPath(path); REMOVE: try { const node = this.select(parent, true); if (node instanceof ObjApi) { const keyStr = key + ''; if (!node.has(keyStr)) break REMOVE; node.del([keyStr]); } else if (node instanceof ArrApi || node instanceof StrApi || node instanceof BinApi) { const len = node.length(); let index = 0; if (typeof key === 'number') index = key; else if (key === '-') index = length; else { index = ~~key; if (index + '' !== key) break REMOVE; } if (index !== index || index < 0 || index > len) break REMOVE; node.del(index, Math.min(length, len - index)); } else if (node instanceof VecApi) { node.set([[~~key, void 0]]); } else break REMOVE; return true; } catch { } return false; } diff(value) { return diff.diff(this, value); } merge(value) { return diff.merge(this, value); } op(operation) { if (!Array.isArray(operation)) return false; const [type, path, value] = operation; switch (type) { case 'add': return this.add(path, value); case 'replace': return this.replace(path, value); case 'merge': return !!this.select(path)?.merge(value); case 'remove': return this.remove(path, value); } } get s() { return { $: this }; } get $() { return proxy$((path) => { try { return this.api.wrap(this.find(path)); } catch { return; } }, '$'); } toString(tab = '') { const name = this.constructor === NodeApi ? '*' : this.node.name(); return 'api(' + name + ')' + printTree(tab, [(tab) => this.node.toString(tab)]); } } /** * Represents the local changes API for the `con` JSON CRDT node {@link ConNode}. * * @category Local API */ export class ConApi extends NodeApi { /** * Returns a proxy object for this node. */ get s() { return { $: this }; } } /** * Local changes API for the `val` JSON CRDT node {@link ValNode}. * * @category Local API */ export class ValApi extends NodeApi { /** * Get API instance of the inner node. * @returns Inner node API. */ get() { return this.in(); } /** * Sets the value of the node. * * @param json JSON/CBOR value or ID (logical timestamp) of the value to set. * @returns Reference to itself. */ set(json) { const { api, node } = this; const builder = api.builder; const val = builder.constOrJson(json); api.builder.setVal(node.id, val); api.apply(); } /** * Returns a proxy object for this node. Allows to access the value of the * node by accessing the `.val` property. */ get s() { const self = this; const proxy = { $: this, get _() { const childNode = self.node.node(); return self.api.wrap(childNode).s; }, }; return proxy; } } /** * Local changes API for the `vec` JSON CRDT node {@link VecNode}. * * @category Local API */ export class VecApi extends NodeApi { /** * Get API instance of a child node. * * @param key Object key to get. * @returns A specified child node API. */ get(key) { return this.in(key); } /** * Sets a list of elements to the given values. * * @param entries List of index-value pairs to set. * @returns Reference to itself. */ set(entries) { const { api, node } = this; const { builder } = api; builder.insVec(node.id, entries.map(([index, json]) => [index, builder.constOrJson(json)])); api.apply(); } push(...values) { const length = this.length(); this.set(values.map((value, index) => [length + index, value])); } /** * Get the length of the vector without materializing it to a view. * * @returns Length of the vector. */ length() { return this.node.elements.length; } /** * Returns a proxy object for this node. Allows to access vector elements by * index. */ get s() { const proxy = new Proxy({}, { get: (target, prop, receiver) => { if (prop === '$') return this; if (prop === 'toExt') return () => this.asExt(); const index = Number(prop); if (Number.isNaN(index)) throw new Error('INVALID_INDEX'); const child = this.node.get(index); if (!child) throw new Error('OUT_OF_BOUNDS'); return this.api.wrap(child).s; }, }); return proxy; } } /** * Local changes API for the `obj` JSON CRDT node {@link ObjNode}. * * @category Local API */ export class ObjApi extends NodeApi { /** * Get API instance of a child node. * * @param key Object key to get. * @returns A specified child node API. */ get(key) { return this.in(key); } /** * Sets a list of keys to the given values. * * @param entries List of key-value pairs to set. * @returns Reference to itself. */ set(entries) { const { api, node } = this; const { builder } = api; builder.insObj(node.id, Object.entries(entries).map(([key, json]) => [key, builder.constOrJson(json)])); api.apply(); } /** * Deletes a list of keys from the object. * * @param keys List of keys to delete. * @returns Reference to itself. */ del(keys) { const { api, node } = this; const { builder } = api; api.builder.insObj(node.id, keys.map((key) => [key, builder.con(undefined)])); api.apply(); } /** * Checks if a key exists in the object. * * @param key Key to check. * @returns True if the key exists, false otherwise. */ has(key) { return this.node.keys.has(key); } /** * Returns a proxy object for this node. Allows to access object properties * by key. */ get s() { const proxy = new Proxy({}, { get: (target, prop, receiver) => { if (prop === '$') return this; const key = String(prop); const child = this.node.get(key); if (!child) throw new Error('NO_SUCH_KEY'); return this.api.wrap(child).s; }, }); return proxy; } } /** * Local changes API for the `str` JSON CRDT node {@link StrNode}. This API * allows to insert and delete bytes in the UTF-16 string by referencing its * local character positions. * * @category Local API */ export class StrApi extends NodeApi { /** * Inserts text at a given position. * * @param index Position at which to insert text. * @param text Text to insert. * @returns Reference to itself. */ ins(index, text) { const { api, node } = this; api.onBeforeLocalChange.emit(api.next); const builder = api.builder; builder.pad(); const nextTime = api.builder.nextTime(); const id = new Timestamp(builder.clock.sid, nextTime); const after = node.insAt(index, id, text); if (!after) throw new Error('OUT_OF_BOUNDS'); builder.insStr(node.id, after, text); api.advance(); } /** * Deletes a range of text at a given position. * * @param index Position at which to delete text. * @param length Number of UTF-16 code units to delete. * @returns Reference to itself. */ del(index, length) { const { api, node } = this; api.onBeforeLocalChange.emit(api.next); const builder = api.builder; builder.pad(); const spans = node.findInterval(index, length); if (!spans) throw new Error('OUT_OF_BOUNDS'); node.delete(spans); builder.del(node.id, spans); api.advance(); } /** * Given a character index in local coordinates, find the ID of the character * in the global coordinates. * * @param index Index of the character or `-1` for before the first character. * @returns ID of the character after which the given position is located. */ findId(index) { const node = this.node; const length = node.length(); const max = length - 1; if (index > max) index = max; if (index < 0) return node.id; const id = node.find(index); return id || node.id; } /** * Given a position in global coordinates, find the position in local * coordinates. * * @param id ID of the character. * @returns Index of the character in local coordinates. Returns -1 if the * the position refers to the beginning of the string. */ findPos(id) { const node = this.node; const nodeId = node.id; if (nodeId.sid === id.sid && nodeId.time === id.time) return -1; const chunk = node.findById(id); if (!chunk) return -1; const pos = node.pos(chunk); return pos + (chunk.del ? 0 : id.time - chunk.id.time); } /** * Get the length of the string without materializing it to a view. * * @returns Length of the string. */ length() { return this.node.length(); } /** * Returns a proxy object for this node. */ get s() { return { $: this }; } } /** * Local changes API for the `bin` JSON CRDT node {@link BinNode}. This API * allows to insert and delete bytes in the binary string by referencing their * local index. * * @category Local API */ export class BinApi extends NodeApi { /** * Inserts octets at a given position. * * @param index Position at which to insert octets. * @param data Octets to insert. * @returns Reference to itself. */ ins(index, data) { const { api, node } = this; const after = !index ? node.id : node.find(index - 1); if (!after) throw new Error('OUT_OF_BOUNDS'); api.builder.insBin(node.id, after, data); api.apply(); } /** * Deletes a range of octets at a given position. * * @param index Position at which to delete octets. * @param length Number of octets to delete. * @returns Reference to itself. */ del(index, length) { const { api, node } = this; const spans = node.findInterval(index, length); if (!spans) throw new Error('OUT_OF_BOUNDS'); api.builder.del(node.id, spans); api.apply(); } /** * Get the length of the binary blob without materializing it to a view. * * @returns Length of the binary blob. */ length() { return this.node.length(); } /** * Returns a proxy object for this node. */ get s() { return { $: this }; } } /** * Local changes API for the `arr` JSON CRDT node {@link ArrNode}. This API * allows to insert and delete elements in the array by referencing their local * index. * * @category Local API */ export class ArrApi extends NodeApi { /** * Get API instance of a child node. * * @param index Index of the element to get. * @returns Child node API for the element at the given index. */ get(index) { return this.in(index); } /** * Inserts elements at a given position. * * @param index Position at which to insert elements. * @param values Values or schema of the elements to insert. */ ins(index, values) { const { api, node } = this; const { builder } = api; const after = !index ? node.id : node.find(index - 1); if (!after) throw new Error('OUT_OF_BOUNDS'); const valueIds = []; for (let i = 0; i < values.length; i++) valueIds.push(builder.json(values[i])); builder.insArr(node.id, after, valueIds); api.apply(); } /** * Inserts elements at the end of the array. * * @param values Values or schema of the elements to insert at the end of the array. */ push(...values) { const length = this.length(); this.ins(length, values); } /** * Updates (overwrites) an element at a given position. * * @param index Position at which to update the element. * @param value Value or schema of the element to replace with. */ upd(index, value) { const { api, node } = this; const ref = node.getId(index); if (!ref) throw new Error('OUT_OF_BOUNDS'); const { builder } = api; builder.updArr(node.id, ref, builder.constOrJson(value)); api.apply(); } /** * Deletes a range of elements at a given position. * * @param index Position at which to delete elements. * @param length Number of elements to delete. * @returns Reference to itself. */ del(index, length) { const { api, node } = this; const spans = node.findInterval(index, length); if (!spans) throw new Error('OUT_OF_BOUNDS'); api.builder.del(node.id, spans); api.apply(); } /** * Get the length of the array without materializing it to a view. * * @returns Length of the array. */ length() { return this.node.length(); } /** * Returns a proxy object that allows to access array elements by index. * * @returns Proxy object that allows to access array elements by index. */ get s() { const proxy = new Proxy({}, { get: (target, prop, receiver) => { if (prop === '$') return this; const index = Number(prop); if (Number.isNaN(index)) throw new Error('INVALID_INDEX'); const child = this.node.getNode(index); if (!child) throw new Error('OUT_OF_BOUNDS'); return this.api.wrap(child).s; }, }); return proxy; } } /** * Local changes API for a JSON CRDT model. This class is the main entry point * for executing local user actions on a JSON CRDT document. * * @category Local API */ export class ModelApi extends ValApi { model; /** * Patch builder for the local changes. */ builder; /** * Index of the next operation in builder's patch to be committed locally. * * @ignore */ next = 0; /** Emitted before the model is reset, using the `.reset()` method. */ onBeforeReset = new FanOut(); /** Emitted after the model is reset, using the `.reset()` method. */ onReset = new FanOut(); /** Emitted before a patch is applied using `model.applyPatch()`. */ onBeforePatch = new FanOut(); /** Emitted after a patch is applied using `model.applyPatch()`. */ onPatch = new FanOut(); /** Emitted before local changes through `model.api` are applied. */ onBeforeLocalChange = new FanOut(); /** Emitted after local changes through `model.api` are applied. */ onLocalChange = new FanOut(); /** * Emitted after local changes through `model.api` are applied. Same as * `.onLocalChange`, but this event buffered withing a microtask. */ onLocalChanges = new MicrotaskBufferFanOut(this.onLocalChange); /** Emitted before a transaction is started. */ onBeforeTransaction = new FanOut(); /** Emitted after transaction completes. */ onTransaction = new FanOut(); /** Emitted when the model changes. Combines `onReset`, `onPatch` and `onLocalChange`. */ onChange = new MergeFanOut([ this.onReset, this.onPatch, this.onLocalChange, ]); /** Emitted when the model changes. Same as `.onChange`, but this event is emitted once per microtask. */ onChanges = new MicrotaskBufferFanOut(this.onChange); /** Emitted when the `model.api` builder change buffer is flushed. */ onFlush = new FanOut(); /** * @param model Model instance on which the API operates. */ constructor(model) { super(model.root, void 0); this.model = model; this.api = this; this.builder = new PatchBuilder(model.clock); model.onbeforereset = () => this.onBeforeReset.emit(); model.onreset = () => this.onReset.emit(); model.onbeforepatch = (patch) => this.onBeforePatch.emit(patch); model.onpatch = (patch) => this.onPatch.emit(patch); } wrap(node) { if (node instanceof ValNode) return node.api || (node.api = new ValApi(node, this)); else if (node instanceof StrNode) return node.api || (node.api = new StrApi(node, this)); else if (node instanceof BinNode) return node.api || (node.api = new BinApi(node, this)); else if (node instanceof ArrNode) return node.api || (node.api = new ArrApi(node, this)); else if (node instanceof ObjNode) return node.api || (node.api = new ObjApi(node, this)); else if (node instanceof ConNode) return node.api || (node.api = new ConApi(node, this)); else if (node instanceof VecNode) return node.api || (node.api = new VecApi(node, this)); else if (node instanceof ExtNode) { if (node.api) return node.api; const extension = this.model.ext.get(node.extId); return (node.api = new extension.Api(node, this)); } else throw new Error('UNKNOWN_NODE'); } /** * Given a JSON/CBOR value, constructs CRDT nodes recursively out of it and * sets the root node of the model to the constructed nodes. * * @param json JSON/CBOR value to set as the view of the model. * @returns Reference to itself. * * @deprecated Use `.set()` instead. */ root(json) { return this.set(json); } set(json) { super.set(json); return this; } /** * Apply locally any operations from the `.builder`, which haven't been * applied yet. */ apply() { const ops = this.builder.patch.ops; const length = ops.length; const model = this.model; const from = this.next; this.onBeforeLocalChange.emit(from); for (let i = this.next; i < length; i++) model.applyOperation(ops[i]); this.next = length; model.tick++; this.onLocalChange.emit(from); } /** * Advance patch pointer to the end without applying the operations. With the * idea that they have already been applied locally. * * You need to manually call `this.onBeforeLocalChange.emit(this.next)` before * calling this method. * * @ignore */ advance() { const from = this.next; this.next = this.builder.patch.ops.length; this.model.tick++; this.onLocalChange.emit(from); } inTx = false; transaction(callback) { if (this.inTx) callback(); else { this.inTx = true; try { this.onBeforeTransaction.emit(); callback(); this.onTransaction.emit(); } finally { this.inTx = false; } } } /** * Flushes the builder and returns a patch. * * @returns A JSON CRDT patch. * @todo Make this return undefined if there are no operations in the builder. */ flush() { const patch = this.builder.flush(); this.next = 0; if (patch.ops.length) this.onFlush.emit(patch); return patch; } stopAutoFlush = undefined; /** * Begins to automatically flush buffered operations into patches, grouping * operations by microtasks or by transactions. To capture the patch, listen * to the `.onFlush` event. * * @returns Callback to stop auto flushing. */ autoFlush(drainNow = false) { const drain = () => this.builder.patch.ops.length && this.flush(); const onLocalChangesUnsubscribe = this.onLocalChanges.listen(drain); const onBeforeTransactionUnsubscribe = this.onBeforeTransaction.listen(drain); const onTransactionUnsubscribe = this.onTransaction.listen(drain); if (drainNow) drain(); return (this.stopAutoFlush = () => { this.stopAutoFlush = undefined; onLocalChangesUnsubscribe(); onBeforeTransactionUnsubscribe(); onTransactionUnsubscribe(); }); } // ---------------------------------------------------------------- SyncStore subscribe = (callback) => this.onChanges.listen(() => callback()); getSnapshot = () => this.view(); } //# sourceMappingURL=nodes.js.map