From 2cd017425905aa66097be76768e191ae2b64b65e Mon Sep 17 00:00:00 2001 From: streamich Date: Mon, 29 Apr 2024 16:41:47 +0200 Subject: [PATCH 1/7] =?UTF-8?q?feat(json-crdt-extensions):=20=F0=9F=8E=B8?= =?UTF-8?q?=20add=20initial=20Overlay=20implementatin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-extensions/peritext/Peritext.ts | 19 +- .../peritext/overlay/Overlay.ts | 253 ++++++++++++++++++ 2 files changed, 269 insertions(+), 3 deletions(-) create mode 100644 src/json-crdt-extensions/peritext/overlay/Overlay.ts diff --git a/src/json-crdt-extensions/peritext/Peritext.ts b/src/json-crdt-extensions/peritext/Peritext.ts index d9fde4c150..aa31a19424 100644 --- a/src/json-crdt-extensions/peritext/Peritext.ts +++ b/src/json-crdt-extensions/peritext/Peritext.ts @@ -8,6 +8,7 @@ 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 {StringChunk} from './util/types'; /** * Context for a Peritext instance. Contains all the data and methods needed to @@ -30,7 +31,19 @@ export class Peritext implements Printable { return this.model.api.wrap(this.str); } - // ------------------------------------------------------------------- Points + /** @todo Find a better place for this function. */ + public firstVisChunk(): StringChunk | undefined { + const str = this.str; + let curr = str.first(); + if (!curr) return; + while (curr.del) { + curr = str.next(curr); + if (!curr) return; + } + return curr; + } + + // ------------------------------------------------------------------- points /** * Creates a point at a character ID. @@ -81,7 +94,7 @@ export class Peritext implements Printable { return this.point(this.str.id, Anchor.Before); } - // ------------------------------------------------------------------- Ranges + // ------------------------------------------------------------------- ranges /** * Creates a range from two points. The points can be in any order. @@ -117,7 +130,7 @@ export class Peritext implements Printable { return Range.at(this.str, start, length); } - // --------------------------------------------------------------- Insertions + // --------------------------------------------------------------- insertions /** * Insert plain text at a view position in the text. diff --git a/src/json-crdt-extensions/peritext/overlay/Overlay.ts b/src/json-crdt-extensions/peritext/overlay/Overlay.ts new file mode 100644 index 0000000000..cdd9199c09 --- /dev/null +++ b/src/json-crdt-extensions/peritext/overlay/Overlay.ts @@ -0,0 +1,253 @@ +import {first, insertLeft, insertRight, next, prev, remove} from 'sonic-forest/lib/util'; +import {splay} from 'sonic-forest/lib/splay/util'; +import {Anchor} from '../rga/constants'; +import {Point} from '../rga/Point'; +import {OverlayPoint} from './OverlayPoint'; +import {MarkerOverlayPoint} from './MarkerOverlayPoint'; +import {OverlayRefSliceEnd, OverlayRefSliceStart} from './refs'; +import {equal, ITimestampStruct} from '../../../json-crdt-patch/clock'; +import {CONST, updateNum} from '../../../json-hash'; +import {printBinary} from '../../../util/print/printBinary'; +import {printTree} from '../../../util/print/printTree'; +import {MarkerSlice} from '../slice/MarkerSlice'; +import type {Peritext} from '../Peritext'; +import type {Stateful} from '../types'; +import type {Printable} from '../../../util/print/types'; +import type {MutableSlice, Slice} from '../slice/types'; + +export class Overlay implements Printable, Stateful { + public root: OverlayPoint | undefined = undefined; + + constructor(protected readonly txt: Peritext) {} + + /** + * @todo Rename to .point(). + */ + protected overlayPoint(id: ITimestampStruct, anchor: Anchor): OverlayPoint { + return new OverlayPoint(this.txt.str, id, anchor); + } + + protected markerPoint(marker: MarkerSlice, anchor: Anchor): OverlayPoint { + return new MarkerOverlayPoint(this.txt.str, marker.start.id, anchor, marker); + } + + public first(): OverlayPoint | undefined { + return this.root ? first(this.root) : undefined; + } + + public iterator(): () => OverlayPoint | undefined { + let curr = this.first(); + return () => { + const ret = curr; + if (curr) curr = next(curr); + return ret; + }; + } + + public splitIterator(): () => MarkerOverlayPoint | undefined { + let curr = this.first(); + return () => { + while (curr) { + const ret = curr; + if (curr) curr = next(curr); + if (ret instanceof MarkerOverlayPoint) return ret; + } + return undefined; + }; + } + + /** + * 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; + while (curr) { + const cmp = curr.cmpSpatial(point); + if (cmp === 0) return curr; + if (cmp > 0) curr = curr.l; + else { + const next = curr.r; + result = curr; + if (!next) return result; + curr = next; + } + } + return result; + } + + // ----------------------------------------------------------------- Stateful + + public hash: number = 0; + + public refresh(slicesOnly: boolean = false): number { + let hash: number = CONST.START_STATE; + hash = this.refreshSlices(hash); + // if (!slicesOnly) this.computeSplitTextHashes(); + return (this.hash = hash); + } + + /** + * Retrieve an existing {@link OverlayPoint} or create a new one, inserted + * in the tree, sorted by spatial dimension. + */ + protected upsertPoint(point: Point): [point: OverlayPoint, isNew: boolean] { + const newPoint = this.overlayPoint(point.id, point.anchor); + const pivot = this.insertPoint(newPoint); + if (pivot) return [pivot, false]; + return [newPoint, true]; + } + + /** + * Inserts a point into the tree, sorted by spatial dimension. + * @param point Point to insert. + * @returns Returns the existing point if it was already in the tree. + */ + protected insertPoint(point: OverlayPoint): OverlayPoint | undefined { + let pivot = this.getOrNextLower(point); + if (!pivot) pivot = first(this.root); + if (!pivot) { + this.root = point; + return; + } else { + if (pivot.cmp(point) === 0) return pivot; + const cmp = pivot.cmpSpatial(point); + if (cmp < 0) insertRight(point, pivot); + else insertLeft(point, pivot); + } + if (this.root !== point) this.root = splay(this.root!, point, 10); + return undefined; + } + + protected delPoint(point: OverlayPoint): void { + this.root = remove(this.root, point); + } + + public slices = new Map(); + + private refreshSlices(state: number): number { + const slices = this.txt.slices; + const changed = 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); + if (tuple) { + if (slice.isDel()) { + this.delSlice(slice, tuple); + return; + } + const positionMoved = tuple[0].cmp(slice.start) !== 0 || tuple[1].cmp(slice.end) !== 0; + if (positionMoved) this.delSlice(slice, tuple); + else return; + } + tuple = this.insSlice(slice); + this.slices.set(slice, tuple); + }); + if (slices.size() < sliceSet.size) { + sliceSet.forEach((tuple, slice) => { + const mutSlice = slice as Slice | MutableSlice; + if ((mutSlice).isDel) { + if (!(mutSlice).isDel()) return; + this.delSlice(slice, tuple); + } + }); + } + } + const cursor = this.txt.editor.cursor; + let tuple: [start: OverlayPoint, end: OverlayPoint] | undefined = sliceSet.get(cursor); + const positionMoved = tuple && (tuple[0].cmp(cursor.start) !== 0 || tuple[1].cmp(cursor.end) !== 0); + if (tuple && positionMoved) { + this.delSlice(cursor, tuple!); + } + if (!tuple || positionMoved) { + tuple = this.insSlice(cursor); + this.slices.set(cursor, tuple); + } + return state; + } + + protected insSplit(slice: MarkerSlice): [start: OverlayPoint, end: OverlayPoint] { + // const point = new MarkerOverlayPoint(this.txt, slice.start.id, Anchor.Before, slice); + const point = this.markerPoint(slice, Anchor.Before); + const pivot = this.insertPoint(point); + if (!pivot) { + point.refs.push(slice); + const prevPoint = prev(point); + if (prevPoint) point.layers.push(...prevPoint.layers); + } + return [point, point]; + } + + private insSlice(slice: Slice): [start: OverlayPoint, end: OverlayPoint] { + if (slice instanceof MarkerSlice) return this.insSplit(slice); + const txt = this.txt; + const str = txt.str; + let startPoint = slice.start; + let endPoint = slice.end; + const startIsStringRoot = equal(startPoint.id, str.id); + if (startIsStringRoot) { + const firstVisibleChunk = txt.firstVisChunk(); + if (firstVisibleChunk) { + startPoint = txt.point(firstVisibleChunk.id, Anchor.Before); + const endIsStringRoot = equal(endPoint.id, str.id); + if (endIsStringRoot) { + endPoint = txt.point(firstVisibleChunk.id, Anchor.Before); + } + } + } + const [start, isStartNew] = this.upsertPoint(startPoint); + const [end, isEndNew] = this.upsertPoint(endPoint); + start.refs.push(new OverlayRefSliceStart(slice)); + end.refs.push(new OverlayRefSliceEnd(slice)); + if (isStartNew) { + const beforeStartPoint = prev(start); + if (beforeStartPoint) start.layers.push(...beforeStartPoint.layers); + } + if (isEndNew) { + const beforeEndPoint = prev(end); + if (beforeEndPoint) end.layers.push(...beforeEndPoint.layers); + } + const isCollapsed = startPoint.cmp(endPoint) === 0; + let curr: OverlayPoint | undefined = start; + while (curr !== end && curr) { + curr.addLayer(slice); + curr = next(curr); + } + if (!isCollapsed) { + } else { + start.addMarker(slice); + } + return [start, end]; + } + + private delSlice(slice: Slice, [start, end]: [start: OverlayPoint, end: OverlayPoint]): void { + this.slices.delete(slice); + let curr: OverlayPoint | undefined = start; + do { + curr.removeLayer(slice); + curr.removeMarker(slice); + curr = next(curr); + } while (curr && curr !== end); + start.removeRef(slice); + end.removeRef(slice); + if (!start.refs.length) this.delPoint(start); + if (!end.refs.length && start !== end) this.delPoint(end); + } + + // ---------------------------------------------------------------- Printable + + public toString(tab: string = ''): string { + const printPoint = (tab: string, point: OverlayPoint): string => { + return ( + point.toString(tab) + + printBinary(tab, [ + !point.l ? null : (tab) => printPoint(tab, point.l!), + !point.r ? null : (tab) => printPoint(tab, point.r!), + ]) + ); + }; + return this.constructor.name + printTree(tab, [!this.root ? null : (tab) => printPoint(tab, this.root!)]); + } +} From deb028f553d8b65b78d1ab25779db4b68ef5a767 Mon Sep 17 00:00:00 2001 From: streamich Date: Mon, 29 Apr 2024 17:13:32 +0200 Subject: [PATCH 2/7] =?UTF-8?q?refactor(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=92=A1=20rename=20"split"=20to=20"marker"=20terminology?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../peritext/slice/PersistedSlice.ts | 2 +- src/json-crdt-extensions/peritext/slice/Slices.ts | 9 +++------ .../peritext/slice/__tests__/PersistedSlice.spec.ts | 4 ++-- .../peritext/slice/__tests__/Slices.spec.ts | 2 +- src/json-crdt-extensions/peritext/slice/constants.ts | 4 +--- 5 files changed, 8 insertions(+), 13 deletions(-) diff --git a/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts b/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts index fd6765b939..bbe0e90c97 100644 --- a/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts +++ b/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts @@ -58,7 +58,7 @@ export class PersistedSlice extends Range implements MutableSlice } public isSplit(): boolean { - return this.behavior === SliceBehavior.Split; + return this.behavior === SliceBehavior.Marker; } protected tupleApi() { diff --git a/src/json-crdt-extensions/peritext/slice/Slices.ts b/src/json-crdt-extensions/peritext/slice/Slices.ts index ded7f7944b..0c4f3f0a7f 100644 --- a/src/json-crdt-extensions/peritext/slice/Slices.ts +++ b/src/json-crdt-extensions/peritext/slice/Slices.ts @@ -55,18 +55,15 @@ export class Slices implements Stateful, Printable { // TODO: Need to check if split slice text was deleted const txt = this.txt; const slice = - behavior === SliceBehavior.Split + behavior === SliceBehavior.Marker ? new MarkerSlice(txt, txt.str, chunk, tuple, behavior, type, start, end) : new PersistedSlice(txt, txt.str, chunk, tuple, behavior, type, start, end); this.list.set(chunk.id, slice); return slice; } - /** - * @todo Rename to `insMarker`. - */ - public insSplit(range: Range, type: SliceType, data?: unknown): MarkerSlice { - return this.ins(range, SliceBehavior.Split, type, data) as MarkerSlice; + public insMarker(range: Range, type: SliceType, data?: unknown): MarkerSlice { + return this.ins(range, SliceBehavior.Marker, type, data) as MarkerSlice; } public insStack(range: Range, type: SliceType, data?: unknown): PersistedSlice { diff --git a/src/json-crdt-extensions/peritext/slice/__tests__/PersistedSlice.spec.ts b/src/json-crdt-extensions/peritext/slice/__tests__/PersistedSlice.spec.ts index 08d75e9dad..42bdb2bc2e 100644 --- a/src/json-crdt-extensions/peritext/slice/__tests__/PersistedSlice.spec.ts +++ b/src/json-crdt-extensions/peritext/slice/__tests__/PersistedSlice.spec.ts @@ -4,14 +4,14 @@ import {setup} from './setup'; const setupSlice = () => { const deps = setup(); const range = deps.peritext.rangeAt(2, 3); - const slice = deps.peritext.slices.insSplit(range, 0); + const slice = deps.peritext.slices.insMarker(range, 0); return {...deps, range, slice}; }; test('can read slice data', () => { const {range, slice} = setupSlice(); expect(slice.isSplit()).toBe(true); - expect(slice.behavior).toBe(SliceBehavior.Split); + expect(slice.behavior).toBe(SliceBehavior.Marker); expect(slice.type).toBe(0); expect(slice.data()).toBe(undefined); expect(slice.start).not.toBe(range.start); diff --git a/src/json-crdt-extensions/peritext/slice/__tests__/Slices.spec.ts b/src/json-crdt-extensions/peritext/slice/__tests__/Slices.spec.ts index f2f29c0e6a..bd062a8692 100644 --- a/src/json-crdt-extensions/peritext/slice/__tests__/Slices.spec.ts +++ b/src/json-crdt-extensions/peritext/slice/__tests__/Slices.spec.ts @@ -80,7 +80,7 @@ describe('.ins()', () => { const ranges = [r1, r2, r3, r4]; const types = ['b', ['li', 'ul'], 0, 123, [1, 2, 3]]; const datas = [{bold: true}, {list: 'ul'}, 0, 123, [1, 2, 3], null, undefined]; - const behaviors = [SliceBehavior.Stack, SliceBehavior.Erase, SliceBehavior.Overwrite, SliceBehavior.Split]; + const behaviors = [SliceBehavior.Stack, SliceBehavior.Erase, SliceBehavior.Overwrite, SliceBehavior.Marker]; for (const range of ranges) { for (const type of types) { for (const data of datas) { diff --git a/src/json-crdt-extensions/peritext/slice/constants.ts b/src/json-crdt-extensions/peritext/slice/constants.ts index 310b934c0c..6ed172066a 100644 --- a/src/json-crdt-extensions/peritext/slice/constants.ts +++ b/src/json-crdt-extensions/peritext/slice/constants.ts @@ -27,10 +27,8 @@ export const enum SliceBehavior { /** * A Split slice, which is used to mark a block split position in the document. * For example, paragraph, heading, blockquote, etc. - * - * @todo Rename to `Marker`. */ - Split = 0b000, + Marker = 0b000, /** * Appends attributes to a stack of attributes for a specific slice type. This From 7789cedc104316ed4a0f3230fc1e648b9f105467 Mon Sep 17 00:00:00 2001 From: streamich Date: Mon, 29 Apr 2024 17:14:10 +0200 Subject: [PATCH 3/7] =?UTF-8?q?feat(json-crdt-extensions):=20=F0=9F=8E=B8?= =?UTF-8?q?=20add=20higher-level=20API=20for=20inserting=20markers?= 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 | 42 ++++++++++++++----- .../peritext/constants.ts | 3 ++ .../peritext/editor/Editor.ts | 7 ++++ 4 files changed, 41 insertions(+), 15 deletions(-) create mode 100644 src/json-crdt-extensions/peritext/constants.ts diff --git a/src/json-crdt-extensions/constants.ts b/src/json-crdt-extensions/constants.ts index fd071bca51..2364d76b94 100644 --- a/src/json-crdt-extensions/constants.ts +++ b/src/json-crdt-extensions/constants.ts @@ -4,7 +4,3 @@ 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 aa31a19424..7b5b56778f 100644 --- a/src/json-crdt-extensions/peritext/Peritext.ts +++ b/src/json-crdt-extensions/peritext/Peritext.ts @@ -1,14 +1,18 @@ +import {printTree} from 'sonic-forest/lib/print/printTree'; import {Anchor} from './rga/constants'; import {Point} from './rga/Point'; import {Range} from './rga/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 {Overlay} from './overlay/Overlay'; +import {Chars} from './constants'; +import type {ITimestampStruct} from '../../json-crdt-patch/clock'; import type {Model} from '../../json-crdt/model'; import type {Printable} from '../../util/print/types'; import type {StringChunk} from './util/types'; +import type {SliceType} from './types'; +import type {MarkerSlice} from './slice/MarkerSlice'; /** * Context for a Peritext instance. Contains all the data and methods needed to @@ -17,6 +21,7 @@ import type {StringChunk} from './util/types'; export class Peritext implements Printable { public readonly slices: Slices; public readonly editor: Editor; + public readonly overlay = new Overlay(this); constructor( public readonly model: Model, @@ -43,6 +48,17 @@ export class Peritext implements Printable { return curr; } + /** 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)); + } + // ------------------------------------------------------------------- points /** @@ -159,15 +175,19 @@ export class Peritext implements Printable { 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)); + 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.slices.insMarker(range, type, data); } // ---------------------------------------------------------------- Printable diff --git a/src/json-crdt-extensions/peritext/constants.ts b/src/json-crdt-extensions/peritext/constants.ts new file mode 100644 index 0000000000..05532030bb --- /dev/null +++ b/src/json-crdt-extensions/peritext/constants.ts @@ -0,0 +1,3 @@ +export const enum Chars { + BlockSplitSentinel = '\n', +} diff --git a/src/json-crdt-extensions/peritext/editor/Editor.ts b/src/json-crdt-extensions/peritext/editor/Editor.ts index de975f2133..5f5f654d99 100644 --- a/src/json-crdt-extensions/peritext/editor/Editor.ts +++ b/src/json-crdt-extensions/peritext/editor/Editor.ts @@ -3,11 +3,13 @@ import {Anchor} from '../rga/constants'; import {SliceBehavior} from '../slice/constants'; import {tick, type ITimestampStruct} from '../../../json-crdt-patch/clock'; import {PersistedSlice} from '../slice/PersistedSlice'; +import {Chars} from '../constants'; import type {Range} from '../rga/Range'; import type {Peritext} from '../Peritext'; import type {Printable} from '../../../util/print/types'; import type {Point} from '../rga/Point'; import type {SliceType} from '../types'; +import type {MarkerSlice} from '../slice/MarkerSlice'; export class Editor implements Printable { /** @@ -132,4 +134,9 @@ export class Editor implements Printable { public insertEraseSlice(type: SliceType, data?: unknown | ITimestampStruct): PersistedSlice { return this.txt.slices.ins(this.cursor, SliceBehavior.Erase, type, data); } + + public insMarker(type: SliceType, data?: unknown): MarkerSlice { + const after = this.collapseSelection(); + return this.txt.insMarker(after, type, data, Chars.BlockSplitSentinel); + } } From a83518d7c1c499730198427fe2477c9d2a4f3825 Mon Sep 17 00:00:00 2001 From: streamich Date: Tue, 30 Apr 2024 09:44:34 +0200 Subject: [PATCH 4/7] =?UTF-8?q?feat(json-crdt-extensions):=20=F0=9F=8E=B8?= =?UTF-8?q?=20improve=20how=20slices=20are=20presented?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-extensions/peritext/slice/PersistedSlice.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts b/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts index bbe0e90c97..5121fe64a5 100644 --- a/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts +++ b/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts @@ -143,7 +143,9 @@ export class PersistedSlice extends Range implements MutableSlice public toString(tab: string = ''): string { const data = this.data(); - const header = `${this.constructor.name} ${super.toString(tab)}, ${this.behavior}, ${JSON.stringify(this.type)}`; - return header + printTree(tab, [!data ? null : (tab) => prettyOneLine(data)]); + const dataFormatted = data ? prettyOneLine(data) : ''; + const dataLengthBreakpoint = 32; + const header = `${this.constructor.name} ${super.toString(tab)}, ${this.behavior}, ${JSON.stringify(this.type)}${dataFormatted.length < dataLengthBreakpoint ? `, ${dataFormatted}` : ''}`; + return header + printTree(tab, [dataFormatted.length < dataLengthBreakpoint ? null : (tab) => dataFormatted]); } } From 75e26209007676b2d8ee022bad7a5a142b02ddfb Mon Sep 17 00:00:00 2001 From: streamich Date: Tue, 30 Apr 2024 09:48:35 +0200 Subject: [PATCH 5/7] =?UTF-8?q?feat(json-crdt-extensions):=20=F0=9F=8E=B8?= =?UTF-8?q?=20improve=20overlay=20layer=20insertions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-extensions/peritext/Peritext.ts | 24 +- .../peritext/overlay/Overlay.ts | 13 +- .../overlay/__tests__/Overlay.spec.ts | 237 ++++++++++++++++++ .../overlay/__tests__/OverlayPoint.spec.ts | 36 +-- 4 files changed, 288 insertions(+), 22 deletions(-) create mode 100644 src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.spec.ts diff --git a/src/json-crdt-extensions/peritext/Peritext.ts b/src/json-crdt-extensions/peritext/Peritext.ts index 7b5b56778f..d5fc53f219 100644 --- a/src/json-crdt-extensions/peritext/Peritext.ts +++ b/src/json-crdt-extensions/peritext/Peritext.ts @@ -7,6 +7,8 @@ import {ArrNode, StrNode} from '../../json-crdt/nodes'; import {Slices} from './slice/Slices'; import {Overlay} from './overlay/Overlay'; import {Chars} from './constants'; +import {interval} from '../../json-crdt-patch/clock'; +import {CONST, updateNum} from '../../json-hash'; import type {ITimestampStruct} from '../../json-crdt-patch/clock'; import type {Model} from '../../json-crdt/model'; import type {Printable} from '../../util/print/types'; @@ -146,7 +148,7 @@ export class Peritext implements Printable { return Range.at(this.str, start, length); } - // --------------------------------------------------------------- insertions + // --------------------------------------------------------------------- text /** * Insert plain text at a view position in the text. @@ -175,6 +177,8 @@ export class Peritext implements Printable { return textId; } + // ------------------------------------------------------------------ markers + public insMarker(after: ITimestampStruct, type: SliceType, data?: unknown, char: string = Chars.BlockSplitSentinel): MarkerSlice { const api = this.model.api; const builder = api.builder; @@ -190,6 +194,17 @@ export class Peritext implements Printable { return this.slices.insMarker(range, type, data); } + /** @todo This can probably use .del() */ + public delMarker(split: MarkerSlice): void { + const str = this.str; + const api = this.model.api; + const builder = api.builder; + const strChunk = split.start.chunk(); + if (strChunk) builder.del(str.id, [interval(strChunk.id, 0, 1)]); + builder.del(this.slices.set.id, [interval(split.id, 0, 1)]); + api.apply(); + } + // ---------------------------------------------------------------- Printable public toString(tab: string = ''): string { @@ -202,6 +217,8 @@ export class Peritext implements Printable { (tab) => this.str.toString(tab), nl, (tab) => this.slices.toString(tab), + nl, + (tab) => this.overlay.toString(tab), ]) ); } @@ -211,6 +228,9 @@ export class Peritext implements Printable { public hash: number = 0; public refresh(): number { - return this.slices.refresh(); + let state: number = CONST.START_STATE; + this.overlay.refresh(); + state = updateNum(state, this.overlay.hash); + return (this.hash = state); } } diff --git a/src/json-crdt-extensions/peritext/overlay/Overlay.ts b/src/json-crdt-extensions/peritext/overlay/Overlay.ts index cdd9199c09..21216ff46a 100644 --- a/src/json-crdt-extensions/peritext/overlay/Overlay.ts +++ b/src/json-crdt-extensions/peritext/overlay/Overlay.ts @@ -1,3 +1,5 @@ +import {printTree} from 'sonic-forest/lib/print/printTree'; +import {printBinary} from 'sonic-forest/lib/print/printBinary'; import {first, insertLeft, insertRight, next, prev, remove} from 'sonic-forest/lib/util'; import {splay} from 'sonic-forest/lib/splay/util'; import {Anchor} from '../rga/constants'; @@ -7,8 +9,6 @@ import {MarkerOverlayPoint} from './MarkerOverlayPoint'; import {OverlayRefSliceEnd, OverlayRefSliceStart} from './refs'; import {equal, ITimestampStruct} from '../../../json-crdt-patch/clock'; import {CONST, updateNum} from '../../../json-hash'; -import {printBinary} from '../../../util/print/printBinary'; -import {printTree} from '../../../util/print/printTree'; import {MarkerSlice} from '../slice/MarkerSlice'; import type {Peritext} from '../Peritext'; import type {Stateful} from '../types'; @@ -76,6 +76,15 @@ export class Overlay implements Printable, Stateful { return result; } + public find(predicate: (point: OverlayPoint) => boolean): OverlayPoint | undefined { + let point = this.first(); + while (point) { + if (predicate(point)) return point; + point = next(point); + } + return undefined; + } + // ----------------------------------------------------------------- Stateful public hash: number = 0; diff --git a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.spec.ts b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.spec.ts new file mode 100644 index 0000000000..05d2f89ce7 --- /dev/null +++ b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.spec.ts @@ -0,0 +1,237 @@ +import {Model} from '../../../../json-crdt/model'; +import {first, next} from 'sonic-forest/lib/util'; +import {Peritext} from '../../Peritext'; +import {Anchor} from '../../rga/constants'; +import {MarkerOverlayPoint} from '../MarkerOverlayPoint'; + +const setup = () => { + const model = Model.withLogicalClock(); + model.api.root({ + text: '', + slices: [], + markers: [], + }); + 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); + const peritext = new Peritext(model, model.api.str(['text']).node, model.api.arr(['slices']).node); + return {model, peritext}; +}; + +const splitCount = (peritext: Peritext): number => { + const overlay = peritext.overlay; + const iterator = overlay.splitIterator(); + let count = 0; + for (let split = iterator(); split; split = iterator()) { + count++; + } + return count; +}; + +describe('markers', () => { + describe('inserts', () => { + test('overlays starts with no markers', () => { + const {peritext} = setup(); + expect(splitCount(peritext)).toBe(0); + }); + + test('can insert one marker in the middle of text', () => { + const {peritext} = setup(); + peritext.editor.setCursor(6); + peritext.editor.insMarker(['p'], '¶'); + expect(splitCount(peritext)).toBe(0); + peritext.overlay.refresh(); + expect(splitCount(peritext)).toBe(1); + const points = []; + let point; + for (const iterator = peritext.overlay.iterator(); (point = iterator()); ) points.push(point); + // console.log(peritext + ''); + expect(points.length).toBe(2); + point = points[0]; + expect(point.pos()).toBe(5); + }); + + test('can insert two markers', () => { + const {peritext} = setup(); + peritext.editor.setCursor(3); + peritext.editor.insMarker(['p'], '¶'); + expect(splitCount(peritext)).toBe(0); + peritext.overlay.refresh(); + expect(splitCount(peritext)).toBe(1); + peritext.overlay.refresh(); + expect(splitCount(peritext)).toBe(1); + peritext.editor.setCursor(9); + peritext.editor.insMarker(['li'], '- '); + expect(splitCount(peritext)).toBe(1); + peritext.overlay.refresh(); + expect(splitCount(peritext)).toBe(2); + peritext.overlay.refresh(); + expect(splitCount(peritext)).toBe(2); + }); + }); + + describe('deletes', () => { + test('can delete a marker', () => { + const {peritext} = setup(); + peritext.editor.setCursor(6); + const slice = peritext.editor.insMarker(['p'], '¶'); + peritext.refresh(); + expect(splitCount(peritext)).toBe(1); + const points = []; + let point; + for (const iterator = peritext.overlay.iterator(); (point = iterator()); ) points.push(point); + point = points[0]; + peritext.delMarker(slice); + peritext.refresh(); + expect(splitCount(peritext)).toBe(0); + }); + + test('can delete one of two splits', () => { + const {peritext} = setup(); + peritext.editor.setCursor(2); + peritext.editor.insMarker(['p'], '¶'); + peritext.editor.setCursor(11); + const slice = peritext.editor.insMarker(['p'], '¶'); + peritext.refresh(); + expect(splitCount(peritext)).toBe(2); + const points = []; + let point; + for (const iterator = peritext.overlay.iterator(); (point = iterator()); ) points.push(point); + point = points[0]; + peritext.delMarker(slice); + peritext.refresh(); + expect(splitCount(peritext)).toBe(1); + }); + }); + + describe('iterates', () => { + test('can iterate over markers', () => { + const {peritext} = setup(); + peritext.editor.setCursor(1, 6); + peritext.editor.insertSlice('a', {a: 'b'}); + peritext.editor.setCursor(2); + peritext.editor.insMarker(['p'], '¶'); + peritext.editor.setCursor(11); + peritext.editor.insMarker(['p'], '¶'); + peritext.refresh(); + expect(splitCount(peritext)).toBe(2); + const points = []; + let point; + for (const iterator = peritext.overlay.splitIterator(); (point = iterator()); ) points.push(point); + expect(points.length).toBe(2); + expect(points[0].pos()).toBe(2); + expect(points[1].pos()).toBe(11); + }); + }); +}); + +describe('slices', () => { + describe('inserts', () => { + test('overlays starts with no slices', () => { + const {peritext} = setup(); + expect(peritext.overlay.slices.size).toBe(0); + }); + + test('can insert one slice in the middle of text', () => { + const {peritext} = setup(); + peritext.editor.setCursor(6, 2); + peritext.editor.insertSlice('em', {emphasis: true}); + expect(peritext.overlay.slices.size).toBe(0); + peritext.overlay.refresh(); + expect(peritext.overlay.slices.size).toBe(2); + const points = []; + let point; + for (const iterator = peritext.overlay.iterator(); (point = iterator()); ) points.push(point); + expect(points.length).toBe(2); + expect(points[0].pos()).toBe(6); + expect(points[0].anchor).toBe(Anchor.Before); + expect(points[1].pos()).toBe(7); + expect(points[1].anchor).toBe(Anchor.After); + }); + + test('can insert two slices', () => { + const {peritext} = setup(); + peritext.editor.setCursor(2, 8); + peritext.editor.insertSlice('em', {emphasis: true}); + peritext.editor.setCursor(4, 8); + peritext.editor.insertSlice('strong', {bold: true}); + expect(peritext.overlay.slices.size).toBe(0); + peritext.overlay.refresh(); + expect(peritext.overlay.slices.size).toBe(3); + const points = []; + let point; + for (const iterator = peritext.overlay.iterator(); (point = iterator()); ) points.push(point); + expect(points.length).toBe(4); + }); + + test('intersecting slice chunks point to two slices', () => { + const {peritext} = setup(); + peritext.editor.setCursor(2, 2); + peritext.editor.insertSlice('em', {emphasis: true}); + peritext.editor.setCursor(3, 2); + peritext.editor.insertSlice('strong', {bold: true}); + peritext.refresh(); + const point1 = first(peritext.overlay.root)!; + expect(point1.layers.length).toBe(1); + expect(point1.layers[0].data()).toStrictEqual({emphasis: true}); + const point2 = next(point1)!; + expect(point2.layers.length).toBe(3); + expect(point2.layers[0].data()).toStrictEqual(undefined); + expect(point2.layers[1].data()).toStrictEqual({emphasis: true}); + expect(point2.layers[2].data()).toStrictEqual({bold: true}); + const point3 = next(point2)!; + expect(point3.layers.length).toBe(2); + expect(point3.layers[0].data()).toStrictEqual(undefined); + expect(point3.layers[1].data()).toStrictEqual({bold: true}); + const point4 = next(point3)!; + expect(point4.layers.length).toBe(0); + console.log(peritext + ''); + }); + + test('one char slice should correctly sort overlay points', () => { + const {peritext} = setup(); + peritext.editor.setCursor(0, 1); + peritext.editor.insertSlice('em', {emphasis: true}); + peritext.refresh(); + const point1 = peritext.overlay.first()!; + const point2 = next(point1)!; + expect(point1.pos()).toBe(0); + expect(point2.pos()).toBe(0); + expect(point1.anchor).toBe(Anchor.Before); + expect(point2.anchor).toBe(Anchor.After); + }); + + test('intersecting slice before split, should not update the split', () => { + const {peritext} = setup(); + peritext.editor.setCursor(6); + const slice = peritext.editor.insMarker(['p']); + peritext.refresh(); + const point = peritext.overlay.find((point) => point instanceof MarkerOverlayPoint)!; + expect(point.layers.length).toBe(0); + peritext.editor.setCursor(2, 2); + peritext.editor.insertSlice(''); + peritext.refresh(); + expect(point.layers.length).toBe(0); + peritext.editor.setCursor(2, 1); + peritext.editor.insertSlice(''); + peritext.refresh(); + expect(point.layers.length).toBe(0); + }); + }); + + describe('deletes', () => { + test('can remove a slice', () => { + const {peritext} = setup(); + peritext.editor.setCursor(6, 2); + const slice = peritext.editor.insertSlice('em', {emphasis: true}); + expect(peritext.overlay.slices.size).toBe(0); + peritext.overlay.refresh(); + expect(peritext.overlay.slices.size).toBe(2); + peritext.slices.del(slice.id); + expect(peritext.overlay.slices.size).toBe(2); + peritext.overlay.refresh(); + expect(peritext.overlay.slices.size).toBe(1); + }); + }); +}); diff --git a/src/json-crdt-extensions/peritext/overlay/__tests__/OverlayPoint.spec.ts b/src/json-crdt-extensions/peritext/overlay/__tests__/OverlayPoint.spec.ts index 629e972b04..75a56352ca 100644 --- a/src/json-crdt-extensions/peritext/overlay/__tests__/OverlayPoint.spec.ts +++ b/src/json-crdt-extensions/peritext/overlay/__tests__/OverlayPoint.spec.ts @@ -124,7 +124,7 @@ describe('layers', () => { describe('markers', () => { test('can add a marker', () => { const {peritext, getPoint} = setupOverlayPoint(); - const marker = peritext.slices.insSplit(peritext.rangeAt(5, 0), '

