Skip to content

Commit

Permalink
feat(json-crdt-extensions): 馃幐 improve overlay layer insertions
Browse files Browse the repository at this point in the history
  • Loading branch information
streamich committed Apr 30, 2024
1 parent a83518d commit 75e2620
Show file tree
Hide file tree
Showing 4 changed files with 288 additions and 22 deletions.
24 changes: 22 additions & 2 deletions src/json-crdt-extensions/peritext/Peritext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {ArrNode, StrNode} from '../../json-crdt/nodes';
import {Slices} from './slice/Slices';
import {Overlay} from './overlay/Overlay';
import {Chars} from './constants';
import {interval} from '../../json-crdt-patch/clock';
import {CONST, updateNum} from '../../json-hash';
import type {ITimestampStruct} from '../../json-crdt-patch/clock';
import type {Model} from '../../json-crdt/model';
import type {Printable} from '../../util/print/types';
Expand Down Expand Up @@ -146,7 +148,7 @@ export class Peritext implements Printable {
return Range.at(this.str, start, length);
}

// --------------------------------------------------------------- insertions
// --------------------------------------------------------------------- text

/**
* Insert plain text at a view position in the text.
Expand Down Expand Up @@ -175,6 +177,8 @@ export class Peritext implements Printable {
return textId;
}

// ------------------------------------------------------------------ markers

public insMarker(after: ITimestampStruct, type: SliceType, data?: unknown, char: string = Chars.BlockSplitSentinel): MarkerSlice {
const api = this.model.api;
const builder = api.builder;
Expand All @@ -190,6 +194,17 @@ export class Peritext implements Printable {
return this.slices.insMarker(range, type, data);
}

/** @todo This can probably use .del() */
public delMarker(split: MarkerSlice): void {
const str = this.str;
const api = this.model.api;
const builder = api.builder;
const strChunk = split.start.chunk();
if (strChunk) builder.del(str.id, [interval(strChunk.id, 0, 1)]);
builder.del(this.slices.set.id, [interval(split.id, 0, 1)]);
api.apply();
}

// ---------------------------------------------------------------- Printable

public toString(tab: string = ''): string {
Expand All @@ -202,6 +217,8 @@ export class Peritext implements Printable {
(tab) => this.str.toString(tab),
nl,
(tab) => this.slices.toString(tab),
nl,
(tab) => this.overlay.toString(tab),
])
);
}
Expand All @@ -211,6 +228,9 @@ export class Peritext implements Printable {
public hash: number = 0;

public refresh(): number {
return this.slices.refresh();
let state: number = CONST.START_STATE;
this.overlay.refresh();
state = updateNum(state, this.overlay.hash);
return (this.hash = state);
}
}
13 changes: 11 additions & 2 deletions src/json-crdt-extensions/peritext/overlay/Overlay.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import {printTree} from 'sonic-forest/lib/print/printTree';
import {printBinary} from 'sonic-forest/lib/print/printBinary';
import {first, insertLeft, insertRight, next, prev, remove} from 'sonic-forest/lib/util';
import {splay} from 'sonic-forest/lib/splay/util';
import {Anchor} from '../rga/constants';
Expand All @@ -7,8 +9,6 @@ import {MarkerOverlayPoint} from './MarkerOverlayPoint';
import {OverlayRefSliceEnd, OverlayRefSliceStart} from './refs';
import {equal, ITimestampStruct} from '../../../json-crdt-patch/clock';
import {CONST, updateNum} from '../../../json-hash';
import {printBinary} from '../../../util/print/printBinary';
import {printTree} from '../../../util/print/printTree';
import {MarkerSlice} from '../slice/MarkerSlice';
import type {Peritext} from '../Peritext';
import type {Stateful} from '../types';
Expand Down Expand Up @@ -76,6 +76,15 @@ export class Overlay implements Printable, Stateful {
return result;
}

public find(predicate: (point: OverlayPoint) => boolean): OverlayPoint | undefined {
let point = this.first();
while (point) {
if (predicate(point)) return point;
point = next(point);
}
return undefined;
}

// ----------------------------------------------------------------- Stateful

