Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
94ac96b
refactor(json-crdt): 💡 implement method which find first visible chunk
streamich Apr 30, 2024
5e7e661
perf(json-crdt): ⚡️ improve first chunk finding implementation
streamich Apr 30, 2024
be724c3
test(json-crdt-extensions): 💍 add Peritext overlay smoke tests
streamich Apr 30, 2024
554a0fd
test(json-crdt-extensions): 💍 add Overlay.getOrNextLower() tests
streamich Apr 30, 2024
4391dbe
refactor(json-crdt-extensions): 💡 use non-deprecated cursor setting m…
streamich Apr 30, 2024
2774c3d
chore: 🤖 update yarn.lock
streamich Apr 30, 2024
65440fe
chore: 🤖 simplify Jest config and reduce test output
streamich Apr 30, 2024
de81169
test(json-crdt-extensions): 💍 add Overlay.refresh() slices tests
streamich Apr 30, 2024
bdf99be
refactor(json-crdt-extensions): 💡 improve method naming
streamich Apr 30, 2024
bcd0cbf
chore(json-crdt-extensions): 🤖 start refresh re-implementation rething
streamich May 1, 2024
45b09de
Merge remote-tracking branch 'origin/master' into overlay-4
streamich May 1, 2024
e7a21c8
refactor(json-crdt-extensions): 💡 decouple slices from Peritext class
streamich May 1, 2024
7971f21
feat(json-crdt-extensions): 🎸 add more slice layers
streamich May 1, 2024
bc3417d
refactor(json-crdt-extensions): 💡 implement Cursor as a local slice
streamich May 1, 2024
553c9ee
fix: 🐛 bump tree-dump dependency
streamich May 1, 2024
52cf2b9
test(json-crdt-extensions): 💍 pass Overlay smoke tests after refactor
streamich May 1, 2024
232457b
feat(json-crdt-extensions): 🎸 cleanup internal data structures after …
streamich May 1, 2024
abbac3a
style(json-crdt-extensions): 💄 run Prettier
streamich May 1, 2024
576bb2c
style(json-crdt-extensions): 💄 remove unnecessary console.log
streamich May 1, 2024
ee76f28
refactor(json-crdt-extensions): 💡 cleanup Overlay refresh logic
streamich May 1, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@
"hyperdyperid": "^1.2.0",
"sonic-forest": "^1.0.2",
"thingies": "^2.0.0",
"tree-dump": "^1.0.0"
"tree-dump": "^1.0.1"
},
"devDependencies": {
"@types/benchmark": "^2.1.5",
Expand Down
64 changes: 44 additions & 20 deletions src/json-crdt-extensions/peritext/Peritext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@ import {Range} from './rga/Range';
import {Editor} from './editor/Editor';
import {ArrNode, StrNode} from '../../json-crdt/nodes';
import {Slices} from './slice/Slices';
import {LocalSlices} from './slice/LocalSlices';
import {Overlay} from './overlay/Overlay';
import {Chars} from './constants';
import {interval} from '../../json-crdt-patch/clock';
import {Model} from '../../json-crdt/model';
import {CONST, updateNum} from '../../json-hash';
import {SESSION} from '../../json-crdt-patch/constants';
import {s} from '../../json-crdt-patch';
import type {ITimestampStruct} from '../../json-crdt-patch/clock';
import type {Model} from '../../json-crdt/model';
import type {Printable} from 'tree-dump/lib/types';
import type {StringChunk} from './util/types';
import type {SliceType} from './types';
import type {MarkerSlice} from './slice/MarkerSlice';

Expand All @@ -21,7 +23,26 @@ import type {MarkerSlice} from './slice/MarkerSlice';
* interact with the text.
*/
export class Peritext implements Printable {
public readonly slices: Slices;
/**
* *Slices* are rich-text annotations that appear in the text. The "saved"
* slices are the ones that are persisted in the document.
*/
public readonly savedSlices: Slices;

/**
* *Extra slices* are slices that are not persisted in the document. However,
* they are still shared across users, i.e. they are ephemerally persisted
* during the editing session.
*/
public readonly extraSlices: Slices;

/**
* *Local slices* are slices that are not persisted in the document and are
* not shared with other users. They are used only for local annotations for
* the current user.
*/
public readonly localSlices: Slices;

public readonly editor: Editor;
public readonly overlay = new Overlay(this);

Expand All @@ -30,26 +51,29 @@ export class Peritext implements Printable {
public readonly str: StrNode,
slices: ArrNode,
) {
this.slices = new Slices(this, slices);
this.editor = new Editor(this);
this.savedSlices = new Slices(this.model, slices, this.str);

const extraModel = Model.withLogicalClock(SESSION.GLOBAL)
.setSchema(s.vec(s.arr([])))
.fork(this.model.clock.sid + 1);
this.extraSlices = new Slices(extraModel, extraModel.root.node().get(0)!, this.str);

// TODO: flush patches
// TODO: remove `arr` tombstones
const localModel = Model.withLogicalClock(SESSION.LOCAL).setSchema(s.vec(s.arr([])));
const localApi = localModel.api;
localApi.onLocalChange.listen(() => {
localApi.flush();
});
this.localSlices = new LocalSlices(localModel, localModel.root.node().get(0)!, this.str);

this.editor = new Editor(this, this.localSlices);
}

public strApi() {
return this.model.api.wrap(this.str);
}

/** @todo Find a better place for this function. */
public firstVisChunk(): StringChunk | undefined {
const str = this.str;
let curr = str.first();
if (!curr) return;
while (curr.del) {
curr = str.next(curr);
if (!curr) return;
}
return curr;
}

/** Select a single character before a point. */
public findCharBefore(point: Point): Range | undefined {
if (point.anchor === Anchor.After) {
Expand Down Expand Up @@ -196,7 +220,7 @@ export class Peritext implements Printable {
const textId = builder.insStr(str.id, after, char[0]);
const point = this.point(textId, Anchor.Before);
const range = this.range(point, point);
return this.slices.insMarker(range, type, data);
return this.savedSlices.insMarker(range, type, data);
}

/** @todo This can probably use .del() */
Expand All @@ -206,7 +230,7 @@ export class Peritext implements Printable {
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)]);
builder.del(this.savedSlices.set.id, [interval(split.id, 0, 1)]);
api.apply();
}

Expand All @@ -221,7 +245,7 @@ export class Peritext implements Printable {
nl,
(tab) => this.str.toString(tab),
nl,
(tab) => this.slices.toString(tab),
(tab) => this.savedSlices.toString(tab),
nl,
(tab) => this.overlay.toString(tab),
])
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import {Model} from '../../../json-crdt/model';
import {Peritext} from '../Peritext';