'); + const marker = peritext.slices.insMarker(peritext.rangeAt(5, 0), '

'); const point = getPoint(marker.start); expect(point.markers.length).toBe(0); point.addMarker(marker); @@ -134,7 +134,7 @@ describe('markers', () => { test('inserting same marker twice is a no-op', () => { const {peritext, getPoint} = setupOverlayPoint(); - const marker = peritext.slices.insSplit(peritext.rangeAt(5, 0), '

'); + const marker = peritext.slices.insMarker(peritext.rangeAt(5, 0), '

'); const point = getPoint(marker.start); expect(point.markers.length).toBe(0); point.addMarker(marker); @@ -147,8 +147,8 @@ describe('markers', () => { test('can add two markers with the same start position', () => { const {peritext, getPoint} = setupOverlayPoint(); - const marker1 = peritext.slices.insSplit(peritext.rangeAt(5, 0), '

'); - const marker2 = peritext.slices.insSplit(peritext.rangeAt(5, 0), '

'); + const marker1 = peritext.slices.insMarker(peritext.rangeAt(5, 0), '

'); + const marker2 = peritext.slices.insMarker(peritext.rangeAt(5, 0), '

'); const point = getPoint(marker1.start); expect(point.markers.length).toBe(0); point.addMarker(marker1); @@ -162,8 +162,8 @@ describe('markers', () => { test('orders markers by their ID', () => { const {peritext, getPoint} = setupOverlayPoint(); - const marker1 = peritext.slices.insSplit(peritext.rangeAt(5, 0), '

