Skip to content

Commit

Permalink
Merge pull request #624 from streamich/block-inlines
Browse files Browse the repository at this point in the history
Block level `Inline` class implementation
  • Loading branch information
streamich authored May 11, 2024
2 parents 2325bf6 + c6c5b62 commit 8ffd3d8
Show file tree
Hide file tree
Showing 6 changed files with 417 additions and 39 deletions.
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

0 comments on commit 8ffd3d8

Please sign in to comment.