Skip to content

Commit

Permalink
Merge pull request #611 from streamich/overlay-4
Browse files Browse the repository at this point in the history
Add support for ephemeral Peritext overlays
  • Loading branch information
streamich authored May 1, 2024
2 parents 5ef7ba9 + ee76f28 commit d6689fe
Show file tree
Hide file tree
Showing 29 changed files with 780 additions and 418 deletions.
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

0 comments on commit d6689fe

Please sign in to comment.