UNPKG

json-joy

Version:

Collection of libraries for building collaborative editing apps.

581 lines (580 loc) 16.8 kB
import { printTree } from 'tree-dump/lib/printTree'; 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 { ExtNode } from '../../extensions/ExtNode'; /** * 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) { const node = this.node; 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]; return find(this.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_ARR'); } 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_CONST'); } asExt(ext) { let extNode = undefined; 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(); } proxy() { return { toApi: () => this, toView: () => this.node.view(), }; } toString(tab = '') { return 'api' + 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. */ proxy() { return { toApi: () => this, toView: () => this.node.view(), }; } } /** * 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. */ proxy() { const self = this; const proxy = { toApi: () => this, toView: () => this.node.view(), get val() { const childNode = self.node.node(); return self.api.wrap(childNode).proxy(); }, }; 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. */ proxy() { const proxy = new Proxy({}, { get: (target, prop, receiver) => { if (prop === 'toApi') return () => this; if (prop === 'toView') return () => this.view(); 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).proxy(); }, }); 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.const(undefined)])); api.apply(); } /** * Returns a proxy object for this node. Allows to access object properties * by key. */ proxy() { const proxy = new Proxy({}, { get: (target, prop, receiver) => { if (prop === 'toApi') return () => this; if (prop === 'toView') return () => this.view(); const key = String(prop); const child = this.node.get(key); if (!child) throw new Error('NO_SUCH_KEY'); return this.api.wrap(child).proxy(); }, }); 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. */ proxy() { return { toApi: () => this, toView: () => this.node.view(), }; } } /** * 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. */ proxy() { return { toApi: () => this, toView: () => this.node.view(), }; } } /** * 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 JSON/CBOR values or IDs of the values to insert. * @returns Reference to itself. */ 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(); } /** * 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. */ proxy() { const proxy = new Proxy({}, { get: (target, prop, receiver) => { if (prop === 'toApi') return () => this; if (prop === 'toView') return () => this.view(); 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).proxy(); }, }); return proxy; } }