diff --git a/src/json-crdt-extensions/peritext/Peritext.ts b/src/json-crdt-extensions/peritext/Peritext.ts index 367087501d..f0219e80bb 100644 --- a/src/json-crdt-extensions/peritext/Peritext.ts +++ b/src/json-crdt-extensions/peritext/Peritext.ts @@ -10,8 +10,11 @@ import type {Model} from '../../json-crdt/model'; import type {Printable} from '../../util/print/types'; import type {SliceType} from './types'; import type {PersistedSlice} from './slice/PersistedSlice'; -import {CONST} from '../../json-hash'; +/** + * Context for a Peritext instance. Contains all the data and methods needed to + * interact with the text. + */ export class Peritext implements Printable { public readonly slices: Slices; public readonly editor: Editor; @@ -25,35 +28,97 @@ export class Peritext implements Printable { this.editor = new Editor(this); } - public point(id: ITimestampStruct, anchor: Anchor = Anchor.After): Point { + public strApi() { + return this.model.api.wrap(this.str); + } + + // ------------------------------------------------------------------- Points + + /** + * Creates a point at a character ID. + * + * @param id Character ID to which the point should be attached. + * @param anchor Whether the point should be before or after the character. + * @returns The point. + */ + public point(id: ITimestampStruct = this.str.id, anchor: Anchor = Anchor.After): Point { return new Point(this, id, anchor); } + /** + * Creates a point at a view position in the text. The `pos` argument specifies + * the position of the character, not the gap between characters. + * + * @param pos Position of the character in the text. + * @param anchor Whether the point should attach before or after a character. + * @returns The point. + */ public pointAt(pos: number, anchor: Anchor = Anchor.Before): Point { + // TODO: Provide ability to attach to the beginning of the text? + // TODO: Provide ability to attach to the end of the text? const str = this.str; const id = str.find(pos); if (!id) return this.point(str.id, Anchor.After); return this.point(id, anchor); } - public pointAtStart(): Point { + /** + * Creates a point which is attached to the start of the text, before the + * first character. + * + * @returns A point at the start of the text. + */ + public pointAbsStart(): Point { return this.point(this.str.id, Anchor.After); } - public pointAtEnd(): Point { + /** + * Creates a point which is attached to the end of the text, after the last + * character. + * + * @returns A point at the end of the text. + */ + public pointAbsEnd(): Point { return this.point(this.str.id, Anchor.Before); } + // ------------------------------------------------------------------- Ranges + + /** + * Creates a range from two points. The points can be in any order. + * + * @param p1 Point + * @param p2 Point + * @returns A range with points in correct order. + */ + public rangeFromPoints(p1: Point, p2: Point): Range { + return Range.from(this, p1, p2); + } + + /** + * Creates a range from two points, the points have to be in the correct order. + * + * @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. + * @returns A range with the given start and end points. + */ public range(start: Point, end: Point): Range { return new Range(this, start, end); } + /** + * Creates a range from a view position and a length. + * + * @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); + return this.range(point, point.clone()); } const startId = str.find(start) || str.id; const endId = str.find(start + length - 1) || startId; @@ -62,11 +127,27 @@ export class Peritext implements Printable { return this.range(startEndpoint, endEndpoint); } + // --------------------------------------------------------------- Insertions + + /** + * Insert plain text at a view position in the text. + * + * @param pos View position in the text. + * @param text Text to insert. + */ public insAt(pos: number, text: string): void { - const str = this.model.api.wrap(this.str); + const str = this.strApi(); str.ins(pos, text); } + /** + * Insert plain text after a character referenced by its ID and return the + * ID of the insertion operation. + * + * @param after Character ID after which the text should be inserted. + * @param text Text to insert. + * @returns ID of the insertion operation. + */ public ins(after: ITimestampStruct, text: string): ITimestampStruct { if (!text) throw new Error('NO_TEXT'); const api = this.model.api; @@ -87,6 +168,8 @@ export class Peritext implements Printable { return slice; } + // ---------------------------------------------------------------- Deletions + public delSlice(sliceId: ITimestampStruct): void { this.slices.del(sliceId); } diff --git a/src/json-crdt-extensions/peritext/editor/Editor.ts b/src/json-crdt-extensions/peritext/editor/Editor.ts index 5ee1f1bcb5..f0d7681b0c 100644 --- a/src/json-crdt-extensions/peritext/editor/Editor.ts +++ b/src/json-crdt-extensions/peritext/editor/Editor.ts @@ -122,4 +122,12 @@ export class Editor implements Printable { public insertSlice(type: SliceType, data?: unknown | ITimestampStruct): PersistedSlice { return this.txt.insSlice(this.cursor, SliceBehavior.Stack, type, data); } + + public insertOverwriteSlice(type: SliceType, data?: unknown | ITimestampStruct): PersistedSlice { + return this.txt.insSlice(this.cursor, SliceBehavior.Overwrite, type, data); + } + + public insertEraseSlice(type: SliceType, data?: unknown | ITimestampStruct): PersistedSlice { + return this.txt.insSlice(this.cursor, SliceBehavior.Erase, type, data); + } } diff --git a/src/json-crdt-extensions/peritext/point/Point.ts b/src/json-crdt-extensions/peritext/point/Point.ts index e5299cd764..63b3553714 100644 --- a/src/json-crdt-extensions/peritext/point/Point.ts +++ b/src/json-crdt-extensions/peritext/point/Point.ts @@ -12,7 +12,16 @@ import type {StringChunk} from '../util/types'; * character ID and an anchor. Anchor specifies the side of the character to * which the point is attached. For example, a point with an anchor "before" .▢ * points just before the character, while a point with an anchor "after" ▢. - * points just after the character. + * points just after the character. Points attached to string characters are + * referred to as *relative* points, while points attached to the beginning or + * end of the string are referred to as *absolute* points. + * + * The *absolute* points are reference the string itself, by using the string's + * ID as the character ID. The *absolute (abs) start* references the very start + * of the string, before the first character, and even before any deleted + * characters. The *absolute (abs) end* references the very end of the string, + * after the last character, and even after any deleted characters at the end + * of the string. */ export class Point implements Pick, Printable { constructor( @@ -42,6 +51,9 @@ export class Point implements Pick, Printable { } /** + * Compares two points by their character IDs and anchors. First, the character + * IDs are compared. If they are equal, the anchors are compared. The anchor + * "before" is considered less than the anchor "after". * * @param other The other point to compare to. * @returns Returns 0 if the two points are equal, -1 if this point is less @@ -54,9 +66,26 @@ export class Point implements Pick, Printable { return (this.anchor - other.anchor) as -1 | 0 | 1; } + /** + * 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. + * + * @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 { const thisId = this.id; const otherId = other.id; + if (this.isAbs()) { + const isStart = this.anchor === Anchor.After; + return isStart ? (other.isAbsStart() ? 0 : -1) : other.isAbsEnd() ? 0 : 1; + } else if (other.isAbs()) { + const isStart = other.anchor === Anchor.After; + return isStart ? (this.isAbsStart() ? 0 : 1) : this.isAbsEnd() ? 0 : -1; + } const cmp0 = compare(thisId, otherId); if (!cmp0) return this.anchor - other.anchor; const cmp1 = this.pos() - other.pos(); @@ -74,6 +103,11 @@ export class Point implements Pick, Printable { } private _chunk: StringChunk | 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 { let chunk = this._chunk; const id = this.id; @@ -99,6 +133,7 @@ export class Point implements Pick, Printable { } private _pos: number = -1; + /** @todo Is this needed? */ public posCached(): number { if (this._pos >= 0) return this._pos; @@ -107,12 +142,12 @@ export class Point implements Pick, Printable { } /** - * @returns Returns position of the point, as if it is a cursor in a text - * pointing between characters. + * @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.isStartOfStr() ? 0 : this.txt.str.length(); + if (pos < 0) return this.isAbsStart() ? 0 : this.txt.str.length(); return this.anchor === Anchor.Before ? pos : pos + 1; } @@ -125,12 +160,12 @@ export class Point implements Pick, Printable { * @returns Next visible ID in string. */ public nextId(move: number = 1): ITimestampStruct | undefined { - if (this.isEndOfStr()) return; + if (this.isAbsEnd()) return; let remaining: number = move; const {id, txt} = this; const str = txt.str; let chunk: StringChunk | undefined; - if (this.isStartOfStr()) { + if (this.isAbsStart()) { chunk = str.first(); while (chunk && chunk.del) chunk = str.next(chunk); if (!chunk) return; @@ -171,7 +206,7 @@ export class Point implements Pick, Printable { * such character. */ public prevId(move: number = 1): ITimestampStruct | undefined { - if (this.isStartOfStr()) return; + if (this.isAbsStart()) return; let remaining: number = move; const {id, txt} = this; const str = txt.str; @@ -198,116 +233,196 @@ export class Point implements Pick, Printable { return; } + /** + * Returns one character to the left of the point, or `undefined` if there + * is no such character. Skips any deleted characters. Handles absolute points. + * + * @returns A character slice to the left of the point. + */ public leftChar(): ChunkSlice | undefined { const str = this.txt.str; - if (this.isEndOfStr()) { - let chunk = str.last(); - while (chunk && chunk.del) chunk = str.prev(chunk); - return chunk ? new ChunkSlice(chunk, chunk.span - 1, 1) : undefined; - } - let chunk = this.chunk(); - if (!chunk) return; - if (chunk.del) { - const prevId = this.prevId(); - if (!prevId) return; - const tmp = new Point(this.txt, prevId, Anchor.After); - return tmp.leftChar(); + if (this.isAbsEnd()) { + const res = str.findChunk(str.length() - 1); + if (!res) return; + return new ChunkSlice(res[0], res[1], 1); } - if (this.anchor === Anchor.After) { - const off = this.id.time - chunk.id.time; - return new ChunkSlice(chunk, off, 1); - } - const off = this.id.time - chunk.id.time - 1; - if (off >= 0) return new ChunkSlice(chunk, off, 1); - chunk = str.prev(chunk); - while (chunk && chunk.del) chunk = str.prev(chunk); - if (!chunk) return; - return new ChunkSlice(chunk, chunk.span - 1, 1); + const tmp = this.clone(); + tmp.refAfter(); + if (tmp.isAbsStart()) return; + const chunk = tmp.chunk(); + if (!chunk || chunk.del) return; + const off = tmp.id.time - chunk.id.time; + return new ChunkSlice(chunk, off, 1); } + /** + * Returns one character to the right of the point, or `undefined` if there + * is no such character. Skips any deleted characters. Handles absolute points. + * + * @returns A character slice to the right of the point. + */ public rightChar(): ChunkSlice | undefined { const str = this.txt.str; - if (this.isStartOfStr()) { - let chunk = str.first(); - while (chunk && chunk.del) chunk = str.next(chunk); - return chunk ? new ChunkSlice(chunk, 0, 1) : undefined; - } - let chunk = this.chunk(); - if (!chunk) return; - if (chunk.del) { - const nextId = this.nextId(); - if (!nextId) return; - const tmp = new Point(this.txt, nextId, Anchor.Before); - return tmp.rightChar(); - } - if (this.anchor === Anchor.Before) { - const off = this.id.time - chunk.id.time; - return new ChunkSlice(chunk, off, 1); + if (this.isAbsStart()) { + const res = str.findChunk(0); + if (!res) return; + return new ChunkSlice(res[0], res[1], 1); } - const off = this.id.time - chunk.id.time + 1; - if (off < chunk.span) return new ChunkSlice(chunk, off, 1); - chunk = str.next(chunk); - while (chunk && chunk.del) chunk = str.next(chunk); - if (!chunk) return; - return new ChunkSlice(chunk, 0, 1); + const tmp = this.clone(); + tmp.refBefore(); + if (tmp.isAbsEnd()) return; + const chunk = tmp.chunk(); + if (!chunk || chunk.del) return; + const off = tmp.id.time - chunk.id.time; + return new ChunkSlice(chunk, off, 1); } - public isStartOfStr(): boolean { - return equal(this.id, this.txt.str.id) && this.anchor === Anchor.After; + /** + * Checks if the point is an absolute point. An absolute point is a point that + * references the string itself, rather than a character in the string. It can + * be either the very start or the very end of the string. + * + * @returns Returns `true` if the point is an absolute point. + */ + public isAbs(): boolean { + return equal(this.id, this.txt.str.id); } - public isEndOfStr(): boolean { - return equal(this.id, this.txt.str.id) && this.anchor === Anchor.Before; + /** + * @returns Returns `true` if the point is an absolute point and is anchored + * before the first character in the string. + */ + public isAbsStart(): boolean { + return this.isAbs() && this.anchor === Anchor.After; } /** - * Modifies the location of the point, such that the spatial location remains - * and anchor remains the same, but ensures that the point references a - * visible (non-deleted) character. + * @returns Returns `true` if the point is an absolute point and is anchored + * after the last character in the string. */ - public refVisible(): void { - if (this.anchor === Anchor.Before) this.refBefore(); - else this.refAfter(); + public isAbsEnd(): boolean { + return this.isAbs() && this.anchor === Anchor.Before; } - public refStart(): void { + /** + * @returns Returns `true` if the point is exactly the relative start, i.e. + * it is attached to the first visible character in the string and + * anchored "before". + */ + public isRelStart(): boolean { + if (this.anchor !== Anchor.Before) return false; + const id = this.txt.str.find(0); + return !!id && equal(this.id, id); + } + + /** + * @returns Returns `true` if the point is exactly the relative end, i.e. it + * is attached to the last visible character in the string and + * anchored "after". + */ + public isRelEnd(): boolean { + if (this.anchor !== Anchor.After) return false; + const str = this.txt.str; + const length = str.length(); + if (length === 0) return false; + const id = str.find(length - 1); + return !!id && equal(this.id, id); + } + + /** + * Sets the point to the absolute start of the string. + */ + public refAbsStart(): void { this.id = this.txt.str.id; this.anchor = Anchor.After; } - public refEnd(): void { + /** + * Sets the point to the absolute end of the string. + */ + public refAbsEnd(): void { this.id = this.txt.str.id; this.anchor = Anchor.Before; } /** - * Modifies the location of the point, such that the spatial location remains + * Sets the point to the relative start of the string. + */ + public refStart(): void { + this.refAbsStart(); + this.refBefore(); + } + + /** + * Sets the point to the relative end of the string. + */ + public refEnd(): void { + this.refAbsEnd(); + this.refAfter(); + } + + /** + * Modifies the location of the point, such that the view location remains * the same, but ensures that it is anchored before a character. Skips any * deleted characters (chunks), attaching the point to the next visible * character. */ public refBefore(): void { const chunk = this.chunk(); - if (!chunk) return this.refEnd(); + if (!chunk) { + if (this.isAbsStart()) { + const id = this.txt.str.find(0); + if (id) { + this.id = id; + this.anchor = Anchor.Before; + return; + } + } + return this.refAbsEnd(); + } if (!chunk.del && this.anchor === Anchor.Before) return; this.anchor = Anchor.Before; this.id = this.nextId() || this.txt.str.id; } /** - * Modifies the location of the point, such that the spatial location remains + * Modifies the location of the point, such that the view location remains * the same, but ensures that it is anchored after a character. Skips any * deleted characters (chunks), attaching the point to the next visible * character. */ public refAfter(): void { const chunk = this.chunk(); - if (!chunk) return this.refStart(); + if (!chunk) { + if (this.isAbsEnd()) { + const str = this.txt.str; + const length = str.length(); + if (length !== 0) { + const id = str.find(length - 1); + if (id) { + this.id = id; + this.anchor = Anchor.After; + return; + } + } + } + return this.refAbsStart(); + } if (!chunk.del && this.anchor === Anchor.After) return; this.anchor = Anchor.After; this.id = this.prevId() || this.txt.str.id; } + /** + * Modifies the location of the point, such that the spatial location remains + * the same and tries to preserve anchor location, but ensures that the point + * references a visible (not deleted) character. + */ + public refVisible(): void { + if (this.anchor === Anchor.Before) this.refBefore(); + else this.refAfter(); + } + /** * Moves point past given number of visible characters. Accepts positive * and negative distances. @@ -318,14 +433,14 @@ export class Point implements Pick, Printable { if (anchor !== Anchor.After) this.refAfter(); if (skip > 0) { const nextId = this.nextId(skip); - if (!nextId) this.refEnd(); + if (!nextId) this.refAbsEnd(); else { this.id = nextId; if (anchor !== Anchor.After) this.refBefore(); } } else { const prevId = this.prevId(-skip); - if (!prevId) this.refStart(); + if (!prevId) this.refAbsStart(); else { this.id = prevId; if (anchor !== Anchor.After) this.refBefore(); 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 5e4b62c693..c8d79978ba 100644 --- a/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts +++ b/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts @@ -164,6 +164,85 @@ describe('.compareSpatial()', () => { } } }); + + test('absolute end point is always greater than any other point', () => { + const {peritext} = setup(); + const chunk1 = peritext.str.first()!; + const absoluteEnd = peritext.pointAbsEnd(); + const id1 = chunk1.id; + const id2 = tick(id1, 1); + const id3 = tick(id1, 2); + const p0 = peritext.pointAbsStart(); + const p1 = peritext.point(id1, Anchor.Before); + const p2 = peritext.point(id1, Anchor.After); + const p3 = peritext.point(id2, Anchor.Before); + const p4 = peritext.point(id2, Anchor.After); + const p5 = peritext.point(id3, Anchor.Before); + 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); + } + }); + + test('two absolute ends are equal', () => { + const {peritext} = setup(); + const p1 = peritext.pointAbsEnd(); + const p2 = peritext.pointAbsEnd(); + expect(p1.compareSpatial(p2)).toBe(0); + expect(p2.compareSpatial(p1)).toBe(0); + }); + + test('absolute start point is always less than any other point', () => { + const {peritext} = setup(); + const chunk1 = peritext.str.first()!; + const absoluteEnd = peritext.pointAbsStart(); + const id1 = chunk1.id; + const id2 = tick(id1, 1); + const id3 = tick(id1, 2); + const p0 = peritext.pointAbsEnd(); + const p1 = peritext.point(id1, Anchor.Before); + const p2 = peritext.point(id1, Anchor.After); + const p3 = peritext.point(id2, Anchor.Before); + const p4 = peritext.point(id2, Anchor.After); + const p5 = peritext.point(id3, Anchor.Before); + 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); + } + }); + + test('two absolute starts are equal', () => { + const {peritext} = setup(); + const p1 = peritext.pointAbsStart(); + const p2 = peritext.pointAbsStart(); + expect(p1.compareSpatial(p2)).toBe(0); + expect(p2.compareSpatial(p1)).toBe(0); + }); +}); + +describe('.chunk()', () => { + test('returns correct chunk when chunk is split', () => { + const {peritext} = setup(); + const p1 = peritext.pointAt(0, Anchor.Before); + const p2 = peritext.pointAt(1, Anchor.Before); + const p3 = peritext.pointAt(2, Anchor.Before); + expect(p1.rightChar()!.view()).toBe('a'); + expect(p2.rightChar()!.view()).toBe('b'); + expect(p3.rightChar()!.view()).toBe('c'); + expect(p1.chunk()!.id.time).toBe(p1.id.time); + expect(p2.chunk()!.id.time + 1).toBe(p2.id.time); + expect(p3.chunk()!.id.time + 2).toBe(p3.id.time); + peritext.strApi().del(1, 1); + expect(p1.rightChar()!.view()).toBe('a'); + expect(p3.rightChar()!.view()).toBe('c'); + expect(p1.chunk()!.id.time).toBe(p1.id.time); + expect(p2.chunk()!.id.time).toBe(p2.id.time); + expect(p3.chunk()!.id.time).toBe(p3.id.time); + }); }); const setupWithText = () => { @@ -419,7 +498,7 @@ describe('.nextId()', () => { test('returns undefined, when at end of str', () => { const {peritext} = setupWithChunkedText(); - const point = peritext.pointAtEnd(); + const point = peritext.pointAbsEnd(); expect(point.nextId()).toBe(undefined); }); @@ -431,7 +510,7 @@ describe('.nextId()', () => { test('returns first char, when at start of str', () => { const {peritext, chunk1} = setupWithChunkedText(); - const point = peritext.pointAtStart(); + const point = peritext.pointAbsStart(); const id = point.nextId(); expect(id).toEqual(chunk1.id); }); @@ -542,7 +621,7 @@ describe('.prevId()', () => { test('returns undefined, when at start of str', () => { const {peritext} = setupWithChunkedText(); - const point = peritext.pointAtStart(); + const point = peritext.pointAbsStart(); expect(point.prevId()).toBe(undefined); }); @@ -556,7 +635,7 @@ describe('.prevId()', () => { test('returns last char, when at end of str', () => { const {peritext} = setupWithChunkedText(); - const point1 = peritext.pointAtEnd(); + const point1 = peritext.pointAbsEnd(); const point2 = peritext.pointAt(9, Anchor.Before); const id = point1.prevId(); expect(id).toEqual(point2.id); @@ -648,7 +727,7 @@ describe('.leftChar()', () => { test('at end of text should return the last char', () => { const {peritext} = setupWithChunkedText(); const p1 = peritext.pointAt(8, Anchor.After); - const p2 = peritext.pointAtEnd(); + const p2 = peritext.pointAbsEnd(); expect(p1.leftChar()!.view()).toBe('9'); expect(p2.leftChar()!.view()).toBe('9'); }); @@ -723,29 +802,105 @@ describe('.rightChar()', () => { test('at start of text should return the first char', () => { const {peritext} = setupWithChunkedText(); const p1 = peritext.pointAt(0, Anchor.Before); - const p2 = peritext.pointAtStart(); + const p2 = peritext.pointAbsStart(); expect(p1.rightChar()!.view()).toBe('1'); expect(p2.rightChar()!.view()).toBe('1'); }); }); -describe('.isStartOfStr()', () => { +describe('.isAbsStart()', () => { test('returns true if is start of string', () => { const {peritext} = setupWithChunkedText(); - const p1 = peritext.pointAtStart(); + const p1 = peritext.pointAbsStart(); const p2 = peritext.pointAt(0, Anchor.Before); - expect(p1.isStartOfStr()).toBe(true); - expect(p2.isStartOfStr()).toBe(false); + expect(p1.isAbsStart()).toBe(true); + expect(p2.isAbsStart()).toBe(false); }); }); -describe('.isEndOfStr()', () => { +describe('.isAbsEnd()', () => { test('returns true if is end of string', () => { const {peritext} = setupWithChunkedText(); - const p1 = peritext.pointAtEnd(); + const p1 = peritext.pointAbsEnd(); const p2 = peritext.pointAt(8, Anchor.After); - expect(p1.isEndOfStr()).toBe(true); - expect(p2.isEndOfStr()).toBe(false); + expect(p1.isAbsEnd()).toBe(true); + expect(p2.isAbsEnd()).toBe(false); + }); +}); + +describe('.isRelStart()', () => { + test('returns true only for relative start', () => { + const {peritext} = setupWithChunkedText(); + const p1 = peritext.pointAbsStart(); + const id = peritext.str.find(0)!; + const p2 = peritext.point(id, Anchor.Before); + const p3 = peritext.point(id, Anchor.After); + expect(p1.isRelStart()).toBe(false); + expect(p2.isRelStart()).toBe(true); + expect(p3.isRelStart()).toBe(false); + }); +}); + +describe('.isRelEnd()', () => { + test('returns true only for relative start', () => { + const {peritext} = setupWithChunkedText(); + const p1 = peritext.pointAbsEnd(); + const id = peritext.str.find(peritext.str.length() - 1)!; + const p2 = peritext.point(id, Anchor.Before); + const p3 = peritext.point(id, Anchor.After); + expect(p1.isRelEnd()).toBe(false); + expect(p2.isRelEnd()).toBe(false); + expect(p3.isRelEnd()).toBe(true); + }); +}); + +describe('.refAbsStart()', () => { + test('attaches to the absolute start', () => { + const {peritext} = setupWithChunkedText(); + const p1 = peritext.pointAt(2, Anchor.After); + p1.refAbsStart(); + expect(p1.viewPos()).toBe(0); + expect(p1.id.sid).toBe(peritext.str.id.sid); + expect(p1.id.time).toBe(peritext.str.id.time); + expect(p1.anchor).toBe(Anchor.After); + }); +}); + +describe('.refAbsEnd()', () => { + test('attaches to the absolute end', () => { + const {peritext} = setupWithChunkedText(); + const p1 = peritext.pointAt(2, Anchor.After); + p1.refAbsEnd(); + expect(p1.viewPos()).toBe(peritext.str.length()); + expect(p1.id.sid).toBe(peritext.str.id.sid); + expect(p1.id.time).toBe(peritext.str.id.time); + expect(p1.anchor).toBe(Anchor.Before); + }); +}); + +describe('.refStart()', () => { + test('attaches to the relative start', () => { + const {peritext} = setupWithChunkedText(); + const p1 = peritext.pointAt(2, Anchor.After); + p1.refStart(); + expect(p1.viewPos()).toBe(0); + const id = peritext.str.find(0)!; + expect(p1.id.sid).toBe(id.sid); + expect(p1.id.time).toBe(id.time); + expect(p1.anchor).toBe(Anchor.Before); + }); +}); + +describe('.refEnd()', () => { + test('attaches to the relative end', () => { + const {peritext} = setupWithChunkedText(); + const p1 = peritext.pointAt(2, Anchor.After); + p1.refEnd(); + expect(p1.viewPos()).toBe(peritext.str.length()); + const id = peritext.str.find(peritext.str.length() - 1)!; + expect(p1.id.sid).toBe(id.sid); + expect(p1.id.time).toBe(id.time); + expect(p1.anchor).toBe(Anchor.After); }); }); @@ -780,7 +935,26 @@ describe('.refBefore()', () => { expect(p1.leftChar()!.view()).toBe('9'); const p2 = p1.clone(); p2.refBefore(); - expect(p2.isEndOfStr()).toBe(true); + expect(p2.isAbsEnd()).toBe(true); + }); + + test('when relative end, attaches to absolute end', () => { + const {peritext} = setupWithChunkedText(); + const p1 = peritext.pointAt(8, Anchor.After); + expect(p1.leftChar()!.view()).toBe('9'); + const p2 = p1.clone(); + p2.refBefore(); + expect(p2.isAbsEnd()).toBe(true); + }); + + test('when absolute start, attaches to the first character', () => { + const {peritext} = setup(); + const chunk1 = peritext.str.first()!; + const absoluteStart = peritext.pointAbsStart(); + const start = peritext.point(chunk1.id, Anchor.Before); + expect(absoluteStart.compareSpatial(start) < 0).toBe(true); + absoluteStart.refBefore(); + expect(absoluteStart.compareSpatial(start) === 0).toBe(true); }); }); @@ -809,13 +983,66 @@ describe('.refAfter()', () => { expect(p2.chunk()!.del).toBe(false); }); - test('when on first character, attaches to start of str', () => { + test('when relative start, attaches to absolute start', () => { const {peritext} = setupWithChunkedText(); const p1 = peritext.pointAt(0, Anchor.Before); expect(p1.rightChar()!.view()).toBe('1'); const p2 = p1.clone(); p2.refAfter(); - expect(p2.isStartOfStr()).toBe(true); + expect(p2.isAbsStart()).toBe(true); + }); + + test('when absolute end, attaches to last char', () => { + const {peritext} = setup(); + const chunk1 = peritext.str.first()!; + const id = tick(chunk1.id, 2); + const absoluteEnd = peritext.pointAbsEnd(); + const end = peritext.point(id, Anchor.After); + expect(absoluteEnd.compareSpatial(end) > 0).toBe(true); + absoluteEnd.refAfter(); + expect(absoluteEnd.compareSpatial(end) === 0).toBe(true); + }); + + test('when absolute end, attaches to last visible char', () => { + const {peritext} = setup(); + const chunk1 = peritext.str.first()!; + const absoluteEnd = peritext.pointAbsEnd(); + 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); + end2.refAfter(); + absoluteEnd.refAfter(); + expect(end2.compareSpatial(end1) === 0).toBe(true); + expect(absoluteEnd.compareSpatial(end1) === 0).toBe(true); + }); +}); + +describe('.refVisible()', () => { + test('skips deleted chars, attaches to visible char', () => { + const {peritext} = setupWithChunkedText(); + peritext.strApi().del(0, peritext.str.length()); + peritext.strApi().ins(0, '123456789'); + const mid1 = peritext.pointAt(4, Anchor.After); + const mid2 = peritext.pointAt(5, Anchor.Before); + expect(mid1.leftChar()!.view()).toBe('5'); + expect(mid1.rightChar()!.view()).toBe('6'); + expect(mid2.leftChar()!.view()).toBe('5'); + expect(mid2.rightChar()!.view()).toBe('6'); + const left = peritext.pointAt(2, Anchor.After); + expect(left.leftChar()!.view()).toBe('3'); + const right = peritext.pointAt(6, Anchor.Before); + expect(right.rightChar()!.view()).toBe('7'); + peritext.strApi().del(3, 3); + expect(left.leftChar()!.view()).toBe('3'); + expect(right.rightChar()!.view()).toBe('7'); + expect(mid1.compare(left) > 0).toBe(true); + mid1.refVisible(); + expect(mid1.compare(left) === 0).toBe(true); + expect(mid2.compare(right) < 0).toBe(true); + mid2.refVisible(); + expect(mid2.compare(right) === 0).toBe(true); }); }); @@ -847,7 +1074,7 @@ describe('.move()', () => { p.move(4); p.move(5); p.move(6); - expect(p.isEndOfStr()).toBe(true); + expect(p.isAbsEnd()).toBe(true); expect(p.viewPos()).toBe(9); expect(p.leftChar()!.view()).toBe('9'); expect(p.anchor).toBe(Anchor.Before); @@ -857,7 +1084,7 @@ describe('.move()', () => { const {peritext} = setupWithChunkedText(); const p = peritext.pointAt(8, Anchor.Before); p.move(-22); - expect(p.isStartOfStr()).toBe(true); + expect(p.isAbsStart()).toBe(true); expect(p.viewPos()).toBe(0); expect(p.rightChar()!.view()).toBe('1'); expect(p.anchor).toBe(Anchor.After); diff --git a/src/json-crdt-extensions/peritext/slice/Cursor.ts b/src/json-crdt-extensions/peritext/slice/Cursor.ts index 50755700c4..f417884ca3 100644 --- a/src/json-crdt-extensions/peritext/slice/Cursor.ts +++ b/src/json-crdt-extensions/peritext/slice/Cursor.ts @@ -15,6 +15,8 @@ export class Cursor extends Range implements Slice { * the end which does not move when user changes selection. The other * end is free to move, the moving end of the cursor is "focus". By default * "anchor" is the start of the cursor. + * + * @todo Create a custom enum for this, instead of using `Anchor`. */ public base: Anchor = Anchor.Before; @@ -35,10 +37,10 @@ export class Cursor extends Range implements Slice { return this.base === Anchor.Before ? this.end : this.start; } - public set(start: Point, end?: Point, anchor: Anchor = Anchor.Before): void { + public set(start: Point, end?: Point, base: Anchor = Anchor.Before): void { if (!end || end === start) end = start.clone(); super.set(start, end); - this.base = anchor; + this.base = base; } public setAt(start: number, length: number = 0): void { @@ -75,7 +77,7 @@ export class Cursor extends Range implements Slice { } } - /** @deprecated What is this method for? */ + /** @todo Maybe move it to another interface? */ public del(): boolean { return false; } diff --git a/src/json-crdt-extensions/peritext/slice/Range.ts b/src/json-crdt-extensions/peritext/slice/Range.ts index 5b8d7032aa..8c0b7a2fdc 100644 --- a/src/json-crdt-extensions/peritext/slice/Range.ts +++ b/src/json-crdt-extensions/peritext/slice/Range.ts @@ -5,55 +5,88 @@ import {type ITimestampStruct, tick} from '../../../json-crdt-patch/clock'; import type {Peritext} from '../Peritext'; import type {Printable} from '../../../util/print/types'; +/** + * 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 { + /** + * 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 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); } + /** + * @param txt 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, ) {} + /** + * Clones the range. + * + * @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()); } + /** + * Determines if the range is collapsed to a single point. Handles special + * cases where the range is collapsed, but the points are not equal, for + * example, when the characters between the points are invisible. + * + * @returns True if the range is collapsed to a single point. + */ public isCollapsed(): boolean { - const start = this.start; - const end = this.end; - if (start === end) return true; - const pos1 = start.pos(); - const pos2 = end.pos(); - if (pos1 === pos2) { - if (start.anchor === end.anchor) return true; - // TODO: inspect below cases, if they are needed - if (start.anchor === Anchor.After) return true; - else { - const chunk = start.chunk(); - if (chunk && chunk.del) { - this.start = this.end.clone(); - return true; - } - } - } - return false; + const {start, end} = this; + if (start.compareSpatial(end) === 0) return true; + const start2 = start.clone(); + const end2 = end.clone(); + start2.refAfter(); + end2.refAfter(); + return start2.compare(end2) === 0; } + /** + * Collapse the range to the start point and sets the anchor position to be + * "after" the character. + */ public collapseToStart(): void { this.start = this.start.clone(); this.start.refAfter(); this.end = this.start.clone(); } + /** + * Collapse the range to the end point and sets the anchor position to be + * "before" the character. + */ public collapseToEnd(): void { this.end = this.end.clone(); this.end.refAfter(); this.start = this.end.clone(); } - public viewRange(): [at: number, len: number] { + /** + * 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]; @@ -69,6 +102,7 @@ export class Range implements Printable { } public setAt(start: number, length: number = 0): void { + // TODO: move implementation to here const range = this.txt.rangeAt(start, length); this.setRange(range); } @@ -163,6 +197,12 @@ export class Range implements Printable { } } + /** + * Concatenates all text chunks in the range ignoring tombstones and returns + * the result. + * + * @returns The text content of the range. + */ public text(): string { const isCaret = this.isCollapsed(); if (isCaret) return ''; 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 6f794e7e68..642333fa14 100644 --- a/src/json-crdt-extensions/peritext/slice/__tests__/Range.spec.ts +++ b/src/json-crdt-extensions/peritext/slice/__tests__/Range.spec.ts @@ -1,32 +1,234 @@ import {Model} from '../../../../json-crdt/model'; import {Peritext} from '../../Peritext'; import {Anchor} from '../../constants'; -import {Editor} from '../../editor/Editor'; -const setup = (insert: (editor: Editor) => void = (editor) => editor.insert('Hello world!')) => { +const setup = (insert: (peritext: Peritext) => void = (peritext) => peritext.strApi().ins(0, 'Hello world!')) => { const model = Model.withLogicalClock(); model.api.root({ text: '', slices: [], }); const peritext = new Peritext(model, model.api.str(['text']).node, model.api.arr(['slices']).node); - const editor = peritext.editor; - insert(editor); - return {model, peritext, editor}; + insert(peritext); + return {model, peritext}; }; +const setupEvenDeleted = () => { + return setup((peritext) => { + peritext.strApi().ins(0, '0123456789'); + peritext.strApi().del(0, 1); + peritext.strApi().del(1, 1); + peritext.strApi().del(2, 1); + peritext.strApi().del(3, 1); + peritext.strApi().del(4, 1); + }); +}; + +describe('new', () => { + test('creates a range from two points', () => { + const {peritext} = setup(); + const range = peritext.rangeAt(1, 2); + expect(range.text()).toBe('el'); + expect(range.start.pos()).toBe(1); + expect(range.start.viewPos()).toBe(1); + expect(range.start.anchor).toBe(Anchor.Before); + expect(range.end.pos()).toBe(2); + expect(range.end.viewPos()).toBe(3); + expect(range.end.anchor).toBe(Anchor.After); + }); +}); + +describe('.from()', () => { + test('creates a when two points are in reverse order', () => { + const {peritext} = setup(); + const rangeTmp = peritext.rangeAt(1, 2); + const range = peritext.rangeFromPoints(rangeTmp.end, rangeTmp.start); + expect(range.text()).toBe('el'); + expect(range.start.pos()).toBe(1); + expect(range.start.viewPos()).toBe(1); + expect(range.start.anchor).toBe(Anchor.Before); + expect(range.end.pos()).toBe(2); + expect(range.end.viewPos()).toBe(3); + expect(range.end.anchor).toBe(Anchor.After); + }); +}); + +describe('.clone()', () => { + test('can clone a range', () => { + const {peritext} = setup(); + const range1 = peritext.rangeAt(2, 3); + const range2 = range1.clone(); + expect(range2).not.toBe(range1); + expect(range1.text()).toBe(range2.text()); + expect(range2.start).not.toBe(range1.start); + 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); + }); +}); + +describe('.isCollapsed()', () => { + describe('when range is collapsed', () => { + test('returns true at the beginning of string', () => { + const {peritext} = setup(); + const point = peritext.pointAbsStart(); + const range = peritext.range(point, point); + const isCollapsed = range.isCollapsed(); + expect(isCollapsed).toBe(true); + }); + + test('returns true at the end of string', () => { + const {peritext} = setup(); + const point = peritext.pointAbsEnd(); + const range = peritext.range(point, point); + const isCollapsed = range.isCollapsed(); + expect(isCollapsed).toBe(true); + }); + + test('returns true when before first character', () => { + const {peritext} = setup(); + const point = peritext.pointAt(0, Anchor.Before); + const range = peritext.range(point, point); + const isCollapsed = range.isCollapsed(); + expect(isCollapsed).toBe(true); + }); + + test('returns true when after last character', () => { + const {peritext} = setup(); + const point = peritext.pointAt(peritext.str.length() - 1, Anchor.After); + const range = peritext.range(point, point); + const isCollapsed = range.isCollapsed(); + expect(isCollapsed).toBe(true); + }); + + test('returns true when in the middle of plain/undeleted text', () => { + const {peritext} = setup(); + const point1 = peritext.pointAt(2, Anchor.After); + const point2 = peritext.pointAt(3, Anchor.Before); + const range1 = peritext.range(point1, point1); + const range2 = peritext.range(point2, point2); + expect(range1.isCollapsed()).toBe(true); + expect(range2.isCollapsed()).toBe(true); + }); + + describe('when first character is deleted', () => { + test('returns true at the beginning of string', () => { + const {peritext} = setupEvenDeleted(); + const point = peritext.pointAbsStart(); + const range = peritext.range(point, point); + const isCollapsed = range.isCollapsed(); + expect(isCollapsed).toBe(true); + }); + + test('returns true when before first character', () => { + const {peritext} = setupEvenDeleted(); + const point = peritext.pointAt(0, Anchor.Before); + const range = peritext.range(point, point); + const isCollapsed = range.isCollapsed(); + expect(isCollapsed).toBe(true); + }); + }); + + describe('when characters are deleted', () => { + test('returns true when in the middle of deleted characters', () => { + const {peritext} = setupEvenDeleted(); + const range = peritext.rangeAt(2, 1); + expect(range.isCollapsed()).toBe(false); + peritext.strApi().del(1, 3); + expect(range.isCollapsed()).toBe(true); + }); + + test('returns true when whole text was deleted', () => { + const {peritext} = setupEvenDeleted(); + const range = peritext.rangeAt(1, 3); + expect(range.isCollapsed()).toBe(false); + peritext.strApi().del(0, 5); + expect(range.isCollapsed()).toBe(true); + }); + + test('when all text is selected', () => { + const {peritext} = setupEvenDeleted(); + const range = peritext.range(peritext.pointAbsStart(), peritext.pointAbsEnd()); + expect(range.isCollapsed()).toBe(false); + peritext.strApi().del(0, 5); + expect(range.isCollapsed()).toBe(true); + }); + }); + }); +}); + +describe('.collapseToStart()', () => { + test('collapses range to start', () => { + const {peritext} = setup(); + const range = peritext.rangeAt(2, 3); + range.collapseToStart(); + expect(range.isCollapsed()).toBe(true); + expect(range.start.rightChar()?.view()).toBe('l'); + expect(range.end.rightChar()?.view()).toBe('l'); + }); +}); + +describe('.collapseToEnd()', () => { + test('collapses range to end', () => { + const {peritext} = setup(); + const range = peritext.rangeAt(2, 3); + range.collapseToEnd(); + expect(range.isCollapsed()).toBe(true); + expect(range.start.leftChar()?.view()).toBe('o'); + expect(range.end.leftChar()?.view()).toBe('o'); + }); +}); + +describe('.view()', () => { + test('returns correct view', () => { + const {peritext} = setup(); + const range = peritext.rangeAt(2, 3); + expect(range.views()).toEqual([2, 3]); + }); +}); + +describe('.contains()', () => { + test('returns true if slice is contained', () => { + const {peritext} = setup(); + peritext.editor.setCursor(3, 2); + const slice = peritext.editor.insertOverwriteSlice('b'); + peritext.editor.setCursor(0); + peritext.refresh(); + expect(peritext.rangeAt(2, 4).contains(slice)).toBe(true); + expect(peritext.rangeAt(3, 4).contains(slice)).toBe(true); + expect(peritext.rangeAt(2, 3).contains(slice)).toBe(true); + expect(peritext.rangeAt(3, 2).contains(slice)).toBe(true); + }); + + test('returns false if slice is not contained', () => { + const {peritext} = setup(); + peritext.editor.setCursor(3, 2); + const slice = peritext.editor.insertOverwriteSlice('b'); + peritext.editor.setCursor(0); + peritext.refresh(); + expect(peritext.rangeAt(3, 1).contains(slice)).toBe(false); + expect(peritext.rangeAt(2, 1).contains(slice)).toBe(false); + expect(peritext.rangeAt(2, 2).contains(slice)).toBe(false); + expect(peritext.rangeAt(1, 1).contains(slice)).toBe(false); + expect(peritext.rangeAt(4, 5).contains(slice)).toBe(false); + expect(peritext.rangeAt(8, 1).contains(slice)).toBe(false); + }); +}); + describe('.isCollapsed()', () => { test('returns true when endpoints point to the same location', () => { - const {editor} = setup(); - editor.setCursor(3); - expect(editor.cursor.isCollapsed()).toBe(true); + const {peritext} = setup(); + peritext.editor.setCursor(3); + expect(peritext.editor.cursor.isCollapsed()).toBe(true); }); test('returns true when when there is no visible content between endpoints', () => { - const {peritext, editor} = setup(); + const {peritext} = setup(); const range = peritext.rangeAt(2, 1); - editor.setCursor(2, 1); - editor.delete(); + peritext.editor.setCursor(2, 1); + peritext.editor.delete(); expect(range.isCollapsed()).toBe(true); }); }); @@ -34,7 +236,8 @@ describe('.isCollapsed()', () => { describe('.expand()', () => { const runExpandTests = (setup2: typeof setup) => { test('can expand anchors to include adjacent elements', () => { - const {editor} = setup2(); + const {peritext} = setup2(); + const editor = peritext.editor; editor.setCursor(1, 1); expect(editor.cursor.start.pos()).toBe(1); expect(editor.cursor.start.anchor).toBe(Anchor.Before); @@ -49,15 +252,15 @@ describe('.expand()', () => { }); test('can expand anchors to contain include adjacent tombstones', () => { - const {peritext, editor} = setup2(); + const {peritext} = setup2(); const tombstone1 = peritext.rangeAt(1, 1); tombstone1.expand(); const tombstone2 = peritext.rangeAt(3, 1); tombstone2.expand(); - editor.cursor.setRange(tombstone1); - editor.delete(); - editor.cursor.setRange(tombstone2); - editor.delete(); + peritext.editor.cursor.setRange(tombstone1); + peritext.editor.delete(); + peritext.editor.cursor.setRange(tombstone2); + peritext.editor.delete(); const range = peritext.rangeAt(1, 1); range.expand(); expect(range.start.pos()).toBe(tombstone1.start.pos()); @@ -73,7 +276,8 @@ describe('.expand()', () => { describe('each car is own chunk', () => { runExpandTests(() => - setup((editor) => { + setup((peritext) => { + const editor = peritext.editor; editor.insert('!'); editor.setCursor(0); editor.insert('d');