Skip to content

Commit

Permalink
feat(json-crdt-extensions): 🎸 improve Inline.key() implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
streamich committed May 11, 2024
1 parent 9994f2a commit 4f5f012
Show file tree
Hide file tree
Showing 5 changed files with 142 additions and 18 deletions.
10 changes: 6 additions & 4 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,7 +66,7 @@ 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 => {
export const setupNumbersWithTombstonesKit = (sid?: number): Kit => {
return setupKit('1234', (model) => {
const str = model.s.text.toExt().text();
str.ins(0, '234');
Expand All @@ -92,8 +93,9 @@ export const setupNumbersWithTombstonesKit = (): Kit => {
str.ins(7, '78');
str.del(10, 2);
str.del(2, 3);
str.ins(2, '234');
str.ins(2, 'x234');
str.del(2, 1);
str.del(10, 3);
if (str.view() !== '0123456789') throw new Error('Invalid text');
});
}, sid);
};
33 changes: 26 additions & 7 deletions src/json-crdt-extensions/peritext/block/Inline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,38 @@ 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 {ChunkSlice} from '../util/ChunkSlice';
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 Marks = 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,
Expand All @@ -33,12 +56,8 @@ export class Inline extends Range implements Printable {
* inlines of the parent block. Can be used for UI libraries to track the
* identity of the inline across renders.
*/
public key(): number | string {
const start = this.start;
const startId = this.start.id;
const endId = this.end.id;
const key = startId.sid.toString(36) + start.anchor + startId.time.toString(36) + endId.time.toString(36);
return key;
public key(): number {
return updateNum(this.start.refresh(), this.end.refresh());
}

public str(): string {
Expand Down
105 changes: 105 additions & 0 deletions src/json-crdt-extensions/peritext/block/__tests__/Inline.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
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 {peritext} = setupKit();
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 runPairsTests = (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', () => {
runPairsTests(() => setupKit('lorem ipsum dolor sit amet consectetur adipiscing elit'));
});

describe('numbers "0123456789", no edits', () => {
runPairsTests(setupNumbersKit);
});

describe('numbers "0123456789", with default schema and tombstones', () => {
runPairsTests(setupNumbersWithTombstonesKit);
});

describe('numbers "0123456789", with default schema and tombstones and constant sid', () => {
runPairsTests(() => setupNumbersWithTombstonesKit(12313123));
});
});
5 changes: 2 additions & 3 deletions src/json-crdt-extensions/peritext/rga/Point.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -443,9 +444,7 @@ export class Point<T = string> implements Pick<Stateful, 'refresh'>, Printable {
// ----------------------------------------------------------------- Stateful

public refresh(): number {
let state = this.anchor;
state = updateId(state, this.id);
return state;
return updateId(this.anchor, this.id);
}

// ---------------------------------------------------------------- Printable
Expand Down
7 changes: 3 additions & 4 deletions src/json-crdt/hash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down

0 comments on commit 4f5f012

Please sign in to comment.