Skip to content

Commit 4f5f012

Browse files
committed
feat(json-crdt-extensions): 🎸 improve Inline.key() implementation
1 parent 9994f2a commit 4f5f012

File tree

5 files changed

+142
-18
lines changed

5 files changed

+142
-18
lines changed

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

Lines changed: 6 additions & 4 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,7 +66,7 @@ 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+
export const setupNumbersWithTombstonesKit = (sid?: number): Kit => {
6970
return setupKit('1234', (model) => {
7071
const str = model.s.text.toExt().text();
7172
str.ins(0, '234');
@@ -92,8 +93,9 @@ export const setupNumbersWithTombstonesKit = (): Kit => {
9293
str.ins(7, '78');
9394
str.del(10, 2);
9495
str.del(2, 3);
95-
str.ins(2, '234');
96+
str.ins(2, 'x234');
97+
str.del(2, 1);
9698
str.del(10, 3);
9799
if (str.view() !== '0123456789') throw new Error('Invalid text');
98-
});
100+
}, sid);
99101
};

src/json-crdt-extensions/peritext/block/Inline.ts

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,38 @@ import {OverlayPoint} from '../overlay/OverlayPoint';
33
import {stringify} from '../../../json-text/stringify';
44
import {SliceBehavior} from '../slice/constants';
55
import {Range} from '../rga/Range';
6+
import {ChunkSlice} from '../util/ChunkSlice';
7+
import {updateNum} from '../../../json-hash';
68
import type {AbstractRga} from '../../../json-crdt/nodes/rga';
7-
import type {ChunkSlice} from '../util/ChunkSlice';
89
import type {Printable} from 'tree-dump/lib/types';
910
import type {PathStep} from '../../../json-pointer';
1011
import type {Slice} from '../slice/types';
12+
import type {Peritext} from '../Peritext';
1113

1214
export type Marks = Record<string | number, unknown>;
1315

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+
*/
1424
export class Inline extends Range implements Printable {
25+
public static create(
26+
txt: Peritext,
27+
start: OverlayPoint,
28+
end: OverlayPoint,
29+
) {
30+
const texts: ChunkSlice[] = [];
31+
txt.overlay.chunkSlices0(undefined, start, end, (chunk, off, len) => {
32+
if (txt.overlay.isMarker(chunk.id)) return;
33+
texts.push(new ChunkSlice(chunk, off, len));
34+
});
35+
return new Inline(txt.str, start, end, texts);
36+
}
37+
1538
constructor(
1639
rga: AbstractRga<string>,
1740
public start: OverlayPoint,
@@ -33,12 +56,8 @@ export class Inline extends Range implements Printable {
3356
* inlines of the parent block. Can be used for UI libraries to track the
3457
* identity of the inline across renders.
3558
*/
36-
public key(): number | string {
37-
const start = this.start;
38-
const startId = this.start.id;
39-
const endId = this.end.id;
40-
const key = startId.sid.toString(36) + start.anchor + startId.time.toString(36) + endId.time.toString(36);
41-
return key;
59+
public key(): number {
60+
return updateNum(this.start.refresh(), this.end.refresh());
4261
}
4362

4463
public str(): string {
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
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 {peritext} = setupKit();
44+
const hash1 = updateNum(
45+
updateId(0, new Timestamp(2, 7)),
46+
updateId(1, new Timestamp(2, 7)),
47+
);
48+
const hash2 = updateNum(
49+
updateId(0, new Timestamp(2, 6)),
50+
updateId(1, new Timestamp(2, 40)),
51+
);
52+
expect(hash1).not.toBe(hash2);
53+
});
54+
});
55+
56+
const runPairsTests = (setup: () => Kit) => {
57+
describe('.key()', () => {
58+
test('construct unique keys for all ranges', () => {
59+
const {peritext} = setup();
60+
const overlay = peritext.overlay;
61+
const length = peritext.strApi().length();
62+
const keys = new Map<number | string, Inline>();
63+
let cnt = 0;
64+
for (let i = 0; i < length; i++) {
65+
for (let j = 1; j <= length - i; j++) {
66+
peritext.editor.cursor.setAt(i, j);
67+
overlay.refresh();
68+
const [start, end] = [...overlay.points()];
69+
const inline = Inline.create(peritext, start, end);
70+
if (keys.has(inline.key())) {
71+
const inline2 = keys.get(inline.key())!;
72+
// tslint:disable-next-line:no-console
73+
console.error('DUPLICATE HASH:', inline.key());
74+
// tslint:disable-next-line:no-console
75+
console.log('INLINE 1:', inline.start.id, inline.start.anchor, inline.end.id, inline.end.anchor);
76+
// tslint:disable-next-line:no-console
77+
console.log('INLINE 2:', inline2.start.id, inline2.start.anchor, inline2.end.id, inline2.end.anchor);
78+
throw new Error('Duplicate key');
79+
}
80+
keys.set(inline.key(), inline);
81+
cnt++;
82+
}
83+
}
84+
expect(keys.size).toBe(cnt);
85+
});
86+
});
87+
};
88+
89+
describe('Inline', () => {
90+
describe('lorem ipsum', () => {
91+
runPairsTests(() => setupKit('lorem ipsum dolor sit amet consectetur adipiscing elit'));
92+
});
93+
94+
describe('numbers "0123456789", no edits', () => {
95+
runPairsTests(setupNumbersKit);
96+
});
97+
98+
describe('numbers "0123456789", with default schema and tombstones', () => {
99+
runPairsTests(setupNumbersWithTombstonesKit);
100+
});
101+
102+
describe('numbers "0123456789", with default schema and tombstones and constant sid', () => {
103+
runPairsTests(() => setupNumbersWithTombstonesKit(12313123));
104+
});
105+
});

src/json-crdt-extensions/peritext/rga/Point.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {Position} from '../constants';
66
import type {AbstractRga, Chunk} from '../../../json-crdt/nodes/rga';
77
import type {Stateful} from '../types';
88
import type {Printable} from 'tree-dump/lib/types';
9+
import {CONST, updateNum} from '../../../json-hash';
910

1011
/**
1112
* A "point" in a rich-text Peritext document. It is a combination of a
@@ -443,9 +444,7 @@ export class Point<T = string> implements Pick<Stateful, 'refresh'>, Printable {
443444
// ----------------------------------------------------------------- Stateful
444445

445446
public refresh(): number {
446-
let state = this.anchor;
447-
state = updateId(state, this.id);
448-
return state;
447+
return updateId(this.anchor, this.id);
449448
}
450449

451450
// ---------------------------------------------------------------- Printable

src/json-crdt/hash.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,9 @@ import type {ITimestampStruct} from '../json-crdt-patch/clock';
77
import type {Model} from './model';
88

99
export const updateId = (state: number, id: ITimestampStruct): number => {
10-
const sid = id.sid;
11-
state = updateNum(state, sid >>> 0);
12-
// state = updateNum(state, Math.round(sid / 0x100000000));
13-
state = updateNum(state, id.time);
10+
const time = id.time;
11+
state = updateNum(state, state ^ time);
12+
state = updateNum(state, id.sid ^ time);
1413
return state;
1514
};
1615

0 commit comments

Comments
 (0)