'); - const marker2 = peritext.slices.insSplit(peritext.rangeAt(5, 0), '

'); + const marker1 = peritext.slices.insMarker(peritext.rangeAt(5, 0), '

'); + const marker2 = peritext.slices.insMarker(peritext.rangeAt(5, 0), '

'); const point = getPoint(marker1.start); point.addMarker(marker2); point.addMarker(marker1); @@ -177,9 +177,9 @@ describe('markers', () => { test('can add tree markers and sort them correctly', () => { const {peritext, getPoint} = setupOverlayPoint(); - const marker1 = peritext.slices.insSplit(peritext.rangeAt(5, 0), '

'); - const marker2 = peritext.slices.insSplit(peritext.rangeAt(5, 0), '

'); - const marker3 = peritext.slices.insSplit(peritext.rangeAt(5, 0), '

'); + const marker1 = peritext.slices.insMarker(peritext.rangeAt(5, 0), '

'); + const marker2 = peritext.slices.insMarker(peritext.rangeAt(5, 0), '

'); + const marker3 = peritext.slices.insMarker(peritext.rangeAt(5, 0), '

'); const point = getPoint(marker1.start); point.addMarker(marker3); point.addMarker(marker3); @@ -197,9 +197,9 @@ describe('markers', () => { test('can add tree markers by appending them', () => { const {peritext, getPoint} = setupOverlayPoint(); - const marker1 = peritext.slices.insSplit(peritext.rangeAt(6, 1), '

