Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Block layer Inline class implementation #624

Merged
merged 6 commits into from
May 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 38 additions & 32 deletions src/json-crdt-extensions/peritext/__tests__/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ const schema = (text: string) =>
export const setupKit = (
initialText: string = '',
edits: (model: Model<SchemaToJsonNode<Schema>>) => 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();
Expand Down Expand Up @@ -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,
);
};
152 changes: 152 additions & 0 deletions src/json-crdt-extensions/peritext/block/Inline.ts
Original file line number Diff line number Diff line change
@@ -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<string | number, unknown>;

/**
* 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 (`<span>`) 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<string>,
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)),
),
])
);
}
}
Original file line number Diff line number Diff line change
@@ -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<number | string, Inline>();
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));
});
});
Loading
Loading