diff --git a/src/json-crdt-extensions/peritext/Peritext.ts b/src/json-crdt-extensions/peritext/Peritext.ts index 62ae4b7927..367087501d 100644 --- a/src/json-crdt-extensions/peritext/Peritext.ts +++ b/src/json-crdt-extensions/peritext/Peritext.ts @@ -1,14 +1,19 @@ -import {Anchor} from './constants'; +import {Anchor, SliceBehavior} from './constants'; import {Point} from './point/Point'; import {Range} from './slice/Range'; import {Editor} from './editor/Editor'; import {printTree} from '../../util/print/printTree'; import {ArrNode, StrNode} from '../../json-crdt/nodes'; +import {Slices} from './slice/Slices'; import {type ITimestampStruct} from '../../json-crdt-patch/clock'; import type {Model} from '../../json-crdt/model'; import type {Printable} from '../../util/print/types'; +import type {SliceType} from './types'; +import type {PersistedSlice} from './slice/PersistedSlice'; +import {CONST} from '../../json-hash'; export class Peritext implements Printable { + public readonly slices: Slices; public readonly editor: Editor; constructor( @@ -16,6 +21,7 @@ export class Peritext implements Printable { public readonly str: StrNode, slices: ArrNode, ) { + this.slices = new Slices(this, slices); this.editor = new Editor(this); } @@ -69,6 +75,22 @@ export class Peritext implements Printable { return textId; } + public insSlice( + range: Range, + behavior: SliceBehavior, + type: SliceType, + data?: unknown | ITimestampStruct, + ): PersistedSlice { + // if (range.isCollapsed()) throw new Error('INVALID_RANGE'); + // TODO: If range is not collapsed, check if there are any visible characters in the range. + const slice = this.slices.ins(range, behavior, type, data); + return slice; + } + + public delSlice(sliceId: ITimestampStruct): void { + this.slices.del(sliceId); + } + /** Select a single character before a point. */ public findCharBefore(point: Point): Range | undefined { if (point.anchor === Anchor.After) { @@ -84,6 +106,23 @@ export class Peritext implements Printable { public toString(tab: string = ''): string { const nl = () => ''; - return this.constructor.name + printTree(tab, [(tab) => this.str.toString(tab)]); + return ( + this.constructor.name + + printTree(tab, [ + (tab) => this.editor.cursor.toString(tab), + nl, + (tab) => this.str.toString(tab), + nl, + (tab) => this.slices.toString(tab), + ]) + ); + } + + // ----------------------------------------------------------------- Stateful + + public hash: number = 0; + + public refresh(): number { + return this.slices.refresh(); } } diff --git a/src/json-crdt-extensions/peritext/editor/Editor.ts b/src/json-crdt-extensions/peritext/editor/Editor.ts index 2f6efc7a0c..5ee1f1bcb5 100644 --- a/src/json-crdt-extensions/peritext/editor/Editor.ts +++ b/src/json-crdt-extensions/peritext/editor/Editor.ts @@ -1,10 +1,12 @@ import {Cursor} from '../slice/Cursor'; -import {Anchor} from '../constants'; +import {Anchor, SliceBehavior} from '../constants'; import {tick, type ITimestampStruct} from '../../../json-crdt-patch/clock'; +import {PersistedSlice} from '../slice/PersistedSlice'; import type {Range} from '../slice/Range'; import type {Peritext} from '../Peritext'; import type {Printable} from '../../../util/print/types'; import type {Point} from '../point/Point'; +import type {SliceType} from '../types'; export class Editor implements Printable { /** @@ -116,4 +118,8 @@ export class Editor implements Printable { const range = this.all(); if (range) this.cursor.setRange(range); } + + public insertSlice(type: SliceType, data?: unknown | ITimestampStruct): PersistedSlice { + return this.txt.insSlice(this.cursor, SliceBehavior.Stack, type, data); + } } diff --git a/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts b/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts new file mode 100644 index 0000000000..1daeff34a7 --- /dev/null +++ b/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts @@ -0,0 +1,84 @@ +import {Point} from '../point/Point'; +import {Range} from './Range'; +import {hashNode} from '../../../json-crdt/hash'; +import {printTree} from '../../../util/print/printTree'; +import {Anchor, SliceHeaderMask, SliceHeaderShift, SliceBehavior} from '../constants'; +import {ArrChunk} from '../../../json-crdt/nodes'; +import {type ITimestampStruct, Timestamp} from '../../../json-crdt-patch/clock'; +import type {Slice} from './types'; +import type {Peritext} from '../Peritext'; +import type {SliceDto, SliceType, Stateful} from '../types'; +import type {Printable} from '../../../util/print/types'; +import type {JsonNode, VecNode} from '../../../json-crdt/nodes'; + +export class PersistedSlice extends Range implements Slice, Printable, Stateful { + public readonly id: ITimestampStruct; + + constructor( + protected readonly txt: Peritext, + protected readonly chunk: ArrChunk, + public readonly tuple: VecNode, + public behavior: SliceBehavior, + /** @todo Rename to x1? */ + public start: Point, + /** @todo Rename to x2? */ + public end: Point, + public type: SliceType, + ) { + super(txt, start, end); + this.id = this.chunk.id; + } + + protected tagNode(): JsonNode | undefined { + // TODO: Normalize `.get()` and `.getNode()` methods across VecNode and ArrNode. + return this.tuple.get(3); + } + + public data(): unknown | undefined { + return this.tuple.get(4)?.view(); + } + + public del(): boolean { + return this.chunk.del; + } + + // ---------------------------------------------------------------- Printable + + public toString(tab: string = ''): string { + const tagNode = this.tagNode(); + const range = `${this.start.toString('', true)} ↔ ${this.end.toString('', true)}`; + const header = `${this.constructor.name} ${range}`; + return header + printTree(tab, [!tagNode ? null : (tab) => tagNode.toString(tab)]); + } + + // ----------------------------------------------------------------- Stateful + + public hash: number = 0; + + public refresh(): number { + const hash = hashNode(this.tuple); + const changed = hash !== this.hash; + this.hash = hash; + if (changed) { + const tuple = this.tuple; + const header = +(tuple.get(0)!.view() as SliceDto[0]); + const anchor1: Anchor = (header & SliceHeaderMask.X1Anchor) >>> SliceHeaderShift.X1Anchor; + const anchor2: Anchor = (header & SliceHeaderMask.X2Anchor) >>> SliceHeaderShift.X2Anchor; + const type: SliceBehavior = (header & SliceHeaderMask.Behavior) >>> SliceHeaderShift.Behavior; + const id1 = tuple.get(1)!.view() as ITimestampStruct; + const id2 = (tuple.get(2)!.view() || id1) as ITimestampStruct; + if (!(id1 instanceof Timestamp)) throw new Error('INVALID_ID'); + if (!(id2 instanceof Timestamp)) throw new Error('INVALID_ID'); + const subtype = tuple.get(3)!.view() as SliceType; + this.behavior = type; + this.type = subtype; + const x1 = this.start; + const x2 = this.end; + x1.id = id1; + x1.anchor = anchor1; + x2.id = id2; + x2.anchor = anchor2; + } + return this.hash; + } +} diff --git a/src/json-crdt-extensions/peritext/slice/Slices.ts b/src/json-crdt-extensions/peritext/slice/Slices.ts new file mode 100644 index 0000000000..9dbd9d60df --- /dev/null +++ b/src/json-crdt-extensions/peritext/slice/Slices.ts @@ -0,0 +1,156 @@ +import {PersistedSlice} from './PersistedSlice'; +import {ITimespanStruct, ITimestampStruct, Timespan, Timestamp, compare, tss} from '../../../json-crdt-patch/clock'; +import {Range} from './Range'; +import {updateRga} from '../../../json-crdt/hash'; +import {CONST, updateNum} from '../../../json-hash'; +import {printTree} from '../../../util/print/printTree'; +import {Anchor, SliceBehavior, SliceHeaderMask, SliceHeaderShift} from '../constants'; +import {SplitSlice} from './SplitSlice'; +import {Point} from '../point/Point'; +import {Slice} from './types'; +import {VecNode} from '../../../json-crdt/nodes'; +import type {SliceDto, SliceType, Stateful} from '../types'; +import type {Peritext} from '../Peritext'; +import type {Printable} from '../../../util/print/types'; +import type {ArrChunk, ArrNode} from '../../../json-crdt/nodes'; + +export class Slices implements Stateful, Printable { + private list = new Map(); + + constructor(public readonly txt: Peritext, public readonly set: ArrNode) {} + + public ins(range: Range, behavior: SliceBehavior, type: SliceType, data?: unknown): PersistedSlice { + const peritext = this.txt; + const model = peritext.model; + const set = this.set; + const api = model.api; + const builder = api.builder; + const tupleId = builder.vec(); + const start = range.start; + const end = range.end; + const header = + (behavior << SliceHeaderShift.Behavior) + + (start.anchor << SliceHeaderShift.X1Anchor) + + (end.anchor << SliceHeaderShift.X2Anchor); + const headerId = builder.const(header); + const x1Id = builder.const(start.id); + const x2Id = builder.const(compare(start.id, end.id) === 0 ? 0 : end.id); + const subtypeId = builder.const(type); + const tupleKeysUpdate: [key: number, value: ITimestampStruct][] = [ + [0, headerId], + [1, x1Id], + [2, x2Id], + [3, subtypeId], + ]; + if (data !== undefined) tupleKeysUpdate.push([4, builder.json(data)]); + builder.insVec(tupleId, tupleKeysUpdate); + const chunkId = builder.insArr(set.id, set.id, [tupleId]); + api.apply(); + 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 = + behavior === SliceBehavior.Split + ? new SplitSlice(this.txt, chunk, tuple, behavior, start, end, type) + : new PersistedSlice(this.txt, chunk, tuple, behavior, start, end, type); + this.list.set(chunk, slice); + return slice; + } + + protected unpack(chunk: ArrChunk): PersistedSlice { + const txt = this.txt; + const model = txt.model; + const tupleId = chunk.data ? chunk.data[0] : undefined; + if (!tupleId) throw new Error('MARKER_NOT_FOUND'); + const tuple = model.index.get(tupleId); + if (!(tuple instanceof VecNode)) throw new Error('NOT_TUPLE'); + const header = +(tuple.get(0)!.view() as SliceDto[0]); + 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 id1 = tuple.get(1)!.view() as ITimestampStruct; + const id2 = (tuple.get(2)!.view() || id1) as ITimestampStruct; + if (!(id1 instanceof Timestamp)) throw new Error('INVALID_ID'); + if (!(id2 instanceof Timestamp)) throw new Error('INVALID_ID'); + const p1 = new Point(txt, id1, anchor1); + const p2 = new Point(txt, id2, anchor2); + const type = tuple.get(3)!.view() as SliceType; + const slice = + behavior === SliceBehavior.Split + ? new SplitSlice(this.txt, chunk, tuple, behavior, p1, p2, type) + : new PersistedSlice(this.txt, chunk, tuple, behavior, p1, p2, type); + return slice; + } + + public del(id: ITimestampStruct): void { + const api = this.txt.model.api; + api.builder.del(this.set.id, [tss(id.sid, id.time, 1)]); + api.apply(); + } + + public delMany(slices: Slice[]): void { + const api = this.txt.model.api; + const spans: ITimespanStruct[] = []; + const length = slices.length; + for (let i = 0; i < length; i++) { + const slice = slices[i]; + if (slice instanceof PersistedSlice) { + const id = slice.id; + spans.push(new Timespan(id.sid, id.time, 1)); + } + } + api.builder.del(this.set.id, spans); + api.apply(); + } + + public size(): number { + return this.list.size; + } + + public forEach(callback: (item: PersistedSlice) => void): void { + this.list.forEach(callback); + } + + // ----------------------------------------------------------------- Stateful + + private _topologyHash: number = 0; + public hash: number = 0; + + public refresh(): number { + const topologyHash = updateRga(CONST.START_STATE, this.set); + if (topologyHash !== this._topologyHash) { + this._topologyHash = topologyHash; + let chunk: ArrChunk | undefined; + for (const iterator = this.set.iterator(); (chunk = iterator()); ) { + const item = this.list.get(chunk); + if (chunk.del) { + if (item) this.list.delete(chunk); + } else { + if (!item) this.list.set(chunk, this.unpack(chunk)); + } + } + } + let hash: number = topologyHash; + this.list.forEach((item) => { + item.refresh(); + hash = updateNum(hash, item.hash); + }); + return (this.hash = hash); + } + + // ---------------------------------------------------------------- Printable + + public toString(tab: string = ''): string { + return ( + this.constructor.name + + printTree( + tab, + [...this.list].map( + ([, slice]) => + (tab) => + slice.toString(tab), + ), + ) + ); + } +} diff --git a/src/json-crdt-extensions/peritext/slice/SplitSlice.ts b/src/json-crdt-extensions/peritext/slice/SplitSlice.ts new file mode 100644 index 0000000000..9c3b1ac3b7 --- /dev/null +++ b/src/json-crdt-extensions/peritext/slice/SplitSlice.ts @@ -0,0 +1,3 @@ +import {PersistedSlice} from './PersistedSlice'; + +export class SplitSlice extends PersistedSlice {} diff --git a/src/json-crdt-extensions/peritext/slice/__tests__/Slices.spec.ts b/src/json-crdt-extensions/peritext/slice/__tests__/Slices.spec.ts new file mode 100644 index 0000000000..83da192c01 --- /dev/null +++ b/src/json-crdt-extensions/peritext/slice/__tests__/Slices.spec.ts @@ -0,0 +1,119 @@ +import {Model} from '../../../../json-crdt/model'; +import {Peritext} from '../../Peritext'; + +const setup = () => { + const model = Model.withLogicalClock(); + model.api.root({ + text: '', + slices: [], + }); + model.api.str(['text']).ins(0, 'wworld'); + model.api.str(['text']).ins(0, 'helo '); + model.api.str(['text']).ins(2, 'l'); + model.api.str(['text']).del(7, 1); + model.api.str(['text']).ins(11, ' this game is awesome'); + const peritext = new Peritext(model, model.api.str(['text']).node, model.api.arr(['slices']).node); + return {model, peritext}; +}; + +test('initially slice list is empty', () => { + const {peritext} = setup(); + expect(peritext.slices.size()).toBe(0); + peritext.refresh(); + expect(peritext.slices.size()).toBe(0); +}); + +describe('inserts', () => { + test('can insert a slice', () => { + const {peritext} = setup(); + const {editor} = peritext; + editor.setCursor(12, 7); + const slice = editor.insertSlice('b', {bold: true}); + peritext.refresh(); + expect(peritext.slices.size()).toBe(1); + expect(slice.start).toStrictEqual(editor.cursor.start); + expect(slice.end).toStrictEqual(editor.cursor.end); + expect(slice.data()).toStrictEqual({bold: true}); + }); + + test('can insert two slices', () => { + const {peritext} = setup(); + const {editor} = peritext; + editor.setCursor(6, 5); + const slice1 = editor.insertSlice('strong', {bold: true}); + editor.setCursor(12, 4); + const slice2 = editor.insertSlice('i', {italic: true}); + peritext.refresh(); + expect(peritext.slices.size()).toBe(2); + expect(slice1.data()).toStrictEqual({bold: true}); + expect(slice2.data()).toStrictEqual({italic: true}); + }); + + test('updates hash on slice insert', () => { + const {peritext} = setup(); + const {editor} = peritext; + const changed1 = peritext.slices.hash !== peritext.slices.refresh(); + const hash1 = peritext.slices.hash; + const changed2 = peritext.slices.hash !== peritext.slices.refresh(); + const hash2 = peritext.slices.hash; + expect(changed1).toBe(true); + expect(changed2).toBe(false); + expect(hash1).toBe(hash2); + editor.setCursor(12, 7); + editor.insertSlice('b', {bold: true}); + const changed3 = peritext.slices.hash !== peritext.slices.refresh(); + const hash3 = peritext.slices.hash; + const changed4 = peritext.slices.hash !== peritext.slices.refresh(); + const hash4 = peritext.slices.hash; + expect(changed3).toBe(true); + expect(changed4).toBe(false); + expect(hash1).not.toStrictEqual(hash3); + expect(hash3).toBe(hash4); + editor.setCursor(12, 4); + editor.insertSlice('em', {italic: true}); + const changed5 = peritext.slices.hash !== peritext.slices.refresh(); + const hash5 = peritext.slices.hash; + const changed6 = peritext.slices.hash !== peritext.slices.refresh(); + const hash6 = peritext.slices.hash; + expect(changed5).toBe(true); + expect(changed6).toBe(false); + expect(hash3).not.toBe(hash5); + expect(hash5).toBe(hash6); + }); +}); + +describe('deletes', () => { + test('can delete a slice', () => { + const {peritext} = setup(); + const {editor} = peritext; + editor.setCursor(6, 5); + const slice1 = editor.insertSlice('b', {bold: true}); + peritext.refresh(); + const hash1 = peritext.slices.hash; + expect(peritext.slices.size()).toBe(1); + peritext.delSlice(slice1.id); + peritext.refresh(); + const hash2 = peritext.slices.hash; + expect(peritext.slices.size()).toBe(0); + expect(hash1).not.toBe(hash2); + }); +}); + +describe('tag changes', () => { + test('recomputes hash on tag change', () => { + const {peritext} = setup(); + const {editor} = peritext; + editor.setCursor(6, 5); + const slice1 = editor.insertSlice('b', {bold: true}); + peritext.refresh(); + const hash1 = peritext.slices.hash; + const tag = slice1.data()!; + peritext.model.api.obj(['slices', 0, 4]).set({bold: false}); + peritext.refresh(); + const hash2 = peritext.slices.hash; + peritext.refresh(); + const hash3 = peritext.slices.hash; + expect(hash1).not.toBe(hash2); + expect(hash2).toBe(hash3); + }); +});