Skip to content

Commit 8ffd3d8

Browse files
authored
Merge pull request #624 from streamich/block-inlines
Block level `Inline` class implementation
2 parents 2325bf6 + c6c5b62 commit 8ffd3d8

File tree

6 files changed

+417
-39
lines changed

6 files changed

+417
-39
lines changed

src/json-crdt-extensions/peritext/__tests__/setup.ts

Lines changed: 38 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ const schema = (text: string) =>
1414
export const setupKit = (
1515
initialText: string = '',
1616
edits: (model: Model<SchemaToJsonNode<Schema>>) => void = () => {},
17+
sid?: number,
1718
) => {
18-
const model = ModelWithExt.create(schema(initialText));
19+
const model = ModelWithExt.create(schema(initialText), sid);
1920
edits(model);
2021
const api = model.api;
2122
const peritextApi = model.s.text.toExt();
@@ -65,35 +66,40 @@ export const setupNumbersKit = (): Kit => {
6566
* Creates a Peritext instance with text "0123456789", with single-char and
6667
* block-wise chunks, as well as with plenty of tombstones.
6768
*/
68-
export const setupNumbersWithTombstonesKit = (): Kit => {
69-
return setupKit('1234', (model) => {
70-
const str = model.s.text.toExt().text();
71-
str.ins(0, '234');
72-
str.ins(1, '234');
73-
str.ins(2, '345');
74-
str.ins(3, '456');
75-
str.ins(4, '567');
76-
str.ins(5, '678');
77-
str.ins(6, '789');
78-
str.del(7, 1);
79-
str.del(8, 1);
80-
str.ins(0, '0');
81-
str.del(1, 4);
82-
str.del(2, 1);
83-
str.ins(1, '1');
84-
str.del(0, 1);
85-
str.ins(0, '0');
86-
str.ins(2, '234');
87-
str.del(4, 7);
88-
str.del(4, 2);
89-
str.del(7, 3);
90-
str.ins(6, '6789');
91-
str.del(7, 2);
92-
str.ins(7, '78');
93-
str.del(10, 2);
94-
str.del(2, 3);
95-
str.ins(2, '234');
96-
str.del(10, 3);
97-
if (str.view() !== '0123456789') throw new Error('Invalid text');
98-
});
69+
export const setupNumbersWithTombstonesKit = (sid?: number): Kit => {
70+
return setupKit(
71+
'1234',
72+
(model) => {
73+
const str = model.s.text.toExt().text();
74+
str.ins(0, '234');
75+
str.ins(1, '234');
76+
str.ins(2, '345');
77+
str.ins(3, '456');
78+
str.ins(4, '567');
79+
str.ins(5, '678');
80+
str.ins(6, '789');
81+
str.del(7, 1);
82+
str.del(8, 1);
83+
str.ins(0, '0');
84+
str.del(1, 4);
85+
str.del(2, 1);
86+
str.ins(1, '1');
87+
str.del(0, 1);
88+
str.ins(0, '0');
89+
str.ins(2, '234');
90+
str.del(4, 7);
91+
str.del(4, 2);
92+
str.del(7, 3);
93+
str.ins(6, '6789');
94+
str.del(7, 2);
95+
str.ins(7, '78');
96+
str.del(10, 2);
97+
str.del(2, 3);
98+
str.ins(2, 'x234');
99+
str.del(2, 1);
100+
str.del(10, 3);
101+
if (str.view() !== '0123456789') throw new Error('Invalid text');
102+
},
103+
sid,
104+
);
99105
};
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import {printTree} from 'tree-dump/lib/printTree';
2+
import {OverlayPoint} from '../overlay/OverlayPoint';
3+
import {stringify} from '../../../json-text/stringify';
4+
import {SliceBehavior} from '../slice/constants';
5+
import {Range} from '../rga/Range';
6+
import {ChunkSlice} from '../util/ChunkSlice';
7+
import {updateNum} from '../../../json-hash';
8+
import type {AbstractRga} from '../../../json-crdt/nodes/rga';
9+
import type {Printable} from 'tree-dump/lib/types';
10+
import type {PathStep} from '../../../json-pointer';
11+
import type {Slice} from '../slice/types';
12+
import type {Peritext} from '../Peritext';
13+
14+
export type InlineAttributes = Record<string | number, unknown>;
15+
16+
/**
17+
* The `Inline` class represents a range of inline text within a block, which
18+
* has the same annotations and formatting for all of its text contents, i.e.
19+
* its text contents can be rendered as a single (`<span>`) element. However,
20+
* the text contents might still be composed of multiple {@link ChunkSlice}s,
21+
* which are the smallest units of text and need to be concatenated to get the
22+
* full text content of the inline.
23+
*/
24+
export class Inline extends Range implements Printable {
25+
public static create(txt: Peritext, start: OverlayPoint, end: OverlayPoint) {
26+
const texts: ChunkSlice[] = [];
27+
txt.overlay.chunkSlices0(undefined, start, end, (chunk, off, len) => {
28+
if (txt.overlay.isMarker(chunk.id)) return;
29+
texts.push(new ChunkSlice(chunk, off, len));
30+
});
31+
return new Inline(txt.str, start, end, texts);
32+
}
33+
34+
constructor(
35+
rga: AbstractRga<string>,
36+
public start: OverlayPoint,
37+
public end: OverlayPoint,
38+
39+
/**
40+
* @todo PERF: for performance reasons, we should consider not passing in
41+
* this array. Maybe pass in just the initial chunk and the offset. However,
42+
* maybe even the just is not necessary, as the `.start` point should have
43+
* its chunk cached, or will have it cached after the first access.
44+
*/
45+
public readonly texts: ChunkSlice[],
46+
) {
47+
super(rga, start, end);
48+
}
49+
50+
/**
51+
* @returns A stable unique identifier of this *inline* within a list of other
52+
* inlines of the parent block. Can be used for UI libraries to track the
53+
* identity of the inline across renders.
54+
*/
55+
public key(): number {
56+
return updateNum(this.start.refresh(), this.end.refresh());
57+
}
58+
59+
/**
60+
* @returns The full text content of the inline, which is the concatenation
61+
* of all the underlying {@link ChunkSlice}s.
62+
*/
63+
public str(): string {
64+
let str = '';
65+
for (const slice of this.texts) str += slice.view();
66+
return str;
67+
}
68+
69+
/**
70+
* @returns The position of the inline withing the text.
71+
*/
72+
public pos(): number {
73+
const chunkSlice = this.texts[0];
74+
if (!chunkSlice) return -1;
75+
const chunk = chunkSlice.chunk;
76+
const pos = this.rga.pos(chunk);
77+
return pos + chunkSlice.off;
78+
}
79+
80+
/**
81+
* @returns Returns the attributes of the inline, which are the slice
82+
* annotations and formatting applied to the inline.
83+
*/
84+
public attr(): InlineAttributes {
85+
const attr: InlineAttributes = {};
86+
const point = this.start as OverlayPoint;
87+
const slices: Slice[] = this.texts.length ? point.layers : point.markers;
88+
const length = slices.length;
89+
for (let i = 0; i < length; i++) {
90+
const slice = slices[i];
91+
const type = slice.type as PathStep;
92+
switch (slice.behavior) {
93+
case SliceBehavior.Cursor:
94+
case SliceBehavior.Stack: {
95+
let dataList: unknown[] = (attr[type] as unknown[]) || (attr[type] = []);
96+
if (!Array.isArray(dataList)) dataList = attr[type] = [dataList];
97+
let data = slice.data();
98+
if (data === undefined) data = 1;
99+
dataList.push(data);
100+
break;
101+
}
102+
case SliceBehavior.Overwrite: {
103+
let data = slice.data();
104+
if (data === undefined) data = 1;
105+
attr[type] = data;
106+
break;
107+
}
108+
case SliceBehavior.Erase: {
109+
delete attr[type];
110+
break;
111+
}
112+
}
113+
}
114+
// TODO: Iterate over the markers...
115+
return attr;
116+
}
117+
118+
// ---------------------------------------------------------------- Printable
119+
120+
public toString(tab: string = ''): string {
121+
const str = this.str();
122+
const truncate = str.length > 32;
123+
const text = JSON.stringify(truncate ? str.slice(0, 32) : str) + (truncate ? ' …' : '');
124+
const startFormatted = this.start.toString(tab, true);
125+
const range =
126+
this.start.cmp(this.end) === 0 ? startFormatted : `${startFormatted}${this.end.toString(tab, true)}`;
127+
const header = `${this.constructor.name} ${range} ${text}`;
128+
const marks = this.attr();
129+
const markKeys = Object.keys(marks);
130+
return (
131+
header +
132+
printTree(tab, [
133+
!marks
134+
? null
135+
: (tab) =>
136+
'attributes' +
137+
printTree(
138+
tab,
139+
markKeys.map((key) => () => key + ' = ' + stringify(marks[key])),
140+
),
141+
!this.texts.length
142+
? null
143+
: (tab) =>
144+
'texts' +
145+
printTree(
146+
tab,
147+
this.texts.map((text) => (tab) => text.toString(tab)),
148+
),
149+
])
150+
);
151+
}
152+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import {Timestamp} from '../../../../json-crdt-patch';
2+
import {updateId} from '../../../../json-crdt/hash';
3+
import {updateNum} from '../../../../json-hash';
4+
import {Kit, setupKit, setupNumbersKit, setupNumbersWithTombstonesKit} from '../../__tests__/setup';
5+
import {Point} from '../../rga/Point';
6+
import {Inline} from '../Inline';
7+
8+
describe('range hash', () => {
9+
test('computes unique hash - 1', () => {
10+
const {peritext} = setupKit();
11+
const p1 = new Point(peritext.str, new Timestamp(12313123, 41), 0);
12+
const p2 = new Point(peritext.str, new Timestamp(12313123, 41), 1);
13+
const p3 = new Point(peritext.str, new Timestamp(12313123, 43), 0);
14+
const p4 = new Point(peritext.str, new Timestamp(12313123, 43), 1);
15+
const hash1 = updateNum(p1.refresh(), p2.refresh());
16+
const hash2 = updateNum(p3.refresh(), p4.refresh());
17+
expect(hash1).not.toBe(hash2);
18+
});
19+
20+
test('computes unique hash - 2', () => {
21+
const {peritext} = setupKit();
22+
const p1 = new Point(peritext.str, new Timestamp(12313123, 61), 0);
23+
const p2 = new Point(peritext.str, new Timestamp(12313123, 23), 1);
24+
const p3 = new Point(peritext.str, new Timestamp(12313123, 60), 0);
25+
const p4 = new Point(peritext.str, new Timestamp(12313123, 56), 1);
26+
const hash1 = updateNum(p1.refresh(), p2.refresh());
27+
const hash2 = updateNum(p3.refresh(), p4.refresh());
28+
expect(hash1).not.toBe(hash2);
29+
});
30+
31+
test('computes unique hash - 3', () => {
32+
const {peritext} = setupKit();
33+
const p1 = new Point(peritext.str, new Timestamp(12313123, 43), 0);
34+
const p2 = new Point(peritext.str, new Timestamp(12313123, 61), 1);
35+
const p3 = new Point(peritext.str, new Timestamp(12313123, 43), 0);
36+
const p4 = new Point(peritext.str, new Timestamp(12313123, 60), 1);
37+
const hash1 = updateNum(p1.refresh(), p2.refresh());
38+
const hash2 = updateNum(p3.refresh(), p4.refresh());
39+
expect(hash1).not.toBe(hash2);
40+
});
41+
42+
test('computes unique hash - 4', () => {
43+
const hash1 = updateNum(updateId(0, new Timestamp(2, 7)), updateId(1, new Timestamp(2, 7)));
44+
const hash2 = updateNum(updateId(0, new Timestamp(2, 6)), updateId(1, new Timestamp(2, 40)));
45+
expect(hash1).not.toBe(hash2);
46+
});
47+
});
48+
49+
const runKeyTests = (setup: () => Kit) => {
50+
describe('.key()', () => {
51+
test('construct unique keys for all ranges', () => {
52+
const {peritext} = setup();
53+
const overlay = peritext.overlay;
54+
const length = peritext.strApi().length();
55+
const keys = new Map<number | string, Inline>();
56+
let cnt = 0;
57+
for (let i = 0; i < length; i++) {
58+
for (let j = 1; j <= length - i; j++) {
59+
peritext.editor.cursor.setAt(i, j);
60+
overlay.refresh();
61+
const [start, end] = [...overlay.points()];
62+
const inline = Inline.create(peritext, start, end);
63+
if (keys.has(inline.key())) {
64+
const inline2 = keys.get(inline.key())!;
65+
// tslint:disable-next-line:no-console
66+
console.error('DUPLICATE HASH:', inline.key());
67+
// tslint:disable-next-line:no-console
68+
console.log('INLINE 1:', inline.start.id, inline.start.anchor, inline.end.id, inline.end.anchor);
69+
// tslint:disable-next-line:no-console
70+
console.log('INLINE 2:', inline2.start.id, inline2.start.anchor, inline2.end.id, inline2.end.anchor);
71+
throw new Error('Duplicate key');
72+
}
73+
keys.set(inline.key(), inline);
74+
cnt++;
75+
}
76+
}
77+
expect(keys.size).toBe(cnt);
78+
});
79+
});
80+
};
81+
82+
describe('Inline', () => {
83+
describe('lorem ipsum', () => {
84+
runKeyTests(() => setupKit('lorem ipsum dolor sit amet consectetur adipiscing elit'));
85+
});
86+
87+
describe('numbers "0123456789", no edits', () => {
88+
runKeyTests(setupNumbersKit);
89+
});
90+
91+
describe('numbers "0123456789", with default schema and tombstones', () => {
92+
runKeyTests(setupNumbersWithTombstonesKit);
93+
});
94+
95+
describe('numbers "0123456789", with default schema and tombstones and constant sid', () => {
96+
runKeyTests(() => setupNumbersWithTombstonesKit(12313123));
97+
});
98+
});

0 commit comments

Comments
 (0)