diff --git a/src/json-crdt-extensions/peritext/editor/EditorSlices.ts b/src/json-crdt-extensions/peritext/editor/EditorSlices.ts index 26a95f8e39..e1faa18b26 100644 --- a/src/json-crdt-extensions/peritext/editor/EditorSlices.ts +++ b/src/json-crdt-extensions/peritext/editor/EditorSlices.ts @@ -1,9 +1,9 @@ +import {PersistedSlice} from '../slice/PersistedSlice'; import type {Peritext} from '../Peritext'; import type {SliceType} from '../slice/types'; import type {MarkerSlice} from '../slice/MarkerSlice'; import type {Slices} from '../slice/Slices'; import type {ITimestampStruct} from '../../../json-crdt-patch'; -import type {PersistedSlice} from '../slice/PersistedSlice'; import type {Cursor} from './Cursor'; export class EditorSlices { @@ -42,4 +42,8 @@ export class EditorSlices { return marker; }); } + + public del(sliceOrId: PersistedSlice | ITimestampStruct): void { + this.slices.del(sliceOrId instanceof PersistedSlice ? sliceOrId.id : sliceOrId); + } } diff --git a/src/json-crdt-extensions/peritext/overlay/MarkerOverlayPoint.ts b/src/json-crdt-extensions/peritext/overlay/MarkerOverlayPoint.ts index 3b40ea91b3..63126c6c63 100644 --- a/src/json-crdt-extensions/peritext/overlay/MarkerOverlayPoint.ts +++ b/src/json-crdt-extensions/peritext/overlay/MarkerOverlayPoint.ts @@ -1,14 +1,15 @@ import {printTree} from 'tree-dump/lib/printTree'; import {OverlayPoint} from './OverlayPoint'; +import type {HeadlessNode2} from 'sonic-forest/lib/types2'; import type {SliceType} from '../slice/types'; import type {Anchor} from '../rga/constants'; import type {AbstractRga} from '../../../json-crdt/nodes/rga'; import type {ITimestampStruct} from '../../../json-crdt-patch/clock'; import type {MarkerSlice} from '../slice/MarkerSlice'; -export class MarkerOverlayPoint extends OverlayPoint { +export class MarkerOverlayPoint extends OverlayPoint implements HeadlessNode2 { /** - * Hash value of the preceding text contents, up until the next marker. + * Hash value of the following text contents, up until the next marker. */ public textHash: number = 0; @@ -21,14 +22,6 @@ export class MarkerOverlayPoint extends OverlayPoint { super(rga, id, anchor); } - /** - * @todo Rename or access it directly. - * @deprecated - */ - public markerHash(): number { - return this.marker ? this.marker.hash : 0; - } - public type(): SliceType { return this.marker && this.marker.type; } @@ -57,4 +50,10 @@ export class MarkerOverlayPoint extends OverlayPoint { ])) ); } + + // ------------------------------------------------------------ HeadlessNode2 + + public p2: MarkerOverlayPoint | undefined; + public l2: MarkerOverlayPoint | undefined; + public r2: MarkerOverlayPoint | undefined; } diff --git a/src/json-crdt-extensions/peritext/overlay/Overlay.ts b/src/json-crdt-extensions/peritext/overlay/Overlay.ts index 016c6e7896..645c8ca229 100644 --- a/src/json-crdt-extensions/peritext/overlay/Overlay.ts +++ b/src/json-crdt-extensions/peritext/overlay/Overlay.ts @@ -1,6 +1,7 @@ import {printTree} from 'tree-dump/lib/printTree'; import {printBinary} from 'tree-dump/lib/printBinary'; import {first, insertLeft, insertRight, last, next, prev, remove} from 'sonic-forest/lib/util'; +import {first2, insert2, next2, remove2} from 'sonic-forest/lib/util2'; import {splay} from 'sonic-forest/lib/splay/util'; import {Anchor} from '../rga/constants'; import {Point} from '../rga/Point'; @@ -19,6 +20,9 @@ import type {Printable} from 'tree-dump/lib/types'; import type {MutableSlice, Slice} from '../slice/types'; import type {Slices} from '../slice/Slices'; import type {OverlayPair, OverlayTuple} from './types'; +import type {Comparator} from 'sonic-forest/lib/types'; + +const spatialComparator: Comparator = (a: OverlayPoint, b: OverlayPoint) => a.cmpSpatial(b); /** * Overlay is a tree structure that represents all the intersections of slices @@ -29,6 +33,7 @@ import type {OverlayPair, OverlayTuple} from './types'; */ export class Overlay implements Printable, Stateful { public root: OverlayPoint | undefined = undefined; + public root2: MarkerOverlayPoint | undefined = undefined; /** A virtual absolute start point, used when the absolute start is missing. */ public readonly START: OverlayPoint; @@ -110,15 +115,7 @@ 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; - } - + /** @todo Rename to `chunks()`. */ public chunkSlices0( chunk: Chunk | undefined, p1: Point, @@ -179,20 +176,17 @@ export class Overlay implements Printable, Stateful { return new UndefEndIter(this.points0(after)); } - public markers0(): UndefIterator> { - let curr = this.first(); + public markers0(after: undefined | MarkerOverlayPoint): UndefIterator> { + let curr = after ? next2(after) : first2(this.root2); return () => { - while (curr) { - const ret = curr; - if (curr) curr = next(curr); - if (ret instanceof MarkerOverlayPoint) return ret; - } - return; + const ret = curr; + if (curr) curr = next2(curr); + return ret; }; } public markers(): IterableIterator> { - return new UndefEndIter(this.markers0()); + return new UndefEndIter(this.markers0(undefined)); } public pairs0(after: undefined | OverlayPoint): UndefIterator> { @@ -245,6 +239,31 @@ export class Overlay implements Printable, Stateful { return new UndefEndIter(this.tuples0(after)); } + /** + * Finds the first point that satisfies the given predicate function. + * + * @param predicate Predicate function to find the point, returns true if the + * point is found. + * @returns The first point that satisfies the predicate, or undefined if no + * point is found. + */ + public find(predicate: (point: OverlayPoint) => boolean): OverlayPoint | undefined { + let point = this.first(); + while (point) { + if (predicate(point)) return point; + point = next(point); + } + return; + } + + /** + * Finds all slices that are contained within the given range. A slice is + * considered contained if its start and end points are within the range, + * inclusive (uses {@link Range#contains} method to check containment). + * + * @param range The range to search for contained slices. + * @returns A set of slices that are contained within the given range. + */ public findContained(range: Range): Set> { const result = new Set>(); let point = this.getOrNextLower(range.start); @@ -265,6 +284,14 @@ export class Overlay implements Printable, Stateful { return result; } + /** + * Finds all slices that overlap with the given range. A slice is considered + * overlapping if its start or end point is within the range, inclusive + * (uses {@link Range#containsPoint} method to check overlap). + * + * @param range The range to search for overlapping slices. + * @returns A set of slices that overlap with the given range. + */ public findOverlapping(range: Range): Set> { const result = new Set>(); let point = this.getOrNextLower(range.start); @@ -281,12 +308,16 @@ export class Overlay implements Printable, Stateful { return result; } - public isBlockSplit(id: ITimestampStruct): boolean { - const point = this.txt.point(id, Anchor.Before); - const overlayPoint = this.getOrNextLower(point); - return ( - overlayPoint instanceof MarkerOverlayPoint && overlayPoint.id.time === id.time && overlayPoint.id.sid === id.sid - ); + /** + * Returns `true` if the current character is a marker sentinel. + * + * @param id ID of the point to check. + * @returns Whether the point is a marker point. + */ + public isMarker(id: ITimestampStruct): boolean { + const p = this.txt.point(id, Anchor.Before); + const op = this.getOrNextLower(p); + return op instanceof MarkerOverlayPoint && op.id.time === id.time && op.id.sid === id.sid; } // ----------------------------------------------------------------- Stateful @@ -299,7 +330,12 @@ export class Overlay implements Printable, Stateful { hash = this.refreshSlices(hash, txt.savedSlices); hash = this.refreshSlices(hash, txt.extraSlices); hash = this.refreshSlices(hash, txt.localSlices); - if (!slicesOnly) this.computeSplitTextHashes(); + + // TODO: Move test hash calculation out of the overlay. + if (!slicesOnly) { + // hash = updateRga(hash, txt.str); + hash = this.refreshTextSlices(hash); + } return (this.hash = hash); } @@ -339,7 +375,6 @@ export class Overlay implements Printable, Stateful { } private insSlice(slice: Slice): [start: OverlayPoint, end: OverlayPoint] { - // TODO: Test cases where the inserted slice is collapsed to one point. const x0 = slice.start; const x1 = slice.end; const [start, isStartNew] = this.upsertPoint(x0); @@ -359,10 +394,7 @@ export class Overlay implements Printable, Stateful { let curr: OverlayPoint | undefined = start; do curr.addLayer(slice); while ((curr = next(curr)) && curr !== end); - } else { - // TODO: review if this is needed: - start.addMarker(slice); - } + } else start.addMarker(slice); return [start, end]; } @@ -408,6 +440,10 @@ export class Overlay implements Printable, Stateful { * @returns Returns the existing point if it was already in the tree. */ private insPoint(point: OverlayPoint): OverlayPoint | undefined { + if (point instanceof MarkerOverlayPoint) { + this.root2 = insert2(this.root2, point, spatialComparator); + // if (this.root2 !== point) this.root2 = splay2(this.root2!, point, 10); + } let pivot = this.getOrNextLower(point); if (!pivot) pivot = first(this.root); if (!pivot) { @@ -424,23 +460,23 @@ export class Overlay implements Printable, Stateful { } private delPoint(point: OverlayPoint): void { + if (point instanceof MarkerOverlayPoint) this.root2 = remove2(this.root2, point); this.root = remove(this.root, point); } public leadingTextHash: number = 0; - protected computeSplitTextHashes(): void { + protected refreshTextSlices(stateTotal: number): number { const txt = this.txt; const str = txt.str; const firstChunk = str.first(); - if (!firstChunk) return; + if (!firstChunk) return stateTotal; let chunk: Chunk | undefined = firstChunk; let marker: MarkerOverlayPoint | undefined = undefined; - let state: number = CONST.START_STATE; const i = this.tuples0(undefined); + let state: number = CONST.START_STATE; for (let pair = i(); pair; pair = i()) { const [p1, p2] = pair; - // TODO: need to incorporate slice attribute hash here? const id1 = p1.id; state = (state << 5) + state + (id1.sid >>> 0) + id1.time; let overlayPointHash = CONST.START_STATE; @@ -450,15 +486,15 @@ export class Overlay implements Printable, Stateful { (overlayPointHash << 5) + overlayPointHash + ((((id.sid >>> 0) + id.time) << 8) + (off << 4) + len); }); state = updateNum(state, overlayPointHash); - if (p1) { - p1.hash = overlayPointHash; - } + p1.hash = overlayPointHash; + stateTotal = updateNum(stateTotal, overlayPointHash); if (p2 instanceof MarkerOverlayPoint) { if (marker) { marker.textHash = state; } else { this.leadingTextHash = state; } + stateTotal = updateNum(stateTotal, state); state = CONST.START_STATE; marker = p2; } @@ -468,6 +504,7 @@ export class Overlay implements Printable, Stateful { } else { this.leadingTextHash = state; } + return stateTotal; } // ---------------------------------------------------------------- Printable @@ -482,9 +519,21 @@ export class Overlay implements Printable, Stateful { ]) ); }; + const printMarkerPoint = (tab: string, point: MarkerOverlayPoint): string => { + return ( + point.toString(tab) + + printBinary(tab, [ + !point.l2 ? null : (tab) => printMarkerPoint(tab, point.l2!), + !point.r2 ? null : (tab) => printMarkerPoint(tab, point.r2!), + ]) + ); + }; return ( `${this.constructor.name} #${this.hash.toString(36)}` + - printTree(tab, [!this.root ? null : (tab) => printPoint(tab, this.root!)]) + printTree(tab, [ + !this.root ? null : (tab) => printPoint(tab, this.root!), + !this.root2 ? null : (tab) => printMarkerPoint(tab, this.root2!), + ]) ); } } diff --git a/src/json-crdt-extensions/peritext/overlay/OverlayPoint.ts b/src/json-crdt-extensions/peritext/overlay/OverlayPoint.ts index 6ace500f22..d1c5557533 100644 --- a/src/json-crdt-extensions/peritext/overlay/OverlayPoint.ts +++ b/src/json-crdt-extensions/peritext/overlay/OverlayPoint.ts @@ -4,7 +4,7 @@ import {OverlayRef, OverlayRefSliceEnd, OverlayRefSliceStart} from './refs'; import {printTree} from 'tree-dump/lib/printTree'; import type {MarkerSlice} from '../slice/MarkerSlice'; import type {HeadlessNode} from 'sonic-forest/lib/types'; -import type {Printable} from 'tree-dump/lib/types'; +import type {PrintChild, Printable} from 'tree-dump/lib/types'; import type {Slice} from '../slice/types'; /** @@ -85,7 +85,6 @@ export class OverlayPoint extends Point implements Printable, Hea /** * Collapsed slices - markers (block splits), which represent a single point * in the text, even if the start and end of the slice are different. - * @deprecated This field might happen to be not necessary. */ public readonly markers: Slice[] = []; @@ -96,10 +95,8 @@ export class OverlayPoint extends Point implements Printable, Hea * the state of the point. The markers are sorted by the slice ID. * * @param slice Slice to add to the marker list. - * @deprecated This method might happen to be not necessary. */ public addMarker(slice: Slice): void { - /** @deprecated */ const markers = this.markers; const length = markers.length; if (!length) { @@ -131,10 +128,8 @@ export class OverlayPoint extends Point implements Printable, Hea * the text, even if the start and end of the slice are different. * * @param slice Slice to remove from the marker list. - * @deprecated This method might happen to be not necessary. */ public removeMarker(slice: Slice): void { - /** @deprecated */ const markers = this.markers; const length = markers.length; for (let i = 0; i < length; i++) { @@ -218,13 +213,14 @@ export class OverlayPoint extends Point implements Printable, Hea const refs = lite ? '' : `, refs = ${this.refs.length}`; const header = this.toStringName(tab, lite) + refs; if (lite) return header; - return ( - header + - printTree( - tab, - this.layers.map((slice) => (tab) => slice.toString(tab)), - ) - ); + const children: PrintChild[] = []; + const layers = this.layers; + const layerLength = layers.length; + for (let i = 0; i < layerLength; i++) children.push((tab) => layers[i].toString(tab)); + const markers = this.markers; + const markerLength = markers.length; + for (let i = 0; i < markerLength; i++) children.push((tab) => markers[i].toString(tab)); + return header + printTree(tab, children); } // ------------------------------------------------------------- HeadlessNode diff --git a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.findX.spec.ts b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.findX.spec.ts new file mode 100644 index 0000000000..3ca529e648 --- /dev/null +++ b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.findX.spec.ts @@ -0,0 +1,191 @@ +import {Kit, setupHelloWorldKit, setupHelloWorldWithFewEditsKit} from '../../__tests__/setup'; +import {Cursor} from '../../editor/Cursor'; +import {OverlayRefSliceEnd} from '../refs'; + +const runFind = (setup: () => Kit) => { + describe('.find()', () => { + test('can find nothing', () => { + const {peritext} = setup(); + peritext.overlay.refresh(); + const point = peritext.overlay.find(() => false); + expect(point).toBe(undefined); + }); + + test('can find a single caret cursor', () => { + const {peritext} = setup(); + peritext.editor.cursor.setAt(3); + peritext.overlay.refresh(); + const point = peritext.overlay.find((point) => { + return point.markers[0] instanceof Cursor; + })!; + expect(point.markers[0]).toBe(peritext.editor.cursor); + }); + + test('can find the cursor by selection start', () => { + const {peritext} = setup(); + peritext.editor.cursor.setAt(3, 3); + peritext.overlay.refresh(); + const point = peritext.overlay.find((point) => { + return point.layers[0] === peritext.editor.cursor; + })!; + expect(point.layers[0]).toBe(peritext.editor.cursor); + }); + + test('can find the cursor by selection end', () => { + const {peritext} = setup(); + peritext.editor.cursor.setAt(3, 3); + peritext.overlay.refresh(); + const point = peritext.overlay.find((point) => { + if (point.refs[0] instanceof OverlayRefSliceEnd) { + return point.refs[0].slice === peritext.editor.cursor; + } + return false; + })!; + expect((point.refs[0] as OverlayRefSliceEnd).slice).toBe(peritext.editor.cursor); + }); + }); +}; + +const runFindContainedTests = (setup: () => Kit) => { + describe('.findContained()', () => { + test('returns empty set by default', () => { + const {peritext} = setup(); + peritext.overlay.refresh(); + const slices = peritext.overlay.findContained(peritext.rangeAt(3, 4)); + expect(slices.size).toBe(0); + }); + + test('returns a single contained slice', () => { + const {peritext, editor} = setup(); + editor.cursor.setAt(3, 2); + editor.saved.insStack('em'); + editor.cursor.setAt(0); + peritext.overlay.refresh(); + const slices = peritext.overlay.findContained(peritext.rangeAt(3, 4)); + expect(slices.size).toBe(1); + }); + + test('returns two contained slice', () => { + const {peritext, editor} = setup(); + editor.cursor.setAt(3, 1); + editor.saved.insStack('em'); + editor.cursor.setAt(5, 2); + editor.saved.insStack('bold'); + editor.cursor.setAt(0); + peritext.overlay.refresh(); + const slices = peritext.overlay.findContained(peritext.rangeAt(2, 8)); + expect(slices.size).toBe(2); + }); + + test('does not return overlapping slice', () => { + const {peritext, editor} = setup(); + editor.cursor.setAt(3, 4); + editor.saved.insStack('em'); + editor.cursor.setAt(5, 2); + editor.saved.insStack('bold'); + editor.cursor.setAt(0); + peritext.overlay.refresh(); + const slices = peritext.overlay.findContained(peritext.rangeAt(4, 8)); + expect(slices.size).toBe(1); + }); + + test('returns split blocks', () => { + const {peritext, editor} = setup(); + editor.cursor.setAt(3, 4); + editor.saved.insStack('em'); + editor.cursor.setAt(5, 2); + editor.saved.insStack('bold'); + editor.cursor.setAt(8); + editor.saved.insMarker('p'); + editor.cursor.setAt(0); + peritext.overlay.refresh(); + const slices = peritext.overlay.findContained(peritext.rangeAt(4, 8)); + expect(slices.size).toBe(2); + }); + }); +}; + +const runFindOverlappingTests = (setup: () => Kit) => { + describe('.findOverlapping()', () => { + test('returns empty set by default', () => { + const {peritext} = setup(); + peritext.overlay.refresh(); + const slices = peritext.overlay.findOverlapping(peritext.rangeAt(3, 4)); + expect(slices.size).toBe(0); + }); + + test('returns a single contained slice', () => { + const {peritext, editor} = setup(); + editor.cursor.setAt(3, 2); + editor.saved.insStack('em'); + editor.cursor.setAt(0); + peritext.overlay.refresh(); + const slices = peritext.overlay.findOverlapping(peritext.rangeAt(3, 4)); + expect(slices.size).toBe(1); + }); + + test('returns two contained slice', () => { + const {peritext, editor} = setup(); + editor.cursor.setAt(3, 1); + editor.saved.insStack('em'); + editor.cursor.setAt(5, 2); + editor.saved.insStack('bold'); + editor.cursor.setAt(0); + peritext.overlay.refresh(); + const slices = peritext.overlay.findOverlapping(peritext.rangeAt(2, 8)); + expect(slices.size).toBe(2); + }); + + test('returns overlapping slices', () => { + const {peritext, editor} = setup(); + editor.cursor.setAt(3, 4); + editor.saved.insStack('em'); + editor.cursor.setAt(5, 2); + editor.saved.insStack('bold'); + editor.cursor.setAt(0); + peritext.overlay.refresh(); + const slices = peritext.overlay.findOverlapping(peritext.rangeAt(4, 8)); + expect(slices.size).toBe(2); + }); + + test('returns overlapping slices from both ends', () => { + const {peritext, editor} = setup(); + editor.cursor.setAt(3, 2); + editor.saved.insStack('em'); + editor.cursor.setAt(8, 2); + editor.saved.insStack('bold'); + editor.cursor.setAt(0); + peritext.overlay.refresh(); + const slices = peritext.overlay.findOverlapping(peritext.rangeAt(4, 5)); + expect(slices.size).toBe(2); + }); + + test('returns split blocks', () => { + const {peritext, editor} = setup(); + editor.cursor.setAt(3, 4); + editor.saved.insStack('em'); + editor.cursor.setAt(5, 2); + editor.saved.insStack('bold'); + editor.cursor.setAt(8); + editor.saved.insMarker('p'); + editor.cursor.setAt(0); + peritext.overlay.refresh(); + const slices = peritext.overlay.findOverlapping(peritext.rangeAt(4, 8)); + expect(slices.size).toBe(3); + }); + }); +}; + +const runTestSuite = (setup: () => Kit) => { + runFind(setup); + runFindContainedTests(setup); + runFindOverlappingTests(setup); +}; + +describe('text "hello world", no edits', () => { + runTestSuite(setupHelloWorldKit); +}); + +describe('text "hello world", with few edits', () => { + runTestSuite(setupHelloWorldWithFewEditsKit); +}); diff --git a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.markers.spec.ts b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.markers.spec.ts new file mode 100644 index 0000000000..b454f60ff5 --- /dev/null +++ b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.markers.spec.ts @@ -0,0 +1,111 @@ +import {Kit, setupNumbersKit, setupNumbersWithTombstonesKit} from '../../__tests__/setup'; +import {MarkerOverlayPoint} from '../MarkerOverlayPoint'; + +const runMarkersTests = (setup: () => Kit) => { + describe('.markers()', () => { + test('returns empty set by default', () => { + const {peritext} = setup(); + peritext.overlay.refresh(); + const list = [...peritext.overlay.markers()]; + expect(list.length).toBe(0); + }); + + test('returns a single marker', () => { + const {peritext, editor} = setup(); + editor.cursor.setAt(3); + editor.saved.insMarker(''); + peritext.overlay.refresh(); + const list = [...peritext.overlay.markers()]; + expect(list.length).toBe(1); + expect(list[0] instanceof MarkerOverlayPoint).toBe(true); + }); + + test('can iterate through multiple markers', () => { + const {peritext, editor} = setup(); + editor.cursor.setAt(5); + const [m2] = editor.saved.insMarker(''); + peritext.overlay.refresh(); + editor.cursor.setAt(8); + const [m3] = editor.local.insMarker(''); + peritext.overlay.refresh(); + editor.cursor.setAt(2); + const [m1] = editor.local.insMarker(''); + peritext.overlay.refresh(); + const list = [...peritext.overlay.markers()]; + expect(list.length).toBe(3); + list.forEach((m) => expect(m instanceof MarkerOverlayPoint).toBe(true)); + expect(list[0].marker).toBe(m1); + expect(list[1].marker).toBe(m2); + expect(list[2].marker).toBe(m3); + }); + + test('can delete markers', () => { + const {peritext, editor} = setup(); + editor.cursor.setAt(5); + const [m2] = editor.extra.insMarker(''); + editor.cursor.setAt(8); + const [m3] = editor.local.insMarker(''); + editor.cursor.setAt(2); + const [m1] = editor.local.insMarker(''); + peritext.overlay.refresh(); + const list = [...peritext.overlay.markers()]; + expect(list.length).toBe(3); + editor.local.del(m3); + peritext.overlay.refresh(); + const list2 = [...peritext.overlay.markers()]; + expect(list2.length).toBe(2); + expect(list2[0].marker).toBe(m1); + expect(list2[1].marker).toBe(m2); + editor.local.del(m2); + peritext.overlay.refresh(); + const list3 = [...peritext.overlay.markers()]; + expect(list3.length).toBe(2); + expect(list3[0].marker).toBe(m1); + expect(list3[1].marker).toBe(m2); + editor.extra.del(m2); + peritext.overlay.refresh(); + const list4 = [...peritext.overlay.markers()]; + expect(list4.length).toBe(1); + expect(list4[0].marker).toBe(m1); + editor.local.del(m1); + editor.local.del(m1); + editor.local.del(m1); + peritext.overlay.refresh(); + expect([...peritext.overlay.markers()].length).toBe(0); + }); + + test('can add marker at the start of text', () => { + const {peritext, editor} = setup(); + editor.cursor.setAt(0); + const [marker] = editor.extra.insMarker(0); + peritext.overlay.refresh(); + const list = [...peritext.overlay.markers()]; + expect(list.length).toBe(1); + expect(list[0].marker).toBe(marker); + editor.extra.del(marker); + peritext.overlay.refresh(); + expect([...peritext.overlay.markers()].length).toBe(0); + }); + + test('can add marker at the end of text', () => { + const {peritext, editor} = setup(); + editor.cursor.set(peritext.pointEnd()!); + const [marker] = editor.extra.insMarker('0'); + peritext.overlay.refresh(); + const list = [...peritext.overlay.markers()]; + expect(list.length).toBe(1); + expect(list[0].marker).toBe(marker); + editor.extra.del(marker); + peritext.overlay.refresh(); + expect([...peritext.overlay.markers()].length).toBe(0); + }); + }); +}; + +describe('numbers "0123456789", no edits', () => { + runMarkersTests(setupNumbersKit); +}); + +describe('numbers "0123456789", with default schema and tombstones', () => { + runMarkersTests(setupNumbersWithTombstonesKit); +}); diff --git a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.points.spec.ts b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.points.spec.ts index d341897e5a..822feb5d46 100644 --- a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.points.spec.ts +++ b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.points.spec.ts @@ -4,7 +4,7 @@ import {Peritext} from '../../Peritext'; import type {OverlayPoint} from '../OverlayPoint'; const setup = () => { - const model = Model.withLogicalClock(); + const model = Model.create(); const api = model.api; api.root({ text: '', diff --git a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.refresh.spec.ts b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.refresh.spec.ts index 8b5fcbb122..59fa1c2c96 100644 --- a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.refresh.spec.ts +++ b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.refresh.spec.ts @@ -6,7 +6,7 @@ import {SliceBehavior} from '../../slice/constants'; const setup = () => { const sid = 123456789; - const model = Model.withLogicalClock(sid); + const model = Model.create(undefined, sid); model.api.root({ text: '', slices: [], @@ -266,7 +266,7 @@ describe('Overlay.refresh()', () => { }); }); - describe('cursor', () => { + describe('local slices - cursor', () => { describe('updates hash', () => { testRefresh('when cursor char ID changes', (kit, refresh) => { kit.peritext.editor.cursor.setAt(1); @@ -302,4 +302,41 @@ describe('Overlay.refresh()', () => { }); }); }); + + describe('text contents', () => { + describe('updates hash', () => { + testRefresh('when the first character is deleted and reinserted', (kit, refresh) => { + const index = 0; + const str = kit.peritext.strApi(); + const char = str.view()[index]; + const view = str.view(); + refresh(); + kit.peritext.strApi().del(index, 1); + kit.peritext.strApi().ins(index, char); + expect(str.view()).toEqual(view); + }); + + testRefresh('when the last character is deleted and reinserted', (kit, refresh) => { + const index = kit.peritext.strApi().view().length - 1; + const str = kit.peritext.strApi(); + const char = str.view()[index]; + const view = str.view(); + refresh(); + kit.peritext.strApi().del(index, 1); + kit.peritext.strApi().ins(index, char); + expect(str.view()).toEqual(view); + }); + + testRefresh('when the third character is reinserted', (kit, refresh) => { + const index = 3; + const str = kit.peritext.strApi(); + const char = str.view()[index]; + const view = str.view(); + refresh(); + kit.peritext.strApi().del(index, 1); + kit.peritext.strApi().ins(index, char); + expect(str.view()).toEqual(view); + }); + }); + }); }); 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 c6d4fe8d9b..6a717dc99a 100644 --- a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.spec.ts +++ b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.spec.ts @@ -21,7 +21,7 @@ const setup = () => { const markerCount = (peritext: Peritext): number => { const overlay = peritext.overlay; - const iterator = overlay.markers0(); + const iterator = overlay.markers0(void 0); let count = 0; for (let split = iterator(); split; split = iterator()) { count++; @@ -65,6 +65,18 @@ describe('markers', () => { peritext.overlay.refresh(); expect(markerCount(peritext)).toBe(2); }); + + test('does reference cursor, when marker and cursor are at the same position', () => { + const {peritext} = setup(); + peritext.editor.cursor.setAt(3); + const [marker] = peritext.editor.saved.insMarker(['p'], 'ΒΆ'); + peritext.editor.cursor.set(marker.start.clone()); + peritext.overlay.refresh(); + const overlayMarkerPoint = peritext.overlay.root2!; + expect(overlayMarkerPoint instanceof MarkerOverlayPoint).toBe(true); + expect(overlayMarkerPoint.markers.length).toBe(1); + expect(overlayMarkerPoint.markers.find((m) => m === peritext.editor.cursor)).toBe(peritext.editor.cursor); + }); }); describe('deletes', () => { @@ -106,7 +118,7 @@ describe('markers', () => { expect(markerCount(peritext)).toBe(2); const points = []; let point; - for (const iterator = peritext.overlay.markers0(); (point = iterator()); ) points.push(point); + for (const iterator = peritext.overlay.markers0(void 0); (point = iterator()); ) points.push(point); expect(points.length).toBe(2); expect(points[0].pos()).toBe(2); expect(points[1].pos()).toBe(11); @@ -149,6 +161,18 @@ describe('slices', () => { expect(points.length).toBe(4); }); + test('can insert a slice, which is collapsed to a point', () => { + const {peritext} = setup(); + peritext.editor.cursor.setAt(3); + const [slice] = peritext.editor.saved.insStack('em', {emphasis: true}); + peritext.overlay.refresh(); + const [point] = [...peritext.overlay.points()]; + expect(point.layers.length).toBe(0); + expect(point.markers.length).toBe(2); + expect(point.markers.find((m) => m === peritext.editor.cursor)).toBe(peritext.editor.cursor); + expect(point.markers.find((m) => m === slice)).toBe(slice); + }); + test('intersecting slice chunks point to two slices', () => { const {peritext} = setup(); peritext.editor.cursor.setAt(2, 2); diff --git a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.tuples.spec.ts b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.tuples.spec.ts index 6174e7a26a..e61c3db95f 100644 --- a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.tuples.spec.ts +++ b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.tuples.spec.ts @@ -152,10 +152,10 @@ const runPairsTests = (setup: () => Kit) => { }); }; -describe('numbers "hello world", no edits', () => { +describe('text "hello world", no edits', () => { runPairsTests(setupHelloWorldKit); }); -describe('numbers "hello world", with default schema and tombstones', () => { +describe('text "hello world", with few edits', () => { runPairsTests(setupHelloWorldWithFewEditsKit); }); diff --git a/src/json-crdt-extensions/peritext/slice/Slices.ts b/src/json-crdt-extensions/peritext/slice/Slices.ts index a4cb00a804..466b3688d1 100644 --- a/src/json-crdt-extensions/peritext/slice/Slices.ts +++ b/src/json-crdt-extensions/peritext/slice/Slices.ts @@ -42,9 +42,9 @@ export class Slices implements Stateful, Printable { data?: unknown, Klass: K = behavior === SliceBehavior.Marker ? MarkerSlice : PersistedSlice, ): S { - const model = this.set.doc; + const slicesModel = this.set.doc; const set = this.set; - const api = model.api; + const api = slicesModel.api; const builder = api.builder; const tupleId = builder.vec(); const start = range.start.clone(); @@ -68,10 +68,10 @@ export class Slices implements Stateful, Printable { const chunkId = builder.insArr(set.id, set.id, [tupleId]); // TODO: Consider using `s` schema here. api.apply(); - const tuple = model.index.get(tupleId) as VecNode; + const tuple = slicesModel.index.get(tupleId) as VecNode; const chunk = set.findById(chunkId)!; // TODO: Need to check if split slice text was deleted - const slice = new Klass(model, this.txt, chunk, tuple, behavior, type, start, end); + const slice = new Klass(slicesModel, this.txt, chunk, tuple, behavior, type, start, end); this.list.set(chunk.id, slice); return slice; } @@ -87,17 +87,17 @@ export class Slices implements Stateful, Printable { separator: string = Chars.BlockSplitSentinel, ): MarkerSlice { // TODO: test condition when cursors is at absolute or relative starts - const {txt, set} = this; - const model = set.doc; - const api = model.api; + const txt = this.txt; + const api = txt.model.api; const builder = api.builder; - const str = txt.str; /** * We skip one clock cycle to prevent Block-wise RGA from merging adjacent * characters. We want the marker chunk to always be its own distinct chunk. */ builder.nop(1); - const textId = builder.insStr(str.id, after, separator); + // TODO: Handle case when marker is inserted at the abs start, prevent abs start/end inserts. + const textId = builder.insStr(txt.str.id, after, separator); + api.apply(); const point = txt.point(textId, Anchor.Before); const range = txt.range(point, point.clone()); return this.insMarker(range, type, data); @@ -134,8 +134,10 @@ export class Slices implements Stateful, Printable { public del(id: ITimestampStruct): void { this.list.del(id); - const api = this.set.doc.api; - api.builder.del(this.set.id, [tss(id.sid, id.time, 1)]); + const set = this.set; + const api = set.doc.api; + // TODO: Is it worth checking if the slice is already deleted? + api.builder.del(set.id, [tss(id.sid, id.time, 1)]); api.apply(); }