json-joy
Version:
Collection of libraries for building collaborative editing apps.
581 lines (580 loc) • 16.8 kB
JavaScript
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;
}
}