diff --git a/src/json-crdt-extensions/peritext/Peritext.ts b/src/json-crdt-extensions/peritext/Peritext.ts index f0219e80bb..d9fb75cbe3 100644 --- a/src/json-crdt-extensions/peritext/Peritext.ts +++ b/src/json-crdt-extensions/peritext/Peritext.ts @@ -42,7 +42,7 @@ export class Peritext implements Printable { * @returns The point. */ public point(id: ITimestampStruct = this.str.id, anchor: Anchor = Anchor.After): Point { - return new Point(this, id, anchor); + return new Point(this.str, id, anchor); } /** @@ -92,7 +92,7 @@ export class Peritext implements Printable { * @returns A range with points in correct order. */ public rangeFromPoints(p1: Point, p2: Point): Range { - return Range.from(this, p1, p2); + return Range.from(this.str, p1, p2); } /** @@ -103,28 +103,19 @@ export class Peritext implements Printable { * @returns A range with the given start and end points. */ public range(start: Point, end: Point): Range { - return new Range(this, start, end); + return new Range(this.str, start, end); } /** - * Creates a range from a view position and a length. + * A convenience method for creating a range from a view position and a length. + * See {@link Range.at} for more information. * * @param start Position in the text. * @param length Length of the range. * @returns A range from the given position with the given length. */ public rangeAt(start: number, length: number = 0): Range { - 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.clone()); - } - 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); + return Range.at(this.str, start, length); } // --------------------------------------------------------------- Insertions diff --git a/src/json-crdt-extensions/peritext/editor/Editor.ts b/src/json-crdt-extensions/peritext/editor/Editor.ts index f0d7681b0c..058b92264c 100644 --- a/src/json-crdt-extensions/peritext/editor/Editor.ts +++ b/src/json-crdt-extensions/peritext/editor/Editor.ts @@ -54,8 +54,8 @@ export class Editor implements Printable { 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); + if (start.anchor === Anchor.After) cursor.setAfter(start.id); + else cursor.setAfter(start.prevId() || str.id); } return cursor.start.id; } @@ -68,7 +68,8 @@ export class Editor implements Printable { if (!text) return; const after = this.collapseSelection(); const textId = this.txt.ins(after, text); - this.cursor.setCaret(textId, text.length - 1); + const shift = text.length - 1; + this.cursor.setAfter(shift ? tick(textId, shift) : textId); } /** diff --git a/src/json-crdt-extensions/peritext/point/Point.ts b/src/json-crdt-extensions/peritext/point/Point.ts index 63b3553714..aa0821745f 100644 --- a/src/json-crdt-extensions/peritext/point/Point.ts +++ b/src/json-crdt-extensions/peritext/point/Point.ts @@ -2,10 +2,9 @@ import {compare, type ITimestampStruct, toDisplayString, equal, tick, containsId import {Anchor} from '../constants'; import {ChunkSlice} from '../util/ChunkSlice'; import {updateId} from '../../../json-crdt/hash'; +import type {AbstractRga, Chunk} from '../../../json-crdt/nodes/rga'; import type {Stateful} from '../types'; -import type {Peritext} from '../Peritext'; import type {Printable} from '../../../util/print/types'; -import type {StringChunk} from '../util/types'; /** * A "point" in a rich-text Peritext document. It is a combination of a @@ -23,9 +22,9 @@ import type {StringChunk} from '../util/types'; * after the last character, and even after any deleted characters at the end * of the string. */ -export class Point implements Pick, Printable { +export class Point implements Pick, Printable { constructor( - protected readonly txt: Peritext, + protected readonly rga: AbstractRga, public id: ITimestampStruct, public anchor: Anchor, ) {} @@ -36,7 +35,7 @@ export class Point implements Pick, Printable { * * @param point Point to copy. */ - public set(point: Point): void { + public set(point: Point): void { this.id = point.id; this.anchor = point.anchor; } @@ -46,8 +45,8 @@ export class Point implements Pick, Printable { * * @returns Returns a new point with the same ID and anchor as this point. */ - public clone(): Point { - return new Point(this.txt, this.id, this.anchor); + public clone(): Point { + return new Point(this.rga, this.id, this.anchor); } /** @@ -60,7 +59,7 @@ export class Point implements Pick, Printable { * than the other point, and 1 if this point is greater than the other * point. */ - public compare(other: Point): -1 | 0 | 1 { + public cmp(other: Point): -1 | 0 | 1 { const cmp = compare(this.id, other.id); if (cmp !== 0) return cmp; return (this.anchor - other.anchor) as -1 | 0 | 1; @@ -69,14 +68,14 @@ export class Point implements Pick, Printable { /** * Compares two points by their spatial (view) location in the string. Takes * into account not only the character position in the view, but also handles - * deleted characters and absolute points. + * deleted characters, attachment anchors, and absolute points. * * @param other The other point to compare to. * @returns Returns 0 if the two points are equal, negative if this point is * less than the other point, and positive if this point is greater * than the other point. */ - public compareSpatial(other: Point): number { + public cmpSpatial(other: Point): number { const thisId = this.id; const otherId = other.id; if (this.isAbs()) { @@ -93,22 +92,22 @@ export class Point implements Pick, Printable { let chunk = this.chunk(); if (!chunk) return cmp0; if (containsId(chunk.id, chunk.span, otherId)) return thisId.time - otherId.time; - const str = this.txt.str; - chunk = str.next(chunk); + const rga = this.rga; + chunk = rga.next(chunk); while (chunk) { if (containsId(chunk.id, chunk.span, otherId)) return -1; - chunk = str.next(chunk); + chunk = rga.next(chunk); } return 1; } - private _chunk: StringChunk | undefined; + private _chunk: Chunk | undefined; /** * @returns Returns the chunk that contains the character referenced by the * point, or `undefined` if the chunk is not found. */ - public chunk(): StringChunk | undefined { + public chunk(): Chunk | undefined { let chunk = this._chunk; const id = this.id; if (chunk) { @@ -117,7 +116,7 @@ export class Point implements Pick, Printable { const idTime = id.time; if (id.sid === chunkId.sid && idTime >= chunkIdTime && idTime < chunkIdTime + chunk.span) return chunk; } - this._chunk = chunk = this.txt.str.findById(this.id); + this._chunk = chunk = this.rga.findById(this.id); return chunk; } @@ -127,27 +126,18 @@ export class Point implements Pick, Printable { public pos(): number { const chunk = this.chunk(); if (!chunk) return -1; - const pos = this.txt.str.pos(chunk); + const pos = this.rga.pos(chunk); if (chunk.del) return pos; return pos + this.id.time - chunk.id.time; } - private _pos: number = -1; - - /** @todo Is this needed? */ - public posCached(): number { - if (this._pos >= 0) return this._pos; - const pos = (this._pos = this.pos()); - return pos; - } - /** * @returns Returns the view position of the point, as if it is a caret in * the text pointing between characters. */ public viewPos(): number { const pos = this.pos(); - if (pos < 0) return this.isAbsStart() ? 0 : this.txt.str.length(); + if (pos < 0) return this.isAbsStart() ? 0 : this.rga.length(); return this.anchor === Anchor.Before ? pos : pos + 1; } @@ -162,17 +152,16 @@ export class Point implements Pick, Printable { public nextId(move: number = 1): ITimestampStruct | undefined { if (this.isAbsEnd()) return; let remaining: number = move; - const {id, txt} = this; - const str = txt.str; - let chunk: StringChunk | undefined; + const {id, rga} = this; + let chunk: Chunk | undefined; if (this.isAbsStart()) { - chunk = str.first(); - while (chunk && chunk.del) chunk = str.next(chunk); + chunk = rga.first(); + while (chunk && chunk.del) chunk = rga.next(chunk); if (!chunk) return; const span = chunk.span; if (remaining <= span) return tick(chunk.id, remaining - 1); remaining -= span; - chunk = str.next(chunk); + chunk = rga.next(chunk); } else { chunk = this.chunk(); if (!chunk) return undefined; @@ -182,19 +171,19 @@ export class Point implements Pick, Printable { if (offset + remaining < span) return tick(id, remaining); else remaining -= span - offset - 1; } - chunk = str.next(chunk); + chunk = rga.next(chunk); } - let lastVisibleChunk: StringChunk | undefined; + let lastVisibleChunk: Chunk | undefined; while (chunk && remaining >= 0) { if (chunk.del) { - chunk = str.next(chunk); + chunk = rga.next(chunk); continue; } lastVisibleChunk = chunk; const span = chunk.span; if (remaining <= span) return remaining > 1 ? tick(chunk.id, remaining - 1) : chunk.id; remaining -= span; - chunk = str.next(chunk); + chunk = rga.next(chunk); } if (remaining > 0) return; return lastVisibleChunk ? tick(lastVisibleChunk.id, lastVisibleChunk.span - 1) : undefined; @@ -208,19 +197,18 @@ export class Point implements Pick, Printable { public prevId(move: number = 1): ITimestampStruct | undefined { if (this.isAbsStart()) return; let remaining: number = move; - const {id, txt} = this; - const str = txt.str; + const {id, rga} = this; let chunk = this.chunk(); - if (!chunk) return str.id; + if (!chunk) return rga.id; if (!chunk.del) { const offset = id.time - chunk.id.time; if (offset >= remaining) return tick(id, -remaining); remaining -= offset; } - chunk = str.prev(chunk); + chunk = rga.prev(chunk); while (chunk) { if (chunk.del) { - chunk = str.prev(chunk); + chunk = rga.prev(chunk); continue; } const span = chunk.span; @@ -228,7 +216,7 @@ export class Point implements Pick, Printable { return tick(chunk.id, span - remaining); } remaining -= span; - chunk = str.prev(chunk); + chunk = rga.prev(chunk); } return; } @@ -239,10 +227,10 @@ export class Point implements Pick, Printable { * * @returns A character slice to the left of the point. */ - public leftChar(): ChunkSlice | undefined { - const str = this.txt.str; + public leftChar(): ChunkSlice | undefined { + const rga = this.rga; if (this.isAbsEnd()) { - const res = str.findChunk(str.length() - 1); + const res = rga.findChunk(rga.length() - 1); if (!res) return; return new ChunkSlice(res[0], res[1], 1); } @@ -261,10 +249,10 @@ export class Point implements Pick, Printable { * * @returns A character slice to the right of the point. */ - public rightChar(): ChunkSlice | undefined { - const str = this.txt.str; + public rightChar(): ChunkSlice | undefined { + const rga = this.rga; if (this.isAbsStart()) { - const res = str.findChunk(0); + const res = rga.findChunk(0); if (!res) return; return new ChunkSlice(res[0], res[1], 1); } @@ -285,7 +273,7 @@ export class Point implements Pick, Printable { * @returns Returns `true` if the point is an absolute point. */ public isAbs(): boolean { - return equal(this.id, this.txt.str.id); + return equal(this.id, this.rga.id); } /** @@ -311,7 +299,7 @@ export class Point implements Pick, Printable { */ public isRelStart(): boolean { if (this.anchor !== Anchor.Before) return false; - const id = this.txt.str.find(0); + const id = this.rga.find(0); return !!id && equal(this.id, id); } @@ -322,10 +310,10 @@ export class Point implements Pick, Printable { */ public isRelEnd(): boolean { if (this.anchor !== Anchor.After) return false; - const str = this.txt.str; - const length = str.length(); + const rga = this.rga; + const length = rga.length(); if (length === 0) return false; - const id = str.find(length - 1); + const id = rga.find(length - 1); return !!id && equal(this.id, id); } @@ -333,7 +321,7 @@ export class Point implements Pick, Printable { * Sets the point to the absolute start of the string. */ public refAbsStart(): void { - this.id = this.txt.str.id; + this.id = this.rga.id; this.anchor = Anchor.After; } @@ -341,7 +329,7 @@ export class Point implements Pick, Printable { * Sets the point to the absolute end of the string. */ public refAbsEnd(): void { - this.id = this.txt.str.id; + this.id = this.rga.id; this.anchor = Anchor.Before; } @@ -371,7 +359,7 @@ export class Point implements Pick, Printable { const chunk = this.chunk(); if (!chunk) { if (this.isAbsStart()) { - const id = this.txt.str.find(0); + const id = this.rga.find(0); if (id) { this.id = id; this.anchor = Anchor.Before; @@ -382,7 +370,7 @@ export class Point implements Pick, Printable { } if (!chunk.del && this.anchor === Anchor.Before) return; this.anchor = Anchor.Before; - this.id = this.nextId() || this.txt.str.id; + this.id = this.nextId() || this.rga.id; } /** @@ -395,10 +383,10 @@ export class Point implements Pick, Printable { const chunk = this.chunk(); if (!chunk) { if (this.isAbsEnd()) { - const str = this.txt.str; - const length = str.length(); + const rga = this.rga; + const length = rga.length(); if (length !== 0) { - const id = str.find(length - 1); + const id = rga.find(length - 1); if (id) { this.id = id; this.anchor = Anchor.After; @@ -410,7 +398,7 @@ export class Point implements Pick, Printable { } if (!chunk.del && this.anchor === Anchor.After) return; this.anchor = Anchor.After; - this.id = this.prevId() || this.txt.str.id; + this.id = this.prevId() || this.rga.id; } /** diff --git a/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts b/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts index c8d79978ba..e20a8ae5f4 100644 --- a/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts +++ b/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts @@ -23,8 +23,8 @@ describe('.set()', () => { expect(p1.refresh()).not.toBe(p2.refresh()); p1.set(p2); expect(p1.refresh()).toBe(p2.refresh()); - expect(p1.compare(p2)).toBe(0); - expect(p1.compareSpatial(p2)).toBe(0); + expect(p1.cmp(p2)).toBe(0); + expect(p1.cmpSpatial(p2)).toBe(0); expect(p1.id.sid).toBe(p2.id.sid); expect(p1.id.time).toBe(p2.id.time); expect(p1.anchor).toBe(p2.anchor); @@ -39,22 +39,22 @@ describe('.clone()', () => { const p1 = peritext.point(id, Anchor.Before); const p2 = p1.clone(); expect(p1.refresh()).toBe(p2.refresh()); - expect(p1.compare(p2)).toBe(0); - expect(p1.compareSpatial(p2)).toBe(0); + expect(p1.cmp(p2)).toBe(0); + expect(p1.cmpSpatial(p2)).toBe(0); expect(p1.id.sid).toBe(p2.id.sid); expect(p1.id.time).toBe(p2.id.time); expect(p1.anchor).toBe(p2.anchor); }); }); -describe('.compare()', () => { +describe('.cmp()', () => { test('returns 0 for equal points', () => { const {peritext} = setup(); const chunk = peritext.str.first()!; const id = chunk.id; const p1 = peritext.point(id, Anchor.Before); const p2 = peritext.point(id, Anchor.Before); - expect(p1.compare(p2)).toBe(0); + expect(p1.cmp(p2)).toBe(0); }); test('compares by ID first, then by anchor', () => { @@ -75,18 +75,18 @@ describe('.compare()', () => { const p1 = points[i]; const p2 = points[j]; if (i === j) { - expect(p1.compare(p2)).toBe(0); + expect(p1.cmp(p2)).toBe(0); } else if (i < j) { - expect(p1.compare(p2)).toBeLessThan(0); + expect(p1.cmp(p2)).toBeLessThan(0); } else { - expect(p1.compare(p2)).toBeGreaterThan(0); + expect(p1.cmp(p2)).toBeGreaterThan(0); } } } }); }); -describe('.compareSpatial()', () => { +describe('.cmpSpatial()', () => { test('higher spacial points return positive value', () => { const {peritext} = setup(); const chunk1 = peritext.str.first()!; @@ -96,22 +96,22 @@ describe('.compareSpatial()', () => { const p2 = peritext.point(id1, Anchor.After); const p3 = peritext.point(id2, Anchor.Before); const p4 = peritext.point(id2, Anchor.After); - expect(p1.compareSpatial(p1)).toBe(0); - expect(p4.compareSpatial(p4)).toBe(0); - expect(p4.compareSpatial(p4)).toBe(0); - expect(p4.compareSpatial(p4)).toBe(0); - expect(p2.compareSpatial(p1) > 0).toBe(true); - expect(p3.compareSpatial(p1) > 0).toBe(true); - expect(p4.compareSpatial(p1) > 0).toBe(true); - expect(p3.compareSpatial(p2) > 0).toBe(true); - expect(p4.compareSpatial(p2) > 0).toBe(true); - expect(p4.compareSpatial(p3) > 0).toBe(true); - expect(p1.compareSpatial(p2) < 0).toBe(true); - expect(p1.compareSpatial(p3) < 0).toBe(true); - expect(p1.compareSpatial(p4) < 0).toBe(true); - expect(p2.compareSpatial(p3) < 0).toBe(true); - expect(p2.compareSpatial(p4) < 0).toBe(true); - expect(p3.compareSpatial(p4) < 0).toBe(true); + expect(p1.cmpSpatial(p1)).toBe(0); + expect(p4.cmpSpatial(p4)).toBe(0); + expect(p4.cmpSpatial(p4)).toBe(0); + expect(p4.cmpSpatial(p4)).toBe(0); + expect(p2.cmpSpatial(p1) > 0).toBe(true); + expect(p3.cmpSpatial(p1) > 0).toBe(true); + expect(p4.cmpSpatial(p1) > 0).toBe(true); + expect(p3.cmpSpatial(p2) > 0).toBe(true); + expect(p4.cmpSpatial(p2) > 0).toBe(true); + expect(p4.cmpSpatial(p3) > 0).toBe(true); + expect(p1.cmpSpatial(p2) < 0).toBe(true); + expect(p1.cmpSpatial(p3) < 0).toBe(true); + expect(p1.cmpSpatial(p4) < 0).toBe(true); + expect(p2.cmpSpatial(p3) < 0).toBe(true); + expect(p2.cmpSpatial(p4) < 0).toBe(true); + expect(p3.cmpSpatial(p4) < 0).toBe(true); }); test('correctly orders points when tombstones are present', () => { @@ -150,15 +150,15 @@ describe('.compareSpatial()', () => { const p2 = points[j]; try { if (i === j) { - expect(p1.compareSpatial(p2)).toBe(0); + expect(p1.cmpSpatial(p2)).toBe(0); } else if (i < j) { - expect(p1.compareSpatial(p2)).toBeLessThan(0); + expect(p1.cmpSpatial(p2)).toBeLessThan(0); } else { - expect(p1.compareSpatial(p2)).toBeGreaterThan(0); + expect(p1.cmpSpatial(p2)).toBeGreaterThan(0); } } catch (error) { // tslint:disable-next-line:no-console - console.log('i: ', i, 'j: ', j, 'p1: ', p1 + '', 'p2: ', p2 + '', p1.compareSpatial(p2)); + console.log('i: ', i, 'j: ', j, 'p1: ', p1 + '', 'p2: ', p2 + '', p1.cmpSpatial(p2)); throw error; } } @@ -181,8 +181,8 @@ describe('.compareSpatial()', () => { const p6 = peritext.point(id3, Anchor.After); const points = [p0, p1, p2, p3, p4, p5, p6]; for (const point of points) { - expect(absoluteEnd.compareSpatial(point)).toBe(1); - expect(point.compareSpatial(absoluteEnd)).toBe(-1); + expect(absoluteEnd.cmpSpatial(point)).toBe(1); + expect(point.cmpSpatial(absoluteEnd)).toBe(-1); } }); @@ -190,8 +190,8 @@ describe('.compareSpatial()', () => { const {peritext} = setup(); const p1 = peritext.pointAbsEnd(); const p2 = peritext.pointAbsEnd(); - expect(p1.compareSpatial(p2)).toBe(0); - expect(p2.compareSpatial(p1)).toBe(0); + expect(p1.cmpSpatial(p2)).toBe(0); + expect(p2.cmpSpatial(p1)).toBe(0); }); test('absolute start point is always less than any other point', () => { @@ -210,8 +210,8 @@ describe('.compareSpatial()', () => { const p6 = peritext.point(id3, Anchor.After); const points = [p0, p1, p2, p3, p4, p5, p6]; for (const point of points) { - expect(absoluteEnd.compareSpatial(point)).toBe(-1); - expect(point.compareSpatial(absoluteEnd)).toBe(1); + expect(absoluteEnd.cmpSpatial(point)).toBe(-1); + expect(point.cmpSpatial(absoluteEnd)).toBe(1); } }); @@ -219,8 +219,8 @@ describe('.compareSpatial()', () => { const {peritext} = setup(); const p1 = peritext.pointAbsStart(); const p2 = peritext.pointAbsStart(); - expect(p1.compareSpatial(p2)).toBe(0); - expect(p2.compareSpatial(p1)).toBe(0); + expect(p1.cmpSpatial(p2)).toBe(0); + expect(p2.cmpSpatial(p1)).toBe(0); }); }); @@ -952,9 +952,9 @@ describe('.refBefore()', () => { const chunk1 = peritext.str.first()!; const absoluteStart = peritext.pointAbsStart(); const start = peritext.point(chunk1.id, Anchor.Before); - expect(absoluteStart.compareSpatial(start) < 0).toBe(true); + expect(absoluteStart.cmpSpatial(start) < 0).toBe(true); absoluteStart.refBefore(); - expect(absoluteStart.compareSpatial(start) === 0).toBe(true); + expect(absoluteStart.cmpSpatial(start) === 0).toBe(true); }); }); @@ -998,9 +998,9 @@ describe('.refAfter()', () => { const id = tick(chunk1.id, 2); const absoluteEnd = peritext.pointAbsEnd(); const end = peritext.point(id, Anchor.After); - expect(absoluteEnd.compareSpatial(end) > 0).toBe(true); + expect(absoluteEnd.cmpSpatial(end) > 0).toBe(true); absoluteEnd.refAfter(); - expect(absoluteEnd.compareSpatial(end) === 0).toBe(true); + expect(absoluteEnd.cmpSpatial(end) === 0).toBe(true); }); test('when absolute end, attaches to last visible char', () => { @@ -1010,12 +1010,12 @@ describe('.refAfter()', () => { const end1 = peritext.point(tick(chunk1.id, 1), Anchor.After); const end2 = peritext.point(tick(chunk1.id, 2), Anchor.After); peritext.strApi().del(2, 1); - expect(end1.compareSpatial(end2) < 0).toBe(true); - expect(absoluteEnd.compareSpatial(end2) > 0).toBe(true); + expect(end1.cmpSpatial(end2) < 0).toBe(true); + expect(absoluteEnd.cmpSpatial(end2) > 0).toBe(true); end2.refAfter(); absoluteEnd.refAfter(); - expect(end2.compareSpatial(end1) === 0).toBe(true); - expect(absoluteEnd.compareSpatial(end1) === 0).toBe(true); + expect(end2.cmpSpatial(end1) === 0).toBe(true); + expect(absoluteEnd.cmpSpatial(end1) === 0).toBe(true); }); }); @@ -1037,12 +1037,12 @@ describe('.refVisible()', () => { peritext.strApi().del(3, 3); expect(left.leftChar()!.view()).toBe('3'); expect(right.rightChar()!.view()).toBe('7'); - expect(mid1.compare(left) > 0).toBe(true); + expect(mid1.cmp(left) > 0).toBe(true); mid1.refVisible(); - expect(mid1.compare(left) === 0).toBe(true); - expect(mid2.compare(right) < 0).toBe(true); + expect(mid1.cmp(left) === 0).toBe(true); + expect(mid2.cmp(right) < 0).toBe(true); mid2.refVisible(); - expect(mid2.compare(right) === 0).toBe(true); + expect(mid2.cmp(right) === 0).toBe(true); }); }); diff --git a/src/json-crdt-extensions/peritext/slice/Cursor.ts b/src/json-crdt-extensions/peritext/slice/Cursor.ts index f417884ca3..483b47f1ea 100644 --- a/src/json-crdt-extensions/peritext/slice/Cursor.ts +++ b/src/json-crdt-extensions/peritext/slice/Cursor.ts @@ -6,7 +6,7 @@ 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 { +export class Cursor extends Range implements Slice { public readonly behavior = SliceBehavior.Overwrite; public readonly type = Tags.Cursor; @@ -26,7 +26,7 @@ export class Cursor extends Range implements Slice { public start: Point, public end: Point, ) { - super(txt, start, end); + super(txt.str, start, end); } public anchor(): Point { @@ -66,7 +66,7 @@ export class Cursor extends Range implements Slice { let focus = this.focus(); if (edge === 0) focus = point; else anchor = point; - if (focus.compareSpatial(anchor) < 0) { + if (focus.cmpSpatial(anchor) < 0) { this.base = Anchor.After; this.start = focus; this.end = anchor; diff --git a/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts b/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts index 1daeff34a7..0bab0ea42b 100644 --- a/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts +++ b/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts @@ -25,7 +25,7 @@ export class PersistedSlice extends Range implements Slice, Printable, Stateful public end: Point, public type: SliceType, ) { - super(txt, start, end); + super(txt.str, start, end); this.id = this.chunk.id; } diff --git a/src/json-crdt-extensions/peritext/slice/Range.ts b/src/json-crdt-extensions/peritext/slice/Range.ts index 8c0b7a2fdc..41f985bc6c 100644 --- a/src/json-crdt-extensions/peritext/slice/Range.ts +++ b/src/json-crdt-extensions/peritext/slice/Range.ts @@ -1,38 +1,86 @@ 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 {ITimestampStruct} from '../../../json-crdt-patch/clock'; import type {Printable} from '../../../util/print/types'; +import type {AbstractRga, Chunk} from '../../../json-crdt/nodes/rga'; /** * A range is a pair of points that represent a selection in the text. A range * can be collapsed to a single point, then it is called a *marker* * (if it is stored in the text), or *caret* (if it is a cursor position). */ -export class Range implements Printable { +export class Range implements Printable { /** * Creates a range from two points. The points are ordered so that the * start point is before or equal to the end point. * - * @param txt Peritext context. + * @param rga Peritext context. * @param p1 Some point. * @param p2 Another point. * @returns Range with points in correct order. */ - public static from(txt: Peritext, p1: Point, p2: Point) { - return p1.compareSpatial(p2) > 0 ? new Range(txt, p2, p1) : new Range(txt, p1, p2); + public static from(rga: AbstractRga, p1: Point, p2: Point): Range { + return p1.cmpSpatial(p2) > 0 ? new Range(rga, p2, p1) : new Range(rga, p1, p2); } /** - * @param txt Peritext context. + * A convenience method for creating a range from a view position and a length. + * The `start` argument specifies the position between characters, where + * the range should start. The `size` argument specifies the number of + * characters in the range. If `size` is zero or not specified, the range + * will be collapsed to a single point. + * + * When the range is collapsed, the anchor position is set to "after" the + * character. When the range is expanded, the anchor positions are set to + * "before" for the start point and "after" for the end point. + * + * The `size` argument can be negative, in which case the range is selected + * backwards. + * + * @param rga Peritext context. + * @param start Position in the text between characters. + * @param size Length of the range. Can be negative, in which case the range + * is selected backwards. + * @returns A range from the given position with the given length. + */ + public static at(rga: AbstractRga, start: number, size: number = 0): Range { + const length = rga.length(); + if (!size) { + if (start > length) start = length; + const startId = !start ? rga.id : rga.find(start - 1) || rga.id; + const point = new Point(rga, startId, Anchor.After); + return new Range(rga, point, point.clone()); + } + if (size < 0) { + size = -size; + start -= size; + } + if (start < 0) { + size += start; + start = 0; + if (size < 0) return Range.at(rga, start, 0); + } + if (start >= length) { + start = length; + size = 0; + } + if (start + size > length) size = length - start; + const startId = rga.find(start) || rga.id; + const endId = rga.find(start + size - 1) || startId; + const startEndpoint = new Point(rga, startId, Anchor.Before); + const endEndpoint = new Point(rga, endId, Anchor.After); + return new Range(rga, startEndpoint, endEndpoint); + } + + /** + * @param rga Peritext context. * @param start Start point of the range, must be before or equal to end. * @param end End point of the range, must be after or equal to start. */ constructor( - protected readonly txt: Peritext, - public start: Point, - public end: Point, + protected readonly rga: AbstractRga, + public start: Point, + public end: Point, ) {} /** @@ -40,8 +88,8 @@ export class Range implements Printable { * * @returns A new range with the same start and end points. */ - public clone(): Range { - return new Range(this.txt, this.start.clone(), this.end.clone()); + public clone(): Range { + return new Range(this.rga, this.start.clone(), this.end.clone()); } /** @@ -53,12 +101,12 @@ export class Range implements Printable { */ public isCollapsed(): boolean { const {start, end} = this; - if (start.compareSpatial(end) === 0) return true; + if (start.cmpSpatial(end) === 0) return true; const start2 = start.clone(); const end2 = end.clone(); start2.refAfter(); end2.refAfter(); - return start2.compare(end2) === 0; + return start2.cmp(end2) === 0; } /** @@ -81,45 +129,31 @@ export class Range implements Printable { this.start = this.end.clone(); } - /** - * Returns the range in the view coordinates as a position and length. - * - * @returns The range as a view position and length. - */ - public views(): [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 { + public set(start: Point, end: Point = start): void { this.start = start; this.end = end === start ? end.clone() : end; } - public setRange(range: Range): void { + public setRange(range: Range): void { this.set(range.start, range.end); } public setAt(start: number, length: number = 0): void { - // TODO: move implementation to here - const range = this.txt.rangeAt(start, length); + const range = Range.at(this.rga, 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 setAfter(id: ITimestampStruct): void { + const point = new Point(this.rga, id, Anchor.After); + this.set(point); } - public contains(range: Range): boolean { - return this.start.compareSpatial(range.start) <= 0 && this.end.compareSpatial(range.end) >= 0; + public contains(range: Range): boolean { + return this.start.cmpSpatial(range.start) <= 0 && this.end.cmpSpatial(range.end) >= 0; } - public containsPoint(range: Point): boolean { - return this.start.compareSpatial(range) <= 0 && this.end.compareSpatial(range) >= 0; + public containsPoint(point: Point): boolean { + return this.start.cmpSpatial(point) <= 0 && this.end.cmpSpatial(point) >= 0; } /** @@ -127,79 +161,33 @@ export class Range implements Printable { * (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; - } - } - } + this.start.refAfter(); + this.end.refBefore(); } - 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; - } - } - } + // -------------------------------------------------- View coordinate methods + + /** + * Returns the range in the view coordinates as a position and length. + * + * @returns The range as a view position and length. + */ + public view(): [start: number, size: number] { + const start = this.start.viewPos(); + const end = this.end.viewPos(); + return [start, end - start]; + } + + /** + * @returns The length of the range in view coordinates. + */ + public length(): number { + return this.end.viewPos() - this.start.viewPos(); } /** - * Concatenates all text chunks in the range ignoring tombstones and returns - * the result. + * Returns plain text view of the range. Concatenates all text chunks in the + * range ignoring tombstones and returns the result. * * @returns The text content of the range. */ @@ -207,13 +195,13 @@ export class Range implements Printable { const isCaret = this.isCollapsed(); if (isCaret) return ''; const {start, end} = this; - const str = this.txt.str; + const rga = this.rga; 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); + rga.range0(undefined, startId, endId, (chunk: Chunk, from: number, length: number) => { + if (chunk.data) result += chunk.view().slice(from, from + length); }); return result; } @@ -224,6 +212,8 @@ export class Range implements Printable { const name = lite ? '' : `${this.constructor.name} `; const start = this.start.toString(tab, lite); const end = this.end.toString(tab, lite); - return `${name}${start} ↔ ${end}`; + let text = this.text(); + if (text.length > 16) text = text.slice(0, 16) + '...'; + return `${JSON.stringify(text)} ${name}${start} ↔ ${end}`; } } diff --git a/src/json-crdt-extensions/peritext/slice/Slices.ts b/src/json-crdt-extensions/peritext/slice/Slices.ts index b38bef0e70..67b80a4386 100644 --- a/src/json-crdt-extensions/peritext/slice/Slices.ts +++ b/src/json-crdt-extensions/peritext/slice/Slices.ts @@ -75,8 +75,8 @@ export class Slices implements Stateful, Printable { 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 p1 = new Point(txt.str, id1, anchor1); + const p2 = new Point(txt.str, id2, anchor2); const type = tuple.get(3)!.view() as SliceType; const slice = behavior === SliceBehavior.Split diff --git a/src/json-crdt-extensions/peritext/slice/__tests__/Range.spec.ts b/src/json-crdt-extensions/peritext/slice/__tests__/Range.spec.ts index 642333fa14..e08800c7dc 100644 --- a/src/json-crdt-extensions/peritext/slice/__tests__/Range.spec.ts +++ b/src/json-crdt-extensions/peritext/slice/__tests__/Range.spec.ts @@ -53,6 +53,135 @@ describe('.from()', () => { }); }); +describe('.at()', () => { + describe('collapsed', () => { + test('can set caret to absolute start', () => { + const {peritext} = setup(); + const range = peritext.rangeAt(0); + expect(range.start.isAbsStart()).toBe(true); + expect(range.end.isAbsStart()).toBe(true); + expect(range.start).not.toBe(range.end); + }); + + test('can set caret to various text positions', () => { + const {peritext} = setup(); + const length = peritext.str.length(); + for (let i = 1; i <= length; i++) { + const range = peritext.rangeAt(i); + expect(range.start.viewPos()).toBe(i); + expect(range.end.viewPos()).toBe(i); + expect(range.start).not.toBe(range.end); + } + }); + + test('truncates lower bound', () => { + const {peritext} = setup(); + const range = peritext.rangeAt(-123); + expect(range.start.isAbsStart()).toBe(true); + }); + + test('truncates upper bound', () => { + const {peritext} = setup(); + const range = peritext.rangeAt(123); + expect(range.start.viewPos()).toBe(peritext.str.length()); + }); + }); + + describe('expanded', () => { + test('can select first character', () => { + const {peritext} = setup(); + const range = peritext.rangeAt(0, 1); + expect(range.length()).toBe(1); + expect(range.text()).toBe('H'); + expect(range.start.anchor).toBe(Anchor.Before); + expect(range.end.anchor).toBe(Anchor.After); + expect(range.start.id.time).toBe(range.end.id.time); + }); + + test('can select any combination of characters', () => { + const {peritext} = setupEvenDeleted(); + const length = peritext.str.length(); + for (let i = 0; i < length; i++) { + for (let j = 1; j <= length - i; j++) { + const range = peritext.rangeAt(i, j); + expect(range.length()).toBe(j); + expect(range.text()).toBe(peritext.str.view().slice(i, i + j)); + expect(range.start.anchor).toBe(Anchor.Before); + expect(range.end.anchor).toBe(Anchor.After); + } + } + }); + + test('truncates lower bound', () => { + const {peritext} = setup(); + const range = peritext.rangeAt(-2, 5); + expect(range.text()).toBe('Hel'); + expect(range.start.isRelStart()).toBe(true); + expect(range.end.anchor).toBe(Anchor.After); + }); + + test('truncates upper bound', () => { + const {peritext} = setup(); + const range = peritext.rangeAt(2, peritext.str.length() + 10); + expect(range.text()).toBe('llo world!'); + expect(range.start.anchor).toBe(Anchor.Before); + expect(range.end.isRelEnd()).toBe(true); + }); + + test('truncates lower and upper bounds to select all text', () => { + const {peritext} = setup(); + const range = peritext.rangeAt(-123, 256); + expect(range.text()).toBe('Hello world!'); + expect(range.start.isRelStart()).toBe(true); + expect(range.end.isRelEnd()).toBe(true); + }); + + describe('when negative size', () => { + test('can select range backwards', () => { + const {peritext} = setup(); + const range = peritext.rangeAt(2, -1); + expect(range.text()).toBe('e'); + expect(range.start.anchor).toBe(Anchor.Before); + expect(range.end.anchor).toBe(Anchor.After); + }); + + test('can select range backwards, all combinations', () => { + const {peritext} = setupEvenDeleted(); + const length = peritext.str.length(); + for (let i = 1; i < length; i++) { + for (let j = 1; j <= i; j++) { + const range = peritext.rangeAt(i, -j); + expect(range.length()).toBe(j); + expect(range.text()).toBe(peritext.str.view().slice(i - j, i)); + expect(range.start.anchor).toBe(Anchor.Before); + expect(range.end.anchor).toBe(Anchor.After); + } + } + }); + + test('truncates lower bound', () => { + const {peritext} = setupEvenDeleted(); + const range = peritext.rangeAt(2, -5); + expect(range.text()).toBe('13'); + }); + + test('truncates upper bound', () => { + const {peritext} = setupEvenDeleted(); + const range = peritext.rangeAt(7, -4); + expect(range.text()).toBe('79'); + }); + + test('truncates upper and lower bounds, can select all text', () => { + const {peritext} = setupEvenDeleted(); + const range = peritext.rangeAt(10, -20); + expect(range.text()).toBe('13579'); + expect(range.start.isRelStart()).toBe(true); + expect(range.end.isRelEnd()).toBe(true); + }); + }); + }); +}); + describe('.clone()', () => { test('can clone a range', () => { const {peritext} = setup(); @@ -64,8 +193,8 @@ describe('.clone()', () => { expect(range2.end).not.toBe(range1.end); expect(range2.start.refresh()).toBe(range1.start.refresh()); expect(range2.end.refresh()).toBe(range1.end.refresh()); - expect(range2.start.compare(range1.start)).toBe(0); - expect(range2.end.compare(range1.end)).toBe(0); + expect(range2.start.cmp(range1.start)).toBe(0); + expect(range2.end.cmp(range1.end)).toBe(0); }); }); @@ -185,7 +314,7 @@ describe('.view()', () => { test('returns correct view', () => { const {peritext} = setup(); const range = peritext.rangeAt(2, 3); - expect(range.views()).toEqual([2, 3]); + expect(range.view()).toEqual([2, 3]); }); }); @@ -217,6 +346,21 @@ describe('.contains()', () => { }); }); +describe('.containsPoint()', () => { + test('returns true if slice is contained', () => { + const {peritext} = setup(); + const point = peritext.pointAt(2, Anchor.After); + const range1 = peritext.rangeAt(2, 2); + const range2 = peritext.rangeAt(3, 2); + expect(range1.containsPoint(point)).toBe(true); + expect(range2.containsPoint(point)).toBe(false); + range2.start.refAfter(); + expect(range2.containsPoint(point)).toBe(true); + const range3 = peritext.rangeAt(1, 2); + expect(range3.containsPoint(point)).toBe(true); + }); +}); + describe('.isCollapsed()', () => { test('returns true when endpoints point to the same location', () => { const {peritext} = setup();