public hash: number = 0;
Expand Down
237 changes: 237 additions & 0 deletions src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
import {Model} from '../../../../json-crdt/model';
import {first, next} from 'sonic-forest/lib/util';
import {Peritext} from '../../Peritext';
import {Anchor} from '../../rga/constants';
import {MarkerOverlayPoint} from '../MarkerOverlayPoint';

const setup = () => {
const model = Model.withLogicalClock();
model.api.root({
text: '',
slices: [],
markers: [],
});
model.api.str(['text']).ins(0, 'wworld');
model.api.str(['text']).ins(0, 'helo ');
model.api.str(['text']).ins(2, 'l');
model.api.str(['text']).del(7, 1);
const peritext = new Peritext(model, model.api.str(['text']).node, model.api.arr(['slices']).node);
return {model, peritext};
};

const splitCount = (peritext: Peritext): number => {
const overlay = peritext.overlay;
const iterator = overlay.splitIterator();
let count = 0;
for (let split = iterator(); split; split = iterator()) {
count++;
}
return count;
};

describe('markers', () => {
describe('inserts', () => {
test('overlays starts with no markers', () => {
const {peritext} = setup();
expect(splitCount(peritext)).toBe(0);
});

test('can insert one marker in the middle of text', () => {
const {peritext} = setup();
peritext.editor.setCursor(6);
peritext.editor.insMarker(['p'], '露');
expect(splitCount(peritext)).toBe(0);
peritext.overlay.refresh();
expect(splitCount(peritext)).toBe(1);
const points = [];
let point;
for (const iterator = peritext.overlay.iterator(); (point = iterator()); ) points.push(point);
// console.log(peritext + '');
expect(points.length).toBe(2);
point = points[0];
expect(point.pos()).toBe(5);
});

test('can insert two markers', () => {
const {peritext} = setup();
peritext.editor.setCursor(3);
peritext.editor.insMarker(['p'], '露');
expect(splitCount(peritext)).toBe(0);
peritext.overlay.refresh();
expect(splitCount(peritext)).toBe(1);
peritext.overlay.refresh();
expect(splitCount(peritext)).toBe(1);
peritext.editor.setCursor(9);
peritext.editor.insMarker(['li'], '- ');
expect(splitCount(peritext)).toBe(1);
peritext.overlay.refresh();
expect(splitCount(peritext)).toBe(2);
peritext.overlay.refresh();
expect(splitCount(peritext)).toBe(2);
});
});

describe('deletes', () => {
test('can delete a marker', () => {
const {peritext} = setup();
peritext.editor.setCursor(6);
const slice = peritext.editor.insMarker(['p'], '露');
peritext.refresh();
expect(splitCount(peritext)).toBe(1);
const points = [];
let point;
for (const iterator = peritext.overlay.iterator(); (point = iterator()); ) points.push(point);
point = points[0];
peritext.delMarker(slice);
peritext.refresh();
expect(splitCount(peritext)).toBe(0);
});

test('can delete one of two splits', () => {
const {peritext} = setup();
peritext.editor.setCursor(2);
peritext.editor.insMarker(['p'], '露');
peritext.editor.setCursor(11);
const slice = peritext.editor.insMarker(['p'], '露');
peritext.refresh();
expect(splitCount(peritext)).toBe(2);
const points = [];
let point;
for (const iterator = peritext.overlay.iterator(); (point = iterator()); ) points.push(point);
point = points[0];
peritext.delMarker(slice);
peritext.refresh();
expect(splitCount(peritext)).toBe(1);
});
});

describe('iterates', () => {
test('can iterate over markers', () => {
const {peritext} = setup();
peritext.editor.setCursor(1, 6);
peritext.editor.insertSlice('a', {a: 'b'});
peritext.editor.setCursor(2);
peritext.editor.insMarker(['p'], '露');
peritext.editor.setCursor(11);
peritext.editor.insMarker(['p'], '露');
peritext.refresh();
expect(splitCount(peritext)).toBe(2);
const points = [];
let point;
for (const iterator = peritext.overlay.splitIterator(); (point = iterator()); ) points.push(point);
expect(points.length).toBe(2);
expect(points[0].pos()).toBe(2);
expect(points[1].pos()).toBe(11);
});
});
});