'); - const marker2 = peritext.slices.insSplit(peritext.rangeAt(6, 2), '

'); - const marker3 = peritext.slices.insSplit(peritext.rangeAt(6, 3), '

'); + const marker1 = peritext.slices.insMarker(peritext.rangeAt(6, 1), '

'); + const marker2 = peritext.slices.insMarker(peritext.rangeAt(6, 2), '

'); + const marker3 = peritext.slices.insMarker(peritext.rangeAt(6, 3), '

'); const point = getPoint(marker2.start); point.addMarker(marker1); point.addMarker(marker2); @@ -211,9 +211,9 @@ describe('markers', () => { test('can remove markers', () => { const {peritext, getPoint} = setupOverlayPoint(); - const marker1 = peritext.slices.insSplit(peritext.rangeAt(6, 1), '

'); - const marker2 = peritext.slices.insSplit(peritext.rangeAt(6, 1), '

'); - const marker3 = peritext.slices.insSplit(peritext.rangeAt(6, 2), '

'); + const marker1 = peritext.slices.insMarker(peritext.rangeAt(6, 1), '

'); + const marker2 = peritext.slices.insMarker(peritext.rangeAt(6, 1), '

'); + const marker3 = peritext.slices.insMarker(peritext.rangeAt(6, 2), '

