diff --git a/package.json b/package.json
index 8341d032f7..3b8b17f76f 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/json-crdt-extensions/peritext/Peritext.ts b/src/json-crdt-extensions/peritext/Peritext.ts
index e5cba361df..141d04850a 100644
--- a/src/json-crdt-extensions/peritext/Peritext.ts
+++ b/src/json-crdt-extensions/peritext/Peritext.ts
@@ -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';
@@ -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);
@@ -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) {
@@ -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() */
@@ -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();
}
@@ -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),
])
diff --git a/src/json-crdt-extensions/peritext/__tests__/Peritext.localSlices.spec.ts b/src/json-crdt-extensions/peritext/__tests__/Peritext.localSlices.spec.ts
new file mode 100644
index 0000000000..91df0367b1
--- /dev/null
+++ b/src/json-crdt-extensions/peritext/__tests__/Peritext.localSlices.spec.ts
@@ -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;
+});
diff --git a/src/json-crdt-extensions/peritext/__tests__/Peritext.overlay.spec.ts b/src/json-crdt-extensions/peritext/__tests__/Peritext.overlay.spec.ts
new file mode 100644
index 0000000000..917aafb421
--- /dev/null
+++ b/src/json-crdt-extensions/peritext/__tests__/Peritext.overlay.spec.ts
@@ -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'], '
');
+ peritext.refresh();
+ expect(size(peritext.overlay.root)).toBe(1);
+ editor.cursor.setAt(9);
+ editor.insMarker(['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);
+});
diff --git a/src/json-crdt-extensions/peritext/editor/Cursor.ts b/src/json-crdt-extensions/peritext/editor/Cursor.ts
new file mode 100644
index 0000000000..d18498ca48
--- /dev/null
+++ b/src/json-crdt-extensions/peritext/editor/Cursor.ts
@@ -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 extends PersistedSlice {
+ public get anchorSide(): CursorAnchor {
+ return this.type as CursorAnchor;
+ }
+
+ public set anchorSide(value: CursorAnchor) {
+ this.update({type: value});
+ }
+
+ public anchor(): Point {
+ return this.anchorSide === CursorAnchor.Start ? this.start : this.end;
+ }
+
+ public focus(): Point {
+ return this.anchorSide === CursorAnchor.Start ? this.end : this.start;
+ }
+
+ public set(start: Point, end?: Point, 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(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, 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}`;
+ }
+}
diff --git a/src/json-crdt-extensions/peritext/editor/Editor.ts b/src/json-crdt-extensions/peritext/editor/Editor.ts
index 56ac7a7d1a..d111e0d645 100644
--- a/src/json-crdt-extensions/peritext/editor/Editor.ts
+++ b/src/json-crdt-extensions/peritext/editor/Editor.ts
@@ -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';
@@ -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 {
/**
@@ -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(range, SliceBehavior.Cursor, CursorAnchor.Start, undefined, Cursor);
}
/** @deprecated */
@@ -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 {
diff --git a/src/json-crdt-extensions/peritext/overlay/Overlay.ts b/src/json-crdt-extensions/peritext/overlay/Overlay.ts
index 61967d7ca0..4ed30f12f3 100644
--- a/src/json-crdt-extensions/peritext/overlay/Overlay.ts
+++ b/src/json-crdt-extensions/peritext/overlay/Overlay.ts
@@ -10,10 +10,12 @@ import {OverlayRefSliceEnd, OverlayRefSliceStart} from './refs';
import {equal, ITimestampStruct} from '../../../json-crdt-patch/clock';
import {CONST, updateNum} from '../../../json-hash';
import {MarkerSlice} from '../slice/MarkerSlice';
+import {firstVis} from '../../../json-crdt/nodes/rga/util';
import type {Peritext} from '../Peritext';
import type {Stateful} from '../types';
import type {Printable} from 'tree-dump/lib/types';
import type {MutableSlice, Slice} from '../slice/types';
+import type {Slices} from '../slice/Slices';
export class Overlay implements Printable, Stateful {
public root: OverlayPoint | undefined = undefined;
@@ -90,19 +92,57 @@ export class Overlay implements Printable, Stateful {
public hash: number = 0;
public refresh(slicesOnly: boolean = false): number {
+ const txt = this.txt;
let hash: number = CONST.START_STATE;
- hash = this.refreshSlices(hash);
+ hash = this.refreshSlices(hash, txt.savedSlices);
+ hash = this.refreshSlices(hash, txt.extraSlices);
+ hash = this.refreshSlices(hash, txt.localSlices);
// if (!slicesOnly) this.computeSplitTextHashes();
return (this.hash = hash);
}
+ public readonly slices = new Map();
+
+ private refreshSlices(state: number, slices: Slices): number {
+ const oldSlicesHash = slices.hash;
+ const changed = oldSlicesHash !== slices.refresh();
+ const sliceSet = this.slices;
+ state = updateNum(state, slices.hash);
+ if (changed) {
+ slices.forEach((slice) => {
+ let tuple: [start: OverlayPoint, end: OverlayPoint] | undefined = sliceSet.get(slice);
+ if (tuple) {
+ if ((slice as any).isDel && (slice as any).isDel()) {
+ this.delSlice(slice, tuple);
+ return;
+ }
+ const positionMoved = tuple[0].cmp(slice.start) !== 0 || tuple[1].cmp(slice.end) !== 0;
+ if (positionMoved) this.delSlice(slice, tuple);
+ else return;
+ }
+ tuple = this.insSlice(slice);
+ this.slices.set(slice, tuple);
+ });
+ if (slices.size() < sliceSet.size) {
+ sliceSet.forEach((tuple, slice) => {
+ const mutSlice = slice as Slice | MutableSlice;
+ if ((mutSlice).isDel) {
+ if (!(mutSlice).isDel()) return;
+ this.delSlice(slice, tuple);
+ }
+ });
+ }
+ }
+ return state;
+ }
+
/**
* Retrieve an existing {@link OverlayPoint} or create a new one, inserted
* in the tree, sorted by spatial dimension.
*/
protected upsertPoint(point: Point): [point: OverlayPoint, isNew: boolean] {
const newPoint = this.overlayPoint(point.id, point.anchor);
- const pivot = this.insertPoint(newPoint);
+ const pivot = this.insPoint(newPoint);
if (pivot) return [pivot, false];
return [newPoint, true];
}
@@ -112,7 +152,7 @@ export class Overlay implements Printable, Stateful {
* @param point Point to insert.
* @returns Returns the existing point if it was already in the tree.
*/
- protected insertPoint(point: OverlayPoint): OverlayPoint | undefined {
+ private insPoint(point: OverlayPoint): OverlayPoint | undefined {
let pivot = this.getOrNextLower(point);
if (!pivot) pivot = first(this.root);
if (!pivot) {
@@ -128,59 +168,13 @@ export class Overlay implements Printable, Stateful {
return undefined;
}
- protected delPoint(point: OverlayPoint): void {
+ private delPoint(point: OverlayPoint): void {
this.root = remove(this.root, point);
}
- public slices = new Map();
-
- private refreshSlices(state: number): number {
- const slices = this.txt.slices;
- const changed = slices.refresh();
- const sliceSet = this.slices;
- state = updateNum(state, slices.hash);
- if (changed) {
- slices.forEach((slice) => {
- let tuple: [start: OverlayPoint, end: OverlayPoint] | undefined = sliceSet.get(slice);
- if (tuple) {
- if (slice.isDel()) {
- this.delSlice(slice, tuple);
- return;
- }
- const positionMoved = tuple[0].cmp(slice.start) !== 0 || tuple[1].cmp(slice.end) !== 0;
- if (positionMoved) this.delSlice(slice, tuple);
- else return;
- }
- tuple = this.insSlice(slice);
- this.slices.set(slice, tuple);
- });
- if (slices.size() < sliceSet.size) {
- sliceSet.forEach((tuple, slice) => {
- const mutSlice = slice as Slice | MutableSlice;
- if ((mutSlice).isDel) {
- if (!(mutSlice).isDel()) return;
- this.delSlice(slice, tuple);
- }
- });
- }
- }
- const cursor = this.txt.editor.cursor;
- let tuple: [start: OverlayPoint, end: OverlayPoint] | undefined = sliceSet.get(cursor);
- const positionMoved = tuple && (tuple[0].cmp(cursor.start) !== 0 || tuple[1].cmp(cursor.end) !== 0);
- if (tuple && positionMoved) {
- this.delSlice(cursor, tuple!);
- }
- if (!tuple || positionMoved) {
- tuple = this.insSlice(cursor);
- this.slices.set(cursor, tuple);
- }
- return state;
- }
-
- protected insSplit(slice: MarkerSlice): [start: OverlayPoint, end: OverlayPoint] {
- // const point = new MarkerOverlayPoint(this.txt, slice.start.id, Anchor.Before, slice);
+ private insMarker(slice: MarkerSlice): [start: OverlayPoint, end: OverlayPoint] {
const point = this.markerPoint(slice, Anchor.Before);
- const pivot = this.insertPoint(point);
+ const pivot = this.insPoint(point);
if (!pivot) {
point.refs.push(slice);
const prevPoint = prev(point);
@@ -190,14 +184,14 @@ export class Overlay implements Printable, Stateful {
}
private insSlice(slice: Slice): [start: OverlayPoint, end: OverlayPoint] {
- if (slice instanceof MarkerSlice) return this.insSplit(slice);
+ if (slice instanceof MarkerSlice) return this.insMarker(slice);
const txt = this.txt;
const str = txt.str;
let startPoint = slice.start;
let endPoint = slice.end;
const startIsStringRoot = equal(startPoint.id, str.id);
if (startIsStringRoot) {
- const firstVisibleChunk = txt.firstVisChunk();
+ const firstVisibleChunk = firstVis(txt.str);
if (firstVisibleChunk) {
startPoint = txt.point(firstVisibleChunk.id, Anchor.Before);
const endIsStringRoot = equal(endPoint.id, str.id);
@@ -257,6 +251,9 @@ export class Overlay implements Printable, Stateful {
])
);
};
- return this.constructor.name + printTree(tab, [!this.root ? null : (tab) => printPoint(tab, this.root!)]);
+ return (
+ `${this.constructor.name} #${this.hash.toString(36)}` +
+ printTree(tab, [!this.root ? null : (tab) => printPoint(tab, this.root!)])
+ );
}
}
diff --git a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.getOrNextLower.spec.ts b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.getOrNextLower.spec.ts
new file mode 100644
index 0000000000..bb117c1ae1
--- /dev/null
+++ b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.getOrNextLower.spec.ts
@@ -0,0 +1,52 @@
+import {Model} from '../../../../json-crdt/model';
+import {size} from 'sonic-forest/lib/util';
+import {Peritext} from '../../Peritext';
+import {Anchor} from '../../rga/constants';
+
+describe('.getOrNextLower()', () => {
+ test('combines overlay points - right anchor', () => {
+ const model = Model.withLogicalClock();
+ const api = model.api;
+ api.root({
+ text: '1234',
+ slices: [],
+ });
+ const peritext = new Peritext(model, api.str(['text']).node, api.arr(['slices']).node);
+ peritext.editor.cursor.setAt(1, 1);
+ peritext.editor.insStackSlice(2);
+ peritext.refresh();
+ const str = peritext.str;
+ const id1 = str.find(1)!;
+ const id2 = str.find(2)!;
+ const p1 = peritext.point(id1, Anchor.After);
+ const p2 = peritext.point(id2, Anchor.After);
+ peritext.editor.cursor.set(p1, p2);
+ peritext.editor.insStackSlice(3);
+ peritext.refresh();
+ const cnt = size(peritext.overlay.root);
+ expect(cnt).toBe(3);
+ });
+
+ test('combines overlay points - right anchor 2', () => {
+ const model = Model.withLogicalClock();
+ const api = model.api;
+ api.root({
+ text: '1234',
+ slices: [],
+ });
+ const peritext = new Peritext(model, api.str(['text']).node, api.arr(['slices']).node);
+ const str = peritext.str;
+ const id1 = str.find(1)!;
+ const id2 = str.find(2)!;
+ const p1 = peritext.point(id1, Anchor.After);
+ const p2 = peritext.point(id2, Anchor.After);
+ peritext.editor.cursor.set(p1, p2);
+ peritext.editor.insStackSlice(3);
+ peritext.refresh();
+ peritext.editor.cursor.setAt(2, 1);
+ peritext.editor.insStackSlice(33);
+ peritext.refresh();
+ const cnt = size(peritext.overlay.root);
+ expect(cnt).toBe(3);
+ });
+});
diff --git a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.refresh.spec.ts b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.refresh.spec.ts
new file mode 100644
index 0000000000..d061e5e577
--- /dev/null
+++ b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.refresh.spec.ts
@@ -0,0 +1,171 @@
+import {Model, ObjApi} from '../../../../json-crdt/model';
+import {Peritext} from '../../Peritext';
+import {Anchor} from '../../rga/constants';
+import {SliceBehavior} from '../../slice/constants';
+
+const setup = () => {
+ const sid = 123456789;
+ const model = Model.withLogicalClock(sid);
+ 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};
+};
+
+type Kit = ReturnType;
+
+describe('Overlay.refresh()', () => {
+ const testRefresh = (name: string, update: (kit: Kit, refresh: () => void) => void) => {
+ test(name, () => {
+ const kit = setup();
+ const overlay = kit.peritext.overlay;
+ let hash1: number | undefined, hash2: number | undefined, hash3: number | undefined;
+ update(kit, () => {
+ hash1 = overlay.refresh();
+ hash2 = overlay.refresh();
+ hash3 = overlay.refresh();
+ });
+ const hash4 = overlay.refresh();
+ const hash5 = overlay.refresh();
+ const hash6 = overlay.refresh();
+ expect(hash1).toBe(hash2);
+ expect(hash2).toBe(hash3);
+ expect(hash3).not.toBe(hash4);
+ expect(hash4).toBe(hash5);
+ expect(hash5).toBe(hash6);
+ });
+ };
+
+ describe('slices', () => {
+ describe('updates hash', () => {
+ testRefresh('when a slice is inserted', (kit, refresh) => {
+ kit.peritext.editor.cursor.setAt(1, 4);
+ refresh();
+ kit.peritext.editor.insStackSlice('bold');
+ });
+
+ testRefresh('when a collapsed slice is inserted', (kit, refresh) => {
+ kit.peritext.editor.cursor.setAt(5);
+ refresh();
+ kit.peritext.editor.insStackSlice('');
+ });
+
+ testRefresh('when a marker is inserted', (kit, refresh) => {
+ kit.peritext.editor.cursor.setAt(0);
+ refresh();
+ kit.peritext.editor.insMarker('');
+ });
+
+ testRefresh('when a marker is inserted at the same position', (kit, refresh) => {
+ kit.peritext.editor.cursor.setAt(0);
+ kit.peritext.editor.insMarker('');
+ refresh();
+ kit.peritext.editor.insMarker('');
+ });
+
+ testRefresh('when slice is deleted', (kit, refresh) => {
+ kit.peritext.editor.cursor.setAt(0, 1);
+ const slice = kit.peritext.editor.insStackSlice('');
+ refresh();
+ kit.peritext.savedSlices.del(slice.id);
+ });
+
+ testRefresh('when slice type is changed', (kit, refresh) => {
+ kit.peritext.editor.cursor.setAt(0, 1);
+ const slice = kit.peritext.editor.insStackSlice('');
+ refresh();
+ slice.update({type: ''});
+ });
+
+ testRefresh('when slice behavior is changed', (kit, refresh) => {
+ kit.peritext.editor.cursor.setAt(0, 1);
+ const slice = kit.peritext.editor.insStackSlice(123);
+ refresh();
+ slice.update({behavior: SliceBehavior.Erase});
+ });
+
+ testRefresh('when slice data is overwritten', (kit, refresh) => {
+ kit.peritext.editor.cursor.setAt(0, 1);
+ const slice = kit.peritext.editor.insStackSlice(123, 'a');
+ refresh();
+ slice.update({data: 'b'});
+ });
+
+ testRefresh('when slice data is updated inline', (kit, refresh) => {
+ kit.peritext.editor.cursor.setAt(0, 1);
+ const slice = kit.peritext.editor.insStackSlice(123, {foo: 'bar'});
+ refresh();
+ const api = slice.dataNode()! as ObjApi;
+ api.set({foo: 'baz'});
+ });
+
+ testRefresh('when slice start point anchor is changed', (kit, refresh) => {
+ kit.peritext.editor.cursor.setAt(0, 1);
+ const slice = kit.peritext.editor.insStackSlice(123, 456);
+ expect(slice.start.anchor).toBe(Anchor.Before);
+ refresh();
+ const range = slice.range();
+ range.start.anchor = Anchor.After;
+ slice.update({range});
+ });
+
+ testRefresh('when slice end point anchor is changed', (kit, refresh) => {
+ kit.peritext.editor.cursor.setAt(3, 3);
+ const slice = kit.peritext.editor.insStackSlice(0, 0);
+ expect(slice.end.anchor).toBe(Anchor.After);
+ refresh();
+ const range = slice.range();
+ range.end.anchor = Anchor.Before;
+ slice.update({range});
+ });
+
+ testRefresh('when slice range changes', (kit, refresh) => {
+ kit.peritext.editor.cursor.setAt(3, 3);
+ kit.peritext.editor.insStackSlice(0, 0);
+ kit.peritext.editor.insStackSlice(1, 1);
+ kit.peritext.editor.insStackSlice(3, 3);
+ const range1 = kit.peritext.rangeAt(1, 2);
+ const slice = kit.peritext.savedSlices.insErase(range1, 'gg');
+ expect(slice.end.anchor).toBe(Anchor.After);
+ refresh();
+ const range2 = kit.peritext.rangeAt(2, 2);
+ slice.update({range: range2});
+ });
+ });
+ });
+
+ describe('cursor', () => {
+ describe('updates hash', () => {
+ testRefresh('when cursor char ID changes', (kit, refresh) => {
+ kit.peritext.editor.cursor.setAt(1);
+ refresh();
+ kit.peritext.editor.cursor.setAt(1);
+ });
+
+ testRefresh('when cursor start anchor changes', (kit, refresh) => {
+ kit.peritext.editor.cursor.setAt(3, 3);
+ expect(kit.peritext.editor.cursor.start.anchor).toBe(Anchor.Before);
+ refresh();
+ const start = kit.peritext.editor.cursor.start.clone();
+ start.anchor = Anchor.After;
+ kit.peritext.editor.cursor.setRange(kit.peritext.range(start, kit.peritext.editor.cursor.end));
+ });
+
+ testRefresh('when cursor end anchor changes', (kit, refresh) => {
+ kit.peritext.editor.cursor.setAt(3, 3);
+ expect(kit.peritext.editor.cursor.end.anchor).toBe(Anchor.After);
+ refresh();
+ const end = kit.peritext.editor.cursor.start.clone();
+ end.anchor = Anchor.Before;
+ kit.peritext.editor.cursor.setRange(kit.peritext.range(kit.peritext.editor.cursor.start, end));
+ });
+ });
+ });
+});
diff --git a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.spec.ts b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.spec.ts
index a770d0cb27..71ac4585ce 100644
--- a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.spec.ts
+++ b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.spec.ts
@@ -19,7 +19,7 @@ const setup = () => {
return {model, peritext};
};
-const splitCount = (peritext: Peritext): number => {
+const markerCount = (peritext: Peritext): number => {
const overlay = peritext.overlay;
const iterator = overlay.splitIterator();
let count = 0;
@@ -33,20 +33,19 @@ describe('markers', () => {
describe('inserts', () => {
test('overlays starts with no markers', () => {
const {peritext} = setup();
- expect(splitCount(peritext)).toBe(0);
+ expect(markerCount(peritext)).toBe(0);
});
test('can insert one marker in the middle of text', () => {
const {peritext} = setup();
- peritext.editor.setCursor(6);
+ peritext.editor.cursor.setAt(6);
peritext.editor.insMarker(['p'], '¶');
- expect(splitCount(peritext)).toBe(0);
+ expect(markerCount(peritext)).toBe(0);
peritext.overlay.refresh();
- expect(splitCount(peritext)).toBe(1);
+ expect(markerCount(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);
@@ -54,68 +53,68 @@ describe('markers', () => {
test('can insert two markers', () => {
const {peritext} = setup();
- peritext.editor.setCursor(3);
+ peritext.editor.cursor.setAt(3);
peritext.editor.insMarker(['p'], '¶');
- expect(splitCount(peritext)).toBe(0);
+ expect(markerCount(peritext)).toBe(0);
peritext.overlay.refresh();
- expect(splitCount(peritext)).toBe(1);
+ expect(markerCount(peritext)).toBe(1);
peritext.overlay.refresh();
- expect(splitCount(peritext)).toBe(1);
- peritext.editor.setCursor(9);
+ expect(markerCount(peritext)).toBe(1);
+ peritext.editor.cursor.setAt(9);
peritext.editor.insMarker(['li'], '- ');
- expect(splitCount(peritext)).toBe(1);
+ expect(markerCount(peritext)).toBe(1);
peritext.overlay.refresh();
- expect(splitCount(peritext)).toBe(2);
+ expect(markerCount(peritext)).toBe(2);
peritext.overlay.refresh();
- expect(splitCount(peritext)).toBe(2);
+ expect(markerCount(peritext)).toBe(2);
});
});
describe('deletes', () => {
test('can delete a marker', () => {
const {peritext} = setup();
- peritext.editor.setCursor(6);
+ peritext.editor.cursor.setAt(6);
const slice = peritext.editor.insMarker(['p'], '¶');
peritext.refresh();
- expect(splitCount(peritext)).toBe(1);
+ expect(markerCount(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);
+ expect(markerCount(peritext)).toBe(0);
});
test('can delete one of two splits', () => {
const {peritext} = setup();
- peritext.editor.setCursor(2);
+ peritext.editor.cursor.setAt(2);
peritext.editor.insMarker(['p'], '¶');
- peritext.editor.setCursor(11);
+ peritext.editor.cursor.setAt(11);
const slice = peritext.editor.insMarker(['p'], '¶');
peritext.refresh();
- expect(splitCount(peritext)).toBe(2);
+ expect(markerCount(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);
+ expect(markerCount(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.cursor.setAt(1, 6);
+ peritext.editor.insStackSlice('a', {a: 'b'});
+ peritext.editor.cursor.setAt(2);
peritext.editor.insMarker(['p'], '¶');
- peritext.editor.setCursor(11);
+ peritext.editor.cursor.setAt(11);
peritext.editor.insMarker(['p'], '¶');
peritext.refresh();
- expect(splitCount(peritext)).toBe(2);
+ expect(markerCount(peritext)).toBe(2);
const points = [];
let point;
for (const iterator = peritext.overlay.splitIterator(); (point = iterator()); ) points.push(point);
@@ -135,8 +134,8 @@ describe('slices', () => {
test('can insert one slice in the middle of text', () => {
const {peritext} = setup();
- peritext.editor.setCursor(6, 2);
- peritext.editor.insertSlice('em', {emphasis: true});
+ peritext.editor.cursor.setAt(6, 2);
+ peritext.editor.insStackSlice('em', {emphasis: true});
expect(peritext.overlay.slices.size).toBe(0);
peritext.overlay.refresh();
expect(peritext.overlay.slices.size).toBe(2);
@@ -152,10 +151,10 @@ describe('slices', () => {
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});
+ peritext.editor.cursor.setAt(2, 8);
+ peritext.editor.insStackSlice('em', {emphasis: true});
+ peritext.editor.cursor.setAt(4, 8);
+ peritext.editor.insStackSlice('strong', {bold: true});
expect(peritext.overlay.slices.size).toBe(0);
peritext.overlay.refresh();
expect(peritext.overlay.slices.size).toBe(3);
@@ -167,10 +166,10 @@ describe('slices', () => {
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.editor.cursor.setAt(2, 2);
+ peritext.editor.insStackSlice('em', {emphasis: true});
+ peritext.editor.cursor.setAt(3, 2);
+ peritext.editor.insStackSlice('strong', {bold: true});
peritext.refresh();
const point1 = first(peritext.overlay.root)!;
expect(point1.layers.length).toBe(1);
@@ -190,8 +189,8 @@ describe('slices', () => {
test('one char slice should correctly sort overlay points', () => {
const {peritext} = setup();
- peritext.editor.setCursor(0, 1);
- peritext.editor.insertSlice('em', {emphasis: true});
+ peritext.editor.cursor.setAt(0, 1);
+ peritext.editor.insStackSlice('em', {emphasis: true});
peritext.refresh();
const point1 = peritext.overlay.first()!;
const point2 = next(point1)!;
@@ -203,17 +202,17 @@ describe('slices', () => {
test('intersecting slice before split, should not update the split', () => {
const {peritext} = setup();
- peritext.editor.setCursor(6);
+ peritext.editor.cursor.setAt(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('');
+ peritext.editor.cursor.setAt(2, 2);
+ peritext.editor.insStackSlice('');
peritext.refresh();
expect(point.layers.length).toBe(0);
- peritext.editor.setCursor(2, 1);
- peritext.editor.insertSlice('');
+ peritext.editor.cursor.setAt(2, 1);
+ peritext.editor.insStackSlice('');
peritext.refresh();
expect(point.layers.length).toBe(0);
});
@@ -222,12 +221,12 @@ describe('slices', () => {
describe('deletes', () => {
test('can remove a slice', () => {
const {peritext} = setup();
- peritext.editor.setCursor(6, 2);
- const slice = peritext.editor.insertSlice('em', {emphasis: true});
+ peritext.editor.cursor.setAt(6, 2);
+ const slice = peritext.editor.insStackSlice('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);
+ peritext.savedSlices.del(slice.id);
expect(peritext.overlay.slices.size).toBe(2);
peritext.overlay.refresh();
expect(peritext.overlay.slices.size).toBe(1);
diff --git a/src/json-crdt-extensions/peritext/overlay/__tests__/OverlayPoint.spec.ts b/src/json-crdt-extensions/peritext/overlay/__tests__/OverlayPoint.spec.ts
index 75a56352ca..fbeec727e6 100644
--- a/src/json-crdt-extensions/peritext/overlay/__tests__/OverlayPoint.spec.ts
+++ b/src/json-crdt-extensions/peritext/overlay/__tests__/OverlayPoint.spec.ts
@@ -17,7 +17,7 @@ const setupOverlayPoint = () => {
describe('layers', () => {
test('can add a layer', () => {
const {peritext, getPoint} = setupOverlayPoint();
- const slice = peritext.slices.insOverwrite(peritext.rangeAt(5, 5), '');
+ const slice = peritext.savedSlices.insOverwrite(peritext.rangeAt(5, 5), '');
const point = getPoint(slice.start);
expect(point.layers.length).toBe(0);
point.addLayer(slice);
@@ -27,7 +27,7 @@ describe('layers', () => {
test('inserting same slice twice is a no-op', () => {
const {peritext, getPoint} = setupOverlayPoint();
- const slice = peritext.slices.insOverwrite(peritext.rangeAt(5, 5), '');
+ const slice = peritext.savedSlices.insOverwrite(peritext.rangeAt(5, 5), '');
const point = getPoint(slice.start);
expect(point.layers.length).toBe(0);
point.addLayer(slice);
@@ -39,8 +39,8 @@ describe('layers', () => {
test('can add two layers with the same start position', () => {
const {peritext, getPoint} = setupOverlayPoint();
- const slice1 = peritext.slices.insOverwrite(peritext.rangeAt(5, 5), '');
- const slice2 = peritext.slices.insOverwrite(peritext.rangeAt(5, 3), '');
+ const slice1 = peritext.savedSlices.insOverwrite(peritext.rangeAt(5, 5), '');
+ const slice2 = peritext.savedSlices.insOverwrite(peritext.rangeAt(5, 3), '');
const point = getPoint(slice1.start);
expect(point.layers.length).toBe(0);
point.addLayer(slice1);
@@ -54,8 +54,8 @@ describe('layers', () => {
test('orders slices by their ID', () => {
const {peritext, getPoint} = setupOverlayPoint();
- const slice1 = peritext.slices.insOverwrite(peritext.rangeAt(5, 5), '');
- const slice2 = peritext.slices.insOverwrite(peritext.rangeAt(5, 3), '');
+ const slice1 = peritext.savedSlices.insOverwrite(peritext.rangeAt(5, 5), '');
+ const slice2 = peritext.savedSlices.insOverwrite(peritext.rangeAt(5, 3), '');
const point = getPoint(slice1.start);
point.addLayer(slice2);
point.addLayer(slice1);
@@ -65,9 +65,9 @@ describe('layers', () => {
test('can add tree layers and sort them correctly', () => {
const {peritext, getPoint} = setupOverlayPoint();
- const slice1 = peritext.slices.insOverwrite(peritext.rangeAt(5, 5), '');
- const slice2 = peritext.slices.insOverwrite(peritext.rangeAt(5, 3), '');
- const slice3 = peritext.slices.insOverwrite(peritext.rangeAt(2, 10), '');
+ const slice1 = peritext.savedSlices.insOverwrite(peritext.rangeAt(5, 5), '');
+ const slice2 = peritext.savedSlices.insOverwrite(peritext.rangeAt(5, 3), '');
+ const slice3 = peritext.savedSlices.insOverwrite(peritext.rangeAt(2, 10), '');
const point = getPoint(slice1.start);
point.addLayer(slice3);
point.addLayer(slice3);
@@ -84,9 +84,9 @@ describe('layers', () => {
test('can add tree layers by appending them', () => {
const {peritext, getPoint} = setupOverlayPoint();
- const slice1 = peritext.slices.insOverwrite(peritext.rangeAt(5, 5), '');
- const slice2 = peritext.slices.insOverwrite(peritext.rangeAt(5, 3), '');
- const slice3 = peritext.slices.insOverwrite(peritext.rangeAt(2, 10), '');
+ const slice1 = peritext.savedSlices.insOverwrite(peritext.rangeAt(5, 5), '');
+ const slice2 = peritext.savedSlices.insOverwrite(peritext.rangeAt(5, 3), '');
+ const slice3 = peritext.savedSlices.insOverwrite(peritext.rangeAt(2, 10), '');
const point = getPoint(slice1.start);
point.addLayer(slice1);
point.addLayer(slice2);
@@ -98,9 +98,9 @@ describe('layers', () => {
test('can remove layers', () => {
const {peritext, getPoint} = setupOverlayPoint();
- const slice1 = peritext.slices.insOverwrite(peritext.rangeAt(5, 5), '');
- const slice2 = peritext.slices.insOverwrite(peritext.rangeAt(5, 3), '');
- const slice3 = peritext.slices.insOverwrite(peritext.rangeAt(2, 10), '');
+ const slice1 = peritext.savedSlices.insOverwrite(peritext.rangeAt(5, 5), '');
+ const slice2 = peritext.savedSlices.insOverwrite(peritext.rangeAt(5, 3), '');
+ const slice3 = peritext.savedSlices.insOverwrite(peritext.rangeAt(2, 10), '');
const point = getPoint(slice1.start);
point.addLayer(slice2);
point.addLayer(slice1);
@@ -124,7 +124,7 @@ describe('layers', () => {
describe('markers', () => {
test('can add a marker', () => {
const {peritext, getPoint} = setupOverlayPoint();
- const marker = peritext.slices.insMarker(peritext.rangeAt(5, 0), '');
+ const marker = peritext.savedSlices.insMarker(peritext.rangeAt(5, 0), '
');
const point = getPoint(marker.start);
expect(point.markers.length).toBe(0);
point.addMarker(marker);
@@ -134,7 +134,7 @@ describe('markers', () => {
test('inserting same marker twice is a no-op', () => {
const {peritext, getPoint} = setupOverlayPoint();
- const marker = peritext.slices.insMarker(peritext.rangeAt(5, 0), '
');
+ const marker = peritext.savedSlices.insMarker(peritext.rangeAt(5, 0), '
');
const point = getPoint(marker.start);
expect(point.markers.length).toBe(0);
point.addMarker(marker);
@@ -147,8 +147,8 @@ describe('markers', () => {
test('can add two markers with the same start position', () => {
const {peritext, getPoint} = setupOverlayPoint();
- const marker1 = peritext.slices.insMarker(peritext.rangeAt(5, 0), '
');
- const marker2 = peritext.slices.insMarker(peritext.rangeAt(5, 0), '
');
+ const marker1 = peritext.savedSlices.insMarker(peritext.rangeAt(5, 0), '
');
+ const marker2 = peritext.savedSlices.insMarker(peritext.rangeAt(5, 0), '
');
const point = getPoint(marker1.start);
expect(point.markers.length).toBe(0);
point.addMarker(marker1);
@@ -162,8 +162,8 @@ describe('markers', () => {
test('orders markers by their ID', () => {
const {peritext, getPoint} = setupOverlayPoint();
- const marker1 = peritext.slices.insMarker(peritext.rangeAt(5, 0), '
');
- const marker2 = peritext.slices.insMarker(peritext.rangeAt(5, 0), '
');
+ const marker1 = peritext.savedSlices.insMarker(peritext.rangeAt(5, 0), '
');
+ const marker2 = peritext.savedSlices.insMarker(peritext.rangeAt(5, 0), '
');
const point = getPoint(marker1.start);
point.addMarker(marker2);
point.addMarker(marker1);
@@ -177,9 +177,9 @@ describe('markers', () => {
test('can add tree markers and sort them correctly', () => {
const {peritext, getPoint} = setupOverlayPoint();
- const marker1 = peritext.slices.insMarker(peritext.rangeAt(5, 0), '
');
- const marker2 = peritext.slices.insMarker(peritext.rangeAt(5, 0), '
');
- const marker3 = peritext.slices.insMarker(peritext.rangeAt(5, 0), '
');
+ const marker1 = peritext.savedSlices.insMarker(peritext.rangeAt(5, 0), '
');
+ const marker2 = peritext.savedSlices.insMarker(peritext.rangeAt(5, 0), '
');
+ const marker3 = peritext.savedSlices.insMarker(peritext.rangeAt(5, 0), '
');
const point = getPoint(marker1.start);
point.addMarker(marker3);
point.addMarker(marker3);
@@ -197,9 +197,9 @@ describe('markers', () => {
test('can add tree markers by appending them', () => {
const {peritext, getPoint} = setupOverlayPoint();
- const marker1 = peritext.slices.insMarker(peritext.rangeAt(6, 1), '
');
- const marker2 = peritext.slices.insMarker(peritext.rangeAt(6, 2), '
');
- const marker3 = peritext.slices.insMarker(peritext.rangeAt(6, 3), '
');
+ const marker1 = peritext.savedSlices.insMarker(peritext.rangeAt(6, 1), '
');
+ const marker2 = peritext.savedSlices.insMarker(peritext.rangeAt(6, 2), '
');
+ const marker3 = peritext.savedSlices.insMarker(peritext.rangeAt(6, 3), '
');
const point = getPoint(marker2.start);
point.addMarker(marker1);
point.addMarker(marker2);
@@ -211,9 +211,9 @@ describe('markers', () => {
test('can remove markers', () => {
const {peritext, getPoint} = setupOverlayPoint();
- const marker1 = peritext.slices.insMarker(peritext.rangeAt(6, 1), '
');
- const marker2 = peritext.slices.insMarker(peritext.rangeAt(6, 1), '
');
- const marker3 = peritext.slices.insMarker(peritext.rangeAt(6, 2), '
');
+ const marker1 = peritext.savedSlices.insMarker(peritext.rangeAt(6, 1), '
');
+ const marker2 = peritext.savedSlices.insMarker(peritext.rangeAt(6, 1), '
');
+ const marker3 = peritext.savedSlices.insMarker(peritext.rangeAt(6, 2), '
');
const point = getPoint(marker1.start);
point.addMarker(marker2);
point.addMarker(marker1);
@@ -237,7 +237,7 @@ describe('markers', () => {
describe('refs', () => {
test('can add marker ref', () => {
const {peritext, getPoint} = setupOverlayPoint();
- const marker = peritext.slices.insMarker(peritext.rangeAt(10, 1), '
');
+ const marker = peritext.savedSlices.insMarker(peritext.rangeAt(10, 1), '
');
const point = getPoint(marker.start);
expect(point.markers.length).toBe(0);
expect(point.refs.length).toBe(0);
@@ -250,7 +250,7 @@ describe('refs', () => {
test('can add layer ref (start)', () => {
const {peritext, getPoint} = setupOverlayPoint();
- const slice = peritext.slices.insErase(peritext.rangeAt(0, 4), 123);
+ const slice = peritext.savedSlices.insErase(peritext.rangeAt(0, 4), 123);
const point = getPoint(slice.start);
expect(point.layers.length).toBe(0);
expect(point.refs.length).toBe(0);
@@ -263,7 +263,7 @@ describe('refs', () => {
test('can add layer ref (end)', () => {
const {peritext, getPoint} = setupOverlayPoint();
- const slice = peritext.slices.insErase(peritext.rangeAt(0, 4), 123);
+ const slice = peritext.savedSlices.insErase(peritext.rangeAt(0, 4), 123);
const point = getPoint(slice.end);
expect(point.layers.length).toBe(0);
expect(point.refs.length).toBe(0);
@@ -275,8 +275,8 @@ describe('refs', () => {
test('can add marker and layer start', () => {
const {peritext, getPoint} = setupOverlayPoint();
- const marker = peritext.slices.insMarker(peritext.rangeAt(10, 1), '
');
- const slice = peritext.slices.insErase(peritext.rangeAt(10, 4), 123);
+ const marker = peritext.savedSlices.insMarker(peritext.rangeAt(10, 1), '
');
+ const slice = peritext.savedSlices.insErase(peritext.rangeAt(10, 4), 123);
const point = getPoint(slice.end);
expect(point.layers.length).toBe(0);
expect(point.markers.length).toBe(0);
@@ -290,8 +290,8 @@ describe('refs', () => {
test('can remove marker and layer', () => {
const {peritext, getPoint} = setupOverlayPoint();
- const marker = peritext.slices.insMarker(peritext.rangeAt(10, 1), '
');
- const slice = peritext.slices.insErase(peritext.rangeAt(10, 4), 123);
+ const marker = peritext.savedSlices.insMarker(peritext.rangeAt(10, 1), '
');
+ const slice = peritext.savedSlices.insErase(peritext.rangeAt(10, 4), 123);
const point = getPoint(slice.end);
point.addMarkerRef(marker);
point.addLayerStartRef(slice);
diff --git a/src/json-crdt-extensions/peritext/rga/Range.ts b/src/json-crdt-extensions/peritext/rga/Range.ts
index c779bc4018..cd83808cfd 100644
--- a/src/json-crdt-extensions/peritext/rga/Range.ts
+++ b/src/json-crdt-extensions/peritext/rga/Range.ts
@@ -90,7 +90,7 @@ export class Range implements Pick, Printable {
*
* @returns A new range with the same start and end points.
*/
- public clone(): Range {
+ public range(): Range {
return new Range(this.rga, this.start.clone(), this.end.clone());
}
diff --git a/src/json-crdt-extensions/peritext/rga/__tests__/Range.spec.ts b/src/json-crdt-extensions/peritext/rga/__tests__/Range.spec.ts
index 29bf962b4d..0cddf96b07 100644
--- a/src/json-crdt-extensions/peritext/rga/__tests__/Range.spec.ts
+++ b/src/json-crdt-extensions/peritext/rga/__tests__/Range.spec.ts
@@ -186,7 +186,7 @@ describe('.clone()', () => {
test('can clone a range', () => {
const {peritext} = setup();
const range1 = peritext.rangeAt(2, 3);
- const range2 = range1.clone();
+ const range2 = range1.range();
expect(range2).not.toBe(range1);
expect(range1.text()).toBe(range2.text());
expect(range2.start).not.toBe(range1.start);
@@ -322,7 +322,7 @@ describe('.contains()', () => {
test('returns true if slice is contained', () => {
const {peritext} = setup();
peritext.editor.setCursor(3, 2);
- const slice = peritext.editor.insertOverwriteSlice('b');
+ const slice = peritext.editor.insOverwriteSlice('b');
peritext.editor.setCursor(0);
peritext.refresh();
expect(peritext.rangeAt(2, 4).contains(slice)).toBe(true);
@@ -334,7 +334,7 @@ describe('.contains()', () => {
test('returns false if slice is not contained', () => {
const {peritext} = setup();
peritext.editor.setCursor(3, 2);
- const slice = peritext.editor.insertOverwriteSlice('b');
+ const slice = peritext.editor.insOverwriteSlice('b');
peritext.editor.setCursor(0);
peritext.refresh();
expect(peritext.rangeAt(3, 1).contains(slice)).toBe(false);
diff --git a/src/json-crdt-extensions/peritext/slice/Cursor.ts b/src/json-crdt-extensions/peritext/slice/Cursor.ts
deleted file mode 100644
index 250499b4d8..0000000000
--- a/src/json-crdt-extensions/peritext/slice/Cursor.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-import {Point} from '../rga/Point';
-import {CursorAnchor, SliceBehavior, Tags} from './constants';
-import {Range} from '../rga/Range';
-import {printTree} from 'tree-dump/lib/printTree';
-import {updateNum} from '../../../json-hash';
-import type {ITimestampStruct} from '../../../json-crdt-patch/clock';
-import type {Peritext} from '../Peritext';
-import type {Slice} from './types';
-
-export class Cursor extends Range implements Slice {
- public readonly behavior = SliceBehavior.Overwrite;
- public readonly type = Tags.Cursor;
-
- /**
- * Specifies whether the start or the end of the cursor is the "anchor", e.g.
- * the end which does not move when user changes selection. The other
- * end is free to move, the moving end of the cursor is "focus". By default
- * "anchor" is the start of the cursor.
- */
- public anchorSide: CursorAnchor = CursorAnchor.Start;
-
- constructor(
- public readonly id: ITimestampStruct,
- protected readonly txt: Peritext,
- public start: Point,
- public end: Point,
- ) {
- super(txt.str as any, start, end);
- }
-
- public anchor(): Point {
- return this.anchorSide === CursorAnchor.Start ? this.start : this.end;
- }
-
- public focus(): Point {
- return this.anchorSide === CursorAnchor.Start ? this.end : this.start;
- }
-
- public set(start: Point, end?: Point, base: CursorAnchor = CursorAnchor.Start): void {
- if (!end || end === start) end = start.clone();
- super.set(start, end);
- this.anchorSide = base;
- }
-
- public setAt(start: number, length: number = 0): void {
- let at = start;
- let len = length;
- if (len < 0) {
- at += len;
- len = -len;
- }
- super.setAt(at, len);
- this.anchorSide = length < 0 ? CursorAnchor.End : CursorAnchor.Start;
- }
-
- /**
- * 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, 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.anchorSide = CursorAnchor.End;
- this.start = focus;
- this.end = anchor;
- } else {
- this.anchorSide = CursorAnchor.Start;
- this.start = anchor;
- this.end = focus;
- }
- }
-
- public data() {
- return undefined;
- }
-
- public move(move: number): void {
- const {start, end} = this;
- start.move(move);
- if (start === end) return;
- end.move(move);
- }
-
- // ----------------------------------------------------------------- Stateful
-
- public hash: number = 0;
-
- public refresh(): number {
- let state = super.refresh();
- state = updateNum(state, this.anchorSide);
- this.hash = state;
- return state;
- }
-
- // ---------------------------------------------------------------- Printable
-
- public toString(tab: string = ''): string {
- const text = this.text();
- const focusIcon = this.anchorSide === CursorAnchor.Start ? '.→|' : '|←.';
- const main = `${this.constructor.name} ${super.toString(tab + ' ', true)} ${focusIcon}`;
- return main + (text.length > 32 ? printTree(tab, [() => JSON.stringify(text)]) : '');
- }
-}
diff --git a/src/json-crdt-extensions/peritext/slice/LocalSlices.ts b/src/json-crdt-extensions/peritext/slice/LocalSlices.ts
new file mode 100644
index 0000000000..fbb1eb2148
--- /dev/null
+++ b/src/json-crdt-extensions/peritext/slice/LocalSlices.ts
@@ -0,0 +1,9 @@
+import {Slices} from './Slices';
+import type {ITimestampStruct} from '../../../json-crdt-patch';
+
+export class LocalSlices extends Slices {
+ public del(id: ITimestampStruct): void {
+ super.del(id);
+ if (Math.random() < 0.1) this.set.removeTombstones();
+ }
+}
diff --git a/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts b/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts
index f4c05c76c1..1c70d46ce5 100644
--- a/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts
+++ b/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts
@@ -1,3 +1,4 @@
+import {hasOwnProperty} from '@jsonjoy.com/util/lib/hasOwnProperty';
import {Point} from '../rga/Point';
import {Range} from '../rga/Range';
import {updateNode} from '../../../json-crdt/hash';
@@ -13,13 +14,13 @@ import {s} from '../../../json-crdt-patch';
import type {ITimestampStruct} from '../../../json-crdt-patch/clock';
import type {ArrChunk} from '../../../json-crdt/nodes';
import type {MutableSlice, SliceUpdateParams} from './types';
-import type {Peritext} from '../Peritext';
import type {SliceDto, SliceType, Stateful} from '../types';
import type {Printable} from 'tree-dump/lib/types';
import type {AbstractRga} from '../../../json-crdt/nodes/rga';
+import type {Model} from '../../../json-crdt/model';
export class PersistedSlice extends Range implements MutableSlice, Stateful, Printable {
- public static deserialize(txt: Peritext, rga: AbstractRga, chunk: ArrChunk, tuple: VecNode): PersistedSlice {
+ public static deserialize(model: Model, rga: AbstractRga, chunk: ArrChunk, tuple: VecNode): PersistedSlice {
const header = +(tuple.get(0)!.view() as SliceDto[0]);
const id1 = tuple.get(1)!.view() as ITimestampStruct;
const id2 = (tuple.get(2)!.view() || id1) as ITimestampStruct;
@@ -33,13 +34,13 @@ export class PersistedSlice extends Range implements MutableSlice
const behavior: SliceBehavior = (header & SliceHeaderMask.Behavior) >>> SliceHeaderShift.Behavior;
const p1 = new Point(rga, id1, anchor1);
const p2 = new Point(rga, id2, anchor2);
- const slice = new PersistedSlice(txt, rga, chunk, tuple, behavior, type, p1, p2);
+ const slice = new PersistedSlice(model, rga, chunk, tuple, behavior, type, p1, p2);
return slice;
}
constructor(
/** The Peritext context. */
- protected readonly txt: Peritext,
+ protected readonly model: Model,
/** The text RGA. */
protected readonly rga: AbstractRga,
/** The `arr` chunk of `arr` where the slice is stored. */
@@ -62,7 +63,7 @@ export class PersistedSlice extends Range implements MutableSlice
}
protected tupleApi() {
- return this.txt.model.api.wrap(this.tuple);
+ return this.model.api.wrap(this.tuple);
}
// ------------------------------------------------------------- MutableSlice
@@ -73,7 +74,6 @@ export class PersistedSlice extends Range implements MutableSlice
public update(params: SliceUpdateParams): void {
let updateHeader = false;
- const {start, end} = this;
const changes: [number, unknown][] = [];
if (params.behavior !== undefined) {
this.behavior = params.behavior;
@@ -81,17 +81,16 @@ export class PersistedSlice extends Range implements MutableSlice
}
if (params.range) {
const range = params.range;
- if (range.start.anchor !== start.anchor) updateHeader = true;
- if (range.end.anchor !== end.anchor) updateHeader = true;
- if (compare(range.start.id, start.id) !== 0) changes.push([SliceTupleIndex.X1, s.con(range.start.id)]);
- if (compare(range.end.id, end.id) !== 0) changes.push([SliceTupleIndex.X2, s.con(range.end.id)]);
- this.setRange(range);
+ updateHeader = true;
+ changes.push([SliceTupleIndex.X1, s.con(range.start.id)], [SliceTupleIndex.X2, s.con(range.end.id)]);
+ this.start = range.start;
+ this.end = range.start === range.end ? range.end.clone() : range.end;
}
if (params.type !== undefined) {
this.type = params.type;
changes.push([SliceTupleIndex.Type, s.con(this.type)]);
}
- if (params.data !== undefined) changes.push([SliceTupleIndex.Data, s.con(params.data)]);
+ if (hasOwnProperty(params, 'data')) changes.push([SliceTupleIndex.Data, s.con(params.data)]);
if (updateHeader) {
const header =
(this.behavior << SliceHeaderShift.Behavior) +
@@ -108,11 +107,7 @@ export class PersistedSlice extends Range implements MutableSlice
public dataNode() {
const node = this.tuple.get(SliceTupleIndex.Data);
- return node && this.txt.model.api.wrap(node);
- }
-
- public del(): void {
- this.txt.slices.del(this.id);
+ return node && this.model.api.wrap(node);
}
public isDel(): boolean {
@@ -130,7 +125,7 @@ export class PersistedSlice extends Range implements MutableSlice
this.hash = state;
if (changed) {
const tuple = this.tuple;
- const slice = PersistedSlice.deserialize(this.txt, this.rga, this.chunk, tuple);
+ const slice = PersistedSlice.deserialize(this.model, this.rga, this.chunk, tuple);
this.behavior = slice.behavior;
this.type = slice.type;
this.start = slice.start;
@@ -141,11 +136,21 @@ export class PersistedSlice extends Range implements MutableSlice
// ---------------------------------------------------------------- Printable
+ protected toStringName(): string {
+ const data = this.data();
+ const dataFormatted = data ? prettyOneLine(data) : '∅';
+ const dataLengthBreakpoint = 32;
+ const header = `${this.constructor.name} ${super.toString('', true)}, ${this.behavior}, ${JSON.stringify(this.type)}${dataFormatted.length < dataLengthBreakpoint ? `, ${dataFormatted}` : ''}`;
+ return header;
+ }
+
public toString(tab: string = ''): string {
const data = this.data();
const dataFormatted = data ? prettyOneLine(data) : '';
const dataLengthBreakpoint = 32;
- const header = `${this.constructor.name} ${super.toString(tab)}, ${this.behavior}, ${JSON.stringify(this.type)}${dataFormatted.length < dataLengthBreakpoint ? `, ${dataFormatted}` : ''}`;
- return header + printTree(tab, [dataFormatted.length < dataLengthBreakpoint ? null : (tab) => dataFormatted]);
+ return (
+ this.toStringName() +
+ printTree(tab, [dataFormatted.length < dataLengthBreakpoint ? null : (tab) => dataFormatted])
+ );
}
}
diff --git a/src/json-crdt-extensions/peritext/slice/Slices.ts b/src/json-crdt-extensions/peritext/slice/Slices.ts
index 37314019d9..852bca4ee6 100644
--- a/src/json-crdt-extensions/peritext/slice/Slices.ts
+++ b/src/json-crdt-extensions/peritext/slice/Slices.ts
@@ -11,21 +11,34 @@ import {VecNode} from '../../../json-crdt/nodes';
import type {Slice} from './types';
import type {ITimespanStruct, ITimestampStruct} from '../../../json-crdt-patch/clock';
import type {SliceType, Stateful} from '../types';
-import type {Peritext} from '../Peritext';
import type {Printable} from 'tree-dump/lib/types';
import type {ArrChunk, ArrNode} from '../../../json-crdt/nodes';
+import type {Model} from '../../../json-crdt/model';
+import type {AbstractRga} from '../../../json-crdt/nodes/rga';
export class Slices implements Stateful, Printable {
private list = new AvlMap(compare);
constructor(
- public readonly txt: Peritext,
+ /** The model, which powers the CRDT nodes. */
+ public readonly model: Model,
+ /** The `arr` node, used as a set, where slices are stored. */
public readonly set: ArrNode,
+ /** The text RGA. */
+ protected readonly rga: AbstractRga,
) {}
- public ins(range: Range, behavior: SliceBehavior, type: SliceType, data?: unknown): PersistedSlice {
- const peritext = this.txt;
- const model = peritext.model;
+ public ins<
+ S extends PersistedSlice,
+ K extends new (...args: ConstructorParameters>) => S,
+ >(
+ range: Range,
+ behavior: SliceBehavior,
+ type: SliceType,
+ data?: unknown,
+ Klass: K = behavior === SliceBehavior.Marker ? MarkerSlice : PersistedSlice,
+ ): S {
+ const model = this.model;
const set = this.set;
const api = model.api;
const builder = api.builder;
@@ -53,11 +66,7 @@ export class Slices implements Stateful, Printable {
const tuple = model.index.get(tupleId) as VecNode;
const chunk = set.findById(chunkId)!;
// TODO: Need to check if split slice text was deleted
- const txt = this.txt;
- const slice =
- behavior === SliceBehavior.Marker
- ? new MarkerSlice(txt, txt.str, chunk, tuple, behavior, type, start, end)
- : new PersistedSlice(txt, txt.str, chunk, tuple, behavior, type, start, end);
+ const slice = new Klass(model, this.rga, chunk, tuple, behavior, type, start, end);
this.list.set(chunk.id, slice);
return slice;
}
@@ -79,17 +88,15 @@ export class Slices implements Stateful, Printable {
}
protected unpack(chunk: ArrChunk): PersistedSlice {
- const txt = this.txt;
- const rga = txt.str;
- const model = txt.model;
+ const rga = this.rga;
+ const model = this.model;
const tupleId = chunk.data ? chunk.data[0] : undefined;
if (!tupleId) throw new Error('SLICE_NOT_FOUND');
const tuple = model.index.get(tupleId);
if (!(tuple instanceof VecNode)) throw new Error('NOT_TUPLE');
- let slice = PersistedSlice.deserialize(txt, rga, chunk, tuple);
- // TODO: Simplify, remove `SplitSlice` class.
+ let slice = PersistedSlice.deserialize(model, rga, chunk, tuple);
if (slice.isSplit())
- slice = new MarkerSlice(txt, rga, chunk, tuple, slice.behavior, slice.type, slice.start, slice.end);
+ slice = new MarkerSlice(model, rga, chunk, tuple, slice.behavior, slice.type, slice.start, slice.end);
return slice;
}
@@ -99,13 +106,13 @@ export class Slices implements Stateful, Printable {
public del(id: ITimestampStruct): void {
this.list.del(id);
- const api = this.txt.model.api;
+ const api = this.model.api;
api.builder.del(this.set.id, [tss(id.sid, id.time, 1)]);
api.apply();
}
public delSlices(slices: Slice[]): void {
- const api = this.txt.model.api;
+ const api = this.model.api;
const spans: ITimespanStruct[] = [];
const length = slices.length;
for (let i = 0; i < length; i++) {
@@ -123,7 +130,7 @@ export class Slices implements Stateful, Printable {
return this.list._size;
}
- public forEach(callback: (item: PersistedSlice) => void): void {
+ public forEach(callback: (item: Slice) => void): void {
this.list.forEach((node) => callback(node.v));
}
diff --git a/src/json-crdt-extensions/peritext/slice/__tests__/PersistedSlice.spec.ts b/src/json-crdt-extensions/peritext/slice/__tests__/PersistedSlice.spec.ts
index 42bdb2bc2e..bb33b387fd 100644
--- a/src/json-crdt-extensions/peritext/slice/__tests__/PersistedSlice.spec.ts
+++ b/src/json-crdt-extensions/peritext/slice/__tests__/PersistedSlice.spec.ts
@@ -4,7 +4,7 @@ import {setup} from './setup';
const setupSlice = () => {
const deps = setup();
const range = deps.peritext.rangeAt(2, 3);
- const slice = deps.peritext.slices.insMarker(range, 0);
+ const slice = deps.peritext.savedSlices.insMarker(range, 0);
return {...deps, range, slice};
};
@@ -61,15 +61,15 @@ describe('.del() and .isDel()', () => {
const {peritext, slice} = setupSlice();
expect(peritext.model.view().slices.length).toBe(1);
expect(slice.isDel()).toBe(false);
- const slice2 = peritext.slices.get(slice.id)!;
+ const slice2 = peritext.savedSlices.get(slice.id)!;
expect(peritext.model.view().slices.length).toBe(1);
expect(slice2.isDel()).toBe(false);
expect(slice2).toBe(slice);
- slice.del();
+ peritext.savedSlices.del(slice.id);
expect(peritext.model.view().slices.length).toBe(0);
expect(slice.isDel()).toBe(true);
expect(slice2.isDel()).toBe(true);
- const slice3 = peritext.slices.get(slice.id);
+ const slice3 = peritext.savedSlices.get(slice.id);
expect(slice3).toBe(undefined);
});
});
diff --git a/src/json-crdt-extensions/peritext/slice/__tests__/Slices.spec.ts b/src/json-crdt-extensions/peritext/slice/__tests__/Slices.spec.ts
index bd062a8692..c47794d222 100644
--- a/src/json-crdt-extensions/peritext/slice/__tests__/Slices.spec.ts
+++ b/src/json-crdt-extensions/peritext/slice/__tests__/Slices.spec.ts
@@ -8,9 +8,9 @@ import {setup} from './setup';
test('initially slice list is empty', () => {
const {peritext} = setup();
- expect(peritext.slices.size()).toBe(0);
+ expect(peritext.savedSlices.size()).toBe(0);
peritext.refresh();
- expect(peritext.slices.size()).toBe(0);
+ expect(peritext.savedSlices.size()).toBe(0);
});
describe('.ins()', () => {
@@ -18,7 +18,7 @@ describe('.ins()', () => {
const {peritext, slices} = setup();
const range = peritext.rangeAt(12, 7);
const slice = slices.ins(range, SliceBehavior.Stack, 'b', {bold: true});
- expect(peritext.slices.size()).toBe(1);
+ expect(peritext.savedSlices.size()).toBe(1);
expect(slice.start).toStrictEqual(range.start);
expect(slice.end).toStrictEqual(range.end);
expect(slice.behavior).toBe(SliceBehavior.Stack);
@@ -30,11 +30,11 @@ describe('.ins()', () => {
const {peritext} = setup();
const {editor} = peritext;
editor.cursor.setAt(6, 5);
- const slice1 = editor.insertSlice('strong', {bold: true});
+ const slice1 = editor.insStackSlice('strong', {bold: true});
editor.cursor.setAt(12, 4);
- const slice2 = editor.insertSlice('i', {italic: true});
+ const slice2 = editor.insStackSlice('i', {italic: true});
peritext.refresh();
- expect(peritext.slices.size()).toBe(2);
+ expect(peritext.savedSlices.size()).toBe(2);
expect(slice1.data()).toStrictEqual({bold: true});
expect(slice2.data()).toStrictEqual({italic: true});
});
@@ -42,29 +42,29 @@ describe('.ins()', () => {
test('updates hash on slice insert', () => {
const {peritext} = setup();
const {editor} = peritext;
- const changed1 = peritext.slices.hash !== peritext.slices.refresh();
- const hash1 = peritext.slices.hash;
- const changed2 = peritext.slices.hash !== peritext.slices.refresh();
- const hash2 = peritext.slices.hash;
+ const changed1 = peritext.savedSlices.hash !== peritext.savedSlices.refresh();
+ const hash1 = peritext.savedSlices.hash;
+ const changed2 = peritext.savedSlices.hash !== peritext.savedSlices.refresh();
+ const hash2 = peritext.savedSlices.hash;
expect(changed1).toBe(true);
expect(changed2).toBe(false);
expect(hash1).toBe(hash2);
editor.cursor.setAt(12, 7);
- editor.insertSlice('b', {bold: true});
- const changed3 = peritext.slices.hash !== peritext.slices.refresh();
- const hash3 = peritext.slices.hash;
- const changed4 = peritext.slices.hash !== peritext.slices.refresh();
- const hash4 = peritext.slices.hash;
+ editor.insStackSlice('b', {bold: true});
+ const changed3 = peritext.savedSlices.hash !== peritext.savedSlices.refresh();
+ const hash3 = peritext.savedSlices.hash;
+ const changed4 = peritext.savedSlices.hash !== peritext.savedSlices.refresh();
+ const hash4 = peritext.savedSlices.hash;
expect(changed3).toBe(true);
expect(changed4).toBe(false);
expect(hash1).not.toStrictEqual(hash3);
expect(hash3).toBe(hash4);
editor.cursor.setAt(12, 4);
- editor.insertSlice('em', {italic: true});
- const changed5 = peritext.slices.hash !== peritext.slices.refresh();
- const hash5 = peritext.slices.hash;
- const changed6 = peritext.slices.hash !== peritext.slices.refresh();
- const hash6 = peritext.slices.hash;
+ editor.insStackSlice('em', {italic: true});
+ const changed5 = peritext.savedSlices.hash !== peritext.savedSlices.refresh();
+ const hash5 = peritext.savedSlices.hash;
+ const changed6 = peritext.savedSlices.hash !== peritext.savedSlices.refresh();
+ const hash6 = peritext.savedSlices.hash;
expect(changed5).toBe(true);
expect(changed6).toBe(false);
expect(hash3).not.toBe(hash5);
@@ -86,7 +86,7 @@ describe('.ins()', () => {
for (const data of datas) {
for (const behavior of behaviors) {
const {peritext, model} = setup();
- const slice = peritext.slices.ins(range, behavior, type, data);
+ const slice = peritext.savedSlices.ins(range, behavior, type, data);
expect(slice.start.cmp(range.start)).toBe(0);
expect(slice.end.cmp(range.end)).toBe(0);
expect(slice.behavior).toBe(behavior);
@@ -96,7 +96,7 @@ describe('.ins()', () => {
const model2 = Model.fromBinary(buf);
const peritext2 = new Peritext(model2, model2.api.str(['text']).node, model2.api.arr(['slices']).node);
peritext2.refresh();
- const slice2 = peritext2.slices.get(slice.id)!;
+ const slice2 = peritext2.savedSlices.get(slice.id)!;
expect(slice2.start.cmp(range.start)).toBe(0);
expect(slice2.end.cmp(range.end)).toBe(0);
expect(slice2.behavior).toBe(behavior);
@@ -113,8 +113,8 @@ describe('.get()', () => {
test('can retrieve slice by id', () => {
const {peritext} = setup();
const range = peritext.rangeAt(6, 5);
- const slice = peritext.slices.insOverwrite(range, 'italic');
- const slice2 = peritext.slices.get(slice.id);
+ const slice = peritext.savedSlices.insOverwrite(range, 'italic');
+ const slice2 = peritext.savedSlices.get(slice.id);
expect(slice2).toBe(slice);
});
});
@@ -124,14 +124,14 @@ describe('.del()', () => {
const {peritext} = setup();
const {editor} = peritext;
editor.cursor.setAt(6, 5);
- const slice1 = editor.insertSlice('b', {bold: true});
+ const slice1 = editor.insStackSlice('b', {bold: true});
peritext.refresh();
- const hash1 = peritext.slices.hash;
- expect(peritext.slices.size()).toBe(1);
- peritext.slices.del(slice1.id);
+ const hash1 = peritext.savedSlices.hash;
+ expect(peritext.savedSlices.size()).toBe(1);
+ peritext.savedSlices.del(slice1.id);
peritext.refresh();
- const hash2 = peritext.slices.hash;
- expect(peritext.slices.size()).toBe(0);
+ const hash2 = peritext.savedSlices.hash;
+ expect(peritext.savedSlices.size()).toBe(0);
expect(hash1).not.toBe(hash2);
});
});
@@ -141,14 +141,14 @@ describe('.delSlices()', () => {
const {peritext} = setup();
const {editor} = peritext;
editor.cursor.setAt(6, 5);
- const slice1 = editor.insertSlice('b', {bold: true});
+ const slice1 = editor.insStackSlice('b', {bold: true});
peritext.refresh();
- const hash1 = peritext.slices.hash;
- expect(peritext.slices.size()).toBe(1);
- peritext.slices.delSlices([slice1]);
+ const hash1 = peritext.savedSlices.hash;
+ expect(peritext.savedSlices.size()).toBe(1);
+ peritext.savedSlices.delSlices([slice1]);
peritext.refresh();
- const hash2 = peritext.slices.hash;
- expect(peritext.slices.size()).toBe(0);
+ const hash2 = peritext.savedSlices.hash;
+ expect(peritext.savedSlices.size()).toBe(0);
expect(hash1).not.toBe(hash2);
});
});
@@ -158,19 +158,19 @@ describe('.refresh()', () => {
test('changes hash on: ' + name, () => {
const {peritext, encodeAndDecode} = setup();
const range = peritext.rangeAt(6, 5);
- const slice = peritext.slices.insOverwrite(range, 'b', {howBold: 'very'});
- const hash1 = peritext.slices.refresh();
- const hash2 = peritext.slices.refresh();
+ const slice = peritext.savedSlices.insOverwrite(range, 'b', {howBold: 'very'});
+ const hash1 = peritext.savedSlices.refresh();
+ const hash2 = peritext.savedSlices.refresh();
expect(hash1).toBe(hash2);
expect(slice.type).toBe('b');
update({range, slice});
- const hash3 = peritext.slices.refresh();
- const hash4 = peritext.slices.refresh();
+ const hash3 = peritext.savedSlices.refresh();
+ const hash4 = peritext.savedSlices.refresh();
expect(hash3).not.toBe(hash2);
expect(hash4).toBe(hash3);
const {peritext2} = encodeAndDecode();
peritext2.refresh();
- const slice2 = peritext2.slices.get(slice.id)!;
+ const slice2 = peritext2.savedSlices.get(slice.id)!;
expect(slice2.cmp(slice)).toBe(0);
});
};
@@ -214,14 +214,14 @@ describe('.refresh()', () => {
const {peritext} = setup();
const {editor} = peritext;
editor.cursor.setAt(6, 5);
- const slice1 = editor.insertSlice('b', {bold: true});
+ const slice1 = editor.insStackSlice('b', {bold: true});
peritext.refresh();
- const hash1 = peritext.slices.hash;
+ const hash1 = peritext.savedSlices.hash;
peritext.model.api.obj(['slices', 0, 4]).set({bold: false});
peritext.refresh();
- const hash2 = peritext.slices.hash;
+ const hash2 = peritext.savedSlices.hash;
peritext.refresh();
- const hash3 = peritext.slices.hash;
+ const hash3 = peritext.savedSlices.hash;
expect(hash1).not.toBe(hash2);
expect(hash2).toBe(hash3);
});
diff --git a/src/json-crdt-extensions/peritext/slice/__tests__/setup.ts b/src/json-crdt-extensions/peritext/slice/__tests__/setup.ts
index f31bb4c04b..0b7e556cb4 100644
--- a/src/json-crdt-extensions/peritext/slice/__tests__/setup.ts
+++ b/src/json-crdt-extensions/peritext/slice/__tests__/setup.ts
@@ -13,7 +13,7 @@ export const setup = () => {
model.api.str(['text']).del(7, 1);
model.api.str(['text']).ins(11, ' this game is awesome');
const peritext = new Peritext(model, model.api.str(['text']).node, model.api.arr(['slices']).node);
- const slices = peritext.slices;
+ const slices = peritext.savedSlices;
const encodeAndDecode = () => {
const buf = model.toBinary();
const model2 = Model.fromBinary(buf);
diff --git a/src/json-crdt-extensions/peritext/slice/constants.ts b/src/json-crdt-extensions/peritext/slice/constants.ts
index 6ed172066a..97724b4d5a 100644
--- a/src/json-crdt-extensions/peritext/slice/constants.ts
+++ b/src/json-crdt-extensions/peritext/slice/constants.ts
@@ -1,6 +1,8 @@
/**
- * Specifies which cursor end is the "anchor", e.g. the end which does not move
- * when user changes selection.
+ * Specifies whether the start or the end of the cursor is the "anchor", e.g.
+ * the end which does not move when user changes selection. The other
+ * end is free to move, the moving end of the cursor is "focus". By default
+ * "anchor" is usually the start of the cursor.
*/
export const enum CursorAnchor {
Start = 0,
@@ -48,6 +50,11 @@ export const enum SliceBehavior {
* used to re-verse inline formatting, like bold, italic, etc.
*/
Erase = 0b011,
+
+ /**
+ * Used to mark the user's cursor position in the document.
+ */
+ Cursor = 0b100,
}
export const enum SliceTupleIndex {
diff --git a/src/json-crdt-extensions/peritext/slice/types.ts b/src/json-crdt-extensions/peritext/slice/types.ts
index 6afdd511de..5d32d8a0c3 100644
--- a/src/json-crdt-extensions/peritext/slice/types.ts
+++ b/src/json-crdt-extensions/peritext/slice/types.ts
@@ -33,8 +33,6 @@ export interface Slice extends Range, Stateful {
export interface MutableSlice extends Slice {
update(params: SliceUpdateParams): void;
- del(): void;
-
/**
* Whether the slice is deleted.
*/
diff --git a/src/json-crdt-patch/constants.ts b/src/json-crdt-patch/constants.ts
index 39762ab764..e62c3fd595 100644
--- a/src/json-crdt-patch/constants.ts
+++ b/src/json-crdt-patch/constants.ts
@@ -20,6 +20,14 @@ export const enum SESSION {
*/
GLOBAL = 2,
+ /**
+ * Session ID used for models that are not shared with other users. For
+ * example, when a user is editing a document in a local editor, these
+ * documents could capture local information, like the cursor position, which
+ * is not shared with other users.
+ */
+ LOCAL = 3,
+
/** Max allowed session ID, they are capped at 53-bits. */
MAX = 9007199254740991,
}
diff --git a/src/json-crdt/__tests__/guide/1-JsonPatch.spec.ts b/src/json-crdt/__tests__/guide/1-JsonPatch.spec.ts
index 7016e1cf3b..7159d3c16c 100644
--- a/src/json-crdt/__tests__/guide/1-JsonPatch.spec.ts
+++ b/src/json-crdt/__tests__/guide/1-JsonPatch.spec.ts
@@ -7,30 +7,30 @@ const doc = Model.withLogicalClock();
const jsonPatch = new JsonPatch(doc);
test('can edit document using JSON Patch operations', () => {
- console.log(doc.view());
- console.log(doc.toString());
+ // console.log(doc.view());
+ // console.log(doc.toString());
jsonPatch.apply([{op: 'add', path: '', value: {foo: 'bar'}}]);
- console.log(doc.view());
- console.log(doc.toString());
+ // console.log(doc.view());
+ // console.log(doc.toString());
jsonPatch.apply([{op: 'str_ins', path: '/foo', pos: 3, str: '!'}]);
jsonPatch.apply([{op: 'str_ins', path: '/foo', pos: 4, str: ' baz!'}]);
- console.log(doc.view());
- console.log(doc.toString());
+ // console.log(doc.view());
+ // console.log(doc.toString());
jsonPatch.apply([{op: 'str_ins', path: '/foo', pos: 5, str: 'qux! '}]);
- console.log(doc.view());
- console.log(doc.toString());
+ // console.log(doc.view());
+ // console.log(doc.toString());
jsonPatch.apply([{op: 'add', path: '/list', value: [{title: 'To the dishes!'}, {title: 'Write more tests!'}]}]);
- console.log(doc.view());
- console.log(doc.toString());
+ // console.log(doc.view());
+ // console.log(doc.toString());
expect(doc.view()).toStrictEqual({
foo: 'bar! qux! baz!',
diff --git a/src/json-crdt/__tests__/guide/2-ModelApi.spec.ts b/src/json-crdt/__tests__/guide/2-ModelApi.spec.ts
index 67a284c0f1..a81c4c9032 100644
--- a/src/json-crdt/__tests__/guide/2-ModelApi.spec.ts
+++ b/src/json-crdt/__tests__/guide/2-ModelApi.spec.ts
@@ -5,31 +5,31 @@ import {Model} from '../..';
const doc = Model.withLogicalClock();
test('can edit document using JSON Patch operations', () => {
- console.log(doc.view());
- console.log(doc.toString());
+ // console.log(doc.view());
+ // console.log(doc.toString());
doc.api.root({foo: 'bar'});
- console.log(doc.view());
- console.log(doc.toString());
+ // console.log(doc.view());
+ // console.log(doc.toString());
doc.api.str('/foo').ins(3, '!');
doc.api.str(['foo']).ins(4, ' baz!');
- console.log(doc.view());
- console.log(doc.toString());
+ // console.log(doc.view());
+ // console.log(doc.toString());
doc.api.str('/foo').ins(5, 'qux! ');
- console.log(doc.view());
- console.log(doc.toString());
+ // console.log(doc.view());
+ // console.log(doc.toString());
doc.api.obj('').set({
list: [{title: 'To the dishes!'}, {title: 'Write more tests!'}],
});
- console.log(doc.view());
- console.log(doc.toString());
+ // console.log(doc.view());
+ // console.log(doc.toString());
expect(doc.view()).toStrictEqual({
foo: 'bar! qux! baz!',
diff --git a/src/json-crdt/__tests__/guide/3-PatchBuilder.spec.ts b/src/json-crdt/__tests__/guide/3-PatchBuilder.spec.ts
index 9516428997..b7445b27c6 100644
--- a/src/json-crdt/__tests__/guide/3-PatchBuilder.spec.ts
+++ b/src/json-crdt/__tests__/guide/3-PatchBuilder.spec.ts
@@ -7,8 +7,8 @@ const doc = Model.withLogicalClock();
const builder = doc.api.builder;
test('can edit document using JSON Patch operations', () => {
- console.log(doc.view());
- console.log(doc.toString());
+ // console.log(doc.view());
+ // console.log(doc.toString());
const obj = builder.obj();
const str = builder.str();
@@ -17,28 +17,28 @@ test('can edit document using JSON Patch operations', () => {
builder.root(obj);
doc.api.apply();
- console.log(doc.view());
- console.log(doc.toString());
+ // console.log(doc.view());
+ // console.log(doc.toString());
const insert2 = builder.insStr(str, tick(insert1, 2), '!');
doc.api.apply();
const insert3 = builder.insStr(str, insert2, ' baz!');
doc.api.apply();
- console.log(doc.view());
- console.log(doc.toString());
+ // console.log(doc.view());
+ // console.log(doc.toString());
const insert4 = builder.insStr(str, insert3, 'qux! ');
doc.api.apply();
- console.log(doc.view());
- console.log(doc.toString());
+ // console.log(doc.view());
+ // console.log(doc.toString());
builder.insObj(obj, [['list', builder.json([{title: 'To the dishes!'}, {title: 'Write more tests!'}])]]);
doc.api.apply();
- console.log(doc.view());
- console.log(doc.toString());
+ // console.log(doc.view());
+ // console.log(doc.toString());
expect(doc.view()).toStrictEqual({
foo: 'bar! qux! baz!',
diff --git a/src/json-crdt/__tests__/guide/4-Patch.spec.ts b/src/json-crdt/__tests__/guide/4-Patch.spec.ts
index adea47f6e5..697df567aa 100644
--- a/src/json-crdt/__tests__/guide/4-Patch.spec.ts
+++ b/src/json-crdt/__tests__/guide/4-Patch.spec.ts
@@ -12,8 +12,8 @@ const clock = doc.clock;
const patch = new Patch();
test('can edit document using JSON Patch operations', () => {
- console.log(doc.view());
- console.log(doc.toString());
+ // console.log(doc.view());
+ // console.log(doc.toString());
const obj = clock.tick(1);
patch.ops.push(new NewObjOp(obj));
@@ -32,8 +32,8 @@ test('can edit document using JSON Patch operations', () => {
doc.applyPatch(patch);
- console.log(doc.view());
- console.log(doc.toString());
+ // console.log(doc.view());
+ // console.log(doc.toString());
const insert2 = clock.tick(1);
patch.ops.push(new InsStrOp(insert2, str, tick(insert1, 2), '!'));
@@ -43,16 +43,16 @@ test('can edit document using JSON Patch operations', () => {
doc.applyPatch(patch);
- console.log(doc.view());
- console.log(doc.toString());
+ // console.log(doc.view());
+ // console.log(doc.toString());
const insert4 = clock.tick(5);
patch.ops.push(new InsStrOp(insert4, str, insert3, 'qux! '));
doc.applyPatch(patch);
- console.log(doc.view());
- console.log(doc.toString());
+ // console.log(doc.view());
+ // console.log(doc.toString());
const builder = new PatchBuilder(clock);
const list = builder.json([{title: 'To the dishes!'}, {title: 'Write more tests!'}]);
@@ -63,8 +63,8 @@ test('can edit document using JSON Patch operations', () => {
doc.applyPatch(patch);
- console.log(doc.view());
- console.log(doc.toString());
+ // console.log(doc.view());
+ // console.log(doc.toString());
expect(doc.view()).toStrictEqual({
foo: 'bar! qux! baz!',
diff --git a/src/json-crdt/nodes/rga/util.ts b/src/json-crdt/nodes/rga/util.ts
new file mode 100644
index 0000000000..5aa4315859
--- /dev/null
+++ b/src/json-crdt/nodes/rga/util.ts
@@ -0,0 +1,8 @@
+import type {AbstractRga, Chunk} from './AbstractRga';
+
+/** Find the first visible chunk, if any. */
+export const firstVis = (rga: AbstractRga): Chunk | undefined => {
+ let curr = rga.first();
+ while (curr && curr.del) curr = rga.next(curr);
+ return curr;
+};
diff --git a/yarn.lock b/yarn.lock
index c63e597a4d..376daa94d5 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2203,6 +2203,11 @@ tree-dump@^1.0.0:
resolved "https://registry.yarnpkg.com/tree-dump/-/tree-dump-1.0.0.tgz#bd5fdece2b36d888ae0d1cf316e653af3de656ea"
integrity sha512-gDLjiHO2JTBf8JtRNCq/tUYZMdI5EFOA3UKWZJddwqVxRjC8jj/tI/pJEocV0hPtJeztEcF2RqufJZYbF/rKEw==
+tree-dump@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/tree-dump/-/tree-dump-1.0.1.tgz#b448758da7495580e6b7830d6b7834fca4c45b96"
+ integrity sha512-WCkcRBVPSlHHq1dc/px9iOfqklvzCbdRwvlNfxGZsrHqf6aZttfPrd7DJTt6oR10dwUfpFFQeVTkPbBIZxX/YA==
+
ts-jest@^29.1.2:
version "29.1.2"
resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-29.1.2.tgz#7613d8c81c43c8cb312c6904027257e814c40e09"