const setup = () => {
const model = Model.withLogicalClock();
model.api.root({
text: '',
slices: [],
});
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};
};

test('clears change history', () => {
const {peritext} = setup();
const {editor} = peritext;
editor.cursor.setAt(0);
editor.cursor.setAt(1);
editor.cursor.setAt(2);
editor.cursor.setAt(3);
expect(peritext.localSlices.model.api.flush().ops.length).toBe(0);
});

test('clears slice set tombstones', () => {
const _random = Math.random;
// It is probabilistic, if we set `Math.random` to 0 it will always remove tombstones.
Math.random = () => 0;
const {peritext} = setup();
const slicesRga = peritext.localSlices.model.root.node()!.get(0)!;
const count = slicesRga.size();
const slice1 = peritext.localSlices.insOverwrite(peritext.rangeAt(1, 2), 1);
const slice2 = peritext.localSlices.insOverwrite(peritext.rangeAt(1, 2), 3);
const slice3 = peritext.localSlices.insOverwrite(peritext.rangeAt(1, 2), 2);
expect(slicesRga.size()).toBe(count + 3);
peritext.localSlices.del(slice2.id);
expect(slicesRga.size()).toBe(count + 2);
peritext.localSlices.del(slice1.id);
expect(slicesRga.size()).toBe(count + 1);
peritext.localSlices.del(slice3.id);
expect(slicesRga.size()).toBe(count);
Math.random = _random;
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {Model} from '../../../json-crdt/model';
import {size} from 'sonic-forest/lib/util';
import {Peritext} from '../Peritext';

const setup = () => {
const model = Model.withLogicalClock();
model.api.root({
text: '',
slices: [],
});
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};
};

test('can insert markers', () => {
const {peritext} = setup();
const {editor} = peritext;
expect(size(peritext.overlay.root)).toBe(0);
editor.cursor.setAt(0);
editor.insMarker(['p'], '<p>');
peritext.refresh();
expect(size(peritext.overlay.root)).toBe(1);
editor.cursor.setAt(9);
editor.insMarker(['p'], '<p>');
peritext.refresh();
expect(size(peritext.overlay.root)).toBe(3);
});

test('can insert slices', () => {
const {peritext} = setup();
const {editor} = peritext;
expect(size(peritext.overlay.root)).toBe(0);
editor.cursor.setAt(2, 2);
editor.insStackSlice('bold');
peritext.refresh();
expect(size(peritext.overlay.root)).toBe(2);
editor.cursor.setAt(6, 5);
editor.insStackSlice('italic');
peritext.refresh();
expect(size(peritext.overlay.root)).toBe(4);
editor.cursor.setAt(0, 5);
editor.insStackSlice('underline');
peritext.refresh();
expect(size(peritext.overlay.root)).toBe(6);
});
79 changes: 79 additions & 0 deletions src/json-crdt-extensions/peritext/editor/Cursor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import {Point} from '../rga/Point';
import {Range} from '../rga/Range';
import {CursorAnchor} from '../slice/constants';
import {PersistedSlice} from '../slice/PersistedSlice';

export class Cursor<T = string> extends PersistedSlice<T> {
public get anchorSide(): CursorAnchor {
return this.type as CursorAnchor;
}

public set anchorSide(value: CursorAnchor) {
this.update({type: value});
}

public anchor(): Point<T> {
return this.anchorSide === CursorAnchor.Start ? this.start : this.end;
}

public focus(): Point<T> {
return this.anchorSide === CursorAnchor.Start ? this.end : this.start;
}

public set(start: Point<T>, end?: Point<T>, anchorSide: CursorAnchor = this.anchorSide): void {
if (!end || end === start) end = start.clone();
super.set(start, end);
this.update({
range: this,
type: anchorSide,
});
}

/** TODO: Move to {@link PersistedSlice}. */
public setAt(start: number, length: number = 0): void {
let at = start;
let len = length;
if (len < 0) {
at += len;
len = -len;
}
const range = Range.at<T>(this.rga, start, length);
const anchorSide = this.anchorSide;
this.update({
range,
type: anchorSide !== this.anchorSide ? anchorSide : undefined,
});
}

/**
* Move one of the edges of the cursor to a new point.
*
* @param point Point to set the edge to.
* @param edge 0 for "focus", 1 for "anchor."
*/
public setEdge(point: Point<T>, edge: 0 | 1 = 0): void {
if (this.start === this.end) this.end = this.end.clone();
let anchor = this.anchor();
let focus = this.focus();
if (edge === 0) focus = point;
else anchor = point;
if (focus.cmpSpatial(anchor) < 0) this.set(focus, anchor, CursorAnchor.End);
else this.set(anchor, focus, CursorAnchor.Start);
}

public move(move: number): void {
const {start, end} = this;
start.move(move);
if (start !== end) {
end.move(move);
}
this.set(start, end);
}

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

public toStringName(): string {
const focusIcon = this.anchorSide === CursorAnchor.Start ? '.→|' : '|←.';
return `${super.toStringName()}, ${focusIcon}`;
}
}
31 changes: 19 additions & 12 deletions src/json-crdt-extensions/peritext/editor/Editor.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {Cursor} from '../slice/Cursor';
import {Cursor} from './Cursor';
import {Anchor} from '../rga/constants';
import {SliceBehavior} from '../slice/constants';
import {CursorAnchor, SliceBehavior} from '../slice/constants';
import {tick, type ITimestampStruct} from '../../../json-crdt-patch/clock';
import {PersistedSlice} from '../slice/PersistedSlice';
import {Chars} from '../constants';
Expand All @@ -10,6 +10,7 @@ import type {Printable} from 'tree-dump/lib/types';
import type {Point} from '../rga/Point';
import type {SliceType} from '../types';
import type {MarkerSlice} from '../slice/MarkerSlice';
import type {Slices} from '../slice/Slices';

export class Editor implements Printable {
/**
Expand All @@ -18,10 +19,13 @@ export class Editor implements Printable {
*/
public readonly cursor: Cursor;

constructor(public readonly txt: Peritext) {
const point = txt.point(txt.str.id, Anchor.After);
const cursorId = txt.str.id; // TODO: should be autogenerated to something else
this.cursor = new Cursor(cursorId, txt, point, point.clone());
constructor(
public readonly txt: Peritext,
slices: Slices,
) {
const point = txt.pointAbsStart();
const range = txt.range(point, point.clone());
this.cursor = slices.ins<Cursor, typeof Cursor>(range, SliceBehavior.Cursor, CursorAnchor.Start, undefined, Cursor);
}

/** @deprecated */
Expand Down Expand Up @@ -123,16 +127,19 @@ export class Editor implements Printable {
if (range) this.cursor.setRange(range);
}

public insertSlice(type: SliceType, data?: unknown | ITimestampStruct): PersistedSlice {
return this.txt.slices.ins(this.cursor, SliceBehavior.Stack, type, data);
public insStackSlice(type: SliceType, data?: unknown | ITimestampStruct): PersistedSlice {
const range = this.cursor.range();
return this.txt.savedSlices.ins(range, SliceBehavior.Stack, type, data);
}

public insertOverwriteSlice(type: SliceType, data?: unknown | ITimestampStruct): PersistedSlice {
return this.txt.slices.ins(this.cursor, SliceBehavior.Overwrite, type, data);
public insOverwriteSlice(type: SliceType, data?: unknown | ITimestampStruct): PersistedSlice {
const range = this.cursor.range();
return this.txt.savedSlices.ins(range, SliceBehavior.Overwrite, type, data);
}

public insertEraseSlice(type: SliceType, data?: unknown | ITimestampStruct): PersistedSlice {
return this.txt.slices.ins(this.cursor, SliceBehavior.Erase, type, data);
public insEraseSlice(type: SliceType, data?: unknown | ITimestampStruct): PersistedSlice {
const range = this.cursor.range();
return this.txt.savedSlices.ins(range, SliceBehavior.Erase, type, data);
}

public insMarker(type: SliceType, data?: unknown): MarkerSlice {
Expand Down
Loading