From 9aed4e10a00bf97face4e7cfd4ba01fb3ca3843b Mon Sep 17 00:00:00 2001 From: streamich Date: Mon, 15 Apr 2024 09:45:45 +0200 Subject: [PATCH 1/8] =?UTF-8?q?feat(json-crdt-extensions):=20=F0=9F=8E=B8?= =?UTF-8?q?=20implement=20Range=20class?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-extensions/peritext/Peritext.ts | 19 ++ .../peritext/slice/Range.ts | 185 ++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 src/json-crdt-extensions/peritext/slice/Range.ts diff --git a/src/json-crdt-extensions/peritext/Peritext.ts b/src/json-crdt-extensions/peritext/Peritext.ts index 71bf7b4215..0fbeb24777 100644 --- a/src/json-crdt-extensions/peritext/Peritext.ts +++ b/src/json-crdt-extensions/peritext/Peritext.ts @@ -1,5 +1,6 @@ import {Anchor} from './constants'; import {Point} from './point/Point'; +import {Range} from './slice/Range'; import {printTree} from '../../util/print/printTree'; import {ArrNode, StrNode} from '../../json-crdt/nodes'; import {type ITimestampStruct} from '../../json-crdt-patch/clock'; @@ -32,6 +33,24 @@ export class Peritext implements Printable { return this.point(this.str.id, Anchor.Before); } + public range(start: Point, end: Point): Range { + return new Range(this, start, end); + } + + public rangeAt(start: number, length: number = 0): Range { + const str = this.str; + if (!length) { + const startId = !start ? str.id : str.find(start - 1) || str.id; + const point = this.point(startId, Anchor.After); + return this.range(point, point); + } + const startId = str.find(start) || str.id; + const endId = str.find(start + length - 1) || startId; + const startEndpoint = this.point(startId, Anchor.Before); + const endEndpoint = this.point(endId, Anchor.After); + return this.range(startEndpoint, endEndpoint); + } + // ---------------------------------------------------------------- Printable public toString(tab: string = ''): string { diff --git a/src/json-crdt-extensions/peritext/slice/Range.ts b/src/json-crdt-extensions/peritext/slice/Range.ts new file mode 100644 index 0000000000..89c802cfaf --- /dev/null +++ b/src/json-crdt-extensions/peritext/slice/Range.ts @@ -0,0 +1,185 @@ +import {Point} from '../point/Point'; +import {Anchor} from '../constants'; +import {StringChunk} from '../util/types'; +import {type ITimestampStruct, tick} from '../../../json-crdt-patch/clock'; +import type {Peritext} from '../Peritext'; +import type {Printable} from '../../../util/print/types'; + +export class Range implements Printable { + public static from(txt: Peritext, p1: Point, p2: Point) { + return p1.compareSpatial(p2) > 0 ? new Range(txt, p2, p1) : new Range(txt, p1, p2); + } + + constructor(protected readonly txt: Peritext, public start: Point, public end: Point) {} + + public clone(): Range { + return new Range(this.txt, this.start.clone(), this.end.clone()); + } + + public isCollapsed(): boolean { + const start = this.start; + const end = this.end; + if (start === end) return true; + const pos1 = start.pos(); + const pos2 = end.pos(); + if (pos1 === pos2) { + if (start.anchor === end.anchor) return true; + // TODO: inspect below cases, if they are needed + if (start.anchor === Anchor.After) return true; + else { + const chunk = start.chunk(); + if (chunk && chunk.del) { + this.start = this.end.clone(); + return true; + } + } + } + return false; + } + + public collapseToStart(): void { + this.start = this.start.clone() + this.start.refAfter(); + this.end = this.start.clone(); + } + + public collapseToEnd(): void { + this.end = this.end.clone(); + this.end.refAfter(); + this.start = this.end.clone(); + } + + public viewRange(): [at: number, len: number] { + const start = this.start.viewPos(); + const end = this.end.viewPos(); + return [start, end - start]; + } + + public set(start: Point, end: Point = start): void { + this.start = start; + this.end = end === start ? end.clone() : end; + } + + public setRange(range: Range): void { + this.set(range.start, range.end); + } + + public setAt(start: number, length: number = 0): void { + const range = this.txt.rangeAt(start, length); + this.setRange(range); + } + + /** @todo Can this be moved to Cursor? */ + public setCaret(after: ITimestampStruct, shift: number = 0): void { + const id = shift ? tick(after, shift) : after; + const caretAfter = new Point(this.txt, id, Anchor.After); + this.set(caretAfter); + } + + public contains(range: Range): boolean { + return this.start.compareSpatial(range.start) <= 0 && this.end.compareSpatial(range.end) >= 0; + } + + public containsPoint(range: Point): boolean { + return this.start.compareSpatial(range) <= 0 && this.end.compareSpatial(range) >= 0; + } + + /** + * Expand range left and right to contain all invisible space: (1) tombstones, + * (2) anchors of non-deleted adjacent chunks. + */ + public expand(): void { + this.expandStart(); + this.expandEnd(); + } + + public expandStart(): void { + const start = this.start; + const str = this.txt.str; + let chunk = start.chunk(); + if (!chunk) return; + if (!chunk.del) { + if (start.anchor === Anchor.After) return; + const pointIsStartOfChunk = start.id.time === chunk.id.time; + if (!pointIsStartOfChunk) { + start.id = tick(start.id, -1); + start.anchor = Anchor.After; + return; + } + } + while (chunk) { + const prev = str.prev(chunk); + if (!prev) { + start.id = chunk.id; + start.anchor = Anchor.Before; + break; + } else { + if (prev.del) { + chunk = prev; + continue; + } else { + start.id = prev.span > 1 ? tick(prev.id, prev.span - 1) : prev.id; + start.anchor = Anchor.After; + break; + } + } + } + } + + public expandEnd(): void { + const end = this.end; + const str = this.txt.str; + let chunk = end.chunk(); + if (!chunk) return; + if (!chunk.del) { + if (end.anchor === Anchor.Before) return; + const pointIsEndOfChunk = end.id.time === chunk.id.time + chunk.span - 1; + if (!pointIsEndOfChunk) { + end.id = tick(end.id, 1); + end.anchor = Anchor.Before; + return; + } + } + while (chunk) { + const next = str.next(chunk); + if (!next) { + end.id = chunk.span > 1 ? tick(chunk.id, chunk.span - 1) : chunk.id; + end.anchor = Anchor.After; + break; + } else { + if (next.del) { + chunk = next; + continue; + } else { + end.id = next.id; + end.anchor = Anchor.Before; + break; + } + } + } + } + + public text(): string { + const isCaret = this.isCollapsed(); + if (isCaret) return ''; + const {start, end} = this; + const str = this.txt.str; + const startId = start.anchor === Anchor.Before ? start.id : start.nextId(); + const endId = end.anchor === Anchor.After ? end.id : end.prevId(); + if (!startId || !endId) return ''; + let result = ''; + str.range0(undefined, startId, endId, (chunk: StringChunk, from: number, length: number) => { + if (chunk.data) result += chunk.data.slice(from, from + length); + }); + return result; + } + + // ---------------------------------------------------------------- Printable + + public toString(tab: string = '', lite: boolean = true): string { + const name = lite ? '' : `${this.constructor.name} `; + const start = this.start.toString(tab, lite); + const end = this.end.toString(tab, lite); + return `${name}${start} ↔ ${end}`; + } +} From defb88425f2797de62e892b47558ac27bd05bbf8 Mon Sep 17 00:00:00 2001 From: streamich Date: Mon, 15 Apr 2024 09:55:30 +0200 Subject: [PATCH 2/8] =?UTF-8?q?feat(json-crdt-extensions):=20=F0=9F=8E=B8?= =?UTF-8?q?=20add=20Slice=20type?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-extensions/peritext/slice/types.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 src/json-crdt-extensions/peritext/slice/types.ts diff --git a/src/json-crdt-extensions/peritext/slice/types.ts b/src/json-crdt-extensions/peritext/slice/types.ts new file mode 100644 index 0000000000..04ef95a9e9 --- /dev/null +++ b/src/json-crdt-extensions/peritext/slice/types.ts @@ -0,0 +1,13 @@ +import type {Range} from './Range'; +import type {SliceType, Stateful} from '../types'; +import type {ITimestampStruct} from '../../../json-crdt-patch/clock'; +import type {SliceBehavior} from '../constants'; + +export interface Slice extends Range, Stateful { + /** ID used for layer sorting. */ + id: ITimestampStruct; + behavior: SliceBehavior; + type: SliceType; + data(): unknown; + del(): boolean; +} From 2c6c9929daa55476c3a61c15d886e809e77e0904 Mon Sep 17 00:00:00 2001 From: streamich Date: Mon, 15 Apr 2024 09:56:39 +0200 Subject: [PATCH 3/8] =?UTF-8?q?feat(json-crdt-extensions):=20=F0=9F=8E=B8?= =?UTF-8?q?=20add=20Cursor=20tag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-extensions/peritext/constants.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/json-crdt-extensions/peritext/constants.ts b/src/json-crdt-extensions/peritext/constants.ts index dbb27c7074..f91ba633f6 100644 --- a/src/json-crdt-extensions/peritext/constants.ts +++ b/src/json-crdt-extensions/peritext/constants.ts @@ -3,6 +3,10 @@ export const enum Anchor { After = 1, } +export const enum Tags { + Cursor = 0, +} + export const enum SliceHeaderMask { X1Anchor = 0b1, X2Anchor = 0b10, From 48747ab4704f777e49007163de14791fa54678b9 Mon Sep 17 00:00:00 2001 From: streamich Date: Mon, 15 Apr 2024 09:58:06 +0200 Subject: [PATCH 4/8] =?UTF-8?q?feat(json-crdt-extensions):=20=F0=9F=8E=B8?= =?UTF-8?q?=20add=20initial=20Cursor=20implementation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../peritext/slice/Cursor.ts | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 src/json-crdt-extensions/peritext/slice/Cursor.ts diff --git a/src/json-crdt-extensions/peritext/slice/Cursor.ts b/src/json-crdt-extensions/peritext/slice/Cursor.ts new file mode 100644 index 0000000000..50755700c4 --- /dev/null +++ b/src/json-crdt-extensions/peritext/slice/Cursor.ts @@ -0,0 +1,109 @@ +import {Point} from '../point/Point'; +import {Anchor, SliceBehavior, Tags} from '../constants'; +import {Range} from './Range'; +import {printTree} from '../../../util/print/printTree'; +import type {ITimestampStruct} from '../../../json-crdt-patch/clock'; +import type {Peritext} from '../Peritext'; +import type {Slice} from './types'; + +export class Cursor extends Range implements Slice { + public readonly behavior = SliceBehavior.Overwrite; + public readonly type = Tags.Cursor; + + /** + * Specifies whether the start or the end of the cursor is the "anchor", e.g. + * the end which does not move when user changes selection. The other + * end is free to move, the moving end of the cursor is "focus". By default + * "anchor" is the start of the cursor. + */ + public base: Anchor = Anchor.Before; + + constructor( + public readonly id: ITimestampStruct, + protected readonly txt: Peritext, + public start: Point, + public end: Point, + ) { + super(txt, start, end); + } + + public anchor(): Point { + return this.base === Anchor.Before ? this.start : this.end; + } + + public focus(): Point { + return this.base === Anchor.Before ? this.end : this.start; + } + + public set(start: Point, end?: Point, anchor: Anchor = Anchor.Before): void { + if (!end || end === start) end = start.clone(); + super.set(start, end); + this.base = anchor; + } + + public setAt(start: number, length: number = 0): void { + let at = start; + let len = length; + if (len < 0) { + at += len; + len = -len; + } + super.setAt(at, len); + this.base = length < 0 ? Anchor.After : Anchor.Before; + } + + /** + * 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." + */ + public setEdge(point: Point, edge: 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; + else anchor = point; + if (focus.compareSpatial(anchor) < 0) { + this.base = Anchor.After; + this.start = focus; + this.end = anchor; + } else { + this.base = Anchor.Before; + this.start = anchor; + this.end = focus; + } + } + + /** @deprecated What is this method for? */ + public del(): boolean { + return false; + } + + public data(): unknown { + return 1; + } + + public move(move: number): void { + const {start, end} = this; + start.move(move); + if (start === end) return; + end.move(move); + } + + public toString(tab: string = ''): string { + const text = JSON.stringify(this.text()); + const focusIcon = this.base === Anchor.Before ? '.⇨|' : '|⇦.'; + const main = `${this.constructor.name} ${super.toString(tab + ' ', true)} ${focusIcon}`; + return main + printTree(tab, [() => text]); + } + + // ----------------------------------------------------------------- Stateful + + public hash: number = 0; + + public refresh(): number { + // TODO: implement this ... + return this.hash; + } +} From ed43ad83ff9ecaf7c88fe819d3fd440b6b69fb6b Mon Sep 17 00:00:00 2001 From: streamich Date: Mon, 15 Apr 2024 10:07:34 +0200 Subject: [PATCH 5/8] =?UTF-8?q?feat(json-crdt-extensions):=20=F0=9F=8E=B8?= =?UTF-8?q?=20implement=20basic=20editor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-extensions/constants.ts | 4 + src/json-crdt-extensions/peritext/Peritext.ts | 24 ++++ .../peritext/editor/Editor.ts | 119 ++++++++++++++++++ 3 files changed, 147 insertions(+) create mode 100644 src/json-crdt-extensions/peritext/editor/Editor.ts diff --git a/src/json-crdt-extensions/constants.ts b/src/json-crdt-extensions/constants.ts index 2364d76b94..fd071bca51 100644 --- a/src/json-crdt-extensions/constants.ts +++ b/src/json-crdt-extensions/constants.ts @@ -4,3 +4,7 @@ export const enum ExtensionId { peritext = 2, quill = 3, } + +export const enum Chars { + BlockSplitSentinel = '\n', +} diff --git a/src/json-crdt-extensions/peritext/Peritext.ts b/src/json-crdt-extensions/peritext/Peritext.ts index 0fbeb24777..4507214f18 100644 --- a/src/json-crdt-extensions/peritext/Peritext.ts +++ b/src/json-crdt-extensions/peritext/Peritext.ts @@ -51,6 +51,30 @@ export class Peritext implements Printable { return this.range(startEndpoint, endEndpoint); } + public insAt(pos: number, text: string): void { + const str = this.model.api.wrap(this.str); + str.ins(pos, text); + } + + public ins(after: ITimestampStruct, text: string): ITimestampStruct { + if (!text) throw new Error('NO_TEXT'); + const api = this.model.api; + const textId = api.builder.insStr(this.str.id, after, text); + api.apply(); + return textId; + } + + /** Select a single character before a point. */ + 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); + } + const id = point.prevId(); + if (!id) return; + return this.range(this.point(id, Anchor.Before), this.point(id, Anchor.After)); + } + // ---------------------------------------------------------------- Printable public toString(tab: string = ''): string { diff --git a/src/json-crdt-extensions/peritext/editor/Editor.ts b/src/json-crdt-extensions/peritext/editor/Editor.ts new file mode 100644 index 0000000000..2f6efc7a0c --- /dev/null +++ b/src/json-crdt-extensions/peritext/editor/Editor.ts @@ -0,0 +1,119 @@ +import {Cursor} from '../slice/Cursor'; +import {Anchor} from '../constants'; +import {tick, type ITimestampStruct} from '../../../json-crdt-patch/clock'; +import type {Range} from '../slice/Range'; +import type {Peritext} from '../Peritext'; +import type {Printable} from '../../../util/print/types'; +import type {Point} from '../point/Point'; + +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) { + const point = txt.point(txt.str.id, Anchor.After); + const cursorId = txt.str.id; // TODO: should be autogenerated to something else + this.cursor = new Cursor(cursorId, txt, point, point.clone()); + } + + /** @deprecated */ + public setCursor(start: number, length: number = 0): void { + this.cursor.setAt(start, length); + } + + /** @deprecated */ + public getCursorText(): string { + return this.cursor.text(); + } + + /** + * 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 Returns the cursor position after the operation. + */ + 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.setCaret(start.id); + else cursor.setCaret(start.prevId() || str.id); + } + return cursor.start.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; + const after = this.collapseSelection(); + const textId = this.txt.ins(after, text); + this.cursor.setCaret(textId, text.length - 1); + } + + /** + * 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 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; + } + + 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); + } +} From c85fc1093207196c16dd615aa9cce984931d42a3 Mon Sep 17 00:00:00 2001 From: streamich Date: Mon, 15 Apr 2024 10:24:39 +0200 Subject: [PATCH 6/8] =?UTF-8?q?test(json-crdt-extensions):=20=F0=9F=92=8D?= =?UTF-8?q?=20add=20tests=20for=20Range=20implementation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-extensions/peritext/Peritext.ts | 7 +- .../peritext/slice/__tests__/Range.spec.ts | 103 ++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 src/json-crdt-extensions/peritext/slice/__tests__/Range.spec.ts diff --git a/src/json-crdt-extensions/peritext/Peritext.ts b/src/json-crdt-extensions/peritext/Peritext.ts index 4507214f18..62ae4b7927 100644 --- a/src/json-crdt-extensions/peritext/Peritext.ts +++ b/src/json-crdt-extensions/peritext/Peritext.ts @@ -1,6 +1,7 @@ import {Anchor} 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 {type ITimestampStruct} from '../../json-crdt-patch/clock'; @@ -8,11 +9,15 @@ import type {Model} from '../../json-crdt/model'; import type {Printable} from '../../util/print/types'; export class Peritext implements Printable { + public readonly editor: Editor; + constructor( public readonly model: Model, public readonly str: StrNode, slices: ArrNode, - ) {} + ) { + this.editor = new Editor(this); + } public point(id: ITimestampStruct, anchor: Anchor = Anchor.After): Point { return new Point(this, id, anchor); diff --git a/src/json-crdt-extensions/peritext/slice/__tests__/Range.spec.ts b/src/json-crdt-extensions/peritext/slice/__tests__/Range.spec.ts new file mode 100644 index 0000000000..6f794e7e68 --- /dev/null +++ b/src/json-crdt-extensions/peritext/slice/__tests__/Range.spec.ts @@ -0,0 +1,103 @@ +import {Model} from '../../../../json-crdt/model'; +import {Peritext} from '../../Peritext'; +import {Anchor} from '../../constants'; +import {Editor} from '../../editor/Editor'; + +const setup = (insert: (editor: Editor) => void = (editor) => editor.insert('Hello world!')) => { + const model = Model.withLogicalClock(); + model.api.root({ + text: '', + slices: [], + }); + const peritext = new Peritext(model, model.api.str(['text']).node, model.api.arr(['slices']).node); + const editor = peritext.editor; + insert(editor); + return {model, peritext, editor}; +}; + +describe('.isCollapsed()', () => { + test('returns true when endpoints point to the same location', () => { + const {editor} = setup(); + editor.setCursor(3); + expect(editor.cursor.isCollapsed()).toBe(true); + }); + + test('returns true when when there is no visible content between endpoints', () => { + const {peritext, editor} = setup(); + const range = peritext.rangeAt(2, 1); + editor.setCursor(2, 1); + editor.delete(); + expect(range.isCollapsed()).toBe(true); + }); +}); + +describe('.expand()', () => { + const runExpandTests = (setup2: typeof setup) => { + test('can expand anchors to include adjacent elements', () => { + const {editor} = setup2(); + editor.setCursor(1, 1); + expect(editor.cursor.start.pos()).toBe(1); + expect(editor.cursor.start.anchor).toBe(Anchor.Before); + expect(editor.cursor.end.pos()).toBe(1); + expect(editor.cursor.end.anchor).toBe(Anchor.After); + editor.cursor.expand(); + expect(editor.cursor.start.pos()).toBe(0); + 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', () => { + const {peritext, editor} = setup2(); + const tombstone1 = peritext.rangeAt(1, 1); + tombstone1.expand(); + const tombstone2 = peritext.rangeAt(3, 1); + tombstone2.expand(); + editor.cursor.setRange(tombstone1); + editor.delete(); + editor.cursor.setRange(tombstone2); + editor.delete(); + const range = peritext.rangeAt(1, 1); + range.expand(); + expect(range.start.pos()).toBe(tombstone1.start.pos()); + expect(range.start.anchor).toBe(tombstone1.start.anchor); + expect(range.end.pos()).toBe(tombstone2.end.pos()); + expect(range.end.anchor).toBe(tombstone2.end.anchor); + }); + }; + + describe('single text chunk', () => { + runExpandTests(setup); + }); + + describe('each car is own chunk', () => { + runExpandTests(() => + setup((editor) => { + editor.insert('!'); + editor.setCursor(0); + editor.insert('d'); + editor.setCursor(0); + editor.insert('l'); + editor.setCursor(0); + editor.insert('r'); + editor.setCursor(0); + editor.insert('o'); + editor.setCursor(0); + editor.insert('w'); + editor.setCursor(0); + editor.insert(' '); + editor.setCursor(0); + editor.insert('o'); + editor.setCursor(0); + editor.insert('l'); + editor.setCursor(0); + editor.insert('l'); + editor.setCursor(0); + editor.insert('e'); + editor.setCursor(0); + editor.insert('H'); + }), + ); + }); +}); From 2024b027b2b80f499e7037e0f697e855144dfdbd Mon Sep 17 00:00:00 2001 From: streamich Date: Mon, 15 Apr 2024 10:45:36 +0200 Subject: [PATCH 7/8] =?UTF-8?q?feat(json-crdt-extensions):=20=F0=9F=8E=B8?= =?UTF-8?q?=20add=20slices=20manager?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-extensions/peritext/Peritext.ts | 43 ++++- .../peritext/editor/Editor.ts | 8 +- .../peritext/slice/PersistedSlice.ts | 84 ++++++++++ .../peritext/slice/Slices.ts | 156 ++++++++++++++++++ .../peritext/slice/SplitSlice.ts | 3 + .../peritext/slice/__tests__/Slices.spec.ts | 119 +++++++++++++ 6 files changed, 410 insertions(+), 3 deletions(-) create mode 100644 src/json-crdt-extensions/peritext/slice/PersistedSlice.ts create mode 100644 src/json-crdt-extensions/peritext/slice/Slices.ts create mode 100644 src/json-crdt-extensions/peritext/slice/SplitSlice.ts create mode 100644 src/json-crdt-extensions/peritext/slice/__tests__/Slices.spec.ts 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); + }); +}); From 7152fe9bce4c25952e454e140695c7b668a12bd3 Mon Sep 17 00:00:00 2001 From: streamich Date: Mon, 15 Apr 2024 10:48:05 +0200 Subject: [PATCH 8/8] =?UTF-8?q?style(json-crdt-extensions):=20=F0=9F=92=84?= =?UTF-8?q?=20apply=20linter=20suggestions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-extensions/peritext/slice/Range.ts | 8 ++++++-- src/json-crdt-extensions/peritext/slice/Slices.ts | 5 ++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/json-crdt-extensions/peritext/slice/Range.ts b/src/json-crdt-extensions/peritext/slice/Range.ts index 89c802cfaf..5b8d7032aa 100644 --- a/src/json-crdt-extensions/peritext/slice/Range.ts +++ b/src/json-crdt-extensions/peritext/slice/Range.ts @@ -10,7 +10,11 @@ export class Range implements Printable { return p1.compareSpatial(p2) > 0 ? new Range(txt, p2, p1) : new Range(txt, p1, p2); } - constructor(protected readonly txt: Peritext, public start: Point, public end: Point) {} + constructor( + protected readonly txt: Peritext, + public start: Point, + public end: Point, + ) {} public clone(): Range { return new Range(this.txt, this.start.clone(), this.end.clone()); @@ -38,7 +42,7 @@ export class Range implements Printable { } public collapseToStart(): void { - this.start = this.start.clone() + this.start = this.start.clone(); this.start.refAfter(); this.end = this.start.clone(); } diff --git a/src/json-crdt-extensions/peritext/slice/Slices.ts b/src/json-crdt-extensions/peritext/slice/Slices.ts index 9dbd9d60df..b38bef0e70 100644 --- a/src/json-crdt-extensions/peritext/slice/Slices.ts +++ b/src/json-crdt-extensions/peritext/slice/Slices.ts @@ -17,7 +17,10 @@ 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) {} + constructor( + public readonly txt: Peritext, + public readonly set: ArrNode, + ) {} public ins(range: Range, behavior: SliceBehavior, type: SliceType, data?: unknown): PersistedSlice { const peritext = this.txt;