UNPKG

json-joy

Version:

Collection of libraries for building collaborative editing apps.

308 lines (307 loc) 12.2 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.Peritext = void 0; const printTree_1 = require("tree-dump/lib/printTree"); const constants_1 = require("./rga/constants"); const Point_1 = require("./rga/Point"); const Range_1 = require("./rga/Range"); const Editor_1 = require("./editor/Editor"); const nodes_1 = require("../../json-crdt/nodes"); const Slices_1 = require("./slice/Slices"); const LocalSlices_1 = require("./slice/LocalSlices"); const Overlay_1 = require("./overlay/Overlay"); const constants_2 = require("./constants"); const clock_1 = require("../../json-crdt-patch/clock"); const model_1 = require("../../json-crdt/model"); const json_hash_1 = require("../../json-hash"); const constants_3 = require("../../json-crdt-patch/constants"); const json_crdt_patch_1 = require("../../json-crdt-patch"); const ExtraSlices_1 = require("./slice/ExtraSlices"); const Fragment_1 = require("./block/Fragment"); const hash_1 = require("../../json-crdt/hash"); const EXTRA_SLICES_SCHEMA = json_crdt_patch_1.s.vec(json_crdt_patch_1.s.arr([])); /** * Context for a Peritext instance. Contains all the data and methods needed to * interact with the text. */ class Peritext { /** * 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_1.Model.create(EXTRA_SLICES_SCHEMA, model.clock.sid - 1), localSlicesModel = model_1.Model.create(EXTRA_SLICES_SCHEMA, constants_3.SESSION.LOCAL)) { this.model = model; this.str = str; this.overlay = new Overlay_1.Overlay(this); // ----------------------------------------------------------------- Stateful this.hash = 0; this.savedSlices = new Slices_1.Slices(this, slices); this.extraSlices = new ExtraSlices_1.ExtraSlices(this, extraSlicesModel.root.node().get(0)); const localApi = localSlicesModel.api; localApi.onLocalChange.listen(() => { localApi.flush(); }); this.localSlices = new LocalSlices_1.LocalSlices(this, localSlicesModel.root.node().get(0)); this.editor = new Editor_1.Editor(this); this.blocks = new Fragment_1.Fragment(this, this.pointAbsStart(), this.pointAbsEnd()); } strApi() { if (this.str instanceof nodes_1.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 === constants_1.Anchor.After) { const chunk = point.chunk(); if (chunk && !chunk.del) return this.range(this.point(point.id, constants_1.Anchor.Before), point); } const id = point.prevId(); if (!id) return; return this.range(this.point(id, constants_1.Anchor.Before), this.point(id, constants_1.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 = constants_1.Anchor.After) { return new Point_1.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 = constants_1.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 ? constants_1.Anchor.Before : constants_1.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, constants_1.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, constants_1.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_1.Range.from(this.str, p1, p2); } rangeFromChunkSlice(slice) { const startId = slice.off === 0 ? slice.chunk.id : (0, clock_1.tick)(slice.chunk.id, slice.off); const endId = (0, clock_1.tick)(slice.chunk.id, slice.off + slice.len - 1); const start = this.point(startId, constants_1.Anchor.Before); const end = this.point(endId, constants_1.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_1.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_1.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_1.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 === constants_1.Anchor.Before ? start.id : start.nextId(); const delEndId = end.isAbsEnd() ? this.point().refEnd().id : end.anchor === constants_1.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 = constants_2.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, [(0, clock_1.interval)(strChunk.id, 0, 1)]); builder.del(this.savedSlices.set.id, [(0, clock_1.interval)(split.id, 0, 1)]); api.apply(); } // ---------------------------------------------------------------- Printable toString(tab = '') { const nl = () => ''; const { savedSlices, extraSlices, localSlices } = this; return ('Peritext' + (0, printTree_1.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), ])); } refresh() { let state = json_hash_1.CONST.START_STATE; state = (0, hash_1.updateRga)(state, this.str); state = (0, json_hash_1.updateNum)(state, this.overlay.refresh()); state = (0, json_hash_1.updateNum)(state, this.blocks.refresh()); return (this.hash = state); } } exports.Peritext = Peritext;