diff --git a/src/json-crdt-extensions/peritext/__tests__/setup.ts b/src/json-crdt-extensions/peritext/__tests__/setup.ts index 267e8f91a2..7a2e2832f1 100644 --- a/src/json-crdt-extensions/peritext/__tests__/setup.ts +++ b/src/json-crdt-extensions/peritext/__tests__/setup.ts @@ -14,8 +14,9 @@ const schema = (text: string) => export const setupKit = ( initialText: string = '', edits: (model: Model>) => void = () => {}, + sid?: number, ) => { - const model = ModelWithExt.create(schema(initialText)); + const model = ModelWithExt.create(schema(initialText), sid); edits(model); const api = model.api; const peritextApi = model.s.text.toExt(); @@ -65,35 +66,40 @@ export const setupNumbersKit = (): Kit => { * Creates a Peritext instance with text "0123456789", with single-char and * block-wise chunks, as well as with plenty of tombstones. */ -export const setupNumbersWithTombstonesKit = (): Kit => { - return setupKit('1234', (model) => { - const str = model.s.text.toExt().text(); - str.ins(0, '234'); - str.ins(1, '234'); - str.ins(2, '345'); - str.ins(3, '456'); - str.ins(4, '567'); - str.ins(5, '678'); - str.ins(6, '789'); - str.del(7, 1); - str.del(8, 1); - str.ins(0, '0'); - str.del(1, 4); - str.del(2, 1); - str.ins(1, '1'); - str.del(0, 1); - str.ins(0, '0'); - str.ins(2, '234'); - str.del(4, 7); - str.del(4, 2); - str.del(7, 3); - str.ins(6, '6789'); - str.del(7, 2); - str.ins(7, '78'); - str.del(10, 2); - str.del(2, 3); - str.ins(2, '234'); - str.del(10, 3); - if (str.view() !== '0123456789') throw new Error('Invalid text'); - }); +export const setupNumbersWithTombstonesKit = (sid?: number): Kit => { + return setupKit( + '1234', + (model) => { + const str = model.s.text.toExt().text(); + str.ins(0, '234'); + str.ins(1, '234'); + str.ins(2, '345'); + str.ins(3, '456'); + str.ins(4, '567'); + str.ins(5, '678'); + str.ins(6, '789'); + str.del(7, 1); + str.del(8, 1); + str.ins(0, '0'); + str.del(1, 4); + str.del(2, 1); + str.ins(1, '1'); + str.del(0, 1); + str.ins(0, '0'); + str.ins(2, '234'); + str.del(4, 7); + str.del(4, 2); + str.del(7, 3); + str.ins(6, '6789'); + str.del(7, 2); + str.ins(7, '78'); + str.del(10, 2); + str.del(2, 3); + str.ins(2, 'x234'); + str.del(2, 1); + str.del(10, 3); + if (str.view() !== '0123456789') throw new Error('Invalid text'); + }, + sid, + ); }; diff --git a/src/json-crdt-extensions/peritext/block/Inline.ts b/src/json-crdt-extensions/peritext/block/Inline.ts new file mode 100644 index 0000000000..3296e99238 --- /dev/null +++ b/src/json-crdt-extensions/peritext/block/Inline.ts @@ -0,0 +1,152 @@ +import {printTree} from 'tree-dump/lib/printTree'; +import {OverlayPoint} from '../overlay/OverlayPoint'; +import {stringify} from '../../../json-text/stringify'; +import {SliceBehavior} from '../slice/constants'; +import {Range} from '../rga/Range'; +import {ChunkSlice} from '../util/ChunkSlice'; +import {updateNum} from '../../../json-hash'; +import type {AbstractRga} from '../../../json-crdt/nodes/rga'; +import type {Printable} from 'tree-dump/lib/types'; +import type {PathStep} from '../../../json-pointer'; +import type {Slice} from '../slice/types'; +import type {Peritext} from '../Peritext'; + +export type InlineAttributes = Record; + +/** + * The `Inline` class represents a range of inline text within a block, which + * has the same annotations and formatting for all of its text contents, i.e. + * its text contents can be rendered as a single (``) element. However, + * the text contents might still be composed of multiple {@link ChunkSlice}s, + * which are the smallest units of text and need to be concatenated to get the + * full text content of the inline. + */ +export class Inline extends Range implements Printable { + public static create(txt: Peritext, start: OverlayPoint, end: OverlayPoint) { + const texts: ChunkSlice[] = []; + txt.overlay.chunkSlices0(undefined, start, end, (chunk, off, len) => { + if (txt.overlay.isMarker(chunk.id)) return; + texts.push(new ChunkSlice(chunk, off, len)); + }); + return new Inline(txt.str, start, end, texts); + } + + constructor( + rga: AbstractRga, + public start: OverlayPoint, + public end: OverlayPoint, + + /** + * @todo PERF: for performance reasons, we should consider not passing in + * this array. Maybe pass in just the initial chunk and the offset. However, + * maybe even the just is not necessary, as the `.start` point should have + * its chunk cached, or will have it cached after the first access. + */ + public readonly texts: ChunkSlice[], + ) { + super(rga, start, end); + } + + /** + * @returns A stable unique identifier of this *inline* within a list of other + * inlines of the parent block. Can be used for UI libraries to track the + * identity of the inline across renders. + */ + public key(): number { + return updateNum(this.start.refresh(), this.end.refresh()); + } + + /** + * @returns The full text content of the inline, which is the concatenation + * of all the underlying {@link ChunkSlice}s. + */ + public str(): string { + let str = ''; + for (const slice of this.texts) str += slice.view(); + return str; + } + + /** + * @returns The position of the inline withing the text. + */ + public pos(): number { + const chunkSlice = this.texts[0]; + if (!chunkSlice) return -1; + const chunk = chunkSlice.chunk; + const pos = this.rga.pos(chunk); + return pos + chunkSlice.off; + } + + /** + * @returns Returns the attributes of the inline, which are the slice + * annotations and formatting applied to the inline. + */ + public attr(): InlineAttributes { + const attr: InlineAttributes = {}; + const point = this.start as OverlayPoint; + const slices: Slice[] = this.texts.length ? point.layers : point.markers; + const length = slices.length; + for (let i = 0; i < length; i++) { + const slice = slices[i]; + const type = slice.type as PathStep; + switch (slice.behavior) { + case SliceBehavior.Cursor: + case SliceBehavior.Stack: { + let dataList: unknown[] = (attr[type] as unknown[]) || (attr[type] = []); + if (!Array.isArray(dataList)) dataList = attr[type] = [dataList]; + let data = slice.data(); + if (data === undefined) data = 1; + dataList.push(data); + break; + } + case SliceBehavior.Overwrite: { + let data = slice.data(); + if (data === undefined) data = 1; + attr[type] = data; + break; + } + case SliceBehavior.Erase: { + delete attr[type]; + break; + } + } + } + // TODO: Iterate over the markers... + return attr; + } + + // ---------------------------------------------------------------- Printable + + public toString(tab: string = ''): string { + const str = this.str(); + const truncate = str.length > 32; + const text = JSON.stringify(truncate ? str.slice(0, 32) : str) + (truncate ? ' …' : ''); + const startFormatted = this.start.toString(tab, true); + const range = + this.start.cmp(this.end) === 0 ? startFormatted : `${startFormatted} ↔ ${this.end.toString(tab, true)}`; + const header = `${this.constructor.name} ${range} ${text}`; + const marks = this.attr(); + const markKeys = Object.keys(marks); + return ( + header + + printTree(tab, [ + !marks + ? null + : (tab) => + 'attributes' + + printTree( + tab, + markKeys.map((key) => () => key + ' = ' + stringify(marks[key])), + ), + !this.texts.length + ? null + : (tab) => + 'texts' + + printTree( + tab, + this.texts.map((text) => (tab) => text.toString(tab)), + ), + ]) + ); + } +} diff --git a/src/json-crdt-extensions/peritext/block/__tests__/Inline.key.spec.ts b/src/json-crdt-extensions/peritext/block/__tests__/Inline.key.spec.ts new file mode 100644 index 0000000000..d4b6dcf7ca --- /dev/null +++ b/src/json-crdt-extensions/peritext/block/__tests__/Inline.key.spec.ts @@ -0,0 +1,98 @@ +import {Timestamp} from '../../../../json-crdt-patch'; +import {updateId} from '../../../../json-crdt/hash'; +import {updateNum} from '../../../../json-hash'; +import {Kit, setupKit, setupNumbersKit, setupNumbersWithTombstonesKit} from '../../__tests__/setup'; +import {Point} from '../../rga/Point'; +import {Inline} from '../Inline'; + +describe('range hash', () => { + test('computes unique hash - 1', () => { + const {peritext} = setupKit(); + const p1 = new Point(peritext.str, new Timestamp(12313123, 41), 0); + const p2 = new Point(peritext.str, new Timestamp(12313123, 41), 1); + const p3 = new Point(peritext.str, new Timestamp(12313123, 43), 0); + const p4 = new Point(peritext.str, new Timestamp(12313123, 43), 1); + const hash1 = updateNum(p1.refresh(), p2.refresh()); + const hash2 = updateNum(p3.refresh(), p4.refresh()); + expect(hash1).not.toBe(hash2); + }); + + test('computes unique hash - 2', () => { + const {peritext} = setupKit(); + const p1 = new Point(peritext.str, new Timestamp(12313123, 61), 0); + const p2 = new Point(peritext.str, new Timestamp(12313123, 23), 1); + const p3 = new Point(peritext.str, new Timestamp(12313123, 60), 0); + const p4 = new Point(peritext.str, new Timestamp(12313123, 56), 1); + const hash1 = updateNum(p1.refresh(), p2.refresh()); + const hash2 = updateNum(p3.refresh(), p4.refresh()); + expect(hash1).not.toBe(hash2); + }); + + test('computes unique hash - 3', () => { + const {peritext} = setupKit(); + const p1 = new Point(peritext.str, new Timestamp(12313123, 43), 0); + const p2 = new Point(peritext.str, new Timestamp(12313123, 61), 1); + const p3 = new Point(peritext.str, new Timestamp(12313123, 43), 0); + const p4 = new Point(peritext.str, new Timestamp(12313123, 60), 1); + const hash1 = updateNum(p1.refresh(), p2.refresh()); + const hash2 = updateNum(p3.refresh(), p4.refresh()); + expect(hash1).not.toBe(hash2); + }); + + test('computes unique hash - 4', () => { + const hash1 = updateNum(updateId(0, new Timestamp(2, 7)), updateId(1, new Timestamp(2, 7))); + const hash2 = updateNum(updateId(0, new Timestamp(2, 6)), updateId(1, new Timestamp(2, 40))); + expect(hash1).not.toBe(hash2); + }); +}); + +const runKeyTests = (setup: () => Kit) => { + describe('.key()', () => { + test('construct unique keys for all ranges', () => { + const {peritext} = setup(); + const overlay = peritext.overlay; + const length = peritext.strApi().length(); + const keys = new Map(); + let cnt = 0; + for (let i = 0; i < length; i++) { + for (let j = 1; j <= length - i; j++) { + peritext.editor.cursor.setAt(i, j); + overlay.refresh(); + const [start, end] = [...overlay.points()]; + const inline = Inline.create(peritext, start, end); + if (keys.has(inline.key())) { + const inline2 = keys.get(inline.key())!; + // tslint:disable-next-line:no-console + console.error('DUPLICATE HASH:', inline.key()); + // tslint:disable-next-line:no-console + console.log('INLINE 1:', inline.start.id, inline.start.anchor, inline.end.id, inline.end.anchor); + // tslint:disable-next-line:no-console + console.log('INLINE 2:', inline2.start.id, inline2.start.anchor, inline2.end.id, inline2.end.anchor); + throw new Error('Duplicate key'); + } + keys.set(inline.key(), inline); + cnt++; + } + } + expect(keys.size).toBe(cnt); + }); + }); +}; + +describe('Inline', () => { + describe('lorem ipsum', () => { + runKeyTests(() => setupKit('lorem ipsum dolor sit amet consectetur adipiscing elit')); + }); + + describe('numbers "0123456789", no edits', () => { + runKeyTests(setupNumbersKit); + }); + + describe('numbers "0123456789", with default schema and tombstones', () => { + runKeyTests(setupNumbersWithTombstonesKit); + }); + + describe('numbers "0123456789", with default schema and tombstones and constant sid', () => { + runKeyTests(() => setupNumbersWithTombstonesKit(12313123)); + }); +}); diff --git a/src/json-crdt-extensions/peritext/block/__tests__/Inline.str.spec.ts b/src/json-crdt-extensions/peritext/block/__tests__/Inline.str.spec.ts new file mode 100644 index 0000000000..62ee4f634f --- /dev/null +++ b/src/json-crdt-extensions/peritext/block/__tests__/Inline.str.spec.ts @@ -0,0 +1,124 @@ +import {Kit, setupKit, setupNumbersKit, setupNumbersWithTombstonesKit} from '../../__tests__/setup'; +import {Inline} from '../Inline'; + +const runStrTests = (setup: () => Kit) => { + describe('.str()', () => { + test('concatenates parts of Inline correctly', () => { + const {peritext} = setup(); + const overlay = peritext.overlay; + const length = peritext.strApi().length(); + for (let i = 0; i < length; i++) { + for (let j = 1; j <= length - i; j++) { + peritext.editor.cursor.setAt(i, j); + overlay.refresh(); + const [start, end] = [...overlay.points()]; + const inline = Inline.create(peritext, start, end); + const str = inline.str(); + expect(str).toBe( + peritext + .strApi() + .view() + .slice(i, i + j), + ); + } + } + }); + }); + + describe('.pos()', () => { + test('returns correct offset in text', () => { + const {peritext} = setup(); + const overlay = peritext.overlay; + const length = peritext.strApi().length(); + for (let i = 0; i < length; i++) { + for (let j = 1; j <= length - i; j++) { + peritext.editor.cursor.setAt(i, j); + overlay.refresh(); + const [start, end] = [...overlay.points()]; + const inline = Inline.create(peritext, start, end); + const pos = inline.pos(); + expect(pos).toBe(i); + } + } + }); + }); + + describe('.attr()', () => { + test('returns all STACK annotations of a slice', () => { + const {peritext} = setup(); + const overlay = peritext.overlay; + peritext.editor.cursor.setAt(3, 3); + peritext.editor.saved.insStack('bold', 1); + peritext.editor.saved.insStack('bold', 2); + peritext.editor.saved.insStack('em', 1); + overlay.refresh(); + const [start, end] = [...overlay.points()]; + const inline = Inline.create(peritext, start, end); + const attr = inline.attr(); + expect(attr.bold).toEqual([1, 2]); + expect(attr.em).toEqual([1]); + }); + + test('returns latest OVERWRITE annotation', () => { + const {peritext} = setup(); + const overlay = peritext.overlay; + peritext.editor.cursor.setAt(3, 3); + peritext.editor.saved.insOverwrite('bold', 1); + peritext.editor.saved.insOverwrite('bold', 2); + peritext.editor.saved.insOverwrite('em', 1); + overlay.refresh(); + const [start, end] = [...overlay.points()]; + const inline = Inline.create(peritext, start, end); + const attr = inline.attr(); + expect(attr.bold).toBe(2); + expect(attr.em).toBe(1); + }); + + test('hides annotation hidden with another ERASE annotation', () => { + const {peritext} = setup(); + const overlay = peritext.overlay; + peritext.editor.cursor.setAt(3, 3); + peritext.editor.saved.insOverwrite('bold'); + peritext.editor.saved.insErase('bold'); + peritext.editor.saved.insOverwrite('em'); + overlay.refresh(); + const [start, end] = [...overlay.points()]; + const inline = Inline.create(peritext, start, end); + const attr = inline.attr(); + expect(attr.bold).toBe(undefined); + expect(attr.em).toBe(1); + }); + + test('concatenates with "," steps of nested Path type annotations', () => { + const {peritext} = setup(); + const overlay = peritext.overlay; + peritext.editor.cursor.setAt(3, 3); + peritext.editor.saved.insStack(['bold', 'very'], 1); + peritext.editor.saved.insStack(['bold', 'normal'], 2); + overlay.refresh(); + const [start, end] = [...overlay.points()]; + const inline = Inline.create(peritext, start, end); + const attr = inline.attr(); + expect(attr['bold,very']).toEqual([1]); + expect(attr['bold,normal']).toEqual([2]); + }); + }); +}; + +describe('Inline', () => { + describe('lorem ipsum', () => { + runStrTests(() => setupKit('lorem ipsum dolor sit amet')); + }); + + describe('numbers "0123456789", no edits', () => { + runStrTests(setupNumbersKit); + }); + + describe('numbers "0123456789", with default schema and tombstones', () => { + runStrTests(setupNumbersWithTombstonesKit); + }); + + describe('numbers "0123456789", with default schema and tombstones and constant sid', () => { + runStrTests(() => setupNumbersWithTombstonesKit(12313123)); + }); +}); diff --git a/src/json-crdt-extensions/peritext/rga/Point.ts b/src/json-crdt-extensions/peritext/rga/Point.ts index 9e5edf94e4..03d61dcd97 100644 --- a/src/json-crdt-extensions/peritext/rga/Point.ts +++ b/src/json-crdt-extensions/peritext/rga/Point.ts @@ -6,6 +6,7 @@ import {Position} from '../constants'; import type {AbstractRga, Chunk} from '../../../json-crdt/nodes/rga'; import type {Stateful} from '../types'; import type {Printable} from 'tree-dump/lib/types'; +import {CONST, updateNum} from '../../../json-hash'; /** * A "point" in a rich-text Peritext document. It is a combination of a @@ -443,9 +444,7 @@ export class Point implements Pick, Printable { // ----------------------------------------------------------------- Stateful public refresh(): number { - let state = this.anchor; - state = updateId(state, this.id); - return state; + return updateId(this.anchor, this.id); } // ---------------------------------------------------------------- Printable diff --git a/src/json-crdt/hash.ts b/src/json-crdt/hash.ts index 8d998ecbc8..e16bdbd747 100644 --- a/src/json-crdt/hash.ts +++ b/src/json-crdt/hash.ts @@ -7,10 +7,9 @@ import type {ITimestampStruct} from '../json-crdt-patch/clock'; import type {Model} from './model'; export const updateId = (state: number, id: ITimestampStruct): number => { - const sid = id.sid; - state = updateNum(state, sid >>> 0); - // state = updateNum(state, Math.round(sid / 0x100000000)); - state = updateNum(state, id.time); + const time = id.time; + state = updateNum(state, state ^ time); + state = updateNum(state, id.sid ^ time); return state; };