diff --git a/src/json-crdt-extensions/peritext/Peritext.ts b/src/json-crdt-extensions/peritext/Peritext.ts index 8c2345f692..44e2fe2c60 100644 --- a/src/json-crdt-extensions/peritext/Peritext.ts +++ b/src/json-crdt-extensions/peritext/Peritext.ts @@ -9,7 +9,7 @@ import {LocalSlices} from './slice/LocalSlices'; import {Overlay} from './overlay/Overlay'; import {Chars} from './constants'; import {interval} from '../../json-crdt-patch/clock'; -import {Model} from '../../json-crdt/model'; +import {Model, StrApi} from '../../json-crdt/model'; import {CONST, updateNum} from '../../json-hash'; import {SESSION} from '../../json-crdt-patch/constants'; import {s} from '../../json-crdt-patch'; @@ -19,6 +19,7 @@ import type {Printable} from 'tree-dump/lib/types'; import type {MarkerSlice} from './slice/MarkerSlice'; import type {SliceSchema, SliceType} from './slice/types'; import type {SchemaToJsonNode} from '../../json-crdt/schema/types'; +import type {AbstractRga} from '../../json-crdt/nodes/rga'; const EXTRA_SLICES_SCHEMA = s.vec(s.arr([])); @@ -28,29 +29,29 @@ type SlicesModel = Model>; * Context for a Peritext instance. Contains all the data and methods needed to * interact with the text. */ -export class Peritext implements Printable { +export class Peritext implements Printable { /** * *Slices* are rich-text annotations that appear in the text. The "saved" * slices are the ones that are persisted in the document. */ - public readonly savedSlices: Slices; + public readonly savedSlices: Slices; /** * *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. */ - public readonly extraSlices: Slices; + public readonly extraSlices: Slices; /** * *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. */ - public readonly localSlices: Slices; + public readonly localSlices: Slices; - public readonly editor: Editor; - public readonly overlay = new Overlay(this); + public readonly editor: Editor; + public readonly overlay = new Overlay(this); /** * Creates a new Peritext context. @@ -67,27 +68,29 @@ export class Peritext implements Printable { */ constructor( public readonly model: Model, - public readonly str: StrNode, + // TODO: Rename `str` to `rga`. + public readonly str: AbstractRga, 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); - this.extraSlices = new ExtraSlices(extraSlicesModel, extraSlicesModel.root.node().get(0)!, this.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(localSlicesModel, localSlicesModel.root.node().get(0)!, this.str); - this.editor = new Editor(this, this.localSlices); + this.localSlices = new LocalSlices(this, localSlicesModel.root.node().get(0)!); + this.editor = new Editor(this); } - public strApi() { - return this.model.api.wrap(this.str); + public strApi(): 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. */ - public findCharBefore(point: Point): Range | undefined { + public findCharBefore(point: Point): Range | undefined { if (point.anchor === Anchor.After) { const chunk = point.chunk(); if (chunk && !chunk.del) return this.range(this.point(point.id, Anchor.Before), point); @@ -106,8 +109,8 @@ export class Peritext implements Printable { * @param anchor Whether the point should be before or after the character. * @returns The point. */ - public point(id: ITimestampStruct = this.str.id, anchor: Anchor = Anchor.After): Point { - return new Point(this.str, id, anchor); + public point(id: ITimestampStruct = this.str.id, anchor: Anchor = Anchor.After): Point { + return new Point(this.str as unknown as AbstractRga, id, anchor); } /** @@ -119,7 +122,7 @@ export class Peritext implements Printable { * Defaults to "before". * @returns The point. */ - public pointAt(pos: number, anchor: Anchor = Anchor.Before): Point { + public pointAt(pos: number, anchor: Anchor = Anchor.Before): Point { // 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; @@ -134,7 +137,7 @@ export class Peritext implements Printable { * * @returns A point at the start of the text. */ - public pointAbsStart(): Point { + public pointAbsStart(): Point { return this.point(this.str.id, Anchor.After); } @@ -144,10 +147,24 @@ export class Peritext implements Printable { * * @returns A point at the end of the text. */ - public pointAbsEnd(): Point { + public pointAbsEnd(): Point { return this.point(this.str.id, Anchor.Before); } + public pointStart(): Point | undefined { + if (!this.str.length()) return; + const point = this.pointAbsStart(); + point.refBefore(); + return point; + } + + public pointEnd(): Point | undefined { + if (!this.str.length()) return; + const point = this.pointAbsEnd(); + point.refAfter(); + return point; + } + // ------------------------------------------------------------------- ranges /** @@ -157,7 +174,7 @@ export class Peritext implements Printable { * @param p2 Point * @returns A range with points in correct order. */ - public rangeFromPoints(p1: Point, p2: Point): Range { + public rangeFromPoints(p1: Point, p2: Point): Range { return Range.from(this.str, p1, p2); } @@ -169,7 +186,7 @@ export class Peritext implements Printable { * @param end End point of the range, must be after or equal to start. * @returns A range with the given start and end points. */ - public range(start: Point, end: Point): Range { + public range(start: Point, end: Point): Range { return new Range(this.str, start, end); } @@ -181,10 +198,17 @@ export class Peritext implements Printable { * @param length Length of the range. * @returns A range from the given position with the given length. */ - public rangeAt(start: number, length: number = 0): Range { + public rangeAt(start: number, length: number = 0): Range { return Range.at(this.str, start, length); } + public rangeAll(): Range | undefined { + const start = this.pointStart(); + const end = this.pointEnd(); + if (!start || !end) return; + return this.range(start, end); + } + // --------------------------------------------------------------------- text /** @@ -216,28 +240,18 @@ export class Peritext implements Printable { // ------------------------------------------------------------------ markers + /** @deprecated Use the method in `Editor` and `Cursor` instead. */ public insMarker( after: ITimestampStruct, type: SliceType, data?: unknown, char: string = Chars.BlockSplitSentinel, - ): MarkerSlice { - const api = this.model.api; - const builder = api.builder; - const str = this.str; - /** - * We skip one clock cycle to prevent Block-wise RGA from merging adjacent - * characters. We want the marker chunk to always be its own distinct chunk. - */ - builder.nop(1); - const textId = builder.insStr(str.id, after, char[0]); - const point = this.point(textId, Anchor.Before); - const range = this.range(point, point); - return this.savedSlices.insMarker(range, type, data); + ): MarkerSlice { + return this.savedSlices.insMarkerAfter(after, type, data, char); } /** @todo This can probably use .del() */ - public delMarker(split: MarkerSlice): void { + public delMarker(split: MarkerSlice): void { const str = this.str; const api = this.model.api; const builder = api.builder; diff --git a/src/json-crdt-extensions/peritext/PeritextApi.ts b/src/json-crdt-extensions/peritext/PeritextApi.ts index a413a41272..397c18059c 100644 --- a/src/json-crdt-extensions/peritext/PeritextApi.ts +++ b/src/json-crdt-extensions/peritext/PeritextApi.ts @@ -1,9 +1,24 @@ import {NodeApi} from '../../json-crdt/model/api/nodes'; +import {Peritext} from './Peritext'; +import {printTree} from 'tree-dump/lib/printTree'; +import type {Editor} from './editor/Editor'; import type {PeritextNode} from './PeritextNode'; -import type {ExtApi, StrApi, ArrApi, ArrNode} from '../../json-crdt'; +import type {ExtApi, StrApi, ArrApi, ArrNode, ModelApi} from '../../json-crdt'; import type {SliceNode} from './slice/types'; export class PeritextApi extends NodeApi implements ExtApi { + public readonly txt: Peritext; + public readonly editor: Editor; + + constructor( + public node: PeritextNode, + public readonly api: ModelApi, + ) { + super(node, api); + this.txt = new Peritext(api.model, node.text(), node.slices()); + this.editor = this.txt.editor; + } + public text(): StrApi { return this.api.wrap(this.node.text()); } @@ -11,4 +26,11 @@ export class PeritextApi extends NodeApi implements ExtApi> { return this.api.wrap(this.node.slices()); } + + public toString(tab?: string): string { + return ( + this.constructor.name + + printTree(tab, [(tab) => this.node.toString(tab), () => '', (tab) => this.txt.toString(tab)]) + ); + } } diff --git a/src/json-crdt-extensions/peritext/__tests__/Peritext.localSlices.spec.ts b/src/json-crdt-extensions/peritext/__tests__/Peritext.localSlices.spec.ts index 91df0367b1..4ecb1a62e0 100644 --- a/src/json-crdt-extensions/peritext/__tests__/Peritext.localSlices.spec.ts +++ b/src/json-crdt-extensions/peritext/__tests__/Peritext.localSlices.spec.ts @@ -22,7 +22,7 @@ test('clears change history', () => { editor.cursor.setAt(1); editor.cursor.setAt(2); editor.cursor.setAt(3); - expect(peritext.localSlices.model.api.flush().ops.length).toBe(0); + expect(peritext.localSlices.set.doc.api.flush().ops.length).toBe(0); }); test('clears slice set tombstones', () => { @@ -30,7 +30,7 @@ test('clears slice set tombstones', () => { // It is probabilistic, if we set `Math.random` to 0 it will always remove tombstones. Math.random = () => 0; const {peritext} = setup(); - const slicesRga = peritext.localSlices.model.root.node()!.get(0)!; + const slicesRga = peritext.localSlices.set.doc.root.node()!.get(0)!; const count = slicesRga.size(); const slice1 = peritext.localSlices.insOverwrite(peritext.rangeAt(1, 2), 1); const slice2 = peritext.localSlices.insOverwrite(peritext.rangeAt(1, 2), 3); diff --git a/src/json-crdt-extensions/peritext/__tests__/extension.spec.ts b/src/json-crdt-extensions/peritext/__tests__/extension.spec.ts index e8b4f45dfb..2e65c89ceb 100644 --- a/src/json-crdt-extensions/peritext/__tests__/extension.spec.ts +++ b/src/json-crdt-extensions/peritext/__tests__/extension.spec.ts @@ -1,8 +1,10 @@ import {s} from '../../../json-crdt-patch'; import {ArrApi, StrApi, VecApi} from '../../../json-crdt/model'; import {ModelWithExt, ext} from '../../ModelWithExt'; +import {Peritext} from '../Peritext'; import {PeritextApi} from '../PeritextApi'; import {PeritextNode} from '../PeritextNode'; +import {Editor} from '../editor/Editor'; const schema = s.obj({ nested: s.obj({ @@ -12,68 +14,104 @@ const schema = s.obj({ }), }); -test('can access PeritextNode in type safe way (using the proxy selector)', () => { - const model = ModelWithExt.create(schema); - let api = model.s.nested.obj.text.ext(); - expect(api).toBeInstanceOf(PeritextApi); - api = new PeritextApi(api.node, api.api); -}); +describe('non-typed access', () => { + test('can access PeritextApi using path selector', () => { + const model = ModelWithExt.create(schema); + const api = model.api.vec(['nested', 'obj', 'text']); + expect(api).toBeInstanceOf(VecApi); + const api2 = api.asExt(); + expect(api2).toBeInstanceOf(PeritextApi); + model.api.str(['nested', 'obj', 'text', 1, 0]).ins(12, '!'); + expect(api.view()).toBe('Hello, world!\n'); + }); -test('can access raw text "str" node in type safe way', () => { - const model = ModelWithExt.create(schema); - const str = model.s.nested.obj.text.ext().text(); - expect(str).toBeInstanceOf(StrApi); - str.ins(str.length() - 1, '!'); - expect(model.view().nested.obj.text).toBe('Hello, world!\n'); -}); + test('can access PeritextApi using JSON Pointer path selector', () => { + const model = ModelWithExt.create(schema); + const api = model.api.vec('/nested/obj/text'); + expect(api).toBeInstanceOf(VecApi); + const api2 = api.asExt(); + expect(api2).toBeInstanceOf(PeritextApi); + model.api.str('/nested/obj/text/1/0').ins(12, '!'); + expect(api.view()).toBe('Hello, world!\n'); + }); -test('can access slices "arr" node in type safe way', () => { - const model = ModelWithExt.create(schema); - const arr = model.s.nested.obj.text.ext().slices(); - expect(arr).toBeInstanceOf(ArrApi); - expect(arr.view()).toEqual([]); -}); + test('can access PeritextApi using step-wise selector', () => { + const model = ModelWithExt.create(schema); + const api = model.api.in('nested').in('obj').in('text').asVec(); + expect(api).toBeInstanceOf(VecApi); + const api2 = api.asExt(); + expect(api2).toBeInstanceOf(PeritextApi); + model.api.in('nested').in('obj').in('text').in(1).in(0).asStr().ins(12, '!'); + model.api.in('/nested/obj/text/1/0').asStr().ins(12, '!'); + model.api.in(['nested', 'obj', 'text', 1, 0]).asStr().ins(12, '!'); + expect(api.view()).toBe('Hello, world!!!\n'); + }); -test('can access PeritextApi using path selector', () => { - const model = ModelWithExt.create(schema); - const api = model.api.vec(['nested', 'obj', 'text']); - expect(api).toBeInstanceOf(VecApi); - const api2 = api.ext(); - expect(api2).toBeInstanceOf(PeritextApi); - model.api.str(['nested', 'obj', 'text', 1, 0]).ins(12, '!'); - expect(api.view()).toBe('Hello, world!\n'); -}); + test('can access PeritextApi .asExt(peritext) with typing', () => { + const model = ModelWithExt.create(schema); + const api = model.api.in('nested').in('obj').in('text').asVec(); + expect(api).toBeInstanceOf(VecApi); + const api2 = model.api.in('nested').in('obj').in('text').asExt(ext.peritext); + expect(api2).toBeInstanceOf(PeritextApi); + }); -test('can access PeritextApi using JSON Pointer path selector', () => { - const model = ModelWithExt.create(schema); - const api = model.api.vec('/nested/obj/text'); - expect(api).toBeInstanceOf(VecApi); - const api2 = api.ext(); - expect(api2).toBeInstanceOf(PeritextApi); - model.api.str('/nested/obj/text/1/0').ins(12, '!'); - expect(api.view()).toBe('Hello, world!\n'); + test('can access PeritextApi .ext(peritext) with typing', () => { + const model = ModelWithExt.create(schema); + const api = model.api.in('nested').in('obj').in('text').asVec(); + expect(api).toBeInstanceOf(VecApi); + const api2 = api.asExt(ext.peritext); + expect(api2).toBeInstanceOf(PeritextApi); + }); }); -test('can access PeritextApi using step-wise selector', () => { - const model = ModelWithExt.create(schema); - const api = model.api.in('nested').in('obj').in('text').asVec(); - expect(api).toBeInstanceOf(VecApi); - const api2 = api.ext(); - expect(api2).toBeInstanceOf(PeritextApi); - model.api.in('nested').in('obj').in('text').in(1).in(0).asStr().ins(12, '!'); - model.api.in('/nested/obj/text/1/0').asStr().ins(12, '!'); - model.api.in(['nested', 'obj', 'text', 1, 0]).asStr().ins(12, '!'); - expect(api.view()).toBe('Hello, world!!!\n'); -}); +describe('typed access', () => { + test('can access PeritextNode in type safe way (using the proxy selector)', () => { + const model = ModelWithExt.create(schema); + let api = model.s.nested.obj.text.toExt(); + expect(api).toBeInstanceOf(PeritextApi); + api = new PeritextApi(api.node, api.api); + }); + + test('can access raw text "str" node in type safe way', () => { + const model = ModelWithExt.create(schema); + const str = model.s.nested.obj.text.toExt().text(); + expect(str).toBeInstanceOf(StrApi); + str.ins(str.length() - 1, '!'); + expect(model.view().nested.obj.text).toBe('Hello, world!\n'); + }); + + test('can access slices "arr" node in type safe way', () => { + const model = ModelWithExt.create(schema); + const arr = model.s.nested.obj.text.toExt().slices(); + expect(arr).toBeInstanceOf(ArrApi); + expect(arr.view()).toEqual([]); + }); + + test('can access PeritextApi using parent proxy selector', () => { + const model = ModelWithExt.create(schema); + const api = model.s.nested.obj.text.toApi(); + expect(api).toBeInstanceOf(VecApi); + let node = api.node.ext(); + expect(node).toBeInstanceOf(PeritextNode); + node = new PeritextNode(node.data); + let api2 = api.asExt()!; + expect(api2).toBeInstanceOf(PeritextApi); + api2 = new PeritextApi(node, api.api); + }); + + test('can access Peritext context and Editor', () => { + const model = ModelWithExt.create(schema); + const api = model.s.nested.obj.text.toExt(); + expect(api.txt).toBeInstanceOf(Peritext); + expect(api.editor).toBeInstanceOf(Editor); + }); -test('can access PeritextApi using parent proxy selector', () => { - const model = ModelWithExt.create(schema); - const api = model.s.nested.obj.text.toApi(); - expect(api).toBeInstanceOf(VecApi); - let node = api.node.ext(); - expect(node).toBeInstanceOf(PeritextNode); - node = new PeritextNode(node.data); - let api2 = api.ext()!; - expect(api2).toBeInstanceOf(PeritextApi); - api2 = new PeritextApi(node, api.api); + test('can modify Peritext document', () => { + const model = ModelWithExt.create(schema); + const api = model.s.nested.obj.text.toExt(); + expect(api.view()).toBe('Hello, world\n'); + api.editor.cursor.setAt(12); + api.editor.insert('!'); + expect(api.view()).toBe('Hello, world!\n'); + }); }); diff --git a/src/json-crdt-extensions/peritext/editor/Cursor.ts b/src/json-crdt-extensions/peritext/editor/Cursor.ts index d584e60493..997b23f725 100644 --- a/src/json-crdt-extensions/peritext/editor/Cursor.ts +++ b/src/json-crdt-extensions/peritext/editor/Cursor.ts @@ -1,3 +1,5 @@ +import {ITimestampStruct, tick} from '../../../json-crdt-patch'; +import {Anchor} from '../rga/constants'; import {Point} from '../rga/Point'; import {CursorAnchor} from '../slice/constants'; import {PersistedSlice} from '../slice/PersistedSlice'; @@ -53,6 +55,64 @@ export class Cursor extends PersistedSlice { this.set(start, end); } + /** + * Ensures there is no range selection. If user has selected a range, + * the contents is removed and the cursor is set at the start of the range as cursor. + * + * @todo If block boundaries are withing the range, remove the blocks. + * @todo Stress test this method. + * + * @returns Returns the cursor position after the operation. + */ + public collapse(): void { + const isCaret = this.isCollapsed(); + if (!isCaret) { + const {start, end} = this; + const delStartId = start.isAbsStart() + ? this.txt.point().refStart().id + : start.anchor === Anchor.Before + ? start.id + : start.nextId(); + const delEndId = end.isAbsEnd() + ? this.txt.point().refEnd().id + : end.anchor === Anchor.After + ? end.id + : end.prevId(); + if (!delStartId || !delEndId) throw new Error('INVALID_RANGE'); + const rga = this.rga; + const spans = rga.findInterval2(delStartId, delEndId); + const api = this.txt.model.api; + api.builder.del(rga.id, spans); + api.apply(); + if (start.anchor === Anchor.After) this.setAfter(start.id); + else this.setAfter(start.prevId() || rga.id); + } + } + + /** + * Insert inline text at current cursor position. If cursor selects a range, + * the range is removed and the text is inserted at the start of the range. + */ + public insert(text: string): void { + if (!text) return; + this.collapse(); + const after = this.start.clone(); + after.refAfter(); + const textId = this.txt.ins(after.id, text); + const shift = text.length - 1; + this.setAfter(shift ? tick(textId, shift) : textId); + } + + public delBwd(): void { + const isCollapsed = this.isCollapsed(); + if (isCollapsed) { + const range = this.txt.findCharBefore(this.start); + if (!range) return; + this.set(range.start, range.end); + } + this.collapse(); + } + // ---------------------------------------------------------------- Printable public toStringName(): string { diff --git a/src/json-crdt-extensions/peritext/editor/Editor.ts b/src/json-crdt-extensions/peritext/editor/Editor.ts index d20ceee1da..28cf469045 100644 --- a/src/json-crdt-extensions/peritext/editor/Editor.ts +++ b/src/json-crdt-extensions/peritext/editor/Editor.ts @@ -1,74 +1,56 @@ import {Cursor} from './Cursor'; -import {Anchor} from '../rga/constants'; import {CursorAnchor, SliceBehavior} from '../slice/constants'; -import {tick, type ITimestampStruct} from '../../../json-crdt-patch/clock'; import {PersistedSlice} from '../slice/PersistedSlice'; +import {EditorSlices} from './EditorSlices'; import {Chars} from '../constants'; -import type {Range} from '../rga/Range'; +import type {ITimestampStruct} from '../../../json-crdt-patch/clock'; import type {Peritext} from '../Peritext'; -import type {Printable} from 'tree-dump/lib/types'; -import type {Point} from '../rga/Point'; import type {SliceType} from '../slice/types'; import type {MarkerSlice} from '../slice/MarkerSlice'; -import type {Slices} from '../slice/Slices'; -/** - * @todo Rename to `PeritextApi`. - */ -export class Editor implements Printable { - /** - * Cursor is the the current user selection. It can be a caret or a - * range. If range is collapsed to a single point, it is a caret. - */ - public readonly cursor: Cursor; - - constructor( - public readonly txt: Peritext, - slices: Slices, - ) { - 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); - } +export class Editor { + public readonly saved: EditorSlices; - /** @deprecated */ - public setCursor(start: number, length: number = 0): void { - this.cursor.setAt(start, length); + constructor(public readonly txt: Peritext) { + this.saved = new EditorSlices(txt, txt.savedSlices); } - /** @deprecated */ - public getCursorText(): string { - return this.cursor.text(); + public firstCursor(): Cursor | undefined { + const iterator = this.txt.localSlices.iterator0(); + let cursor = iterator(); + while (cursor) { + if (cursor instanceof Cursor) return cursor; + cursor = iterator(); + } + return; } /** - * Ensures there is no range selection. If user has selected a range, - * the contents is removed and the cursor is set at the start of the range as cursor. - * - * @todo If block boundaries are withing the range, remove the blocks. + * Returns the first cursor in the text. If there is no cursor, creates one + * and inserts it at the start of the text. To work with multiple cursors, use + * `.cursors()` method. * - * @returns Returns the cursor position after the operation. + * Cursor is the the current user selection. It can be a caret or a range. If + * range is collapsed to a single point, it is a *caret*. */ - public collapseSelection(): ITimestampStruct { - const cursor = this.cursor; - const isCaret = cursor.isCollapsed(); - if (!isCaret) { - const {start, end} = cursor; - const txt = this.txt; - const deleteStartId = start.anchor === Anchor.Before ? start.id : start.nextId(); - const deleteEndId = end.anchor === Anchor.After ? end.id : end.prevId(); - const str = txt.str; - if (!deleteStartId || !deleteEndId) throw new Error('INVALID_RANGE'); - const range = str.findInterval2(deleteStartId, deleteEndId); - const model = txt.model; - const api = model.api; - api.builder.del(str.id, range); - api.apply(); - if (start.anchor === Anchor.After) cursor.setAfter(start.id); - else cursor.setAfter(start.prevId() || str.id); - } - return cursor.start.id; + public get cursor(): Cursor { + const maybeCursor = this.firstCursor(); + if (maybeCursor) return maybeCursor; + const txt = this.txt; + const cursor = txt.localSlices.ins, typeof Cursor>( + txt.rangeAt(0), + SliceBehavior.Cursor, + CursorAnchor.Start, + undefined, + Cursor, + ); + return cursor; + } + + public cursors(callback: (cursor: Cursor) => void): void { + this.txt.localSlices.forEach((slice) => { + if (slice instanceof Cursor) callback(slice); + }); } /** @@ -76,78 +58,42 @@ export class Editor implements Printable { * the range is removed and the text is inserted at the start of the range. */ public insert(text: string): void { - if (!text) return; - const after = this.collapseSelection(); - const textId = this.txt.ins(after, text); - const shift = text.length - 1; - this.cursor.setAfter(shift ? tick(textId, shift) : textId); + this.cursors((cursor) => cursor.insert(text)); } /** * Deletes the previous character at current cursor position. If cursor * selects a range, deletes the whole range. */ - public delete(): void { - const isCollapsed = this.cursor.isCollapsed(); - if (isCollapsed) { - const range = this.txt.findCharBefore(this.cursor.start); - if (!range) return; - this.cursor.set(range.start, range.end); - } - this.collapseSelection(); - } - - public start(): Point | undefined { - const txt = this.txt; - const str = txt.str; - if (!str.length()) return; - const firstChunk = str.first(); - if (!firstChunk) return; - const firstId = firstChunk.id; - const start = txt.point(firstId, Anchor.Before); - return start; + public delBwd(): void { + this.cursors((cursor) => cursor.delBwd()); } - public end(): Point | undefined { - const txt = this.txt; - const str = txt.str; - if (!str.length()) return; - const lastChunk = str.last(); - if (!lastChunk) return; - const lastId = lastChunk.span > 1 ? tick(lastChunk.id, lastChunk.span - 1) : lastChunk.id; - const end = txt.point(lastId, Anchor.After); - return end; + /** @todo Add main impl details of this to `Cursor`, but here ensure there is only one cursor. */ + public selectAll(): boolean { + const range = this.txt.rangeAll(); + if (!range) return false; + this.cursor.setRange(range); + return true; } - public all(): Range | undefined { - const start = this.start(); - const end = this.end(); - if (!start || !end) return; - return this.txt.range(start, end); - } - - public selectAll(): void { - const range = this.all(); - if (range) this.cursor.setRange(range); - } - - public insStackSlice(type: SliceType, data?: unknown | ITimestampStruct): PersistedSlice { + public insStackSlice(type: SliceType, data?: unknown | ITimestampStruct): PersistedSlice { const range = this.cursor.range(); return this.txt.savedSlices.ins(range, SliceBehavior.Stack, type, data); } - public insOverwriteSlice(type: SliceType, data?: unknown | ITimestampStruct): PersistedSlice { + public insOverwriteSlice(type: SliceType, data?: unknown | ITimestampStruct): PersistedSlice { const range = this.cursor.range(); return this.txt.savedSlices.ins(range, SliceBehavior.Overwrite, type, data); } - public insEraseSlice(type: SliceType, data?: unknown | ITimestampStruct): PersistedSlice { + public insEraseSlice(type: SliceType, data?: unknown | ITimestampStruct): PersistedSlice { const range = this.cursor.range(); return this.txt.savedSlices.ins(range, SliceBehavior.Erase, type, data); } - public insMarker(type: SliceType, data?: unknown): MarkerSlice { - const after = this.collapseSelection(); - return this.txt.insMarker(after, type, data, Chars.BlockSplitSentinel); + /** @deprecated */ + public insMarker(type: SliceType, data?: unknown): MarkerSlice { + return this.saved.insMarker(type, data, Chars.BlockSplitSentinel)[0]; } } diff --git a/src/json-crdt-extensions/peritext/editor/EditorSlices.ts b/src/json-crdt-extensions/peritext/editor/EditorSlices.ts new file mode 100644 index 0000000000..8066a377da --- /dev/null +++ b/src/json-crdt-extensions/peritext/editor/EditorSlices.ts @@ -0,0 +1,24 @@ +import type {Peritext} from '../Peritext'; +import type {SliceType} from '../slice/types'; +import type {MarkerSlice} from '../slice/MarkerSlice'; +import type {Slices} from '../slice/Slices'; + +export class EditorSlices { + constructor( + protected readonly txt: Peritext, + protected readonly slices: Slices, + ) {} + + public insMarker(type: SliceType, data?: unknown, separator?: string): MarkerSlice[] { + const {txt, slices} = this; + const markers: MarkerSlice[] = []; + txt.editor.cursors((cursor) => { + cursor.collapse(); + const after = cursor.start.clone(); + after.refAfter(); + const marker = slices.insMarkerAfter(after.id, type, data, separator); + markers.push(marker); + }); + return markers; + } +} diff --git a/src/json-crdt-extensions/peritext/overlay/MarkerOverlayPoint.ts b/src/json-crdt-extensions/peritext/overlay/MarkerOverlayPoint.ts index ab45554d0d..ff9db977a6 100644 --- a/src/json-crdt-extensions/peritext/overlay/MarkerOverlayPoint.ts +++ b/src/json-crdt-extensions/peritext/overlay/MarkerOverlayPoint.ts @@ -6,17 +6,17 @@ import type {AbstractRga} from '../../../json-crdt/nodes/rga'; import type {ITimestampStruct} from '../../../json-crdt-patch/clock'; import type {MarkerSlice} from '../slice/MarkerSlice'; -export class MarkerOverlayPoint extends OverlayPoint { +export class MarkerOverlayPoint extends OverlayPoint { /** * Hash value of the preceding text contents, up until the next marker. */ public textHash: number = 0; constructor( - protected readonly rga: AbstractRga, + protected readonly rga: AbstractRga, id: ITimestampStruct, anchor: Anchor, - public readonly marker: MarkerSlice, + public readonly marker: MarkerSlice, ) { super(rga, id, anchor); } diff --git a/src/json-crdt-extensions/peritext/overlay/Overlay.ts b/src/json-crdt-extensions/peritext/overlay/Overlay.ts index 9e73fe8f1c..2faf194c60 100644 --- a/src/json-crdt-extensions/peritext/overlay/Overlay.ts +++ b/src/json-crdt-extensions/peritext/overlay/Overlay.ts @@ -24,16 +24,16 @@ import type {Slices} from '../slice/Slices'; * 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; +export class Overlay implements Printable, Stateful { + public root: OverlayPoint | undefined = undefined; - constructor(protected readonly txt: Peritext) {} + constructor(protected readonly txt: Peritext) {} - public first(): OverlayPoint | undefined { + public first(): OverlayPoint | undefined { return this.root ? first(this.root) : undefined; } - public iterator(): () => OverlayPoint | undefined { + public iterator(): () => OverlayPoint | undefined { let curr = this.first(); return () => { const ret = curr; @@ -57,9 +57,9 @@ export class Overlay implements Printable, Stateful { /** * Retrieve overlay point or the previous one, measured in spacial dimension. */ - public getOrNextLower(point: Point): OverlayPoint | undefined { - let curr: OverlayPoint | undefined = this.root; - let result: OverlayPoint | undefined = undefined; + public getOrNextLower(point: Point): OverlayPoint | undefined { + let curr: OverlayPoint | undefined = this.root; + let result: OverlayPoint | undefined = undefined; while (curr) { const cmp = curr.cmpSpatial(point); if (cmp === 0) return curr; @@ -74,7 +74,7 @@ export class Overlay implements Printable, Stateful { return result; } - public find(predicate: (point: OverlayPoint) => boolean): OverlayPoint | undefined { + public find(predicate: (point: OverlayPoint) => boolean): OverlayPoint | undefined { let point = this.first(); while (point) { if (predicate(point)) return point; @@ -97,16 +97,16 @@ export class Overlay implements Printable, Stateful { return (this.hash = hash); } - public readonly slices = new Map(); + public readonly slices = new Map, [start: OverlayPoint, end: OverlayPoint]>(); - private refreshSlices(state: number, slices: Slices): number { + private refreshSlices(state: number, slices: Slices): number { const oldSlicesHash = slices.hash; const changed = oldSlicesHash !== slices.refresh(); const sliceSet = this.slices; state = updateNum(state, slices.hash); if (changed) { slices.forEach((slice) => { - let tuple: [start: OverlayPoint, end: OverlayPoint] | undefined = sliceSet.get(slice); + let tuple: [start: OverlayPoint, end: OverlayPoint] | undefined = sliceSet.get(slice); if (tuple) { if ((slice as any).isDel && (slice as any).isDel()) { this.delSlice(slice, tuple); @@ -132,11 +132,11 @@ export class Overlay implements Printable, Stateful { return state; } - private point(id: ITimestampStruct, anchor: Anchor): OverlayPoint { + private point(id: ITimestampStruct, anchor: Anchor): OverlayPoint { return new OverlayPoint(this.txt.str, id, anchor); } - private mPoint(marker: MarkerSlice, anchor: Anchor): MarkerOverlayPoint { + private mPoint(marker: MarkerSlice, anchor: Anchor): MarkerOverlayPoint { return new MarkerOverlayPoint(this.txt.str, marker.start.id, anchor, marker); } @@ -144,7 +144,7 @@ export class Overlay implements Printable, Stateful { * Retrieve an existing {@link OverlayPoint} or create a new one, inserted * in the tree, sorted by spatial dimension. */ - private upsertPoint(point: Point): [point: OverlayPoint, isNew: boolean] { + 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]; @@ -156,7 +156,7 @@ export class Overlay implements Printable, Stateful { * @param point Point to insert. * @returns Returns the existing point if it was already in the tree. */ - private insPoint(point: OverlayPoint): OverlayPoint | undefined { + private insPoint(point: OverlayPoint): OverlayPoint | undefined { let pivot = this.getOrNextLower(point); if (!pivot) pivot = first(this.root); if (!pivot) { @@ -172,11 +172,11 @@ export class Overlay implements Printable, Stateful { return undefined; } - private delPoint(point: OverlayPoint): void { + private delPoint(point: OverlayPoint): void { this.root = remove(this.root, point); } - private insMarker(slice: MarkerSlice): [start: OverlayPoint, end: OverlayPoint] { + private insMarker(slice: MarkerSlice): [start: OverlayPoint, end: OverlayPoint] { const point = this.mPoint(slice, Anchor.Before); const pivot = this.insPoint(point); if (!pivot) { @@ -187,7 +187,7 @@ export class Overlay implements Printable, Stateful { return [point, point]; } - private insSlice(slice: Slice): [start: OverlayPoint, end: OverlayPoint] { + private insSlice(slice: Slice): [start: OverlayPoint, end: OverlayPoint] { if (slice instanceof MarkerSlice) return this.insMarker(slice); const txt = this.txt; const str = txt.str; @@ -217,7 +217,7 @@ export class Overlay implements Printable, Stateful { if (beforeEndPoint) end.layers.push(...beforeEndPoint.layers); } const isCollapsed = startPoint.cmp(endPoint) === 0; - let curr: OverlayPoint | undefined = start; + let curr: OverlayPoint | undefined = start; while (curr !== end && curr) { curr.addLayer(slice); curr = next(curr); @@ -229,9 +229,9 @@ export class Overlay implements Printable, Stateful { return [start, end]; } - private delSlice(slice: Slice, [start, end]: [start: OverlayPoint, end: OverlayPoint]): void { + private delSlice(slice: Slice, [start, end]: [start: OverlayPoint, end: OverlayPoint]): void { this.slices.delete(slice); - let curr: OverlayPoint | undefined = start; + let curr: OverlayPoint | undefined = start; do { curr.removeLayer(slice); curr.removeMarker(slice); @@ -246,7 +246,7 @@ export class Overlay implements Printable, Stateful { // ---------------------------------------------------------------- Printable public toString(tab: string = ''): string { - const printPoint = (tab: string, point: OverlayPoint): string => { + const printPoint = (tab: string, point: OverlayPoint): string => { return ( point.toString(tab) + printBinary(tab, [ diff --git a/src/json-crdt-extensions/peritext/overlay/OverlayPoint.ts b/src/json-crdt-extensions/peritext/overlay/OverlayPoint.ts index e0d8092c4f..6ace500f22 100644 --- a/src/json-crdt-extensions/peritext/overlay/OverlayPoint.ts +++ b/src/json-crdt-extensions/peritext/overlay/OverlayPoint.ts @@ -12,7 +12,7 @@ import type {Slice} from '../slice/types'; * sparse locations in the string of the places where annotation slices start, * end, or are broken down by other intersecting slices. */ -export class OverlayPoint extends Point implements Printable, HeadlessNode { +export class OverlayPoint extends Point implements Printable, HeadlessNode { /** * Hash of text contents until the next {@link OverlayPoint}. This field is * modified by the {@link Overlay} tree. @@ -26,7 +26,7 @@ export class OverlayPoint extends Point implements Printable, HeadlessNode { * one. A *layer* is a part of a slice from the current point to the next one. * This interval can contain many layers, as the slices can be overlap. */ - public readonly layers: Slice[] = []; + public readonly layers: Slice[] = []; /** * Inserts a slice to the list of layers which contains the area from this @@ -36,7 +36,7 @@ export class OverlayPoint extends Point implements Printable, HeadlessNode { * * @param slice Slice to add to the layer list. */ - public addLayer(slice: Slice): void { + public addLayer(slice: Slice): void { const layers = this.layers; const length = layers.length; if (!length) { @@ -69,7 +69,7 @@ export class OverlayPoint extends Point implements Printable, HeadlessNode { * * @param slice Slice to remove from the layer list. */ - public removeLayer(slice: Slice): void { + public removeLayer(slice: Slice): void { const layers = this.layers; const length = layers.length; for (let i = 0; i < length; i++) { @@ -87,7 +87,7 @@ export class OverlayPoint extends Point implements Printable, HeadlessNode { * in the text, even if the start and end of the slice are different. * @deprecated This field might happen to be not necessary. */ - public readonly markers: Slice[] = []; + public readonly markers: Slice[] = []; /** * Inserts a slice to the list of markers which represent a single point in @@ -98,7 +98,7 @@ export class OverlayPoint extends Point implements Printable, HeadlessNode { * @param slice Slice to add to the marker list. * @deprecated This method might happen to be not necessary. */ - public addMarker(slice: Slice): void { + public addMarker(slice: Slice): void { /** @deprecated */ const markers = this.markers; const length = markers.length; @@ -133,7 +133,7 @@ export class OverlayPoint extends Point implements Printable, HeadlessNode { * @param slice Slice to remove from the marker list. * @deprecated This method might happen to be not necessary. */ - public removeMarker(slice: Slice): void { + public removeMarker(slice: Slice): void { /** @deprecated */ const markers = this.markers; const length = markers.length; @@ -150,14 +150,14 @@ export class OverlayPoint extends Point implements Printable, HeadlessNode { /** * Sorted list of all references to rich-text constructs. */ - public readonly refs: OverlayRef[] = []; + public readonly refs: OverlayRef[] = []; /** * Insert a reference to a marker. * * @param slice A marker (split slice). */ - public addMarkerRef(slice: MarkerSlice): void { + public addMarkerRef(slice: MarkerSlice): void { this.refs.push(slice); this.addMarker(slice); } @@ -167,8 +167,8 @@ export class OverlayPoint extends Point implements Printable, HeadlessNode { * * @param slice A slice that starts at this point. */ - public addLayerStartRef(slice: Slice): void { - this.refs.push(new OverlayRefSliceStart(slice)); + public addLayerStartRef(slice: Slice): void { + this.refs.push(new OverlayRefSliceStart(slice)); this.addLayer(slice); } @@ -177,7 +177,7 @@ export class OverlayPoint extends Point implements Printable, HeadlessNode { * * @param slice A slice that ends at this point. */ - public addLayerEndRef(slice: Slice): void { + public addLayerEndRef(slice: Slice): void { this.refs.push(new OverlayRefSliceEnd(slice)); } @@ -187,7 +187,7 @@ export class OverlayPoint extends Point implements Printable, HeadlessNode { * * @param slice A slice to remove the reference to. */ - public removeRef(slice: Slice): void { + public removeRef(slice: Slice): void { const refs = this.refs; const length = refs.length; for (let i = 0; i < length; i++) { @@ -229,7 +229,7 @@ export class OverlayPoint extends Point implements Printable, HeadlessNode { // ------------------------------------------------------------- HeadlessNode - public p: OverlayPoint | undefined = undefined; - public l: OverlayPoint | undefined = undefined; - public r: OverlayPoint | undefined = undefined; + public p: OverlayPoint | undefined = undefined; + public l: OverlayPoint | undefined = undefined; + public r: OverlayPoint | undefined = undefined; } diff --git a/src/json-crdt-extensions/peritext/overlay/refs.ts b/src/json-crdt-extensions/peritext/overlay/refs.ts index 831f1e5677..45748b540c 100644 --- a/src/json-crdt-extensions/peritext/overlay/refs.ts +++ b/src/json-crdt-extensions/peritext/overlay/refs.ts @@ -7,15 +7,15 @@ import type {Slice} from '../slice/types'; * a regular annotation slice, two references are needed: one to the start slice * and one to the end slice. */ -export type OverlayRef = - | MarkerSlice // Ref to a *marker* - | OverlayRefSliceStart // Ref to the start of an annotation slice - | OverlayRefSliceEnd; // Ref to the end of an annotation slice +export type OverlayRef = + | MarkerSlice // Ref to a *marker* + | OverlayRefSliceStart // Ref to the start of an annotation slice + | OverlayRefSliceEnd; // Ref to the end of an annotation slice -export class OverlayRefSliceStart { - constructor(public readonly slice: Slice) {} +export class OverlayRefSliceStart { + constructor(public readonly slice: Slice) {} } -export class OverlayRefSliceEnd { - constructor(public readonly slice: Slice) {} +export class OverlayRefSliceEnd { + constructor(public readonly slice: Slice) {} } diff --git a/src/json-crdt-extensions/peritext/rga/Point.ts b/src/json-crdt-extensions/peritext/rga/Point.ts index 3b68ffdf36..ed74960797 100644 --- a/src/json-crdt-extensions/peritext/rga/Point.ts +++ b/src/json-crdt-extensions/peritext/rga/Point.ts @@ -336,17 +336,19 @@ export class Point implements Pick, Printable { /** * Sets the point to the relative start of the string. */ - public refStart(): void { + public refStart(): this { this.refAbsStart(); this.refBefore(); + return this; } /** * Sets the point to the relative end of the string. */ - public refEnd(): void { + public refEnd(): this { this.refAbsEnd(); this.refAfter(); + return this; } /** diff --git a/src/json-crdt-extensions/peritext/rga/__tests__/Range.spec.ts b/src/json-crdt-extensions/peritext/rga/__tests__/Range.spec.ts index 0cddf96b07..ca3437311e 100644 --- a/src/json-crdt-extensions/peritext/rga/__tests__/Range.spec.ts +++ b/src/json-crdt-extensions/peritext/rga/__tests__/Range.spec.ts @@ -105,7 +105,12 @@ describe('.at()', () => { for (let j = 1; j <= length - i; j++) { const range = peritext.rangeAt(i, j); expect(range.length()).toBe(j); - expect(range.text()).toBe(peritext.str.view().slice(i, i + j)); + expect(range.text()).toBe( + peritext + .strApi() + .view() + .slice(i, i + j), + ); expect(range.start.anchor).toBe(Anchor.Before); expect(range.end.anchor).toBe(Anchor.After); } @@ -152,7 +157,12 @@ describe('.at()', () => { for (let j = 1; j <= i; j++) { const range = peritext.rangeAt(i, -j); expect(range.length()).toBe(j); - expect(range.text()).toBe(peritext.str.view().slice(i - j, i)); + expect(range.text()).toBe( + peritext + .strApi() + .view() + .slice(i - j, i), + ); expect(range.start.anchor).toBe(Anchor.Before); expect(range.end.anchor).toBe(Anchor.After); } @@ -321,9 +331,9 @@ describe('.view()', () => { describe('.contains()', () => { test('returns true if slice is contained', () => { const {peritext} = setup(); - peritext.editor.setCursor(3, 2); + peritext.editor.cursor.setAt(3, 2); const slice = peritext.editor.insOverwriteSlice('b'); - peritext.editor.setCursor(0); + peritext.editor.cursor.setAt(0); peritext.refresh(); expect(peritext.rangeAt(2, 4).contains(slice)).toBe(true); expect(peritext.rangeAt(3, 4).contains(slice)).toBe(true); @@ -333,9 +343,9 @@ describe('.contains()', () => { test('returns false if slice is not contained', () => { const {peritext} = setup(); - peritext.editor.setCursor(3, 2); + peritext.editor.cursor.setAt(3, 2); const slice = peritext.editor.insOverwriteSlice('b'); - peritext.editor.setCursor(0); + peritext.editor.cursor.setAt(0); peritext.refresh(); expect(peritext.rangeAt(3, 1).contains(slice)).toBe(false); expect(peritext.rangeAt(2, 1).contains(slice)).toBe(false); @@ -364,15 +374,15 @@ describe('.containsPoint()', () => { describe('.isCollapsed()', () => { test('returns true when endpoints point to the same location', () => { const {peritext} = setup(); - peritext.editor.setCursor(3); + peritext.editor.cursor.setAt(3); expect(peritext.editor.cursor.isCollapsed()).toBe(true); }); test('returns true when when there is no visible content between endpoints', () => { const {peritext} = setup(); const range = peritext.rangeAt(2, 1); - peritext.editor.setCursor(2, 1); - peritext.editor.delete(); + peritext.editor.cursor.setAt(2, 1); + peritext.editor.delBwd(); expect(range.isCollapsed()).toBe(true); }); }); @@ -382,7 +392,7 @@ describe('.expand()', () => { test('can expand anchors to include adjacent elements', () => { const {peritext} = setup2(); const editor = peritext.editor; - editor.setCursor(1, 1); + editor.cursor.setAt(1, 1); expect(editor.cursor.start.pos()).toBe(1); expect(editor.cursor.start.anchor).toBe(Anchor.Before); expect(editor.cursor.end.pos()).toBe(1); @@ -392,7 +402,6 @@ describe('.expand()', () => { expect(editor.cursor.start.anchor).toBe(Anchor.After); expect(editor.cursor.end.pos()).toBe(2); expect(editor.cursor.end.anchor).toBe(Anchor.Before); - // console.log(peritext + '') }); test('can expand anchors to contain include adjacent tombstones', () => { @@ -402,9 +411,9 @@ describe('.expand()', () => { const tombstone2 = peritext.rangeAt(3, 1); tombstone2.expand(); peritext.editor.cursor.setRange(tombstone1); - peritext.editor.delete(); + peritext.editor.delBwd(); peritext.editor.cursor.setRange(tombstone2); - peritext.editor.delete(); + peritext.editor.delBwd(); const range = peritext.rangeAt(1, 1); range.expand(); expect(range.start.pos()).toBe(tombstone1.start.pos()); @@ -423,27 +432,27 @@ describe('.expand()', () => { setup((peritext) => { const editor = peritext.editor; editor.insert('!'); - editor.setCursor(0); + editor.cursor.setAt(0); editor.insert('d'); - editor.setCursor(0); + editor.cursor.setAt(0); editor.insert('l'); - editor.setCursor(0); + editor.cursor.setAt(0); editor.insert('r'); - editor.setCursor(0); + editor.cursor.setAt(0); editor.insert('o'); - editor.setCursor(0); + editor.cursor.setAt(0); editor.insert('w'); - editor.setCursor(0); + editor.cursor.setAt(0); editor.insert(' '); - editor.setCursor(0); + editor.cursor.setAt(0); editor.insert('o'); - editor.setCursor(0); + editor.cursor.setAt(0); editor.insert('l'); - editor.setCursor(0); + editor.cursor.setAt(0); editor.insert('l'); - editor.setCursor(0); + editor.cursor.setAt(0); editor.insert('e'); - editor.setCursor(0); + editor.cursor.setAt(0); editor.insert('H'); }), ); diff --git a/src/json-crdt-extensions/peritext/slice/ExtraSlices.ts b/src/json-crdt-extensions/peritext/slice/ExtraSlices.ts index 4b4c5abc33..a7de7144d2 100644 --- a/src/json-crdt-extensions/peritext/slice/ExtraSlices.ts +++ b/src/json-crdt-extensions/peritext/slice/ExtraSlices.ts @@ -1,3 +1,3 @@ import {Slices} from './Slices'; -export class ExtraSlices extends Slices {} +export class ExtraSlices extends Slices {} diff --git a/src/json-crdt-extensions/peritext/slice/LocalSlices.ts b/src/json-crdt-extensions/peritext/slice/LocalSlices.ts index fbb1eb2148..9a374b6078 100644 --- a/src/json-crdt-extensions/peritext/slice/LocalSlices.ts +++ b/src/json-crdt-extensions/peritext/slice/LocalSlices.ts @@ -1,7 +1,7 @@ import {Slices} from './Slices'; import type {ITimestampStruct} from '../../../json-crdt-patch'; -export class LocalSlices extends Slices { +export class LocalSlices extends Slices { public del(id: ITimestampStruct): void { super.del(id); if (Math.random() < 0.1) this.set.removeTombstones(); diff --git a/src/json-crdt-extensions/peritext/slice/MarkerSlice.ts b/src/json-crdt-extensions/peritext/slice/MarkerSlice.ts index 00deaf25f5..df35a19dd6 100644 --- a/src/json-crdt-extensions/peritext/slice/MarkerSlice.ts +++ b/src/json-crdt-extensions/peritext/slice/MarkerSlice.ts @@ -6,4 +6,4 @@ import {PersistedSlice} from './PersistedSlice'; * * @deprecated */ -export class MarkerSlice extends PersistedSlice {} +export class MarkerSlice extends PersistedSlice {} diff --git a/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts b/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts index 24f5fead95..81e0864ace 100644 --- a/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts +++ b/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts @@ -18,6 +18,7 @@ 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'; +import type {Peritext} from '../Peritext'; /** * A persisted slice is a slice that is stored in a {@link Model}. It is used for @@ -26,7 +27,7 @@ import type {Model} from '../../../json-crdt/model'; * @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 { + public static deserialize(model: Model, txt: Peritext, chunk: ArrChunk, tuple: VecNode): PersistedSlice { 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; @@ -38,17 +39,21 @@ export class PersistedSlice extends Range implements MutableSlice const anchor1: Anchor = (header & SliceHeaderMask.X1Anchor) >>> SliceHeaderShift.X1Anchor; const anchor2: Anchor = (header & SliceHeaderMask.X2Anchor) >>> SliceHeaderShift.X2Anchor; const behavior: SliceBehavior = (header & SliceHeaderMask.Behavior) >>> SliceHeaderShift.Behavior; + const rga = txt.str as unknown as AbstractRga; const p1 = new Point(rga, id1, anchor1); const p2 = new Point(rga, id2, anchor2); - const slice = new PersistedSlice(model, rga, chunk, tuple, behavior, type, p1, p2); + const slice = new PersistedSlice(model, txt, chunk, tuple, behavior, type, p1, p2); return slice; } + /** @todo Use API node here. */ + protected readonly rga: AbstractRga; + constructor( - /** The Peritext context. */ + /** The `Model` where the slice is stored. */ protected readonly model: Model, - /** The text RGA. */ - protected readonly rga: AbstractRga, + /** The Peritext context. */ + protected readonly txt: Peritext, /** The `arr` chunk of `arr` where the slice is stored. */ protected readonly chunk: ArrChunk, /** The `vec` node which stores the serialized contents of this slice. */ @@ -58,7 +63,8 @@ export class PersistedSlice extends Range implements MutableSlice public start: Point, public end: Point, ) { - super(rga, start, end); + super(txt.str as unknown as AbstractRga, start, end); + this.rga = txt.str as unknown as AbstractRga; this.id = chunk.id; this.behavior = behavior; this.type = type; @@ -147,7 +153,7 @@ export class PersistedSlice extends Range implements MutableSlice this.hash = state; if (changed) { const tuple = this.tuple; - const slice = PersistedSlice.deserialize(this.model, this.rga, this.chunk, tuple); + const slice = PersistedSlice.deserialize(this.model, this.txt, this.chunk, tuple); this.behavior = slice.behavior; this.type = slice.type; this.start = slice.start; diff --git a/src/json-crdt-extensions/peritext/slice/Slices.ts b/src/json-crdt-extensions/peritext/slice/Slices.ts index 4997c73cb2..42f4ecab9d 100644 --- a/src/json-crdt-extensions/peritext/slice/Slices.ts +++ b/src/json-crdt-extensions/peritext/slice/Slices.ts @@ -13,32 +13,36 @@ import type {ITimespanStruct, ITimestampStruct} from '../../../json-crdt-patch/c 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'; import type {AbstractRga} from '../../../json-crdt/nodes/rga'; +import type {Peritext} from '../Peritext'; +import {Chars} from '../constants'; +import {Anchor} from '../rga/constants'; -export class Slices implements Stateful, Printable { - private list = new AvlMap(compare); +export class Slices implements Stateful, Printable { + private list = new AvlMap>(compare); + + protected readonly rga: AbstractRga; constructor( - /** The model, which powers the CRDT nodes. */ - public readonly model: Model, + /** The text RGA. */ + protected readonly txt: Peritext, /** The `arr` node, used as a set, where slices are stored. */ public readonly set: ArrNode, - /** The text RGA. */ - protected readonly rga: AbstractRga, - ) {} + ) { + this.rga = txt.str as unknown as AbstractRga; + } public ins< - S extends PersistedSlice, - K extends new (...args: ConstructorParameters>) => S, + S extends PersistedSlice, + K extends new (...args: ConstructorParameters>) => S, >( - range: Range, + range: Range, behavior: SliceBehavior, type: SliceType, data?: unknown, Klass: K = behavior === SliceBehavior.Marker ? MarkerSlice : PersistedSlice, ): S { - const model = this.model; + const model = this.set.doc; const set = this.set; const api = model.api; const builder = api.builder; @@ -67,53 +71,76 @@ export class Slices implements Stateful, Printable { const tuple = model.index.get(tupleId) as VecNode; const chunk = set.findById(chunkId)!; // TODO: Need to check if split slice text was deleted - const slice = new Klass(model, this.rga, chunk, tuple, behavior, type, start, end); + const slice = new Klass(model, this.txt, chunk, tuple, behavior, type, start, end); this.list.set(chunk.id, slice); return slice; } - public insMarker(range: Range, type: SliceType, data?: unknown): MarkerSlice { - return this.ins(range, SliceBehavior.Marker, type, data) as MarkerSlice; + public insMarker(range: Range, type: SliceType, data?: unknown): MarkerSlice { + return this.ins(range, SliceBehavior.Marker, type, data) as MarkerSlice; + } + + public insMarkerAfter( + after: ITimestampStruct, + type: SliceType, + data?: unknown, + separator: string = Chars.BlockSplitSentinel, + ): MarkerSlice { + // TODO: test condition when cursors is at absolute or relative starts + const {txt, set} = this; + const model = set.doc; + const api = model.api; + const builder = api.builder; + const str = txt.str; + /** + * We skip one clock cycle to prevent Block-wise RGA from merging adjacent + * characters. We want the marker chunk to always be its own distinct chunk. + */ + builder.nop(1); + const textId = builder.insStr(str.id, after, separator); + const point = txt.point(textId, Anchor.Before); + const range = txt.range(point, point.clone()); + return this.insMarker(range, type, data); } - public insStack(range: Range, type: SliceType, data?: unknown): PersistedSlice { + public insStack(range: Range, type: SliceType, data?: unknown): PersistedSlice { return this.ins(range, SliceBehavior.Stack, type, data); } - public insOverwrite(range: Range, type: SliceType, data?: unknown): PersistedSlice { + public insOverwrite(range: Range, type: SliceType, data?: unknown): PersistedSlice { return this.ins(range, SliceBehavior.Overwrite, type, data); } - public insErase(range: Range, type: SliceType, data?: unknown): PersistedSlice { + public insErase(range: Range, type: SliceType, data?: unknown): PersistedSlice { return this.ins(range, SliceBehavior.Erase, type, data); } - protected unpack(chunk: ArrChunk): PersistedSlice { - const rga = this.rga; - const model = this.model; + protected unpack(chunk: ArrChunk): PersistedSlice { + const txt = this.txt; + const model = this.set.doc; const tupleId = chunk.data ? chunk.data[0] : undefined; if (!tupleId) throw new Error('SLICE_NOT_FOUND'); const tuple = model.index.get(tupleId); if (!(tuple instanceof VecNode)) throw new Error('NOT_TUPLE'); - let slice = PersistedSlice.deserialize(model, rga, chunk, tuple); + let slice = PersistedSlice.deserialize(model, txt, chunk, tuple); if (slice.isSplit()) - slice = new MarkerSlice(model, rga, chunk, tuple, slice.behavior, slice.type, slice.start, slice.end); + slice = new MarkerSlice(model, txt, chunk, tuple, slice.behavior, slice.type, slice.start, slice.end); return slice; } - public get(id: ITimestampStruct): PersistedSlice | undefined { + public get(id: ITimestampStruct): PersistedSlice | undefined { return this.list.get(id); } public del(id: ITimestampStruct): void { this.list.del(id); - const api = this.model.api; + const api = this.set.doc.api; api.builder.del(this.set.id, [tss(id.sid, id.time, 1)]); api.apply(); } public delSlices(slices: Slice[]): void { - const api = this.model.api; + const api = this.set.doc.api; const spans: ITimespanStruct[] = []; const length = slices.length; for (let i = 0; i < length; i++) { @@ -131,7 +158,12 @@ export class Slices implements Stateful, Printable { return this.list._size; } - public forEach(callback: (item: Slice) => void): void { + public iterator0(): () => Slice | undefined { + const iterator = this.list.iterator0(); + return () => iterator()?.v; + } + + public forEach(callback: (item: Slice) => void): void { this.list.forEach((node) => callback(node.v)); } diff --git a/src/json-crdt/model/api/nodes.ts b/src/json-crdt/model/api/nodes.ts index 9a2374483f..f64dbb22ed 100644 --- a/src/json-crdt/model/api/nodes.ts +++ b/src/json-crdt/model/api/nodes.ts @@ -122,6 +122,10 @@ export class NodeApi implements Printable { * @param ext Extension of the node * @returns API of the extension */ + public asExt(): JsonNodeApi> | ExtApi | undefined; + public asExt, EApi extends ExtApi>( + ext: Extension, + ): EApi; public asExt, EApi extends ExtApi>( ext?: Extension, ): EApi { @@ -291,13 +295,6 @@ export class VecApi = VecNode> extends NodeApi { return this.node.elements.length; } - public ext(): JsonNodeApi> | undefined { - const node = this.node.ext(); - if (!node) return node; - const api = this.api.wrap(node); - return api; - } - /** * Returns a proxy object for this node. Allows to access vector elements by * index. @@ -309,7 +306,7 @@ export class VecApi = VecNode> extends NodeApi { get: (target, prop, receiver) => { if (prop === 'toApi') return () => this; if (prop === 'toView') return () => this.view(); - if (prop === 'ext') return () => this.ext(); + 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); diff --git a/src/json-crdt/model/api/proxy.ts b/src/json-crdt/model/api/proxy.ts index 3fc18212a5..5ad782a37d 100644 --- a/src/json-crdt/model/api/proxy.ts +++ b/src/json-crdt/model/api/proxy.ts @@ -15,7 +15,7 @@ export type ProxyNodeVal> = ProxyNode & { export type ProxyNodeVec> = ProxyNode & { [K in keyof nodes.JsonNodeView]: JsonNodeToProxyNode[K]>; } & { - ext: () => JsonNodeApi>; + toExt: () => JsonNodeApi>; }; export type ProxyNodeObj> = ProxyNode & { [K in keyof nodes.JsonNodeView]: JsonNodeToProxyNode<(N extends nodes.ObjNode ? M : never)[K]>; diff --git a/src/json-crdt/nodes/vec/__tests__/extension.spec.ts b/src/json-crdt/nodes/vec/__tests__/extension.spec.ts index 72de56a819..ebb36db026 100644 --- a/src/json-crdt/nodes/vec/__tests__/extension.spec.ts +++ b/src/json-crdt/nodes/vec/__tests__/extension.spec.ts @@ -34,7 +34,7 @@ test('can read view from node or API node', () => { model.api.root({ mv: mval.new('foo'), }); - const api = model.api.in('mv').asExt(); + const api = model.api.in('mv').asExt()!; expect(api.view()).toEqual(['foo']); expect(api.node.view()).toEqual(['foo']); });