diff --git a/src/json-crdt-extensions/peritext/Peritext.ts b/src/json-crdt-extensions/peritext/Peritext.ts index 141d04850a..8c2345f692 100644 --- a/src/json-crdt-extensions/peritext/Peritext.ts +++ b/src/json-crdt-extensions/peritext/Peritext.ts @@ -13,10 +13,16 @@ import {Model} from '../../json-crdt/model'; import {CONST, updateNum} from '../../json-hash'; import {SESSION} from '../../json-crdt-patch/constants'; import {s} from '../../json-crdt-patch'; +import {ExtraSlices} from './slice/ExtraSlices'; import type {ITimestampStruct} from '../../json-crdt-patch/clock'; import type {Printable} from 'tree-dump/lib/types'; -import type {SliceType} from './types'; import type {MarkerSlice} from './slice/MarkerSlice'; +import type {SliceSchema, SliceType} from './slice/types'; +import type {SchemaToJsonNode} from '../../json-crdt/schema/types'; + +const EXTRA_SLICES_SCHEMA = s.vec(s.arr([])); + +type SlicesModel = Model>; /** * Context for a Peritext instance. Contains all the data and methods needed to @@ -46,27 +52,33 @@ export class Peritext implements Printable { public readonly editor: Editor; public readonly overlay = new Overlay(this); + /** + * 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( public readonly model: Model, public readonly str: StrNode, slices: ArrNode, + extraSlicesModel: SlicesModel = Model.create(EXTRA_SLICES_SCHEMA, model.clock.sid - 1), + localSlicesModel: SlicesModel = Model.create(EXTRA_SLICES_SCHEMA, SESSION.LOCAL), ) { this.savedSlices = new Slices(this.model, slices, this.str); - - const extraModel = Model.withLogicalClock(SESSION.GLOBAL) - .setSchema(s.vec(s.arr([]))) - .fork(this.model.clock.sid + 1); - this.extraSlices = new Slices(extraModel, extraModel.root.node().get(0)!, this.str); - - // TODO: flush patches - // TODO: remove `arr` tombstones - const localModel = Model.withLogicalClock(SESSION.LOCAL).setSchema(s.vec(s.arr([]))); - const localApi = localModel.api; + this.extraSlices = new ExtraSlices(extraSlicesModel, extraSlicesModel.root.node().get(0)!, this.str); + const localApi = localSlicesModel.api; localApi.onLocalChange.listen(() => { localApi.flush(); }); - this.localSlices = new LocalSlices(localModel, localModel.root.node().get(0)!, this.str); - + this.localSlices = new LocalSlices(localSlicesModel, localSlicesModel.root.node().get(0)!, this.str); this.editor = new Editor(this, this.localSlices); } @@ -99,8 +111,8 @@ export class Peritext implements Printable { } /** - * Creates a point at a view position in the text. The `pos` argument specifies - * the position of the character, not the gap between characters. + * 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. @@ -150,7 +162,8 @@ export class Peritext implements Printable { } /** - * Creates a range from two points, the points have to be in the correct order. + * 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. @@ -161,8 +174,8 @@ export class Peritext implements Printable { } /** - * A convenience method for creating a range from a view position and a length. - * See {@link Range.at} for more information. + * 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. @@ -238,14 +251,15 @@ export class Peritext implements Printable { public toString(tab: string = ''): string { const nl = () => ''; + const {savedSlices, extraSlices, localSlices} = this; return ( this.constructor.name + printTree(tab, [ - (tab) => this.editor.cursor.toString(tab), - nl, (tab) => this.str.toString(tab), nl, - (tab) => this.savedSlices.toString(tab), + 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), ]) diff --git a/src/json-crdt-extensions/peritext/editor/Cursor.ts b/src/json-crdt-extensions/peritext/editor/Cursor.ts index d18498ca48..d584e60493 100644 --- a/src/json-crdt-extensions/peritext/editor/Cursor.ts +++ b/src/json-crdt-extensions/peritext/editor/Cursor.ts @@ -1,5 +1,4 @@ import {Point} from '../rga/Point'; -import {Range} from '../rga/Range'; import {CursorAnchor} from '../slice/constants'; import {PersistedSlice} from '../slice/PersistedSlice'; @@ -8,6 +7,8 @@ export class Cursor extends PersistedSlice { return this.type as CursorAnchor; } + // ---------------------------------------------------------------- mutations + public set anchorSide(value: CursorAnchor) { this.update({type: value}); } @@ -20,42 +21,26 @@ export class Cursor extends PersistedSlice { return this.anchorSide === CursorAnchor.Start ? this.end : this.start; } - public set(start: Point, end?: Point, anchorSide: CursorAnchor = this.anchorSide): void { - if (!end || end === start) end = start.clone(); - super.set(start, end); + public set(start: Point, end: Point = start, anchorSide: CursorAnchor = this.anchorSide): void { + this.start = start; + this.end = end === start ? end.clone() : end; this.update({ range: this, type: anchorSide, }); } - /** TODO: Move to {@link PersistedSlice}. */ - public setAt(start: number, length: number = 0): void { - let at = start; - let len = length; - if (len < 0) { - at += len; - len = -len; - } - const range = Range.at(this.rga, start, length); - const anchorSide = this.anchorSide; - this.update({ - range, - type: anchorSide !== this.anchorSide ? anchorSide : undefined, - }); - } - /** * Move one of the edges of the cursor to a new point. * * @param point Point to set the edge to. - * @param edge 0 for "focus", 1 for "anchor." + * @param endpoint 0 for "focus", 1 for "anchor." */ - public setEdge(point: Point, edge: 0 | 1 = 0): void { + public setEndpoint(point: Point, endpoint: 0 | 1 = 0): void { if (this.start === this.end) this.end = this.end.clone(); let anchor = this.anchor(); let focus = this.focus(); - if (edge === 0) focus = point; + if (endpoint === 0) focus = point; else anchor = point; if (focus.cmpSpatial(anchor) < 0) this.set(focus, anchor, CursorAnchor.End); else this.set(anchor, focus, CursorAnchor.Start); @@ -64,9 +49,7 @@ export class Cursor extends PersistedSlice { public move(move: number): void { const {start, end} = this; start.move(move); - if (start !== end) { - end.move(move); - } + if (start !== end) end.move(move); this.set(start, end); } diff --git a/src/json-crdt-extensions/peritext/editor/Editor.ts b/src/json-crdt-extensions/peritext/editor/Editor.ts index d111e0d645..d66f1e2adc 100644 --- a/src/json-crdt-extensions/peritext/editor/Editor.ts +++ b/src/json-crdt-extensions/peritext/editor/Editor.ts @@ -8,10 +8,13 @@ import type {Range} from '../rga/Range'; import type {Peritext} from '../Peritext'; import type {Printable} from 'tree-dump/lib/types'; import type {Point} from '../rga/Point'; -import type {SliceType} from '../types'; +import type {SliceType} from '../slice/types'; import type {MarkerSlice} from '../slice/MarkerSlice'; import type {Slices} from '../slice/Slices'; +/** + * Rename to `PeritextApi`. + */ export class Editor implements Printable { /** * Cursor is the the current user selection. It can be a caret or a @@ -25,6 +28,7 @@ export class Editor implements Printable { ) { const point = txt.pointAbsStart(); const range = txt.range(point, point.clone()); + // TODO: Add ability to remove cursor. this.cursor = slices.ins(range, SliceBehavior.Cursor, CursorAnchor.Start, undefined, Cursor); } diff --git a/src/json-crdt-extensions/peritext/overlay/MarkerOverlayPoint.ts b/src/json-crdt-extensions/peritext/overlay/MarkerOverlayPoint.ts index ed569c65e1..ab45554d0d 100644 --- a/src/json-crdt-extensions/peritext/overlay/MarkerOverlayPoint.ts +++ b/src/json-crdt-extensions/peritext/overlay/MarkerOverlayPoint.ts @@ -1,10 +1,10 @@ +import {printTree} from 'tree-dump/lib/printTree'; import {OverlayPoint} from './OverlayPoint'; -import {SliceType} from '../types'; +import type {SliceType} from '../slice/types'; import type {Anchor} from '../rga/constants'; import type {AbstractRga} from '../../../json-crdt/nodes/rga'; import type {ITimestampStruct} from '../../../json-crdt-patch/clock'; import type {MarkerSlice} from '../slice/MarkerSlice'; -import {printTree} from 'tree-dump/lib/printTree'; export class MarkerOverlayPoint extends OverlayPoint { /** diff --git a/src/json-crdt-extensions/peritext/overlay/Overlay.ts b/src/json-crdt-extensions/peritext/overlay/Overlay.ts index 4ed30f12f3..9e73fe8f1c 100644 --- a/src/json-crdt-extensions/peritext/overlay/Overlay.ts +++ b/src/json-crdt-extensions/peritext/overlay/Overlay.ts @@ -17,22 +17,18 @@ import type {Printable} from 'tree-dump/lib/types'; import type {MutableSlice, Slice} from '../slice/types'; import type {Slices} from '../slice/Slices'; +/** + * Overlay is a tree structure that represents all the intersections of slices + * in the text. It is used to quickly find all the slices that overlap a + * given point in the text. The overlay is a read-only structure, its state + * is changed only by calling the `refresh` method, which updates the overlay + * based on the current state of the text and slices. + */ export class Overlay implements Printable, Stateful { public root: OverlayPoint | undefined = undefined; constructor(protected readonly txt: Peritext) {} - /** - * @todo Rename to .point(). - */ - protected overlayPoint(id: ITimestampStruct, anchor: Anchor): OverlayPoint { - return new OverlayPoint(this.txt.str, id, anchor); - } - - protected markerPoint(marker: MarkerSlice, anchor: Anchor): OverlayPoint { - return new MarkerOverlayPoint(this.txt.str, marker.start.id, anchor, marker); - } - public first(): OverlayPoint | undefined { return this.root ? first(this.root) : undefined; } @@ -136,12 +132,20 @@ export class Overlay implements Printable, Stateful { return state; } + private point(id: ITimestampStruct, anchor: Anchor): OverlayPoint { + return new OverlayPoint(this.txt.str, id, anchor); + } + + private mPoint(marker: MarkerSlice, anchor: Anchor): MarkerOverlayPoint { + return new MarkerOverlayPoint(this.txt.str, marker.start.id, anchor, marker); + } + /** * Retrieve an existing {@link OverlayPoint} or create a new one, inserted * in the tree, sorted by spatial dimension. */ - protected upsertPoint(point: Point): [point: OverlayPoint, isNew: boolean] { - const newPoint = this.overlayPoint(point.id, point.anchor); + private upsertPoint(point: Point): [point: OverlayPoint, isNew: boolean] { + const newPoint = this.point(point.id, point.anchor); const pivot = this.insPoint(newPoint); if (pivot) return [pivot, false]; return [newPoint, true]; @@ -173,7 +177,7 @@ export class Overlay implements Printable, Stateful { } private insMarker(slice: MarkerSlice): [start: OverlayPoint, end: OverlayPoint] { - const point = this.markerPoint(slice, Anchor.Before); + const point = this.mPoint(slice, Anchor.Before); const pivot = this.insPoint(point); if (!pivot) { point.refs.push(slice); diff --git a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.refresh.spec.ts b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.refresh.spec.ts index d061e5e577..8c1cbc752d 100644 --- a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.refresh.spec.ts +++ b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.refresh.spec.ts @@ -43,7 +43,7 @@ describe('Overlay.refresh()', () => { }); }; - describe('slices', () => { + describe('saved slices', () => { describe('updates hash', () => { testRefresh('when a slice is inserted', (kit, refresh) => { kit.peritext.editor.cursor.setAt(1, 4); @@ -141,6 +141,104 @@ describe('Overlay.refresh()', () => { }); }); + describe('extra slices', () => { + describe('updates hash', () => { + testRefresh('when a slice is inserted', (kit, refresh) => { + const range = kit.peritext.rangeAt(1, 4); + refresh(); + kit.peritext.extraSlices.insOverwrite(range, 'bold'); + }); + + testRefresh('when a collapsed slice is inserted', (kit, refresh) => { + const range = kit.peritext.rangeAt(5); + refresh(); + kit.peritext.extraSlices.insStack(range, ''); + }); + + testRefresh('when a marker is inserted', (kit, refresh) => { + const range = kit.peritext.rangeAt(0); + refresh(); + kit.peritext.extraSlices.insMarker(range, ''); + }); + + testRefresh('when a marker is inserted at the same position', (kit, refresh) => { + const range = kit.peritext.rangeAt(0); + kit.peritext.extraSlices.insMarker(range, ''); + refresh(); + kit.peritext.extraSlices.insMarker(range, ''); + }); + + testRefresh('when slice is deleted', (kit, refresh) => { + const range = kit.peritext.rangeAt(0, 1); + const slice = kit.peritext.extraSlices.insMarker(range, ''); + refresh(); + kit.peritext.extraSlices.del(slice.id); + }); + + testRefresh('when slice type is changed', (kit, refresh) => { + const range = kit.peritext.rangeAt(0, 1); + const slice = kit.peritext.extraSlices.insStack(range, ''); + refresh(); + slice.update({type: ''}); + }); + + testRefresh('when slice behavior is changed', (kit, refresh) => { + const range = kit.peritext.rangeAt(2, 7); + const slice = kit.peritext.extraSlices.insStack(range, 123); + refresh(); + slice.update({behavior: SliceBehavior.Erase}); + }); + + testRefresh('when slice data is overwritten', (kit, refresh) => { + const range = kit.peritext.rangeAt(2, 7); + const slice = kit.peritext.extraSlices.insStack(range, 123, 'a'); + refresh(); + slice.update({data: 'b'}); + }); + + testRefresh('when slice data is updated inline', (kit, refresh) => { + const range = kit.peritext.rangeAt(1, 1); + const slice = kit.peritext.extraSlices.insStack(range, 123, {foo: 'bar'}); + refresh(); + const api = slice.dataNode()! as ObjApi; + api.set({foo: 'baz'}); + }); + + testRefresh('when slice start point anchor is changed', (kit, refresh) => { + const range = kit.peritext.rangeAt(0, 1); + const slice = kit.peritext.extraSlices.insStack(range, 123, 456); + expect(slice.start.anchor).toBe(Anchor.Before); + refresh(); + const range2 = slice.range(); + range2.start.anchor = Anchor.After; + slice.update({range: range2}); + }); + + testRefresh('when slice end point anchor is changed', (kit, refresh) => { + const range = kit.peritext.rangeAt(3, 3); + const slice = kit.peritext.extraSlices.insStack(range, 0, 0); + expect(slice.end.anchor).toBe(Anchor.After); + refresh(); + const range2 = slice.range(); + range2.end.anchor = Anchor.Before; + slice.update({range: range2}); + }); + + testRefresh('when slice range changes', (kit, refresh) => { + const range = kit.peritext.rangeAt(3, 3); + kit.peritext.extraSlices.insStack(range, 0, 0); + kit.peritext.extraSlices.insStack(range, 1, 1); + kit.peritext.extraSlices.insStack(range, 3, 3); + const range1 = kit.peritext.rangeAt(1, 2); + const slice = kit.peritext.extraSlices.insErase(range1, 'gg'); + expect(slice.end.anchor).toBe(Anchor.After); + refresh(); + const range2 = kit.peritext.rangeAt(2, 2); + slice.update({range: range2}); + }); + }); + }); + describe('cursor', () => { describe('updates hash', () => { testRefresh('when cursor char ID changes', (kit, refresh) => { @@ -166,6 +264,15 @@ describe('Overlay.refresh()', () => { end.anchor = Anchor.Before; kit.peritext.editor.cursor.setRange(kit.peritext.range(kit.peritext.editor.cursor.start, end)); }); + + testRefresh('when cursor data changes', (kit, refresh) => { + kit.peritext.editor.cursor.setAt(3, 3); + const slice = kit.peritext.editor.cursor; + slice.update({data: {a: 'b'}}); + refresh(); + const api = slice.dataNode()! as ObjApi; + api.set({a: 'c'}); + }); }); }); }); diff --git a/src/json-crdt-extensions/peritext/rga/Range.ts b/src/json-crdt-extensions/peritext/rga/Range.ts index cd83808cfd..7ef4e7417c 100644 --- a/src/json-crdt-extensions/peritext/rga/Range.ts +++ b/src/json-crdt-extensions/peritext/rga/Range.ts @@ -119,26 +119,16 @@ export class Range implements Pick, Printable { return start2.cmp(end2) === 0; } - /** - * Collapse the range to the start point and sets the anchor position to be - * "after" the character. - */ - public collapseToStart(): void { - this.start = this.start.clone(); - this.start.refAfter(); - this.end = this.start.clone(); + public contains(range: Range): boolean { + return this.start.cmpSpatial(range.start) <= 0 && this.end.cmpSpatial(range.end) >= 0; } - /** - * Collapse the range to the end point and sets the anchor position to be - * "before" the character. - */ - public collapseToEnd(): void { - this.end = this.end.clone(); - this.end.refAfter(); - this.start = this.end.clone(); + public containsPoint(point: Point): boolean { + return this.start.cmpSpatial(point) <= 0 && this.end.cmpSpatial(point) >= 0; } + // ---------------------------------------------------------------- mutations + public set(start: Point, end: Point = start): void { this.start = start; this.end = end === start ? end.clone() : end; @@ -158,12 +148,26 @@ export class Range implements Pick, Printable { this.set(point); } - public contains(range: Range): boolean { - return this.start.cmpSpatial(range.start) <= 0 && this.end.cmpSpatial(range.end) >= 0; + /** + * Collapse the range to the start point and sets the anchor position to be + * "after" the character. + */ + public collapseToStart(): void { + const start = this.start.clone(); + start.refAfter(); + const end = start.clone(); + this.set(start, end); } - public containsPoint(point: Point): boolean { - return this.start.cmpSpatial(point) <= 0 && this.end.cmpSpatial(point) >= 0; + /** + * Collapse the range to the end point and sets the anchor position to be + * "before" the character. + */ + public collapseToEnd(): void { + const end = this.end.clone(); + end.refAfter(); + const start = this.end.clone(); + this.set(start, end); } /** diff --git a/src/json-crdt-extensions/peritext/slice/ExtraSlices.ts b/src/json-crdt-extensions/peritext/slice/ExtraSlices.ts new file mode 100644 index 0000000000..4b4c5abc33 --- /dev/null +++ b/src/json-crdt-extensions/peritext/slice/ExtraSlices.ts @@ -0,0 +1,3 @@ +import {Slices} from './Slices'; + +export class ExtraSlices extends Slices {} diff --git a/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts b/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts index 1c70d46ce5..24f5fead95 100644 --- a/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts +++ b/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts @@ -6,22 +6,28 @@ import {printTree} from 'tree-dump/lib/printTree'; import {Anchor} from '../rga/constants'; import {SliceHeaderMask, SliceHeaderShift, SliceBehavior, SliceTupleIndex} from './constants'; import {CONST} from '../../../json-hash'; -import {Timestamp, compare} from '../../../json-crdt-patch/clock'; +import {Timestamp} from '../../../json-crdt-patch/clock'; import {VecNode} from '../../../json-crdt/nodes'; import {prettyOneLine} from '../../../json-pretty'; import {validateType} from './util'; import {s} from '../../../json-crdt-patch'; import type {ITimestampStruct} from '../../../json-crdt-patch/clock'; import type {ArrChunk} from '../../../json-crdt/nodes'; -import type {MutableSlice, SliceUpdateParams} from './types'; -import type {SliceDto, SliceType, Stateful} from '../types'; +import type {MutableSlice, SliceView, SliceType, SliceUpdateParams} from './types'; +import type {Stateful} from '../types'; import type {Printable} from 'tree-dump/lib/types'; import type {AbstractRga} from '../../../json-crdt/nodes/rga'; import type {Model} from '../../../json-crdt/model'; +/** + * A persisted slice is a slice that is stored in a {@link Model}. It is used for + * rich-text formatting and annotations. + * + * @todo Maybe rename to "saved", "stored", "mutable". + */ export class PersistedSlice extends Range implements MutableSlice, Stateful, Printable { public static deserialize(model: Model, rga: AbstractRga, chunk: ArrChunk, tuple: VecNode): PersistedSlice { - const header = +(tuple.get(0)!.view() as SliceDto[0]); + const header = +(tuple.get(0)!.view() as SliceView[0]); const id1 = tuple.get(1)!.view() as ITimestampStruct; const id2 = (tuple.get(2)!.view() || id1) as ITimestampStruct; const type = tuple.get(3)!.view() as SliceType; @@ -66,6 +72,22 @@ export class PersistedSlice extends Range implements MutableSlice return this.model.api.wrap(this.tuple); } + // ---------------------------------------------------------------- mutations + + public set(start: Point, end: Point = start): void { + super.set(start, end); + this.update({range: this}); + } + + /** + * Expand range left and right to contain all invisible space: (1) tombstones, + * (2) anchors of non-deleted adjacent chunks. + */ + public expand(): void { + super.expand(); + this.update({range: this}); + } + // ------------------------------------------------------------- MutableSlice public readonly id: ITimestampStruct; @@ -90,7 +112,7 @@ export class PersistedSlice extends Range implements MutableSlice this.type = params.type; changes.push([SliceTupleIndex.Type, s.con(this.type)]); } - if (hasOwnProperty(params, 'data')) changes.push([SliceTupleIndex.Data, s.con(params.data)]); + if (hasOwnProperty(params, 'data')) changes.push([SliceTupleIndex.Data, params.data]); if (updateHeader) { const header = (this.behavior << SliceHeaderShift.Behavior) + diff --git a/src/json-crdt-extensions/peritext/slice/Slices.ts b/src/json-crdt-extensions/peritext/slice/Slices.ts index 852bca4ee6..bf83f539be 100644 --- a/src/json-crdt-extensions/peritext/slice/Slices.ts +++ b/src/json-crdt-extensions/peritext/slice/Slices.ts @@ -8,9 +8,9 @@ import {CONST, updateNum} from '../../../json-hash'; import {SliceBehavior, SliceHeaderShift, SliceTupleIndex} from './constants'; import {MarkerSlice} from './MarkerSlice'; import {VecNode} from '../../../json-crdt/nodes'; -import type {Slice} from './types'; +import type {Slice, SliceType} from './types'; import type {ITimespanStruct, ITimestampStruct} from '../../../json-crdt-patch/clock'; -import type {SliceType, Stateful} from '../types'; +import type {Stateful} from '../types'; import type {Printable} from 'tree-dump/lib/types'; import type {ArrChunk, ArrNode} from '../../../json-crdt/nodes'; import type {Model} from '../../../json-crdt/model'; diff --git a/src/json-crdt-extensions/peritext/slice/constants.ts b/src/json-crdt-extensions/peritext/slice/constants.ts index 97724b4d5a..9d59aac72a 100644 --- a/src/json-crdt-extensions/peritext/slice/constants.ts +++ b/src/json-crdt-extensions/peritext/slice/constants.ts @@ -57,6 +57,9 @@ export const enum SliceBehavior { Cursor = 0b100, } +/** + * Specifies `vec` offsets in the {@link SliceView}. + */ export const enum SliceTupleIndex { Header = 0, X1 = 1, diff --git a/src/json-crdt-extensions/peritext/slice/types.ts b/src/json-crdt-extensions/peritext/slice/types.ts index 5d32d8a0c3..d40dc2c45f 100644 --- a/src/json-crdt-extensions/peritext/slice/types.ts +++ b/src/json-crdt-extensions/peritext/slice/types.ts @@ -1,8 +1,101 @@ +import type {Path, PathStep} from '@jsonjoy.com/json-pack/lib/json-pointer'; import type {Range} from '../rga/Range'; -import type {SliceType, Stateful} from '../types'; +import type {Stateful} from '../types'; import type {ITimestampStruct} from '../../../json-crdt-patch/clock'; import type {SliceBehavior} from './constants'; +import type {nodes} from '../../../json-crdt-patch'; +import type {SchemaToJsonNode} from '../../../json-crdt/schema/types'; +import type {JsonNodeView} from '../../../json-crdt/nodes'; +/** + * Represents a developer-defined type of a slice, allows developers to assign + * rich-text formatting or block types to slices. + * + * For example: + * + * ```ts + * 'bold' + * '' + * ['paragraph'] + * ``` + * + * Slice types can specify block nesting: + * + * ```ts + * ['paragraph', 'blockquote'] + * ['ul', 'li', 'code'] + * ``` + * + * Slice types can use integers for performance: + * + * ```ts + * 1 + * [2] + * [3, 4] + * ``` + */ +export type SliceType = PathStep | Path; + +/** + * The JSON CRDT schema of the stored slices in the document. The slices are + * stored compactly in "vec" nodes, with the first *header* element storing + * multiple values in a single integer. + */ +export type SliceSchema = nodes.vec< + [ + /** + * The header stores the behavior {@link SliceBehavior} of the slice as well + * as anchor {@link Anchor} points of the x1 and x2 points. + */ + header: nodes.con, + + /** + * ID of the start {@link Point} of the slice. + */ + x1: nodes.con, + + /** + * ID of the end {@link Point} of the slice, if 0 then it is equal to x1. + */ + x2: nodes.con, + + /** + * App specific type of the slice. For slices with "split" behavior, this + * is a path of block nesting. For other slices, it specifies inline formatting, such + * as bold, italic, etc.; the value has to be a primitive number or a string. + */ + type: nodes.con, + + /** + * Reference to additional metadata about the slice, usually an object. If + * data is not set, it will default to `1`. For "erase" slice behavior, data + * should not be specified. + * + * In reality this `vec` term can be of any type, it can even be missing + * entirely. It is typed here as a placeholder for the actual data type. + */ + data: nodes.obj<{}>, + ] +>; + +/** + * JSON CRDT node representation of the stored slices. + */ +export type SliceNode = SchemaToJsonNode; + +/** + * The view of a stored slice. + */ +export type SliceView = JsonNodeView; + +/** + * Slices represent Peritext's rich-text formatting/annotations. The "slice" + * concept captures both: (1) range annotations; as well as, (2) *markers*, + * which are a single-point annotations. The markers are used as block splits, + * e.g. paragraph, heading, blockquote, etc. In markers, the start and end + * positions of the range are normally the same, but could also wrap around + * a single RGA chunk. + */ export interface Slice extends Range, Stateful { /** * ID of the slice. ID is used for layer sorting. @@ -39,9 +132,29 @@ export interface MutableSlice extends Slice { isDel(): boolean; } +/** + * Parameters for updating a slice. + */ export interface SliceUpdateParams { + /** + * When set, updates the behavior of the slice. + */ behavior?: SliceBehavior; + + /** + * When set, updates the type of the slice. + */ type?: SliceType; + + /** + * When set, overwrites the custom data of the slice. To edit the data more + * granularly, use the `dataNode()` method of the slice instead, to get + * access to the data node. + */ data?: unknown; + + /** + * When set, updates the range and endpoint anchors of the slice. + */ range?: Range; } diff --git a/src/json-crdt-extensions/peritext/slice/util.ts b/src/json-crdt-extensions/peritext/slice/util.ts index 0a4ad670e3..5b03185309 100644 --- a/src/json-crdt-extensions/peritext/slice/util.ts +++ b/src/json-crdt-extensions/peritext/slice/util.ts @@ -1,4 +1,4 @@ -import type {SliceType} from '../types'; +import type {SliceType} from '../slice/types'; export const validateType = (type: SliceType) => { switch (typeof type) { diff --git a/src/json-crdt-extensions/peritext/types.ts b/src/json-crdt-extensions/peritext/types.ts index 5557617292..44f873dd52 100644 --- a/src/json-crdt-extensions/peritext/types.ts +++ b/src/json-crdt-extensions/peritext/types.ts @@ -1,6 +1,3 @@ -import type {ITimestampStruct} from '../../json-crdt-patch'; -import type {Path, PathStep} from '../../json-pointer'; - /** * Represents an object which state can change over time. */ @@ -16,40 +13,3 @@ export interface Stateful { */ refresh(): number; } - -export type IdDto = [sid: number, time: number]; - -export type SpanDto = [sid: number, time: number, length: number]; - -export type SliceType = PathStep | Path; - -export type SliceDto = [ - /** - * Stores the behavior of the slice as well as anchor points of x1 and x2. - */ - flags: number, - - /** - * Start point of the slice. - */ - x1: ITimestampStruct, - - /** - * End point of the slice, if 0 then it is equal to x1. - */ - x2: ITimestampStruct | 0, - - /** - * App specific type of the slice. For slices with "split" behavior, this - * is a path of block nesting. For other slices, it specifies inline formatting, such - * as bold, italic, etc.; the value has to be a primitive number or a string. - */ - type: SliceType, - - /** - * Reference to additional metadata about the slice, usually an object. If - * data is not set, it will default to `1`. For "erase" slice behavior, data - * should not be specified. - */ - data?: unknown, -]; diff --git a/src/json-crdt-patch/clock/clock.ts b/src/json-crdt-patch/clock/clock.ts index 7af5a11a2a..1545368666 100644 --- a/src/json-crdt-patch/clock/clock.ts +++ b/src/json-crdt-patch/clock/clock.ts @@ -230,7 +230,7 @@ export class ServerClockVector extends LogicalClock implements IClockVector { public readonly peers = new Map(); public observe(ts: ITimespanStruct, span: number) { - if (ts.sid !== SESSION.SERVER) throw new Error('INVALID_SERVER_SESSION'); + if (ts.sid > 8) throw new Error('INVALID_SERVER_SESSION'); if (this.time < ts.time) throw new Error('TIME_TRAVEL'); const time = ts.time + span; if (time > this.time) this.time = time; diff --git a/src/json-crdt/__demos__/schema.ts b/src/json-crdt/__demos__/schema.ts index 4f3cb8d5df..00250dd028 100644 --- a/src/json-crdt/__demos__/schema.ts +++ b/src/json-crdt/__demos__/schema.ts @@ -3,7 +3,7 @@ /** * Run this demo with: * - * npx nodemon -q -x ts-node src/json-crdt/__demos__/schema.ts + * npx nodemon -q -x npx ts-node src/json-crdt/__demos__/schema.ts */ import {Model} from '..'; @@ -24,25 +24,25 @@ const model = Model.withLogicalClock(1234).setSchema(schema); console.log(model + ''); // Model -// ├─ RootNode 0.0 -// │ └─ ObjNode 1234.1 +// ├─ root 0.0 +// │ └─ obj 1234.1 // │ ├─ "text" -// │ │ └─ ConNode 1234.2 { "hello" } +// │ │ └─ con 1234.2 { "hello" } // │ ├─ "counter" -// │ │ └─ ConNode 1234.3 { 0 } +// │ │ └─ con 1234.3 { 0 } // │ ├─ "checked" -// │ │ └─ ConNode 1234.4 { true } +// │ │ └─ con 1234.4 { true } // │ └─ "friend" -// │ └─ ObjNode 1234.5 +// │ └─ obj 1234.5 // │ ├─ "name" -// │ │ └─ ConNode 1234.6 { "John" } +// │ │ └─ con 1234.6 { "John" } // │ ├─ "age" -// │ │ └─ ConNode 1234.7 { 42 } +// │ │ └─ con 1234.7 { 42 } // │ └─ "tags" -// │ └─ ArrNode 1234.8 +// │ └─ arr 1234.8 // │ └─ ArrChunk 1234.11!2 len:2 -// │ ├─ [0]: ConNode 1234.9 { "foo" } -// │ └─ [1]: ConNode 1234.10 { "bar" } +// │ ├─ [0]: con 1234.9 { "foo" } +// │ └─ [1]: con 1234.10 { "bar" } // │ // └─ VectorClock 1234.16 diff --git a/src/json-crdt/model/Model.ts b/src/json-crdt/model/Model.ts index 2889959ea8..8a5924ad35 100644 --- a/src/json-crdt/model/Model.ts +++ b/src/json-crdt/model/Model.ts @@ -23,6 +23,17 @@ export const UNDEFINED = new ConNode(ORIGIN, undefined); * i.e. model, of the JSON CRDT document. */ export class Model> implements Printable { + /** + * Generates a random session ID. Use this method to generate a session ID + * for a new user. Store the session ID in the user's browser or device once + * and reuse it for all editing sessions of that user. + * + * Generating a new session ID for each editing session will work, however, + * that is not recommended. If a user generates a new session ID for each + * editing session, the session clock table will grow indefinitely. + */ + public static readonly sid = randomSessionId; + /** * Create a CRDT model which uses logical clock. Logical clock assigns a * logical timestamp to every node and operation. Logical timestamp consists @@ -31,14 +42,12 @@ export class Model> implements Printable { * * @param clockOrSessionId Logical clock to use. * @returns CRDT model. + * + * @deprecated Use `Model.create()` instead. */ - public static withLogicalClock(clockOrSessionId?: clock.ClockVector | number): Model { - const clockVector = - typeof clockOrSessionId === 'number' - ? new clock.ClockVector(clockOrSessionId, 1) - : clockOrSessionId || new clock.ClockVector(randomSessionId(), 1); - return new Model(clockVector); - } + public static readonly withLogicalClock = (clockOrSessionId?: clock.ClockVector | number): Model => { + return Model.create(undefined, clockOrSessionId); + }; /** * Create a CRDT model which uses server clock. In this model a central server @@ -49,21 +58,126 @@ export class Model> implements Printable { * * @param time Latest known server sequence number. * @returns CRDT model. + * + * @deprecated Use `Model.create()` instead: `Model.create(undefined, SESSION.SERVER)`. */ - public static withServerClock(time: number = 0): Model { - const clockVector = new clock.ServerClockVector(SESSION.SERVER, time); - return new Model(clockVector); - } + public static readonly withServerClock = (time: number = 1): Model => { + return Model.create(undefined, new clock.ServerClockVector(SESSION.SERVER, time)); + }; + + /** + * Create a new JSON CRDT model. If a schema is provided, the model is + * strictly typed and the default value of the model is set to the default + * value of the schema. + * + * By default, the model is created with a random session ID and is using + * a logical clock. It is also possible to create a model which uses a server + * clock by providing the session ID `SESSION.SERVER` (1). + * + * ### Examples + * + * Create a basic model, without schema and default value: + * + * ```ts + * const model = Model.create(); + * ``` + * + * Create a strictly typed model with a schema and default value: + * + * ```ts + * const schema = s.obj({ + * ticker: s.con('BODEN'), + * name: s.str('Jeo Boden'), + * tags: s.arr( + * s.str('token'), + * ), + * }); + * const model = Model.create(schema); + * ``` + * + * Create a model with a custom session ID for your logical clock: + * + * ```ts + * const schema = s.str(''); + * const sid = 123456789; + * const model = Model.create(schema, sid); + * ``` + * + * The session ID must be at least 65,536 or higher, [see JSON CRDT Patch + * specification][json-crdt-patch]. + * + * [json-crdt-patch]: https://jsonjoy.com/specs/json-crdt-patch/patch-document/logical-clock + * + * To create a model with a server clock, use the `SESSION.SERVER`, which is + * equal to 1: + * + * ```ts + * const model = Model.create(undefined, SESSION.SERVER); + * // or + * const model = Model.create(undefined, 1); + * ``` + * + * Finally, you can create a model with your clock vector: + * + * ```ts + * const clock = new ClockVector(123456789, 1); + * const model = Model.create(undefined, clock); + * ``` + * + * @param schema The schema (typing and default value) to set for this model. + * @param sidOrClock Session ID to use for local operations. Defaults to a random + * session ID generated by {@link Model.sid}. + * @returns A strictly typed model. + */ + public static readonly create = ( + schema?: S, + sidOrClock: clock.ClockVector | number = Model.sid(), + ): Model> => { + const cl = + typeof sidOrClock === 'number' + ? sidOrClock === SESSION.SERVER + ? new clock.ServerClockVector(SESSION.SERVER, 1) + : new clock.ClockVector(sidOrClock, 1) + : sidOrClock; + const model = new Model>(cl); + if (schema) model.setSchema(schema, true); + return model; + }; /** - * Un-serializes a model from "binary" structural encoding. + * Decodes a model from a "binary" structural encoding. + * + * Use {@link Model.load} instead, if you want to set the session ID of the + * model and the right schema for the model, during the de-serialization. + * * @param data Binary blob of a model encoded using "binary" structural * encoding. * @returns An instance of a model. */ - public static fromBinary(data: Uint8Array): Model { + public static readonly fromBinary = (data: Uint8Array): Model => { return decoder.decode(data); - } + }; + + /** + * Un-serializes a model from "binary" structural encoding. The session ID of + * the model is set to the provided session ID `sid`, or the default session + * ID of the un-serialized model is used. + * + * @param data Binary blob of a model encoded using "binary" structural + * encoding. + * @param sid Session ID to set for the model. + * @returns An instance of a model. + */ + public static readonly load = ( + data: Uint8Array, + sid?: number, + schema?: S, + ): Model> => { + const model = decoder.decode(data) as unknown as Model>; + if (schema) model.setSchema(schema, true); + if (typeof sid === 'number') model.setSid(sid); + return model; + }; /** * Instantiates a model from a collection of patches. The patches are applied @@ -319,7 +433,7 @@ export class Model> implements Printable { * @param sessionId Session ID to use for the new model. * @returns A copy of this model with a new session ID. */ - public fork(sessionId: number = randomSessionId()): Model { + public fork(sessionId: number = Model.sid()): Model { const copy = Model.fromBinary(this.toBinary()) as unknown as Model; if (copy.clock.sid !== sessionId && copy.clock instanceof clock.ClockVector) copy.clock = copy.clock.fork(sessionId); @@ -409,14 +523,31 @@ export class Model> implements Printable { const oldSid = c.sid; if (useGlobalSession) c.sid = SESSION.GLOBAL; this.api.root(schema); - if (useGlobalSession) { - c.sid = oldSid; - c.observe(new clock.Timestamp(SESSION.GLOBAL, c.time - 1), 1); - } + if (useGlobalSession) this.setSid(oldSid); } return this; } + /** + * Changes the session ID of the model. By modifying the attached clock vector + * of the model. Be careful when changing the session ID of the model, as this + * is an advanced operation. + * + * Use the {@link Model.load} method to load a model with the the right session + * ID, instead of changing the session ID of the model. When in doubt, use the + * {@link Model.fork} method to create a new model with the right session ID. + * + * @param sid The new session ID to set for the model. + */ + public setSid(sid: number): void { + const cl = this.clock; + const oldSid = cl.sid; + if (oldSid !== sid) { + cl.sid = sid; + cl.observe(new clock.Timestamp(oldSid, cl.time - 1), 1); + } + } + // ---------------------------------------------------------------- Printable public toString(tab: string = ''): string {