Skip to content

Commit d6689fe

Browse files
authored
Merge pull request #611 from streamich/overlay-4
Add support for ephemeral Peritext overlays
2 parents 5ef7ba9 + ee76f28 commit d6689fe

29 files changed

+780
-418
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@
129129
"hyperdyperid": "^1.2.0",
130130
"sonic-forest": "^1.0.2",
131131
"thingies": "^2.0.0",
132-
"tree-dump": "^1.0.0"
132+
"tree-dump": "^1.0.1"
133133
},
134134
"devDependencies": {
135135
"@types/benchmark": "^2.1.5",

src/json-crdt-extensions/peritext/Peritext.ts

Lines changed: 44 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,16 @@ import {Range} from './rga/Range';
55
import {Editor} from './editor/Editor';
66
import {ArrNode, StrNode} from '../../json-crdt/nodes';
77
import {Slices} from './slice/Slices';
8+
import {LocalSlices} from './slice/LocalSlices';
89
import {Overlay} from './overlay/Overlay';
910
import {Chars} from './constants';
1011
import {interval} from '../../json-crdt-patch/clock';
12+
import {Model} from '../../json-crdt/model';
1113
import {CONST, updateNum} from '../../json-hash';
14+
import {SESSION} from '../../json-crdt-patch/constants';
15+
import {s} from '../../json-crdt-patch';
1216
import type {ITimestampStruct} from '../../json-crdt-patch/clock';
13-
import type {Model} from '../../json-crdt/model';
1417
import type {Printable} from 'tree-dump/lib/types';
15-
import type {StringChunk} from './util/types';
1618
import type {SliceType} from './types';
1719
import type {MarkerSlice} from './slice/MarkerSlice';
1820

@@ -21,7 +23,26 @@ import type {MarkerSlice} from './slice/MarkerSlice';
2123
* interact with the text.
2224
*/
2325
export class Peritext implements Printable {
24-
public readonly slices: Slices;
26+
/**
27+
* *Slices* are rich-text annotations that appear in the text. The "saved"
28+
* slices are the ones that are persisted in the document.
29+
*/
30+
public readonly savedSlices: Slices;
31+
32+
/**
33+
* *Extra slices* are slices that are not persisted in the document. However,
34+
* they are still shared across users, i.e. they are ephemerally persisted
35+
* during the editing session.
36+
*/
37+
public readonly extraSlices: Slices;
38+
39+
/**
40+
* *Local slices* are slices that are not persisted in the document and are
41+
* not shared with other users. They are used only for local annotations for
42+
* the current user.
43+
*/
44+
public readonly localSlices: Slices;
45+
2546
public readonly editor: Editor;
2647
public readonly overlay = new Overlay(this);
2748

@@ -30,26 +51,29 @@ export class Peritext implements Printable {
3051
public readonly str: StrNode,
3152
slices: ArrNode,
3253
) {
33-
this.slices = new Slices(this, slices);
34-
this.editor = new Editor(this);
54+
this.savedSlices = new Slices(this.model, slices, this.str);
55+
56+
const extraModel = Model.withLogicalClock(SESSION.GLOBAL)
57+
.setSchema(s.vec(s.arr([])))
58+
.fork(this.model.clock.sid + 1);
59+
this.extraSlices = new Slices(extraModel, extraModel.root.node().get(0)!, this.str);
60+
61+
// TODO: flush patches
62+
// TODO: remove `arr` tombstones
63+
const localModel = Model.withLogicalClock(SESSION.LOCAL).setSchema(s.vec(s.arr([])));
64+
const localApi = localModel.api;
65+
localApi.onLocalChange.listen(() => {
66+
localApi.flush();
67+
});
68+
this.localSlices = new LocalSlices(localModel, localModel.root.node().get(0)!, this.str);
69+
70+
this.editor = new Editor(this, this.localSlices);
3571
}
3672

3773
public strApi() {
3874
return this.model.api.wrap(this.str);
3975
}
4076

41-
/** @todo Find a better place for this function. */
42-
public firstVisChunk(): StringChunk | undefined {
43-
const str = this.str;
44-
let curr = str.first();
45-
if (!curr) return;
46-
while (curr.del) {
47-
curr = str.next(curr);
48-
if (!curr) return;
49-
}
50-
return curr;
51-
}
52-
5377
/** Select a single character before a point. */
5478
public findCharBefore(point: Point): Range | undefined {
5579
if (point.anchor === Anchor.After) {
@@ -196,7 +220,7 @@ export class Peritext implements Printable {
196220
const textId = builder.insStr(str.id, after, char[0]);
197221
const point = this.point(textId, Anchor.Before);
198222
const range = this.range(point, point);
199-
return this.slices.insMarker(range, type, data);
223+
return this.savedSlices.insMarker(range, type, data);
200224
}
201225

202226
/** @todo This can probably use .del() */
@@ -206,7 +230,7 @@ export class Peritext implements Printable {
206230
const builder = api.builder;
207231
const strChunk = split.start.chunk();
208232
if (strChunk) builder.del(str.id, [interval(strChunk.id, 0, 1)]);
209-
builder.del(this.slices.set.id, [interval(split.id, 0, 1)]);
233+
builder.del(this.savedSlices.set.id, [interval(split.id, 0, 1)]);
210234
api.apply();
211235
}
212236

@@ -221,7 +245,7 @@ export class Peritext implements Printable {
221245
nl,
222246
(tab) => this.str.toString(tab),
223247
nl,
224-
(tab) => this.slices.toString(tab),
248+
(tab) => this.savedSlices.toString(tab),
225249
nl,
226250
(tab) => this.overlay.toString(tab),
227251
])
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import {Model} from '../../../json-crdt/model';
2+
import {Peritext} from '../Peritext';
3+
4+
const setup = () => {
5+
const model = Model.withLogicalClock();
6+
model.api.root({
7+
text: '',
8+
slices: [],
9+
});
10+
model.api.str(['text']).ins(0, 'wworld');
11+
model.api.str(['text']).ins(0, 'helo ');
12+
model.api.str(['text']).ins(2, 'l');
13+
model.api.str(['text']).del(7, 1);
14+
const peritext = new Peritext(model, model.api.str(['text']).node, model.api.arr(['slices']).node);
15+
return {model, peritext};
16+
};
17+
18+
test('clears change history', () => {
19+
const {peritext} = setup();
20+
const {editor} = peritext;
21+
editor.cursor.setAt(0);
22+
editor.cursor.setAt(1);
23+
editor.cursor.setAt(2);
24+
editor.cursor.setAt(3);
25+
expect(peritext.localSlices.model.api.flush().ops.length).toBe(0);
26+
});
27+
28+
test('clears slice set tombstones', () => {
29+
const _random = Math.random;
30+
// It is probabilistic, if we set `Math.random` to 0 it will always remove tombstones.
31+
Math.random = () => 0;
32+
const {peritext} = setup();
33+
const slicesRga = peritext.localSlices.model.root.node()!.get(0)!;
34+
const count = slicesRga.size();
35+
const slice1 = peritext.localSlices.insOverwrite(peritext.rangeAt(1, 2), 1);
36+
const slice2 = peritext.localSlices.insOverwrite(peritext.rangeAt(1, 2), 3);
37+
const slice3 = peritext.localSlices.insOverwrite(peritext.rangeAt(1, 2), 2);
38+
expect(slicesRga.size()).toBe(count + 3);
39+
peritext.localSlices.del(slice2.id);
40+
expect(slicesRga.size()).toBe(count + 2);
41+
peritext.localSlices.del(slice1.id);
42+
expect(slicesRga.size()).toBe(count + 1);
43+
peritext.localSlices.del(slice3.id);
44+
expect(slicesRga.size()).toBe(count);
45+
Math.random = _random;
46+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import {Model} from '../../../json-crdt/model';
2+
import {size} from 'sonic-forest/lib/util';
3+
import {Peritext} from '../Peritext';
4+
5+
const setup = () => {
6+
const model = Model.withLogicalClock();
7+
model.api.root({
8+
text: '',
9+
slices: [],
10+
});
11+
model.api.str(['text']).ins(0, 'wworld');
12+
model.api.str(['text']).ins(0, 'helo ');
13+
model.api.str(['text']).ins(2, 'l');
14+
model.api.str(['text']).del(7, 1);
15+
const peritext = new Peritext(model, model.api.str(['text']).node, model.api.arr(['slices']).node);
16+
return {model, peritext};
17+
};
18+
19+
test('can insert markers', () => {
20+
const {peritext} = setup();
21+
const {editor} = peritext;
22+
expect(size(peritext.overlay.root)).toBe(0);
23+
editor.cursor.setAt(0);
24+
editor.insMarker(['p'], '<p>');
25+
peritext.refresh();
26+
expect(size(peritext.overlay.root)).toBe(1);
27+
editor.cursor.setAt(9);
28+
editor.insMarker(['p'], '<p>');
29+
peritext.refresh();
30+
expect(size(peritext.overlay.root)).toBe(3);
31+
});
32+
33+
test('can insert slices', () => {
34+
const {peritext} = setup();
35+
const {editor} = peritext;
36+
expect(size(peritext.overlay.root)).toBe(0);
37+
editor.cursor.setAt(2, 2);
38+
editor.insStackSlice('bold');
39+
peritext.refresh();
40+
expect(size(peritext.overlay.root)).toBe(2);
41+
editor.cursor.setAt(6, 5);
42+
editor.insStackSlice('italic');
43+
peritext.refresh();
44+
expect(size(peritext.overlay.root)).toBe(4);
45+
editor.cursor.setAt(0, 5);
46+
editor.insStackSlice('underline');
47+
peritext.refresh();
48+
expect(size(peritext.overlay.root)).toBe(6);
49+
});
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import {Point} from '../rga/Point';
2+
import {Range} from '../rga/Range';
3+
import {CursorAnchor} from '../slice/constants';
4+
import {PersistedSlice} from '../slice/PersistedSlice';
5+
6+
export class Cursor<T = string> extends PersistedSlice<T> {
7+
public get anchorSide(): CursorAnchor {
8+
return this.type as CursorAnchor;
9+
}
10+
11+
public set anchorSide(value: CursorAnchor) {
12+
this.update({type: value});
13+
}
14+
15+
public anchor(): Point<T> {
16+
return this.anchorSide === CursorAnchor.Start ? this.start : this.end;
17+
}
18+
19+
public focus(): Point<T> {
20+
return this.anchorSide === CursorAnchor.Start ? this.end : this.start;
21+
}
22+
23+
public set(start: Point<T>, end?: Point<T>, anchorSide: CursorAnchor = this.anchorSide): void {
24+
if (!end || end === start) end = start.clone();
25+
super.set(start, end);
26+
this.update({
27+
range: this,
28+
type: anchorSide,
29+
});
30+
}
31+
32+
/** TODO: Move to {@link PersistedSlice}. */
33+
public setAt(start: number, length: number = 0): void {
34+
let at = start;
35+
let len = length;
36+
if (len < 0) {
37+
at += len;
38+
len = -len;
39+
}
40+
const range = Range.at<T>(this.rga, start, length);
41+
const anchorSide = this.anchorSide;
42+
this.update({
43+
range,
44+
type: anchorSide !== this.anchorSide ? anchorSide : undefined,
45+
});
46+
}
47+
48+
/**
49+
* Move one of the edges of the cursor to a new point.
50+
*
51+
* @param point Point to set the edge to.
52+
* @param edge 0 for "focus", 1 for "anchor."
53+
*/
54+
public setEdge(point: Point<T>, edge: 0 | 1 = 0): void {
55+
if (this.start === this.end) this.end = this.end.clone();
56+
let anchor = this.anchor();
57+
let focus = this.focus();
58+
if (edge === 0) focus = point;
59+
else anchor = point;
60+
if (focus.cmpSpatial(anchor) < 0) this.set(focus, anchor, CursorAnchor.End);
61+
else this.set(anchor, focus, CursorAnchor.Start);
62+
}
63+
64+
public move(move: number): void {
65+
const {start, end} = this;
66+
start.move(move);
67+
if (start !== end) {
68+
end.move(move);
69+
}
70+
this.set(start, end);
71+
}
72+
73+
// ---------------------------------------------------------------- Printable
74+
75+
public toStringName(): string {
76+
const focusIcon = this.anchorSide === CursorAnchor.Start ? '.→|' : '|←.';
77+
return `${super.toStringName()}, ${focusIcon}`;
78+
}
79+
}

src/json-crdt-extensions/peritext/editor/Editor.ts

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import {Cursor} from '../slice/Cursor';
1+
import {Cursor} from './Cursor';
22
import {Anchor} from '../rga/constants';
3-
import {SliceBehavior} from '../slice/constants';
3+
import {CursorAnchor, SliceBehavior} from '../slice/constants';
44
import {tick, type ITimestampStruct} from '../../../json-crdt-patch/clock';
55
import {PersistedSlice} from '../slice/PersistedSlice';
66
import {Chars} from '../constants';
@@ -10,6 +10,7 @@ import type {Printable} from 'tree-dump/lib/types';
1010
import type {Point} from '../rga/Point';
1111
import type {SliceType} from '../types';
1212
import type {MarkerSlice} from '../slice/MarkerSlice';
13+
import type {Slices} from '../slice/Slices';
1314

1415
export class Editor implements Printable {
1516
/**
@@ -18,10 +19,13 @@ export class Editor implements Printable {
1819
*/
1920
public readonly cursor: Cursor;
2021

21-
constructor(public readonly txt: Peritext) {
22-
const point = txt.point(txt.str.id, Anchor.After);
23-
const cursorId = txt.str.id; // TODO: should be autogenerated to something else
24-
this.cursor = new Cursor(cursorId, txt, point, point.clone());
22+
constructor(
23+
public readonly txt: Peritext,
24+
slices: Slices,
25+
) {
26+
const point = txt.pointAbsStart();
27+
const range = txt.range(point, point.clone());
28+
this.cursor = slices.ins<Cursor, typeof Cursor>(range, SliceBehavior.Cursor, CursorAnchor.Start, undefined, Cursor);
2529
}
2630

2731
/** @deprecated */
@@ -123,16 +127,19 @@ export class Editor implements Printable {
123127
if (range) this.cursor.setRange(range);
124128
}
125129

126-
public insertSlice(type: SliceType, data?: unknown | ITimestampStruct): PersistedSlice {
127-
return this.txt.slices.ins(this.cursor, SliceBehavior.Stack, type, data);
130+
public insStackSlice(type: SliceType, data?: unknown | ITimestampStruct): PersistedSlice {
131+
const range = this.cursor.range();
132+
return this.txt.savedSlices.ins(range, SliceBehavior.Stack, type, data);
128133
}
129134

130-
public insertOverwriteSlice(type: SliceType, data?: unknown | ITimestampStruct): PersistedSlice {
131-
return this.txt.slices.ins(this.cursor, SliceBehavior.Overwrite, type, data);
135+
public insOverwriteSlice(type: SliceType, data?: unknown | ITimestampStruct): PersistedSlice {
136+
const range = this.cursor.range();
137+
return this.txt.savedSlices.ins(range, SliceBehavior.Overwrite, type, data);
132138
}
133139

134-
public insertEraseSlice(type: SliceType, data?: unknown | ITimestampStruct): PersistedSlice {
135-
return this.txt.slices.ins(this.cursor, SliceBehavior.Erase, type, data);
140+
public insEraseSlice(type: SliceType, data?: unknown | ITimestampStruct): PersistedSlice {
141+
const range = this.cursor.range();
142+
return this.txt.savedSlices.ins(range, SliceBehavior.Erase, type, data);
136143
}
137144

138145
public insMarker(type: SliceType, data?: unknown): MarkerSlice {

0 commit comments

Comments
 (0)