UNPKG

json-joy

Version:

Collection of libraries for building collaborative editing apps.

326 lines (325 loc) 12.2 kB
import { printTree } from 'tree-dump/lib/printTree'; import { Anchor } from './rga/constants'; import { Point } from './rga/Point'; import { Range } from './rga/Range'; import { Editor } from './editor/Editor'; import { StrNode } from '../../json-crdt/nodes'; import { Slices } from './slice/Slices'; import { LocalSlices } from './slice/LocalSlices'; import { Overlay } from './overlay/Overlay'; import { Chars } from './constants'; import { interval, tick } from '../../json-crdt-patch/clock'; import { Model } from '../../json-crdt/model'; import { CONST, updateNum } from '../../json-hash/hash'; import { SESSION } from '../../json-crdt-patch/constants'; import { s } from '../../json-crdt-patch'; import { ExtraSlices } from './slice/ExtraSlices'; import { Fragment } from './block/Fragment'; import { updateRga } from '../../json-crdt/hash'; const EXTRA_SLICES_SCHEMA = s.vec(s.arr([])); const LOCAL_DATA_SCHEMA = EXTRA_SLICES_SCHEMA; /** * Context for a Peritext instance. Contains all the data and methods needed to * interact with the text. */ export class Peritext { model; str; /** * *Slices* are rich-text annotations that appear in the text. The "saved" * slices are the ones that are persisted in the document. */ savedSlices; /** * *Extra slices* are slices that are not persisted in the document. However, * they are still shared across users, i.e. they are ephemerally persisted * during the editing session. */ extraSlices; /** * *Local slices* are slices that are not persisted in the document and are * not shared with other users. They are used only for local annotations for * the current user. */ localSlices; editor; overlay = new Overlay(this); blocks; /** * Creates a new Peritext context. * * @param model JSON CRDT model of the document where the text is stored. * @param str The {@link StrNode} where the text is stored. * @param slices The {@link ArrNode} where the slices are stored. * @param extraSlicesModel The JSON CRDT model for the extra slices, which are * not persisted in the main document, but are shared with other users. * @param localSlicesModel The JSON CRDT model for the local slices, which are * not persisted in the main document and are not shared with other * users. The local slices capture current-user-only annotations, such * as the current user's selection. */ constructor(model, // TODO: Rename `str` to `rga`. str, slices, // TODO: Add test that verifies that SIDs are different across all three models. extraSlicesModel = Model.create(EXTRA_SLICES_SCHEMA, model.clock.sid - 1), localSlicesModel = Model.create(LOCAL_DATA_SCHEMA, SESSION.LOCAL)) { this.model = model; this.str = str; this.savedSlices = new Slices(this, slices); this.extraSlices = new ExtraSlices(this, extraSlicesModel.root.node().get(0)); const localApi = localSlicesModel.api; localApi.onLocalChange.listen(() => { localApi.flush(); }); this.localSlices = new LocalSlices(this, localSlicesModel.root.node().get(0)); this.editor = new Editor(this); this.blocks = new Fragment(this, this.pointAbsStart(), this.pointAbsEnd()); } strApi() { if (this.str instanceof StrNode) return this.model.api.wrap(this.str); throw new Error('INVALID_STR'); } /** Select a single character before a point. */ findCharBefore(point) { if (point.anchor === Anchor.After) { const chunk = point.chunk(); if (chunk && !chunk.del) return this.range(this.point(point.id, Anchor.Before), point); } const id = point.prevId(); if (!id) return; return this.range(this.point(id, Anchor.Before), this.point(id, Anchor.After)); } // ------------------------------------------------------------------- points /** * Creates a point at a character ID. * * @param id Character ID to which the point should be attached. * @param anchor Whether the point should be before or after the character. * @returns The point. */ point(id = this.str.id, anchor = Anchor.After) { return new Point(this.str, id, anchor); } /** * Creates a point at a view position in the text. The `pos` argument * specifies the position of the character, not the gap between characters. * * @param pos Position of the character in the text. * @param anchor Whether the point should attach before or after a character. * Defaults to "before". * @returns The point. */ pointAt(pos, anchor = Anchor.Before) { // TODO: Provide ability to attach to the beginning of the text? // TODO: Provide ability to attach to the end of the text? const str = this.str; const id = str.find(pos); if (!id) return this.point(str.id, pos ? Anchor.Before : Anchor.After); return this.point(id, anchor); } /** * Creates a point which is attached to the start of the text, before the * first character. * * @returns A point at the start of the text. */ pointAbsStart() { return this.point(this.str.id, Anchor.After); } /** * Creates a point which is attached to the end of the text, after the last * character. * * @returns A point at the end of the text. */ pointAbsEnd() { return this.point(this.str.id, Anchor.Before); } pointStart() { if (!this.str.length()) return; const point = this.pointAbsStart(); point.refBefore(); return point; } pointEnd() { if (!this.str.length()) return; const point = this.pointAbsEnd(); point.refAfter(); return point; } // ------------------------------------------------------------------- ranges /** * Creates a range from two points. The points can be in any order. * * @param p1 Point * @param p2 Point * @returns A range with points in correct order. */ rangeFromPoints(p1, p2) { return Range.from(this.str, p1, p2); } rangeFromChunkSlice(slice) { const startId = slice.off === 0 ? slice.chunk.id : tick(slice.chunk.id, slice.off); const endId = tick(slice.chunk.id, slice.off + slice.len - 1); const start = this.point(startId, Anchor.Before); const end = this.point(endId, Anchor.After); return this.range(start, end); } /** * Creates a range from two points, the points have to be in the correct * order. * * @param start Start point of the range, must be before or equal to end. * @param end End point of the range, must be after or equal to start. * @returns A range with the given start and end points. */ range(start, end = start) { return new Range(this.str, start, start === end ? end.clone() : end); } /** * A convenience method for creating a range from a view position and a * length. See {@link Range.at} for more information. * * @param start Position in the text. * @param length Length of the range. * @returns A range from the given position with the given length. */ rangeAt(start, length = 0) { return Range.at(this.str, start, length); } /** * Creates selection of relative start and end of the whole document. * * @returns Range, which selects the whole document, if any. */ rangeAll() { const start = this.pointStart(); const end = this.pointEnd(); if (!start || !end) return; return this.range(start, end); } fragment(range) { return new Fragment(this, range.start, range.end); } // ---------------------------------------------------------- text (& slices) /** * Insert plain text at a view position in the text. * * @param pos View position in the text. * @param text Text to insert. */ insAt(pos, text) { const str = this.strApi(); str.ins(pos, text); } /** * Insert plain text after a character referenced by its ID and return the * ID of the insertion operation. * * @param after Character ID after which the text should be inserted. * @param text Text to insert. * @returns ID of the insertion operation. */ ins(after, text) { if (!text) throw new Error('NO_TEXT'); const api = this.model.api; const textId = api.builder.insStr(this.str.id, after, text); api.apply(); return textId; } delAt(pos, len) { const range = this.rangeAt(pos, len); this.del(range); } del(range) { this.delSlices(range); this.delStr(range); } delStr(range) { const isCaret = range.isCollapsed(); if (isCaret) return false; const { start, end } = range; const delStartId = start.isAbsStart() ? this.point().refStart().id : start.anchor === Anchor.Before ? start.id : start.nextId(); const delEndId = end.isAbsEnd() ? this.point().refEnd().id : end.anchor === Anchor.After ? end.id : end.prevId(); if (!delStartId || !delEndId) throw new Error('INVALID_RANGE'); const rga = this.str; const spans = rga.findInterval2(delStartId, delEndId); const api = this.model.api; api.builder.del(rga.id, spans); api.apply(); return true; } delSlices(range) { // TODO: PERF: do we need this refresh? this.overlay.refresh(); range = range.range(); range.expand(); const slices = this.overlay.findContained(range); let deleted = false; if (!slices.size) return deleted; if (this.savedSlices.delSlices(slices)) deleted = true; if (this.extraSlices.delSlices(slices)) deleted = true; if (this.localSlices.delSlices(slices)) deleted = true; return deleted; } // ------------------------------------------------------------------ markers /** @deprecated Use the method in `Editor` and `Cursor` instead. */ insMarker(after, type, data, char = Chars.BlockSplitSentinel) { return this.savedSlices.insMarkerAfter(after, type, data, char); } /** @todo This can probably use .del() */ delMarker(split) { const str = this.str; const api = this.model.api; const builder = api.builder; const strChunk = split.start.chunk(); if (strChunk) builder.del(str.id, [interval(strChunk.id, 0, 1)]); builder.del(this.savedSlices.set.id, [interval(split.id, 0, 1)]); api.apply(); } /** ----------------------------------------------------- {@link Printable} */ toString(tab = '') { const nl = () => ''; const { savedSlices, extraSlices, localSlices } = this; return ('Peritext' + printTree(tab, [ (tab) => this.str.toString(tab), nl, savedSlices.size() ? (tab) => savedSlices.toString(tab) : null, extraSlices.size() ? (tab) => extraSlices.toString(tab) : null, localSlices.size() ? (tab) => localSlices.toString(tab) : null, nl, (tab) => this.overlay.toString(tab), nl, (tab) => this.blocks.toString(tab), ])); } /** ------------------------------------------------------ {@link Stateful} */ hash = 0; refresh() { let state = CONST.START_STATE; state = updateRga(state, this.str); state = updateNum(state, this.overlay.refresh()); state = updateNum(state, this.blocks.refresh()); return (this.hash = state); } }