'); const point = getPoint(marker1.start); point.addMarker(marker2); point.addMarker(marker1); @@ -237,7 +237,7 @@ describe('markers', () => { describe('refs', () => { test('can add marker ref', () => { const {peritext, getPoint} = setupOverlayPoint(); - const marker = peritext.slices.insSplit(peritext.rangeAt(10, 1), '

'); + const marker = peritext.slices.insMarker(peritext.rangeAt(10, 1), '

'); const point = getPoint(marker.start); expect(point.markers.length).toBe(0); expect(point.refs.length).toBe(0); @@ -275,7 +275,7 @@ describe('refs', () => { test('can add marker and layer start', () => { const {peritext, getPoint} = setupOverlayPoint(); - const marker = peritext.slices.insSplit(peritext.rangeAt(10, 1), '

'); + const marker = peritext.slices.insMarker(peritext.rangeAt(10, 1), '

'); const slice = peritext.slices.insErase(peritext.rangeAt(10, 4), 123); const point = getPoint(slice.end); expect(point.layers.length).toBe(0); @@ -290,7 +290,7 @@ describe('refs', () => { test('can remove marker and layer', () => { const {peritext, getPoint} = setupOverlayPoint(); - const marker = peritext.slices.insSplit(peritext.rangeAt(10, 1), '

