diff --git a/src/json-crdt-extensions/peritext/Peritext.ts b/src/json-crdt-extensions/peritext/Peritext.ts new file mode 100644 index 0000000000..71bf7b4215 --- /dev/null +++ b/src/json-crdt-extensions/peritext/Peritext.ts @@ -0,0 +1,41 @@ +import {Anchor} from './constants'; +import {Point} from './point/Point'; +import {printTree} from '../../util/print/printTree'; +import {ArrNode, StrNode} from '../../json-crdt/nodes'; +import {type ITimestampStruct} from '../../json-crdt-patch/clock'; +import type {Model} from '../../json-crdt/model'; +import type {Printable} from '../../util/print/types'; + +export class Peritext implements Printable { + constructor( + public readonly model: Model, + public readonly str: StrNode, + slices: ArrNode, + ) {} + + public point(id: ITimestampStruct, anchor: Anchor = Anchor.After): Point { + return new Point(this, id, anchor); + } + + public pointAt(pos: number, anchor: Anchor = Anchor.Before): Point { + 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 { + return this.point(this.str.id, Anchor.After); + } + + public pointAtEnd(): Point { + return this.point(this.str.id, Anchor.Before); + } + + // ---------------------------------------------------------------- Printable + + public toString(tab: string = ''): string { + const nl = () => ''; + return this.constructor.name + printTree(tab, [(tab) => this.str.toString(tab)]); + } +} diff --git a/src/json-crdt-extensions/peritext/constants.ts b/src/json-crdt-extensions/peritext/constants.ts new file mode 100644 index 0000000000..dbb27c7074 --- /dev/null +++ b/src/json-crdt-extensions/peritext/constants.ts @@ -0,0 +1,43 @@ +export const enum Anchor { + Before = 0, + After = 1, +} + +export const enum SliceHeaderMask { + X1Anchor = 0b1, + X2Anchor = 0b10, + Behavior = 0b11100, +} + +export const enum SliceHeaderShift { + X1Anchor = 0, + X2Anchor = 1, + Behavior = 2, +} + +export const enum SliceBehavior { + /** + * A Split slice, which is used to mark a block split position in the document. + * For example, paragraph, heading, blockquote, etc. + */ + Split = 0b000, + + /** + * Appends attributes to a stack of attributes for a specific slice type. This + * is useful when the same slice type can have multiple attributes, like + * inline comments, highlights, etc. + */ + Stack = 0b001, + + /** + * Overwrites the stack of attributes for a specific slice type. Could be used + * for simple inline formatting, like bold, italic, etc. + */ + Overwrite = 0b010, + + /** + * Removes all attributes for a specific slice type. For example, could be + * used to re-verse inline formatting, like bold, italic, etc. + */ + Erase = 0b011, +} diff --git a/src/json-crdt-extensions/peritext/point/Point.ts b/src/json-crdt-extensions/peritext/point/Point.ts new file mode 100644 index 0000000000..e5299cd764 --- /dev/null +++ b/src/json-crdt-extensions/peritext/point/Point.ts @@ -0,0 +1,353 @@ +import {compare, type ITimestampStruct, toDisplayString, equal, tick, containsId} from '../../../json-crdt-patch/clock'; +import {Anchor} from '../constants'; +import {ChunkSlice} from '../util/ChunkSlice'; +import {updateId} from '../../../json-crdt/hash'; +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 + * 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. + */ +export class Point implements Pick, Printable { + constructor( + protected readonly txt: Peritext, + public id: ITimestampStruct, + public anchor: Anchor, + ) {} + + /** + * Overwrites the internal state of this point with the state of the given + * point. + * + * @param point Point to copy. + */ + public set(point: Point): void { + this.id = point.id; + this.anchor = point.anchor; + } + + /** + * Creates a copy of this point. + * + * @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); + } + + /** + * + * @param other The other point to compare to. + * @returns Returns 0 if the two points are equal, -1 if this point is less + * than the other point, and 1 if this point is greater than the other + * point. + */ + public compare(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; + } + + public compareSpatial(other: Point): number { + const thisId = this.id; + const otherId = other.id; + const cmp0 = compare(thisId, otherId); + if (!cmp0) return this.anchor - other.anchor; + const cmp1 = this.pos() - other.pos(); + if (cmp1) return cmp1; + 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); + while (chunk) { + if (containsId(chunk.id, chunk.span, otherId)) return -1; + chunk = str.next(chunk); + } + return 1; + } + + private _chunk: StringChunk | undefined; + public chunk(): StringChunk | undefined { + let chunk = this._chunk; + const id = this.id; + if (chunk) { + const chunkId = chunk.id; + const chunkIdTime = chunkId.time; + 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); + return chunk; + } + + /** + * @returns Returns position of the character referenced by the point. + */ + public pos(): number { + const chunk = this.chunk(); + if (!chunk) return -1; + const pos = this.txt.str.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 position of the point, as if it is a cursor in a text + * pointing between characters. + */ + public viewPos(): number { + const pos = this.pos(); + if (pos < 0) return this.isStartOfStr() ? 0 : this.txt.str.length(); + return this.anchor === Anchor.Before ? pos : pos + 1; + } + + /** + * Goes to the next visible character in the string. The `move` parameter + * specifies how many characters to move the cursor by. If the cursor reaches + * the end of the string, it will return `undefined`. + * + * @param move How many characters to move the cursor by. + * @returns Next visible ID in string. + */ + public nextId(move: number = 1): ITimestampStruct | undefined { + if (this.isEndOfStr()) return; + let remaining: number = move; + const {id, txt} = this; + const str = txt.str; + let chunk: StringChunk | undefined; + if (this.isStartOfStr()) { + chunk = str.first(); + while (chunk && chunk.del) chunk = str.next(chunk); + if (!chunk) return; + const span = chunk.span; + if (remaining <= span) return tick(chunk.id, remaining - 1); + remaining -= span; + chunk = str.next(chunk); + } else { + chunk = this.chunk(); + if (!chunk) return undefined; + if (!chunk.del) { + const offset = id.time - chunk.id.time; + const span = chunk.span; + if (offset + remaining < span) return tick(id, remaining); + else remaining -= span - offset - 1; + } + chunk = str.next(chunk); + } + let lastVisibleChunk: StringChunk | undefined; + while (chunk && remaining >= 0) { + if (chunk.del) { + chunk = str.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); + } + if (remaining > 0) return; + return lastVisibleChunk ? tick(lastVisibleChunk.id, lastVisibleChunk.span - 1) : undefined; + } + + /** + * @returns ID of the character that is `move` characters before the + * character referenced by the point, or `undefined` if there is no + * such character. + */ + public prevId(move: number = 1): ITimestampStruct | undefined { + if (this.isStartOfStr()) return; + let remaining: number = move; + const {id, txt} = this; + const str = txt.str; + let chunk = this.chunk(); + if (!chunk) return str.id; + if (!chunk.del) { + const offset = id.time - chunk.id.time; + if (offset >= remaining) return tick(id, -remaining); + remaining -= offset; + } + chunk = str.prev(chunk); + while (chunk) { + if (chunk.del) { + chunk = str.prev(chunk); + continue; + } + const span = chunk.span; + if (remaining <= span) { + return tick(chunk.id, span - remaining); + } + remaining -= span; + chunk = str.prev(chunk); + } + return; + } + + 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.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); + } + + 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); + } + 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); + } + + public isStartOfStr(): boolean { + return equal(this.id, this.txt.str.id) && this.anchor === Anchor.After; + } + + public isEndOfStr(): boolean { + return equal(this.id, this.txt.str.id) && this.anchor === Anchor.Before; + } + + /** + * 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. + */ + public refVisible(): void { + if (this.anchor === Anchor.Before) this.refBefore(); + else this.refAfter(); + } + + public refStart(): void { + this.id = this.txt.str.id; + this.anchor = Anchor.After; + } + + public refEnd(): void { + this.id = this.txt.str.id; + this.anchor = Anchor.Before; + } + + /** + * Modifies the location of the point, such that the spatial 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.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 + * 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.del && this.anchor === Anchor.After) return; + this.anchor = Anchor.After; + this.id = this.prevId() || this.txt.str.id; + } + + /** + * Moves point past given number of visible characters. Accepts positive + * and negative distances. + */ + public move(skip: number): void { + if (!skip) return; + const anchor = this.anchor; + if (anchor !== Anchor.After) this.refAfter(); + if (skip > 0) { + const nextId = this.nextId(skip); + if (!nextId) this.refEnd(); + else { + this.id = nextId; + if (anchor !== Anchor.After) this.refBefore(); + } + } else { + const prevId = this.prevId(-skip); + if (!prevId) this.refStart(); + else { + this.id = prevId; + if (anchor !== Anchor.After) this.refBefore(); + } + } + } + + // ----------------------------------------------------------------- Stateful + + public refresh(): number { + let state = this.anchor; + state = updateId(state, this.id); + return state; + } + + // ---------------------------------------------------------------- Printable + + public toString(tab: string = '', lite?: boolean): string { + const name = lite ? '' : this.constructor.name + ' '; + const pos = this.pos(); + const id = toDisplayString(this.id); + const anchor = this.anchor === Anchor.Before ? '.▢' : '▢.'; + return `${name}{ ${pos}, ${id}, ${anchor} }`; + } +} diff --git a/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts b/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts new file mode 100644 index 0000000000..5e4b62c693 --- /dev/null +++ b/src/json-crdt-extensions/peritext/point/__tests__/Point.spec.ts @@ -0,0 +1,917 @@ +import {Model} from '../../../../json-crdt/model'; +import {Peritext} from '../../Peritext'; +import {Anchor} from '../../constants'; +import {tick} from '../../../../json-crdt-patch/clock'; + +const setup = () => { + const model = Model.withLogicalClock(); + model.api.root({ + text: 'abc', + slices: [], + }); + const peritext = new Peritext(model, model.api.str(['text']).node, model.api.arr(['slices']).node); + return {model, peritext}; +}; + +describe('.set()', () => { + test('can overwrite a point with the same identity as another point', () => { + 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.After); + 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.id.sid).toBe(p2.id.sid); + expect(p1.id.time).toBe(p2.id.time); + expect(p1.anchor).toBe(p2.anchor); + }); +}); + +describe('.clone()', () => { + test('can create a new point with the same identity as another point', () => { + const {peritext} = setup(); + const chunk = peritext.str.first()!; + const id = chunk.id; + 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.id.sid).toBe(p2.id.sid); + expect(p1.id.time).toBe(p2.id.time); + expect(p1.anchor).toBe(p2.anchor); + }); +}); + +describe('.compare()', () => { + 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); + }); + + test('compares by ID first, then by anchor', () => { + const {peritext} = setup(); + const chunk = peritext.str.first()!; + const id1 = chunk.id; + const id2 = tick(id1, 1); + const id3 = tick(id1, 2); + 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 = [p1, p2, p3, p4, p5, p6]; + for (let i = 0; i < points.length; i++) { + for (let j = 0; j < points.length; j++) { + const p1 = points[i]; + const p2 = points[j]; + if (i === j) { + expect(p1.compare(p2)).toBe(0); + } else if (i < j) { + expect(p1.compare(p2)).toBeLessThan(0); + } else { + expect(p1.compare(p2)).toBeGreaterThan(0); + } + } + } + }); +}); + +describe('.compareSpatial()', () => { + test('higher spacial points return positive value', () => { + const {peritext} = setup(); + const chunk1 = peritext.str.first()!; + const id1 = chunk1.id; + const id2 = tick(id1, 1); + 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); + 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); + }); + + test('correctly orders points when tombstones are present', () => { + const model = Model.withLogicalClock(123456); + model.api.root({ + text: '3', + slices: [], + }); + const str = model.api.str(['text']).node; + const peritext = new Peritext(model, str, model.api.arr(['slices']).node); + const chunk3 = str.root!; + model.api.str(['text']).ins(1, '4'); + const chunk4 = str.last()!; + model.api.str(['text']).ins(0, '2'); + const chunk2 = str.first()!; + model.api.str(['text']).ins(3, '5'); + const chunk5 = str.last()!; + model.api.str(['text']).ins(0, '1'); + const chunk1 = str.first()!; + model.api.str(['text']).del(0, 2); + model.api.str(['text']).del(1, 2); + const p1 = peritext.point(chunk1.id, Anchor.Before); + const p2 = peritext.point(chunk2.id, Anchor.Before); + const p3 = peritext.point(chunk3.id, Anchor.Before); + const p4 = peritext.point(chunk4.id, Anchor.Before); + const p5 = peritext.point(chunk5.id, Anchor.Before); + const pp1 = peritext.point(chunk1.id, Anchor.After); + const pp2 = peritext.point(chunk2.id, Anchor.After); + const pp3 = peritext.point(chunk3.id, Anchor.After); + const pp4 = peritext.point(chunk4.id, Anchor.After); + const pp5 = peritext.point(chunk5.id, Anchor.After); + const points = [p1, pp1, p2, pp2, p3, pp3, p4, pp4, p5, pp5]; + for (let i = 0; i < points.length; i++) { + for (let j = 0; j < points.length; j++) { + const p1 = points[i]; + const p2 = points[j]; + try { + if (i === j) { + expect(p1.compareSpatial(p2)).toBe(0); + } else if (i < j) { + expect(p1.compareSpatial(p2)).toBeLessThan(0); + } else { + expect(p1.compareSpatial(p2)).toBeGreaterThan(0); + } + } catch (error) { + // tslint:disable-next-line:no-console + console.log('i: ', i, 'j: ', j, 'p1: ', p1 + '', 'p2: ', p2 + '', p1.compareSpatial(p2)); + throw error; + } + } + } + }); +}); + +const setupWithText = () => { + const model = Model.withLogicalClock(123456); + model.api.root({ + text: '3', + slices: [], + }); + const str = model.api.str(['text']).node; + const peritext = new Peritext(model, str, model.api.arr(['slices']).node); + const chunk3 = str.root!; + model.api.str(['text']).ins(1, '4'); + const chunk4 = str.last()!; + model.api.str(['text']).ins(0, '2'); + const chunk2 = str.first()!; + model.api.str(['text']).ins(3, '5'); + const chunk5 = str.last()!; + model.api.str(['text']).ins(4, '678'); + const chunk6 = str.last()!; + model.api.str(['text']).ins(0, '1'); + const chunk1 = str.first()!; + model.api.str(['text']).del(0, 2); + model.api.str(['text']).del(1, 3); + model.api.str(['text']).ins(1, '456'); + model.api.str(['text']).ins(0, '012'); + return { + model, + str, + peritext, + chunk1, + chunk2, + chunk3, + chunk4, + chunk5, + chunk6, + }; +}; + +const setupWithChunkedText = () => { + const model = Model.withLogicalClock(123456); + model.api.root({ + text: '', + slices: [], + }); + const str = model.api.str(['text']).node; + const peritext = new Peritext(model, str, model.api.arr(['slices']).node); + model.api.str(['text']).ins(0, '789'); + const chunk3 = str.first()!; + model.api.str(['text']).ins(0, 'd'); + const chunkD2 = str.first()!; + model.api.str(['text']).ins(0, '456'); + const chunk2 = str.first()!; + model.api.str(['text']).ins(0, 'd'); + const chunkD1 = str.first()!; + model.api.str(['text']).ins(0, '123'); + const chunk1 = str.first()!; + model.api.str(['text']).del(3, 1); + model.api.str(['text']).del(6, 1); + return { + model, + str, + peritext, + chunk1, + chunk2, + chunk3, + chunkD1, + chunkD2, + }; +}; + +describe('.pos()', () => { + test('returns character position, regardless of anchor point, for visible and deleted chars', () => { + const {str, peritext, chunk1, chunk2, chunk3, chunk4, chunk5} = setupWithText(); + const [chunk3Before] = str.findChunk(0)!; + const [chunk3After] = str.findChunk(4)!; + const visibleIDs = [ + chunk3Before.id, + tick(chunk3Before.id, 1), + tick(chunk3Before.id, 2), + chunk3.id, + chunk3After.id, + tick(chunk3After.id, 1), + tick(chunk3After.id, 2), + ]; + // Visible characters + for (let i = 0; i < visibleIDs.length; i++) { + const visibleId = visibleIDs[i]; + const p1 = peritext.point(visibleId, Anchor.Before); + const p2 = peritext.point(visibleId, Anchor.After); + expect(p1.pos()).toBe(i); + expect(p2.pos()).toBe(i); + } + // Deleted characters + const p1Before = peritext.point(chunk1.id, Anchor.Before); + const p1After = peritext.point(chunk1.id, Anchor.After); + expect(p1Before.pos()).toBe(3); + expect(p1After.pos()).toBe(3); + const p2Before = peritext.point(chunk2.id, Anchor.Before); + const p2After = peritext.point(chunk2.id, Anchor.After); + expect(p2Before.pos()).toBe(3); + expect(p2After.pos()).toBe(3); + const p4Before = peritext.point(chunk4.id, Anchor.Before); + const p4After = peritext.point(chunk4.id, Anchor.After); + expect(p4Before.pos()).toBe(7); + expect(p4After.pos()).toBe(7); + const p5Before = peritext.point(chunk5.id, Anchor.Before); + const p5After = peritext.point(chunk5.id, Anchor.After); + expect(p5Before.pos()).toBe(7); + expect(p5After.pos()).toBe(7); + }); +}); + +describe('.viewPos()', () => { + test('returns index position in view, for visible and deleted chars', () => { + const {str, peritext, chunk1, chunk2, chunk3, chunk4, chunk5} = setupWithText(); + const [chunk3Before] = str.findChunk(0)!; + const [chunk3After] = str.findChunk(4)!; + const visibleIDs = [ + chunk3Before.id, + tick(chunk3Before.id, 1), + tick(chunk3Before.id, 2), + chunk3.id, + chunk3After.id, + tick(chunk3After.id, 1), + tick(chunk3After.id, 2), + ]; + // Visible characters + for (let i = 0; i < visibleIDs.length; i++) { + const visibleId = visibleIDs[i]; + const p1 = peritext.point(visibleId, Anchor.Before); + const p2 = peritext.point(visibleId, Anchor.After); + expect(p1.viewPos()).toBe(i); + expect(p2.viewPos()).toBe(i + 1); + } + // Deleted characters + const p1Before = peritext.point(chunk1.id, Anchor.Before); + const p1After = peritext.point(chunk1.id, Anchor.After); + expect(p1Before.viewPos()).toBe(3); + expect(p1After.viewPos()).toBe(4); + const p2Before = peritext.point(chunk2.id, Anchor.Before); + const p2After = peritext.point(chunk2.id, Anchor.After); + expect(p2Before.viewPos()).toBe(3); + expect(p2After.viewPos()).toBe(4); + const p4Before = peritext.point(chunk4.id, Anchor.Before); + const p4After = peritext.point(chunk4.id, Anchor.After); + expect(p4Before.viewPos()).toBe(7); + expect(p4After.viewPos()).toBe(8); + const p5Before = peritext.point(chunk5.id, Anchor.Before); + const p5After = peritext.point(chunk5.id, Anchor.After); + expect(p5Before.viewPos()).toBe(7); + expect(p5After.viewPos()).toBe(8); + }); +}); + +describe('.nextId()', () => { + test('can iterate through all IDs, starting from visible or hidden', () => { + const {str, peritext, chunk1, chunk2, chunk3, chunk4, chunk5, chunk6} = setupWithText(); + const [chunk3Before] = str.findChunk(0)!; + const [chunk3After] = str.findChunk(4)!; + const visibleIDs = [ + chunk3Before.id, + tick(chunk3Before.id, 1), + tick(chunk3Before.id, 2), + chunk3.id, + chunk3After.id, + tick(chunk3After.id, 1), + tick(chunk3After.id, 2), + ]; + // Visible characters + for (let i = 0; i < visibleIDs.length - 1; i++) { + const visibleId = visibleIDs[i]; + const p1 = peritext.point(visibleId, Anchor.Before); + const p2 = peritext.point(visibleId, Anchor.After); + const nextP1 = p1.nextId(); + const nextP2 = p2.nextId(); + expect(nextP1).toEqual(visibleIDs[i + 1]); + expect(nextP2).toEqual(visibleIDs[i + 1]); + } + // Deleted characters + const p1Before = peritext.point(chunk1.id, Anchor.Before); + const p1After = peritext.point(chunk1.id, Anchor.After); + expect(p1Before.nextId()).toEqual(visibleIDs[3]); + expect(p1After.nextId()).toEqual(visibleIDs[3]); + const p2Before = peritext.point(chunk2.id, Anchor.Before); + const p2After = peritext.point(chunk2.id, Anchor.After); + expect(p2Before.nextId()).toEqual(visibleIDs[3]); + expect(p2After.nextId()).toEqual(visibleIDs[3]); + const p4Before = peritext.point(chunk4.id, Anchor.Before); + const p4After = peritext.point(chunk4.id, Anchor.After); + expect(p4Before.nextId()).toEqual(tick(chunk6.id, 2)); + expect(p4After.nextId()).toEqual(tick(chunk6.id, 2)); + const p5Before = peritext.point(chunk5.id, Anchor.Before); + const p5After = peritext.point(chunk5.id, Anchor.After); + expect(p5Before.nextId()).toEqual(tick(chunk6.id, 2)); + expect(p5After.nextId()).toEqual(tick(chunk6.id, 2)); + }); + + test('can iterate through multi-char chunk', () => { + const {peritext, chunk1, chunk2, chunk3, chunkD1, chunkD2} = setupWithChunkedText(); + const visibleIDs = [ + tick(chunk1.id, 0), + tick(chunk1.id, 1), + tick(chunk1.id, 2), + tick(chunk2.id, 0), + tick(chunk2.id, 1), + tick(chunk2.id, 2), + tick(chunk3.id, 0), + tick(chunk3.id, 1), + tick(chunk3.id, 2), + ]; + // Visible characters + for (let i = 0; i < visibleIDs.length - 1; i++) { + const visibleId = visibleIDs[i]; + const p1 = peritext.point(visibleId, Anchor.Before); + const p2 = peritext.point(visibleId, Anchor.After); + for (let j = i; j < visibleIDs.length - 1; j++) { + expect(p1.nextId(1 + j - i)).toEqual(visibleIDs[j + 1]); + expect(p2.nextId(1 + j - i)).toEqual(visibleIDs[j + 1]); + } + expect(p1.nextId(visibleIDs.length)).toEqual(undefined); + expect(p2.nextId(visibleIDs.length)).toEqual(undefined); + } + // Deleted characters + const p1Before = peritext.point(chunkD1.id, Anchor.Before); + const p1After = peritext.point(chunkD1.id, Anchor.After); + for (let i = 0; i < 6; i++) { + expect(p1Before.nextId(i + 1)).toEqual(visibleIDs[3 + i]); + expect(p1After.nextId(i + 1)).toEqual(visibleIDs[3 + i]); + } + expect(p1Before.nextId(visibleIDs.length)).toEqual(undefined); + expect(p1After.nextId(visibleIDs.length)).toEqual(undefined); + const p2Before = peritext.point(chunkD2.id, Anchor.Before); + const p2After = peritext.point(chunkD2.id, Anchor.After); + for (let i = 0; i < 3; i++) { + expect(p2Before.nextId(i + 1)).toEqual(visibleIDs[6 + i]); + expect(p2After.nextId(i + 1)).toEqual(visibleIDs[6 + i]); + } + expect(p2Before.nextId(visibleIDs.length)).toEqual(undefined); + expect(p2After.nextId(visibleIDs.length)).toEqual(undefined); + }); + + test('can move zero characters', () => { + const {peritext, chunk2, chunkD1} = setupWithChunkedText(); + const p1 = peritext.point(chunk2.id, Anchor.Before); + expect(p1.leftChar()!.view()).toBe('3'); + p1.prevId(0); + expect(p1.leftChar()!.view()).toBe('3'); + const p2 = peritext.point(chunkD1.id, Anchor.Before); + expect(p2.leftChar()!.view()).toBe('3'); + p2.prevId(0); + expect(p2.leftChar()!.view()).toBe('3'); + }); + + test('returns undefined, when at end of str', () => { + const {peritext} = setupWithChunkedText(); + const point = peritext.pointAtEnd(); + expect(point.nextId()).toBe(undefined); + }); + + test('returns undefined, when at last char', () => { + const {peritext} = setupWithChunkedText(); + const point = peritext.pointAt(8, Anchor.Before); + expect(point.nextId()).toBe(undefined); + }); + + test('returns first char, when at start of str', () => { + const {peritext, chunk1} = setupWithChunkedText(); + const point = peritext.pointAtStart(); + const id = point.nextId(); + expect(id).toEqual(chunk1.id); + }); +}); + +describe('.prevId()', () => { + test('can iterate through all IDs, starting from visible or hidden', () => { + const {str, peritext, chunk1, chunk2, chunk3, chunk4, chunk5} = setupWithText(); + const [chunk3Before] = str.findChunk(0)!; + const [chunk3After] = str.findChunk(4)!; + const visibleIDs = [ + chunk3Before.id, + tick(chunk3Before.id, 1), + tick(chunk3Before.id, 2), + chunk3.id, + chunk3After.id, + tick(chunk3After.id, 1), + tick(chunk3After.id, 2), + ]; + // Visible characters + for (let i = 1; i < visibleIDs.length; i++) { + const visibleId = visibleIDs[i]; + const p1 = peritext.point(visibleId, Anchor.Before); + const p2 = peritext.point(visibleId, Anchor.After); + const nextP1 = p1.prevId(); + const nextP2 = p2.prevId(); + expect(nextP1).toEqual(visibleIDs[i - 1]); + expect(nextP2).toEqual(visibleIDs[i - 1]); + } + // Deleted characters + const p1Before = peritext.point(chunk1.id, Anchor.Before); + const p1After = peritext.point(chunk1.id, Anchor.After); + expect(p1Before.prevId()).toEqual(visibleIDs[2]); + expect(p1After.prevId()).toEqual(visibleIDs[2]); + const p2Before = peritext.point(chunk2.id, Anchor.Before); + const p2After = peritext.point(chunk2.id, Anchor.After); + expect(p2Before.prevId()).toEqual(visibleIDs[2]); + expect(p2After.prevId()).toEqual(visibleIDs[2]); + const p4Before = peritext.point(chunk4.id, Anchor.Before); + const p4After = peritext.point(chunk4.id, Anchor.After); + expect(p4Before.prevId()).toEqual(visibleIDs[6]); + expect(p4After.prevId()).toEqual(visibleIDs[6]); + const p5Before = peritext.point(chunk5.id, Anchor.Before); + const p5After = peritext.point(chunk5.id, Anchor.After); + expect(p5Before.prevId()).toEqual(visibleIDs[6]); + expect(p5After.prevId()).toEqual(visibleIDs[6]); + }); + + test('can iterate through multi-char chunk', () => { + const {peritext, chunk1, chunk2, chunk3, chunkD1, chunkD2} = setupWithChunkedText(); + const visibleIDs = [ + tick(chunk1.id, 0), + tick(chunk1.id, 1), + tick(chunk1.id, 2), + tick(chunk2.id, 0), + tick(chunk2.id, 1), + tick(chunk2.id, 2), + tick(chunk3.id, 0), + tick(chunk3.id, 1), + tick(chunk3.id, 2), + ]; + // Visible characters + for (let i = 1; i < visibleIDs.length; i++) { + const visibleId = visibleIDs[i]; + const p1 = peritext.point(visibleId, Anchor.Before); + const p2 = peritext.point(visibleId, Anchor.After); + for (let j = 1; j < i; j++) { + expect(p1.prevId(j)).toEqual(visibleIDs[i - j]); + expect(p2.prevId(j)).toEqual(visibleIDs[i - j]); + } + expect(p1.prevId(i + 1)).toEqual(undefined); + expect(p2.prevId(i + 1)).toEqual(undefined); + } + // Deleted characters + const p1Before = peritext.point(chunkD1.id, Anchor.Before); + const p1After = peritext.point(chunkD1.id, Anchor.After); + for (let i = 0; i < 3; i++) { + expect(p1Before.prevId(i + 1)).toEqual(visibleIDs[2 - i]); + expect(p1After.prevId(i + 1)).toEqual(visibleIDs[2 - i]); + } + expect(p1Before.prevId(4)).toEqual(undefined); + expect(p1After.prevId(4)).toEqual(undefined); + expect(p1Before.prevId(5)).toEqual(undefined); + expect(p1After.prevId(5)).toEqual(undefined); + const p2Before = peritext.point(chunkD2.id, Anchor.Before); + const p2After = peritext.point(chunkD2.id, Anchor.After); + for (let i = 0; i < 6; i++) { + expect(p2Before.prevId(i + 1)).toEqual(visibleIDs[5 - i]); + expect(p2After.prevId(i + 1)).toEqual(visibleIDs[5 - i]); + } + expect(p2Before.prevId(7)).toEqual(undefined); + expect(p2After.prevId(7)).toEqual(undefined); + expect(p2Before.prevId(8)).toEqual(undefined); + expect(p2After.prevId(8)).toEqual(undefined); + }); + + test('can move zero characters', () => { + const {peritext, chunk2, chunkD1} = setupWithChunkedText(); + const p1 = peritext.point(chunk2.id, Anchor.Before); + expect(p1.rightChar()!.view()).toBe('4'); + p1.nextId(0); + expect(p1.rightChar()!.view()).toBe('4'); + const p2 = peritext.point(chunkD1.id, Anchor.Before); + expect(p2.rightChar()!.view()).toBe('4'); + p2.nextId(0); + expect(p2.rightChar()!.view()).toBe('4'); + }); + + test('returns undefined, when at start of str', () => { + const {peritext} = setupWithChunkedText(); + const point = peritext.pointAtStart(); + expect(point.prevId()).toBe(undefined); + }); + + test('returns undefined, when at first char', () => { + const {peritext} = setupWithChunkedText(); + const point1 = peritext.pointAt(0, Anchor.Before); + const point2 = peritext.pointAt(0, Anchor.After); + expect(point1.prevId()).toBe(undefined); + expect(point2.prevId()).toBe(undefined); + }); + + test('returns last char, when at end of str', () => { + const {peritext} = setupWithChunkedText(); + const point1 = peritext.pointAtEnd(); + const point2 = peritext.pointAt(9, Anchor.Before); + const id = point1.prevId(); + expect(id).toEqual(point2.id); + }); +}); + +describe('.leftChar()', () => { + test('returns the left character', () => { + const model = Model.withLogicalClock(123456); + model.api.root({ + text: 'abc', + slices: [], + }); + const str = model.api.str(['text']).node; + const peritext = new Peritext(model, str, model.api.arr(['slices']).node); + model.api.str(['text']).del(0, 3); + model.api.str(['text']).ins(0, '00a1b2c3'); + model.api.str(['text']).del(0, 2); + model.api.str(['text']).del(1, 1); + model.api.str(['text']).del(2, 1); + model.api.str(['text']).del(3, 1); + const point0 = peritext.pointAt(2, Anchor.After); + const char0 = point0.leftChar()!; + expect(char0.chunk.data!.slice(char0.off, char0.off + 1)).toBe('c'); + const point1 = peritext.pointAt(1, Anchor.After); + const char1 = point1.leftChar()!; + expect(char1.chunk.data!.slice(char1.off, char1.off + 1)).toBe('b'); + const point2 = peritext.pointAt(0, Anchor.After); + const char2 = point2.leftChar()!; + expect(char2.chunk.data!.slice(char2.off, char2.off + 1)).toBe('a'); + }); + + test('multi-char chunks with deletes', () => { + const {peritext} = setupWithText(); + const res = '012345678'; + const start = peritext.pointAt(0, Anchor.Before); + expect(start.leftChar()).toBe(undefined); + const start2 = peritext.pointAt(0, Anchor.After); + expect(start2.leftChar()!.view()).toBe(res[0]); + const start3 = peritext.pointAt(1, Anchor.Before); + const slice = start3.leftChar(); + expect(slice!.view()).toBe(res[0]); + for (let i = 1; i < res.length; i++) { + const point = peritext.pointAt(i, Anchor.Before); + const char = point.leftChar()!; + expect(char.view()).toBe(res[i - 1]); + } + for (let i = 0; i < res.length; i++) { + const point = peritext.pointAt(i, Anchor.After); + const char = point.leftChar()!; + expect(char.view()).toBe(res[i]); + } + }); + + test('multi-char chunks with deletes (2)', () => { + const {peritext} = setupWithChunkedText(); + const res = '123456789'; + const start = peritext.pointAt(0, Anchor.Before); + expect(start.leftChar()).toBe(undefined); + const start2 = peritext.pointAt(0, Anchor.After); + expect(start2.leftChar()!.view()).toBe(res[0]); + const start3 = peritext.pointAt(1, Anchor.Before); + const slice = start3.leftChar(); + expect(slice!.view()).toBe(res[0]); + for (let i = 1; i < res.length; i++) { + const point = peritext.pointAt(i, Anchor.Before); + const char = point.leftChar()!; + expect(char.view()).toBe(res[i - 1]); + } + for (let i = 0; i < res.length; i++) { + const point = peritext.pointAt(i, Anchor.After); + const char = point.leftChar()!; + expect(char.view()).toBe(res[i]); + } + }); + + test('retrieves left char of a deleted point', () => { + const {peritext, chunkD1, chunkD2} = setupWithChunkedText(); + const p1 = peritext.point(chunkD1.id, Anchor.Before); + expect(p1.leftChar()!.view()).toBe('3'); + const p2 = peritext.point(chunkD1.id, Anchor.After); + expect(p2.leftChar()!.view()).toBe('3'); + const p3 = peritext.point(chunkD2.id, Anchor.Before); + expect(p3.leftChar()!.view()).toBe('6'); + const p4 = peritext.point(chunkD2.id, Anchor.After); + expect(p4.leftChar()!.view()).toBe('6'); + }); + + test('at end of text should return the last char', () => { + const {peritext} = setupWithChunkedText(); + const p1 = peritext.pointAt(8, Anchor.After); + const p2 = peritext.pointAtEnd(); + expect(p1.leftChar()!.view()).toBe('9'); + expect(p2.leftChar()!.view()).toBe('9'); + }); +}); + +describe('.rightChar()', () => { + test('returns the right character', () => { + const model = Model.withLogicalClock(123456); + model.api.root({ + text: 'abc', + slices: [], + }); + const str = model.api.str(['text']).node; + const peritext = new Peritext(model, str, model.api.arr(['slices']).node); + const point0 = peritext.pointAt(0); + const char0 = point0.rightChar()!; + expect(char0.chunk.data!.slice(char0.off, char0.off + 1)).toBe('a'); + const point1 = peritext.pointAt(1); + const char1 = point1.rightChar()!; + expect(char1.chunk.data!.slice(char1.off, char1.off + 1)).toBe('b'); + const point2 = peritext.pointAt(2); + const char2 = point2.rightChar()!; + expect(char2.chunk.data!.slice(char2.off, char2.off + 1)).toBe('c'); + }); + + test('multi-char chunks with deletes', () => { + const {peritext} = setupWithText(); + const res = '012345678'; + for (let i = 0; i < res.length; i++) { + const point = peritext.pointAt(i, Anchor.Before); + const char = point.rightChar()!; + expect(char.view()).toBe(res[i]); + } + for (let i = 0; i < res.length - 1; i++) { + const point = peritext.pointAt(i, Anchor.After); + const char = point.rightChar()!; + expect(char.view()).toBe(res[i + 1]); + } + const end = peritext.pointAt(res.length - 1, Anchor.After); + expect(end.rightChar()).toBe(undefined); + }); + + test('multi-char chunks with deletes (2)', () => { + const {peritext} = setupWithChunkedText(); + const res = '123456789'; + for (let i = 0; i < res.length; i++) { + const point = peritext.pointAt(i, Anchor.Before); + const char = point.rightChar()!; + expect(char.view()).toBe(res[i]); + } + for (let i = 0; i < res.length - 1; i++) { + const point = peritext.pointAt(i, Anchor.After); + const char = point.rightChar()!; + expect(char.view()).toBe(res[i + 1]); + } + const end = peritext.pointAt(res.length - 1, Anchor.After); + expect(end.rightChar()).toBe(undefined); + }); + + test('retrieves right char of a deleted point', () => { + const {peritext, chunkD1, chunkD2} = setupWithChunkedText(); + const p1 = peritext.point(chunkD1.id, Anchor.Before); + expect(p1.rightChar()!.view()).toBe('4'); + const p2 = peritext.point(chunkD1.id, Anchor.After); + expect(p2.rightChar()!.view()).toBe('4'); + const p3 = peritext.point(chunkD2.id, Anchor.Before); + expect(p3.rightChar()!.view()).toBe('7'); + const p4 = peritext.point(chunkD2.id, Anchor.After); + expect(p4.rightChar()!.view()).toBe('7'); + }); + + test('at start of text should return the first char', () => { + const {peritext} = setupWithChunkedText(); + const p1 = peritext.pointAt(0, Anchor.Before); + const p2 = peritext.pointAtStart(); + expect(p1.rightChar()!.view()).toBe('1'); + expect(p2.rightChar()!.view()).toBe('1'); + }); +}); + +describe('.isStartOfStr()', () => { + test('returns true if is start of string', () => { + const {peritext} = setupWithChunkedText(); + const p1 = peritext.pointAtStart(); + const p2 = peritext.pointAt(0, Anchor.Before); + expect(p1.isStartOfStr()).toBe(true); + expect(p2.isStartOfStr()).toBe(false); + }); +}); + +describe('.isEndOfStr()', () => { + test('returns true if is end of string', () => { + const {peritext} = setupWithChunkedText(); + const p1 = peritext.pointAtEnd(); + const p2 = peritext.pointAt(8, Anchor.After); + expect(p1.isEndOfStr()).toBe(true); + expect(p2.isEndOfStr()).toBe(false); + }); +}); + +describe('.refBefore()', () => { + test('goes to next character, when anchor is switched', () => { + const {peritext} = setupWithChunkedText(); + const p1 = peritext.pointAt(0, Anchor.After); + expect(p1.rightChar()!.view()).toBe('2'); + const p2 = p1.clone(); + p2.refBefore(); + expect(p2.rightChar()!.view()).toBe('2'); + expect(p1.anchor).toBe(Anchor.After); + expect(p2.anchor).toBe(Anchor.Before); + expect(p1.id.time + 1).toBe(p2.id.time); + }); + + test('skips deleted chars', () => { + const {peritext} = setupWithChunkedText(); + const p1 = peritext.pointAt(2, Anchor.After); + expect(p1.rightChar()!.view()).toBe('4'); + const p2 = p1.clone(); + p2.refBefore(); + expect(p2.rightChar()!.view()).toBe('4'); + expect(p1.anchor).toBe(Anchor.After); + expect(p2.anchor).toBe(Anchor.Before); + expect(p1.id.time).not.toBe(p2.id.time); + }); + + test('when on last character, attaches to end of str', () => { + const {peritext} = setupWithChunkedText(); + const p1 = peritext.pointAt(8, Anchor.After); + expect(p1.leftChar()!.view()).toBe('9'); + const p2 = p1.clone(); + p2.refBefore(); + expect(p2.isEndOfStr()).toBe(true); + }); +}); + +describe('.refAfter()', () => { + test('goes to next character, when anchor is switched', () => { + const {peritext} = setupWithChunkedText(); + const p1 = peritext.pointAt(4, Anchor.Before); + expect(p1.leftChar()!.view()).toBe('4'); + const p2 = p1.clone(); + p2.refAfter(); + expect(p2.leftChar()!.view()).toBe('4'); + expect(p1.anchor).toBe(Anchor.Before); + expect(p2.anchor).toBe(Anchor.After); + expect(p1.id.time - 1).toBe(p2.id.time); + }); + + test('skips deleted chars', () => { + const {peritext} = setupWithChunkedText(); + const p1 = peritext.pointAt(7, Anchor.Before); + expect(p1.leftChar()!.view()).toBe('7'); + const p2 = p1.clone(); + p2.refAfter(); + expect(p2.leftChar()!.view()).toBe('7'); + expect(p1.anchor).toBe(Anchor.Before); + expect(p2.anchor).toBe(Anchor.After); + expect(p2.chunk()!.del).toBe(false); + }); + + test('when on first character, attaches to start of str', () => { + 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); + }); +}); + +describe('.move()', () => { + test('smoke test', () => { + const {peritext} = setupWithChunkedText(); + const p = peritext.pointAt(1, Anchor.After); + expect(p.viewPos()).toBe(2); + p.move(1); + expect(p.viewPos()).toBe(3); + p.move(2); + expect(p.viewPos()).toBe(5); + p.move(2); + expect(p.viewPos()).toBe(7); + p.move(-3); + expect(p.viewPos()).toBe(4); + p.move(-3); + expect(p.viewPos()).toBe(1); + p.move(-3); + expect(p.viewPos()).toBe(0); + }); + + test('can reach the end of str', () => { + const {peritext} = setupWithChunkedText(); + const p = peritext.pointAt(0, Anchor.After); + p.move(1); + p.move(2); + p.move(3); + p.move(4); + p.move(5); + p.move(6); + expect(p.isEndOfStr()).toBe(true); + expect(p.viewPos()).toBe(9); + expect(p.leftChar()!.view()).toBe('9'); + expect(p.anchor).toBe(Anchor.Before); + }); + + test('can reach the start of str', () => { + const {peritext} = setupWithChunkedText(); + const p = peritext.pointAt(8, Anchor.Before); + p.move(-22); + expect(p.isStartOfStr()).toBe(true); + expect(p.viewPos()).toBe(0); + expect(p.rightChar()!.view()).toBe('1'); + expect(p.anchor).toBe(Anchor.After); + }); + + test('can move forward, when anchor = Before', () => { + const {peritext, model} = setupWithChunkedText(); + model.api.str(['text']).del(4, 1); + const txt = '12346789'; + for (let i = 0; i < txt.length - 1; i++) { + const p = peritext.pointAt(i, Anchor.Before); + expect(p.pos()).toBe(i); + for (let j = i + 1; j < txt.length - 1; j++) { + const p2 = p.clone(); + p2.move(j - i); + expect(p2.pos()).toBe(j); + expect(p2.anchor).toBe(Anchor.Before); + expect(p2.rightChar()!.view()).toBe(txt[j]); + } + } + }); + + test('can move forward, when anchor = After', () => { + const {peritext, model} = setupWithChunkedText(); + model.api.str(['text']).del(4, 1); + const txt = '12346789'; + for (let i = 0; i < txt.length - 1; i++) { + const p = peritext.pointAt(i, Anchor.After); + expect(p.pos()).toBe(i); + expect(p.leftChar()!.view()).toBe(txt[i]); + for (let j = i + 1; j < txt.length - 1; j++) { + const p2 = p.clone(); + p2.move(j - i); + expect(p2.pos()).toBe(j); + expect(p2.anchor).toBe(Anchor.After); + expect(p2.leftChar()!.view()).toBe(txt[j]); + } + } + }); + + test('can move backward, when anchor = Before', () => { + const {peritext, model} = setupWithChunkedText(); + model.api.str(['text']).del(4, 1); + const txt = '12346789'; + for (let i = txt.length - 1; i > 0; i--) { + const p = peritext.pointAt(i, Anchor.Before); + expect(p.viewPos()).toBe(i); + for (let j = i - 1; j > 0; j--) { + const p2 = p.clone(); + p2.move(j - i); + expect(p2.pos()).toBe(j); + expect(p2.anchor).toBe(Anchor.Before); + expect(p2.rightChar()!.view()).toBe(txt[j]); + } + } + }); +}); diff --git a/src/json-crdt-extensions/peritext/types.ts b/src/json-crdt-extensions/peritext/types.ts new file mode 100644 index 0000000000..5557617292 --- /dev/null +++ b/src/json-crdt-extensions/peritext/types.ts @@ -0,0 +1,55 @@ +import type {ITimestampStruct} from '../../json-crdt-patch'; +import type {Path, PathStep} from '../../json-pointer'; + +/** + * Represents an object which state can change over time. + */ +export interface Stateful { + /** + * Hash of the current state. Updated by calling `refresh()`. + */ + hash: number; + + /** + * Recomputes object's hash. + * @returns The new hash. + */ + refresh(): number; +} + +export type IdDto = [sid: number, time: number]; + +export type SpanDto = [sid: number, time: number, length: number]; + +export type SliceType = PathStep | Path; + +export type SliceDto = [ + /** + * Stores the behavior of the slice as well as anchor points of x1 and x2. + */ + flags: number, + + /** + * Start point of the slice. + */ + x1: ITimestampStruct, + + /** + * End point of the slice, if 0 then it is equal to x1. + */ + x2: ITimestampStruct | 0, + + /** + * App specific type of the slice. For slices with "split" behavior, this + * is a path of block nesting. For other slices, it specifies inline formatting, such + * as bold, italic, etc.; the value has to be a primitive number or a string. + */ + type: SliceType, + + /** + * Reference to additional metadata about the slice, usually an object. If + * data is not set, it will default to `1`. For "erase" slice behavior, data + * should not be specified. + */ + data?: unknown, +]; diff --git a/src/json-crdt-extensions/peritext/util/ChunkSlice.ts b/src/json-crdt-extensions/peritext/util/ChunkSlice.ts new file mode 100644 index 0000000000..67fe0a628a --- /dev/null +++ b/src/json-crdt-extensions/peritext/util/ChunkSlice.ts @@ -0,0 +1,68 @@ +import {CONST, updateNum} from '../../../json-hash'; +import {updateId} from '../../../json-crdt/hash'; +import {ITimestampStruct, Timestamp, toDisplayString} from '../../../json-crdt-patch/clock'; +import type {IChunkSlice} from './types'; +import type {Stateful} from '../types'; +import type {Printable} from '../../../util/print/types'; +import type {Chunk} from '../../../json-crdt/nodes/rga'; + +export class ChunkSlice implements IChunkSlice, Stateful, Printable { + constructor( + /** Chunk from which slice is computed. */ + chunk: Chunk, + /** Start offset of the slice within the chunk. */ + public off: number, + /** Length of the slice. */ + public len: number, + ) { + this.chunk = chunk; + } + + // -------------------------------------------------------------- IChunkSlice + + public readonly chunk: Chunk; + + public id(): ITimestampStruct { + const id = this.chunk.id; + const off = this.off; + return !off ? id : new Timestamp(id.sid, id.time + off); + } + + public key(): string { + const id = this.chunk.id; + const sid = id.sid; + const time = id.time + this.off; + return sid.toString(36) + time.toString(36); + } + + public view(): T { + const offset = this.off; + return this.chunk.view().slice(offset, offset + this.len); + } + + // ----------------------------------------------------------------- Stateful + + public hash: number = 0; + + public refresh(): number { + const {chunk, off, len} = this; + const delOffLenState = (((off << 16) + len) << 1) + +chunk.del; + let state = CONST.START_STATE; + state = updateId(state, chunk.id); + state = updateNum(state, delOffLenState); + return (this.hash = state); + } + + // ---------------------------------------------------------------- Printable + + public toString(tab: string = ''): string { + const name = this.constructor.name; + const off = this.off; + const len = this.len; + const str = this.view() + ''; + const truncate = str.length > 32; + const view = JSON.stringify(truncate ? str.slice(0, 32) : str) + (truncate ? ' …' : ''); + const id = toDisplayString(this.chunk.id); + return `${name} ${id} [${off}..${off + len}) ${view}`; + } +} diff --git a/src/json-crdt-extensions/peritext/util/__tests__/ChunkSlice.spec.ts b/src/json-crdt-extensions/peritext/util/__tests__/ChunkSlice.spec.ts new file mode 100644 index 0000000000..365ed1b867 --- /dev/null +++ b/src/json-crdt-extensions/peritext/util/__tests__/ChunkSlice.spec.ts @@ -0,0 +1,83 @@ +import {s} from '../../../../json-crdt-patch'; +import {Model} from '../../../../json-crdt/model'; +import {ChunkSlice} from '../ChunkSlice'; + +const setup = () => { + const model = Model.withLogicalClock().setSchema(s.str('Hello world')); + const node = model.root.node(); + const chunk = node.first()!; + return { + model, + node, + chunk, + }; +}; + +describe('.id()', () => { + it('slice ID is equal to chunk ID plus offset', () => { + const {chunk} = setup(); + const slice = new ChunkSlice(chunk, 2, 2); + expect(slice.id().sid).toBe(chunk.id.sid); + expect(slice.id().time).toBe(chunk.id.time + 2); + }); +}); + +describe('.view()', () => { + it('can create a one-char slice in a visible chunk', () => { + const {chunk} = setup(); + const slice = new ChunkSlice(chunk, 0, 1); + expect(slice.view()).toBe('H'); + }); + + it('can create a two-char slice in a visible chunk', () => { + const {chunk} = setup(); + const slice = new ChunkSlice(chunk, 0, 2); + expect(slice.view()).toBe('He'); + }); + + it('can create a three-char slice in a visible chunk in the middle', () => { + const {chunk} = setup(); + const slice = new ChunkSlice(chunk, 3, 3); + expect(slice.view()).toBe('lo '); + }); + + it('can create a four-char slice in a visible chunk at the end', () => { + const {chunk} = setup(); + const slice = new ChunkSlice(chunk, 7, 4); + expect(slice.view()).toBe('orld'); + }); +}); + +describe('.refresh()', () => { + it('hash changes depending on slice position', () => { + const {chunk} = setup(); + const slice1 = new ChunkSlice(chunk, 0, 3); + const slice2 = new ChunkSlice(chunk, 1, 3); + const slice3 = new ChunkSlice(chunk, 2, 3); + const hash1 = slice1.refresh(); + const hash2 = slice2.refresh(); + const hash3 = slice3.refresh(); + expect(hash1).not.toBe(hash2); + expect(hash2).not.toBe(hash3); + expect(hash3).not.toBe(hash1); + }); + + it('hash is the same for the same position', () => { + const {chunk} = setup(); + const slice1 = new ChunkSlice(chunk, 1, 6); + const slice2 = new ChunkSlice(chunk, 1, 6); + const hash1 = slice1.refresh(); + const hash2 = slice2.refresh(); + expect(hash1).toBe(hash2); + }); + + it('hash is different for different chunks', () => { + const {chunk: chunk1} = setup(); + const {chunk: chunk2} = setup(); + const slice1 = new ChunkSlice(chunk1, 1, 6); + const slice2 = new ChunkSlice(chunk2, 1, 6); + const hash1 = slice1.refresh(); + const hash2 = slice2.refresh(); + expect(hash1).not.toBe(hash2); + }); +}); diff --git a/src/json-crdt-extensions/peritext/util/types.ts b/src/json-crdt-extensions/peritext/util/types.ts new file mode 100644 index 0000000000..23de16ef60 --- /dev/null +++ b/src/json-crdt-extensions/peritext/util/types.ts @@ -0,0 +1,8 @@ +import type {Chunk} from '../../../json-crdt/nodes/rga'; + +export type StringChunk = Chunk; + +export interface IChunkSlice { + readonly chunk: Chunk; + view(): T; +} diff --git a/src/json-crdt/nodes/arr/ArrNode.ts b/src/json-crdt/nodes/arr/ArrNode.ts index be7cee4221..23977f0424 100644 --- a/src/json-crdt/nodes/arr/ArrNode.ts +++ b/src/json-crdt/nodes/arr/ArrNode.ts @@ -63,6 +63,10 @@ export class ArrChunk implements Chunk { public clone(): ArrChunk { return new ArrChunk(this.id, this.span, this.data ? [...this.data] : undefined); } + + public view(): E[] { + return this.data ? [...this.data] : []; + } } /** diff --git a/src/json-crdt/nodes/bin/BinNode.ts b/src/json-crdt/nodes/bin/BinNode.ts index 1149e8c361..a30fe16c19 100644 --- a/src/json-crdt/nodes/bin/BinNode.ts +++ b/src/json-crdt/nodes/bin/BinNode.ts @@ -64,6 +64,10 @@ export class BinChunk implements Chunk { const chunk = new BinChunk(this.id, this.span, this.data); return chunk; } + + public view(): Uint8Array { + return this.data || new Uint8Array(0); + } } /** diff --git a/src/json-crdt/nodes/rga/AbstractRga.ts b/src/json-crdt/nodes/rga/AbstractRga.ts index 5e0437e04c..909b15a45a 100644 --- a/src/json-crdt/nodes/rga/AbstractRga.ts +++ b/src/json-crdt/nodes/rga/AbstractRga.ts @@ -55,6 +55,8 @@ export interface Chunk { delete(): void; /** Return a deep copy of itself. */ clone(): Chunk; + /** Return the data of the chunk, if not deleted. */ + view(): T & {slice: (start: number, end: number) => T}; } const compareById = (c1: Chunk, c2: Chunk): number => { @@ -474,6 +476,12 @@ export abstract class AbstractRga { return next(curr); } + /** @todo Maybe use implementation from tree utils, if does not impact performance. */ + /** @todo Or better remove this method completely, as it does not require "this". */ + public prev(curr: Chunk): Chunk | undefined { + return prev(curr); + } + /** Content length. */ public length(): number { const root = this.root; diff --git a/src/json-crdt/nodes/str/StrNode.ts b/src/json-crdt/nodes/str/StrNode.ts index 0f29711ce4..0338fa584f 100644 --- a/src/json-crdt/nodes/str/StrNode.ts +++ b/src/json-crdt/nodes/str/StrNode.ts @@ -62,6 +62,10 @@ export class StrChunk implements Chunk { const chunk = new StrChunk(this.id, this.span, this.data); return chunk; } + + public view(): string { + return this.data; + } } /**