describe('slices', () => {
describe('inserts', () => {
test('overlays starts with no slices', () => {
const {peritext} = setup();
expect(peritext.overlay.slices.size).toBe(0);
});

test('can insert one slice in the middle of text', () => {
const {peritext} = setup();
peritext.editor.setCursor(6, 2);
peritext.editor.insertSlice('em', {emphasis: true});
expect(peritext.overlay.slices.size).toBe(0);
peritext.overlay.refresh();
expect(peritext.overlay.slices.size).toBe(2);
const points = [];
let point;
for (const iterator = peritext.overlay.iterator(); (point = iterator()); ) points.push(point);
expect(points.length).toBe(2);
expect(points[0].pos()).toBe(6);
expect(points[0].anchor).toBe(Anchor.Before);
expect(points[1].pos()).toBe(7);
expect(points[1].anchor).toBe(Anchor.After);
});

test('can insert two slices', () => {
const {peritext} = setup();
peritext.editor.setCursor(2, 8);
peritext.editor.insertSlice('em', {emphasis: true});
peritext.editor.setCursor(4, 8);
peritext.editor.insertSlice('strong', {bold: true});
expect(peritext.overlay.slices.size).toBe(0);
peritext.overlay.refresh();
expect(peritext.overlay.slices.size).toBe(3);
const points = [];
let point;
for (const iterator = peritext.overlay.iterator(); (point = iterator()); ) points.push(point);
expect(points.length).toBe(4);
});

test('intersecting slice chunks point to two slices', () => {
const {peritext} = setup();
peritext.editor.setCursor(2, 2);
peritext.editor.insertSlice('em', {emphasis: true});
peritext.editor.setCursor(3, 2);
peritext.editor.insertSlice('strong', {bold: true});
peritext.refresh();
const point1 = first(peritext.overlay.root)!;
expect(point1.layers.length).toBe(1);
expect(point1.layers[0].data()).toStrictEqual({emphasis: true});
const point2 = next(point1)!;
expect(point2.layers.length).toBe(3);
expect(point2.layers[0].data()).toStrictEqual(undefined);
expect(point2.layers[1].data()).toStrictEqual({emphasis: true});
expect(point2.layers[2].data()).toStrictEqual({bold: true});
const point3 = next(point2)!;
expect(point3.layers.length).toBe(2);
expect(point3.layers[0].data()).toStrictEqual(undefined);
expect(point3.layers[1].data()).toStrictEqual({bold: true});
const point4 = next(point3)!;
expect(point4.layers.length).toBe(0);
console.log(peritext + '');
});

test('one char slice should correctly sort overlay points', () => {
const {peritext} = setup();
peritext.editor.setCursor(0, 1);
peritext.editor.insertSlice('em', {emphasis: true});
peritext.refresh();
const point1 = peritext.overlay.first()!;
const point2 = next(point1)!;
expect(point1.pos()).toBe(0);
expect(point2.pos()).toBe(0);
expect(point1.anchor).toBe(Anchor.Before);
expect(point2.anchor).toBe(Anchor.After);
});

test('intersecting slice before split, should not update the split', () => {
const {peritext} = setup();
peritext.editor.setCursor(6);
const slice = peritext.editor.insMarker(['p']);
peritext.refresh();
const point = peritext.overlay.find((point) => point instanceof MarkerOverlayPoint)!;
expect(point.layers.length).toBe(0);
peritext.editor.setCursor(2, 2);
peritext.editor.insertSlice('<i>');
peritext.refresh();
expect(point.layers.length).toBe(0);
peritext.editor.setCursor(2, 1);
peritext.editor.insertSlice('<b>');
peritext.refresh();
expect(point.layers.length).toBe(0);
});
});

describe('deletes', () => {
test('can remove a slice', () => {
const {peritext} = setup();
peritext.editor.setCursor(6, 2);
const slice = peritext.editor.insertSlice('em', {emphasis: true});
expect(peritext.overlay.slices.size).toBe(0);
peritext.overlay.refresh();
expect(peritext.overlay.slices.size).toBe(2);
peritext.slices.del(slice.id);
expect(peritext.overlay.slices.size).toBe(2);
peritext.overlay.refresh();
expect(peritext.overlay.slices.size).toBe(1);
});
});
});

0 comments on commit 75e2620

Please sign in to comment.