json-joy
Version:
Collection of libraries for building collaborative editing apps.
308 lines (307 loc) • 12.2 kB
JavaScript
"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;