'); + const marker = peritext.slices.insMarker(peritext.rangeAt(10, 1), '

'); const slice = peritext.slices.insErase(peritext.rangeAt(10, 4), 123); const point = getPoint(slice.end); point.addMarkerRef(marker); From ceadbdd3898e5f4d48e8d9b3e4f26a6d2a00a32b Mon Sep 17 00:00:00 2001 From: streamich Date: Tue, 30 Apr 2024 09:52:19 +0200 Subject: [PATCH 6/7] =?UTF-8?q?feat(json-crdt-extensions):=20=F0=9F=8E=B8?= =?UTF-8?q?=20improve=20how=20cursor=20is=20displayed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../peritext/overlay/__tests__/Overlay.spec.ts | 1 - src/json-crdt-extensions/peritext/slice/Cursor.ts | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.spec.ts b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.spec.ts index 05d2f89ce7..a770d0cb27 100644 --- a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.spec.ts +++ b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.spec.ts @@ -186,7 +186,6 @@ describe('slices', () => { expect(point3.layers[1].data()).toStrictEqual({bold: true}); const point4 = next(point3)!; expect(point4.layers.length).toBe(0); - console.log(peritext + ''); }); test('one char slice should correctly sort overlay points', () => { diff --git a/src/json-crdt-extensions/peritext/slice/Cursor.ts b/src/json-crdt-extensions/peritext/slice/Cursor.ts index 0213522bf8..8d1ab3e45a 100644 --- a/src/json-crdt-extensions/peritext/slice/Cursor.ts +++ b/src/json-crdt-extensions/peritext/slice/Cursor.ts @@ -101,9 +101,9 @@ export class Cursor extends Range implements Slice { // ---------------------------------------------------------------- Printable public toString(tab: string = ''): string { - const text = JSON.stringify(this.text()); + const text = this.text(); const focusIcon = this.anchorSide === CursorAnchor.Start ? '.→|' : '|←.'; const main = `${this.constructor.name} ${super.toString(tab + ' ', true)} ${focusIcon}`; - return main + printTree(tab, [() => text]); + return main + (text.length > 32 ? printTree(tab, [() => JSON.stringify(text)]) : ''); } } From c9c5ae454e7d204cd636a8bcec29170e380bdf45 Mon Sep 17 00:00:00 2001 From: streamich Date: Tue, 30 Apr 2024 09:52:56 +0200 Subject: [PATCH 7/7] =?UTF-8?q?style(json-crdt-extensions):=20=F0=9F=92=84?= =?UTF-8?q?=20run=20Prettier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-extensions/peritext/Peritext.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/json-crdt-extensions/peritext/Peritext.ts b/src/json-crdt-extensions/peritext/Peritext.ts index d5fc53f219..443a097e9c 100644 --- a/src/json-crdt-extensions/peritext/Peritext.ts +++ b/src/json-crdt-extensions/peritext/Peritext.ts @@ -179,7 +179,12 @@ export class Peritext implements Printable { // ------------------------------------------------------------------ markers - public insMarker(after: ITimestampStruct, type: SliceType, data?: unknown, char: string = Chars.BlockSplitSentinel): MarkerSlice { + 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;