diff --git a/src/json-crdt-extensions/peritext/__tests__/Peritext.overlay.spec.ts b/src/json-crdt-extensions/peritext/__tests__/Peritext.overlay.spec.ts
index eaa22f49bd..4c0e8018d3 100644
--- a/src/json-crdt-extensions/peritext/__tests__/Peritext.overlay.spec.ts
+++ b/src/json-crdt-extensions/peritext/__tests__/Peritext.overlay.spec.ts
@@ -19,15 +19,15 @@ const setup = () => {
test('can insert markers', () => {
const {peritext} = setup();
const {editor} = peritext;
- expect([...peritext.overlay].length).toBe(0);
+ expect([...peritext.overlay.points()].length).toBe(0);
editor.cursor.setAt(0);
peritext.refresh();
- expect([...peritext.overlay].length).toBe(1);
- editor.insMarker(['p'], '
');
+ expect([...peritext.overlay.points()].length).toBe(1);
+ editor.saved.insMarker(['p'], '
');
peritext.refresh();
expect(size(peritext.overlay.root)).toBe(2);
editor.cursor.setAt(9);
- editor.insMarker(['p'], '
');
+ editor.saved.insMarker(['p'], '
');
peritext.refresh();
expect(size(peritext.overlay.root)).toBe(3);
});
@@ -37,15 +37,15 @@ test('can insert slices', () => {
const {editor} = peritext;
expect(size(peritext.overlay.root)).toBe(0);
editor.cursor.setAt(2, 2);
- editor.insStackSlice('bold');
+ editor.saved.insStack('bold');
peritext.refresh();
expect(size(peritext.overlay.root)).toBe(2);
editor.cursor.setAt(6, 5);
- editor.insStackSlice('italic');
+ editor.extra.insStack('italic');
peritext.refresh();
expect(size(peritext.overlay.root)).toBe(4);
editor.cursor.setAt(0, 5);
- editor.insStackSlice('underline');
+ editor.local.insStack('underline');
peritext.refresh();
expect(size(peritext.overlay.root)).toBe(6);
});
diff --git a/src/json-crdt-extensions/peritext/__tests__/setup.ts b/src/json-crdt-extensions/peritext/__tests__/setup.ts
index 81f4a09713..267e8f91a2 100644
--- a/src/json-crdt-extensions/peritext/__tests__/setup.ts
+++ b/src/json-crdt-extensions/peritext/__tests__/setup.ts
@@ -1,41 +1,22 @@
import {s} from '../../../json-crdt-patch';
+import {Model} from '../../../json-crdt/model';
+import {SchemaToJsonNode} from '../../../json-crdt/schema/types';
import {ModelWithExt, ext} from '../../ModelWithExt';
-/**
- * Creates a Peritext instance with text "0123456789", with single-char and
- * block-wise chunks, as well as with plenty of tombstones.
- */
-export const setupNumbersWithTombstones = () => {
- const schema = s.obj({
- text: ext.peritext.new('1234'),
+export type Schema = ReturnType;
+export type Kit = ReturnType;
+
+const schema = (text: string) =>
+ s.obj({
+ text: ext.peritext.new(text),
});
- const model = ModelWithExt.create(schema);
- const str = model.s.text.toExt().text();
- str.ins(1, '234');
- str.ins(2, '345');
- str.ins(3, '456');
- str.ins(4, '567');
- str.ins(5, '678');
- str.ins(6, '789');
- str.del(7, 1);
- str.del(8, 1);
- str.ins(0, '0');
- str.del(1, 4);
- str.del(2, 1);
- str.ins(1, '1');
- str.del(0, 1);
- str.ins(0, '0');
- str.ins(2, '234');
- str.del(4, 7);
- str.del(4, 2);
- str.del(7, 3);
- str.ins(6, '6789');
- str.del(7, 2);
- str.ins(7, '78');
- str.del(10, 2);
- str.del(2, 3);
- str.ins(2, '234');
- if (str.view() !== '0123456789') throw new Error('Invalid text');
+
+export const setupKit = (
+ initialText: string = '',
+ edits: (model: Model>) => void = () => {},
+) => {
+ const model = ModelWithExt.create(schema(initialText));
+ edits(model);
const api = model.api;
const peritextApi = model.s.text.toExt();
const peritext = peritextApi.txt;
@@ -49,3 +30,70 @@ export const setupNumbersWithTombstones = () => {
editor,
};
};
+
+export const setupHelloWorldKit = (): Kit => {
+ return setupKit('', (model) => {
+ const str = model.s.text.toExt().text();
+ str.ins(0, 'hello world');
+ if (str.view() !== 'hello world') throw new Error('Invalid text');
+ });
+};
+
+export const setupHelloWorldWithFewEditsKit = (): Kit => {
+ return setupKit('', (model) => {
+ const str = model.s.text.toExt().text();
+ str.ins(0, 'wworld');
+ str.ins(0, 'helo ');
+ str.ins(2, 'l');
+ str.del(7, 1);
+ if (str.view() !== 'hello world') throw new Error('Invalid text');
+ });
+};
+
+/**
+ * Creates a Peritext instance with text "0123456789", no edits.
+ */
+export const setupNumbersKit = (): Kit => {
+ return setupKit('', (model) => {
+ const str = model.s.text.toExt().text();
+ str.ins(0, '0123456789');
+ if (str.view() !== '0123456789') throw new Error('Invalid text');
+ });
+};
+
+/**
+ * Creates a Peritext instance with text "0123456789", with single-char and
+ * block-wise chunks, as well as with plenty of tombstones.
+ */
+export const setupNumbersWithTombstonesKit = (): Kit => {
+ return setupKit('1234', (model) => {
+ const str = model.s.text.toExt().text();
+ str.ins(0, '234');
+ str.ins(1, '234');
+ str.ins(2, '345');
+ str.ins(3, '456');
+ str.ins(4, '567');
+ str.ins(5, '678');
+ str.ins(6, '789');
+ str.del(7, 1);
+ str.del(8, 1);
+ str.ins(0, '0');
+ str.del(1, 4);
+ str.del(2, 1);
+ str.ins(1, '1');
+ str.del(0, 1);
+ str.ins(0, '0');
+ str.ins(2, '234');
+ str.del(4, 7);
+ str.del(4, 2);
+ str.del(7, 3);
+ str.ins(6, '6789');
+ str.del(7, 2);
+ str.ins(7, '78');
+ str.del(10, 2);
+ str.del(2, 3);
+ str.ins(2, '234');
+ str.del(10, 3);
+ if (str.view() !== '0123456789') throw new Error('Invalid text');
+ });
+};
diff --git a/src/json-crdt-extensions/peritext/editor/Editor.ts b/src/json-crdt-extensions/peritext/editor/Editor.ts
index c66d6ac473..46da44f653 100644
--- a/src/json-crdt-extensions/peritext/editor/Editor.ts
+++ b/src/json-crdt-extensions/peritext/editor/Editor.ts
@@ -10,9 +10,13 @@ import type {MarkerSlice} from '../slice/MarkerSlice';
export class Editor {
public readonly saved: EditorSlices;
+ public readonly extra: EditorSlices;
+ public readonly local: EditorSlices;
constructor(public readonly txt: Peritext) {
this.saved = new EditorSlices(txt, txt.savedSlices);
+ this.extra = new EditorSlices(txt, txt.extraSlices);
+ this.local = new EditorSlices(txt, txt.localSlices);
}
public firstCursor(): Cursor | undefined {
diff --git a/src/json-crdt-extensions/peritext/overlay/Overlay.ts b/src/json-crdt-extensions/peritext/overlay/Overlay.ts
index e56f4c5d6f..016c6e7896 100644
--- a/src/json-crdt-extensions/peritext/overlay/Overlay.ts
+++ b/src/json-crdt-extensions/peritext/overlay/Overlay.ts
@@ -7,17 +7,18 @@ import {Point} from '../rga/Point';
import {OverlayPoint} from './OverlayPoint';
import {MarkerOverlayPoint} from './MarkerOverlayPoint';
import {OverlayRefSliceEnd, OverlayRefSliceStart} from './refs';
-import {compare, ITimestampStruct, tick} from '../../../json-crdt-patch/clock';
+import {compare, ITimestampStruct} from '../../../json-crdt-patch/clock';
import {CONST, updateNum} from '../../../json-hash';
import {MarkerSlice} from '../slice/MarkerSlice';
import {Range} from '../rga/Range';
-import {UndefEndIter} from '../../../util/iterator';
+import {UndefEndIter, UndefIterator} from '../../../util/iterator';
import type {Chunk} from '../../../json-crdt/nodes/rga';
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';
+import type {OverlayPair, OverlayTuple} from './types';
/**
* Overlay is a tree structure that represents all the intersections of slices
@@ -28,10 +29,17 @@ import type {Slices} from '../slice/Slices';
*/
export class Overlay implements Printable, Stateful {
public root: OverlayPoint | undefined = undefined;
- public readonly start: OverlayPoint;
+
+ /** A virtual absolute start point, used when the absolute start is missing. */
+ public readonly START: OverlayPoint;
+
+ /** A virtual absolute end point, used when the absolute end is missing. */
+ public readonly END: OverlayPoint;
constructor(protected readonly txt: Peritext) {
- this.start = this.point(this.txt.str.id, Anchor.After);
+ const id = txt.str.id;
+ this.START = this.point(id, Anchor.After);
+ this.END = this.point(id, Anchor.Before);
}
private point(id: ITimestampStruct, anchor: Anchor): OverlayPoint {
@@ -50,39 +58,6 @@ export class Overlay implements Printable, Stateful {
return this.root ? last(this.root) : undefined;
}
- public iterator(): () => OverlayPoint | undefined {
- let curr = this.first();
- return () => {
- const ret = curr;
- if (curr) curr = next(curr);
- return ret;
- };
- }
-
- public entries(): IterableIterator> {
- return new UndefEndIter(this.iterator());
- }
-
- [Symbol.iterator]() {
- return this.entries();
- }
-
- public markerIterator(): () => MarkerOverlayPoint | undefined {
- let curr = this.first();
- return () => {
- while (curr) {
- const ret = curr;
- if (curr) curr = next(curr);
- if (ret instanceof MarkerOverlayPoint) return ret;
- }
- return;
- };
- }
-
- public markers(): IterableIterator> {
- return new UndefEndIter(this.iterator());
- }
-
/**
* Retrieve overlay point or the previous one, measured in spacial dimension.
*/
@@ -191,67 +166,83 @@ export class Overlay implements Printable, Stateful {
}) as Chunk;
}
- public points0(
- start: undefined | OverlayPoint,
- end: undefined | ((next: OverlayPoint) => boolean),
- callback: (point: OverlayPoint) => void,
- ): void {
- const txt = this.txt;
- const str = txt.str;
- const strFirstChunk = str.first();
- if (!strFirstChunk) return;
- let point = start || this.first();
- let prev: typeof point;
- const pointIsStart =
- point &&
- ((!compare(point.id, str.id) && point.anchor === Anchor.After) ||
- (!compare(strFirstChunk.id, point.id) && point.anchor === Anchor.Before));
- if (!start && !pointIsStart) {
- const startPoint = this.start;
- startPoint.id = strFirstChunk.id;
- startPoint.anchor = Anchor.Before;
- callback(startPoint);
- }
- while (point) {
- if (end && end(point)) return;
- callback(point);
- prev = point;
- point = next(point);
- }
- const strLastChunk = str.last()!;
- const strLastChunkId = strLastChunk.id;
- if (prev) {
- const prevId = prev.id;
- if (
- prev.anchor === Anchor.After &&
- prevId.time === strLastChunkId.time + strLastChunk.span - 1 &&
- prevId.sid === strLastChunkId.sid &&
- prevId.sid === strLastChunkId.sid
- )
- return;
- }
- const endId = strLastChunk.span > 1 ? tick(strLastChunkId, strLastChunk.span - 1) : strLastChunkId;
- const ending = this.point(endId!, Anchor.After);
- if (end && end(ending)) return;
- callback(ending);
+ public points0(after: undefined | OverlayPoint): UndefIterator> {
+ let curr = after ? next(after) : this.first();
+ return () => {
+ const ret = curr;
+ if (curr) curr = next(curr);
+ return ret;
+ };
}
- public points1(
- start: undefined | OverlayPoint,
- end: undefined | ((next: OverlayPoint) => boolean),
- callback: (p1: OverlayPoint, p2: OverlayPoint) => void,
- ): void {
+ public points(after?: undefined | OverlayPoint): IterableIterator> {
+ return new UndefEndIter(this.points0(after));
+ }
+
+ public markers0(): UndefIterator> {
+ let curr = this.first();
+ return () => {
+ while (curr) {
+ const ret = curr;
+ if (curr) curr = next(curr);
+ if (ret instanceof MarkerOverlayPoint) return ret;
+ }
+ return;
+ };
+ }
+
+ public markers(): IterableIterator> {
+ return new UndefEndIter(this.markers0());
+ }
+
+ public pairs0(after: undefined | OverlayPoint): UndefIterator> {
+ const isEmpty = !this.root;
+ if (isEmpty) {
+ const u = undefined;
+ let closed = false;
+ return () => (closed ? u : ((closed = true), [u, u]));
+ }
let p1: OverlayPoint | undefined;
- let p2: OverlayPoint | undefined;
- this.points0(start, end, (point) => {
- if (p1) {
- p2 = point;
- callback(p1, p2);
+ let p2: OverlayPoint | undefined = after;
+ const iterator = this.points0(after);
+ return () => {
+ const next = iterator();
+ const isEnd = !next;
+ if (isEnd) {
+ if (!p2 || p2.isAbsEnd()) return;
p1 = p2;
- } else {
- p1 = point;
+ p2 = undefined;
+ return [p1, p2];
+ }
+ p1 = p2;
+ p2 = next;
+ if (!p1) {
+ if (p2 && p2.isAbsStart()) {
+ p1 = p2;
+ p2 = iterator();
+ }
}
- });
+ return p1 || p2 ? [p1, p2] : undefined;
+ };
+ }
+
+ public pairs(after?: undefined | OverlayPoint): IterableIterator> {
+ return new UndefEndIter(this.pairs0(after));
+ }
+
+ public tuples0(after: undefined | OverlayPoint): UndefIterator> {
+ const iterator = this.pairs0(after);
+ return () => {
+ const pair = iterator();
+ if (!pair) return;
+ if (pair[0] === undefined) pair[0] = this.START;
+ if (pair[1] === undefined) pair[1] = this.END;
+ return pair as OverlayTuple;
+ };
+ }
+
+ public tuples(after?: undefined | OverlayPoint): IterableIterator> {
+ return new UndefEndIter(this.tuples0(after));
}
public findContained(range: Range): Set> {
@@ -290,47 +281,6 @@ export class Overlay implements Printable, Stateful {
return result;
}
- public leadingTextHash: number = 0;
-
- protected computeSplitTextHashes(): void {
- const txt = this.txt;
- const str = txt.str;
- const firstChunk = str.first();
- if (!firstChunk) return;
- let chunk: Chunk | undefined = firstChunk;
- let marker: MarkerOverlayPoint | undefined = undefined;
- let state: number = CONST.START_STATE;
- this.points1(undefined, undefined, (p1, p2) => {
- // TODO: need to incorporate slice attribute hash here?
- const id1 = p1.id;
- state = (state << 5) + state + (id1.sid >>> 0) + id1.time;
- let overlayPointHash = CONST.START_STATE;
- chunk = this.chunkSlices0(chunk || firstChunk, p1, p2, (chunk, off, len) => {
- const id = chunk.id;
- overlayPointHash =
- (overlayPointHash << 5) + overlayPointHash + ((((id.sid >>> 0) + id.time) << 8) + (off << 4) + len);
- });
- state = updateNum(state, overlayPointHash);
- if (p1) {
- p1.hash = overlayPointHash;
- }
- if (p2 instanceof MarkerOverlayPoint) {
- if (marker) {
- marker.textHash = state;
- } else {
- this.leadingTextHash = state;
- }
- state = CONST.START_STATE;
- marker = p2;
- }
- });
- if ((marker as any) instanceof MarkerOverlayPoint) {
- (marker as any as MarkerOverlayPoint).textHash = state;
- } else {
- this.leadingTextHash = state;
- }
- }
-
public isBlockSplit(id: ITimestampStruct): boolean {
const point = this.txt.point(id, Anchor.Before);
const overlayPoint = this.getOrNextLower(point);
@@ -477,6 +427,49 @@ export class Overlay implements Printable, Stateful {
this.root = remove(this.root, point);
}
+ public leadingTextHash: number = 0;
+
+ protected computeSplitTextHashes(): void {
+ const txt = this.txt;
+ const str = txt.str;
+ const firstChunk = str.first();
+ if (!firstChunk) return;
+ let chunk: Chunk | undefined = firstChunk;
+ let marker: MarkerOverlayPoint | undefined = undefined;
+ let state: number = CONST.START_STATE;
+ const i = this.tuples0(undefined);
+ for (let pair = i(); pair; pair = i()) {
+ const [p1, p2] = pair;
+ // TODO: need to incorporate slice attribute hash here?
+ const id1 = p1.id;
+ state = (state << 5) + state + (id1.sid >>> 0) + id1.time;
+ let overlayPointHash = CONST.START_STATE;
+ chunk = this.chunkSlices0(chunk || firstChunk, p1, p2, (chunk, off, len) => {
+ const id = chunk.id;
+ overlayPointHash =
+ (overlayPointHash << 5) + overlayPointHash + ((((id.sid >>> 0) + id.time) << 8) + (off << 4) + len);
+ });
+ state = updateNum(state, overlayPointHash);
+ if (p1) {
+ p1.hash = overlayPointHash;
+ }
+ if (p2 instanceof MarkerOverlayPoint) {
+ if (marker) {
+ marker.textHash = state;
+ } else {
+ this.leadingTextHash = state;
+ }
+ state = CONST.START_STATE;
+ marker = p2;
+ }
+ }
+ if ((marker as any) instanceof MarkerOverlayPoint) {
+ (marker as any as MarkerOverlayPoint).textHash = state;
+ } else {
+ this.leadingTextHash = state;
+ }
+ }
+
// ---------------------------------------------------------------- Printable
public toString(tab: string = ''): string {
diff --git a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.chunkSlices0.spec.ts b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.chunkSlices0.spec.ts
new file mode 100644
index 0000000000..c1b498d474
--- /dev/null
+++ b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.chunkSlices0.spec.ts
@@ -0,0 +1,232 @@
+import {tick} from '../../../../json-crdt-patch/clock';
+import {Model} from '../../../../json-crdt/model';
+import {Peritext} from '../../Peritext';
+import {Point} from '../../rga/Point';
+import {Anchor} from '../../rga/constants';
+import {setupNumbersWithTombstonesKit} from '../../__tests__/setup';
+import type {Chunk} from '../../../../json-crdt/nodes/rga';
+
+const setup = () => {
+ const model = Model.withLogicalClock();
+ const api = model.api;
+ api.root({
+ text: '',
+ slices: [],
+ });
+ api.str(['text']).ins(0, 'wworld');
+ api.str(['text']).ins(0, 'helo ');
+ api.str(['text']).ins(2, 'l');
+ api.str(['text']).del(7, 1);
+ const peritext = new Peritext(model, api.str(['text']).node, api.arr(['slices']).node);
+ const {overlay} = peritext;
+ return {model, peritext, overlay};
+};
+
+describe('.chunkSlices0()', () => {
+ test('can iterate through all chunk chunks', () => {
+ const {peritext, overlay} = setup();
+ const chunk1 = peritext.str.first();
+ const chunk2 = peritext.str.last();
+ let str = '';
+ const point1 = peritext.point(chunk1!.id, Anchor.Before);
+ const point2 = peritext.point(tick(chunk2!.id, chunk2!.span - 1), Anchor.After);
+ overlay.chunkSlices0(undefined, point1, point2, (chunk, off, len) => {
+ str += (chunk.data as string).slice(off, off + len);
+ });
+ expect(str).toBe('hello world');
+ });
+
+ test('can skip first character using "After" anchor attachment', () => {
+ const {peritext, overlay} = setup();
+ const chunk1 = peritext.str.first();
+ const chunk2 = peritext.str.last();
+ let str = '';
+ const point1 = peritext.point(chunk1!.id, Anchor.After);
+ const point2 = peritext.point(tick(chunk2!.id, chunk2!.span - 1), Anchor.After);
+ overlay.chunkSlices0(undefined, point1, point2, (chunk, off, len) => {
+ str += (chunk.data as string).slice(off, off + len);
+ });
+ expect(str).toBe('ello world');
+ });
+
+ test('can skip last character using "Before" anchor attachment', () => {
+ const {peritext, overlay} = setup();
+ const chunk1 = peritext.str.first();
+ const chunk2 = peritext.str.last();
+ let str = '';
+ const point1 = peritext.point(chunk1!.id, Anchor.After);
+ const point2 = peritext.point(tick(chunk2!.id, chunk2!.span - 1), Anchor.Before);
+ overlay.chunkSlices0(undefined, point1, point2, (chunk, off, len) => {
+ str += (chunk.data as string).slice(off, off + len);
+ });
+ expect(str).toBe('ello worl');
+ });
+
+ test('can skip first chunk, by anchoring to the end of it', () => {
+ const {peritext, overlay} = setup();
+ const chunk1 = peritext.str.first();
+ const chunk2 = peritext.str.last();
+ let str = '';
+ const endOfFirstChunk = peritext.point(tick(chunk1!.id, chunk1!.span - 1), Anchor.After);
+ const point2 = peritext.point(tick(chunk2!.id, chunk2!.span - 1), Anchor.After);
+ overlay.chunkSlices0(undefined, endOfFirstChunk, point2, (chunk, off, len) => {
+ str += (chunk.data as string).slice(off, off + len);
+ });
+ expect(str).toBe(peritext.strApi().view().slice(chunk1!.span));
+ });
+
+ test('can skip first chunk, by anchoring to the beginning of second chunk', () => {
+ const {peritext, overlay} = setup();
+ const firstChunk = peritext.str.first()!;
+ const secondChunk = peritext.str.next(firstChunk)!;
+ const lastChunk = peritext.str.last()!;
+ let str = '';
+ const startOfChunkTwo = peritext.point(secondChunk.id, Anchor.Before);
+ const point2 = peritext.point(tick(lastChunk!.id, lastChunk!.span - 1), Anchor.After);
+ overlay.chunkSlices0(undefined, startOfChunkTwo, point2, (chunk, off, len) => {
+ str += (chunk.data as string).slice(off, off + len);
+ });
+ expect(str).toBe(peritext.strApi().view().slice(firstChunk.span));
+ });
+
+ test('can skip one character of the second chunk', () => {
+ const {peritext, overlay} = setup();
+ const firstChunk = peritext.str.first()!;
+ const secondChunk = peritext.str.next(firstChunk)!;
+ const lastChunk = peritext.str.last()!;
+ let str = '';
+ const startOfChunkTwo = peritext.point(secondChunk.id, Anchor.After);
+ const point2 = peritext.point(tick(lastChunk!.id, lastChunk!.span - 1), Anchor.After);
+ overlay.chunkSlices0(undefined, startOfChunkTwo, point2, (chunk, off, len) => {
+ str += (chunk.data as string).slice(off, off + len);
+ });
+ expect(str).toBe(
+ peritext
+ .strApi()
+ .view()
+ .slice(firstChunk.span + 1),
+ );
+ });
+
+ const testAllPossibleChunkPointCombinations = (peritext: Peritext) => {
+ test('can generate slices for all possible chunk point combinations', () => {
+ const overlay = peritext.overlay;
+ let chunk1 = peritext.str.first();
+ const getText = (p1: Point, p2: Point) => {
+ let str = '';
+ overlay.chunkSlices0(undefined, p1, p2, (chunk, off, len) => {
+ str += (chunk.data as string).slice(off, off + len);
+ });
+ return str;
+ };
+ while (chunk1) {
+ if (chunk1.del) {
+ chunk1 = peritext.str.next(chunk1);
+ continue;
+ }
+ let chunk2: Chunk | undefined = chunk1;
+ while (chunk2) {
+ if (chunk2.del) {
+ chunk2 = peritext.str.next(chunk2);
+ continue;
+ }
+ if (chunk1 === chunk2) {
+ for (let i = 0; i < chunk1.span; i++) {
+ for (let j = i; j < chunk1.span; j++) {
+ let point1 = peritext.point(tick(chunk1.id, i), Anchor.Before);
+ let point2 = peritext.point(tick(chunk1.id, j), Anchor.After);
+ let str1 = getText(point1, point2);
+ let str2 = (chunk1.data as string).slice(i, j + 1);
+ expect(str1).toBe(str2);
+ point1 = peritext.point(tick(chunk1.id, i), Anchor.Before);
+ point2 = peritext.point(tick(chunk1.id, j), Anchor.Before);
+ str1 = getText(point1, point2);
+ str2 = (chunk1.data as string).slice(i, j);
+ expect(str1).toBe(str2);
+ point1 = peritext.point(tick(chunk1.id, i), Anchor.After);
+ point2 = peritext.point(tick(chunk1.id, j), Anchor.After);
+ str1 = getText(point1, point2);
+ str2 = (chunk1.data as string).slice(i + 1, j + 1);
+ expect(str1).toBe(str2);
+ point1 = peritext.point(tick(chunk1.id, i), Anchor.After);
+ point2 = peritext.point(tick(chunk1.id, j), Anchor.Before);
+ str1 = getText(point1, point2);
+ str2 = i >= j ? '' : (chunk1.data as string).slice(i + 1, j);
+ expect(str1).toBe(str2);
+ }
+ }
+ } else {
+ for (let i = 0; i < chunk1.span; i++) {
+ for (let j = 0; j < chunk2.span; j++) {
+ let point1 = peritext.point(tick(chunk1.id, i), Anchor.Before);
+ let point2 = peritext.point(tick(chunk2.id, j), Anchor.After);
+ let str1 = getText(point1, point2);
+ let str2 = chunk1.data!.slice(i);
+ let chunk3 = peritext.str.next(chunk1);
+ while (chunk3 && chunk3 !== chunk2) {
+ if (!chunk3.del) {
+ str2 += chunk3.data!;
+ }
+ chunk3 = peritext.str.next(chunk3);
+ }
+ str2 += chunk2.data!.slice(0, j + 1);
+ expect(str1).toBe(str2);
+ point1 = peritext.point(tick(chunk1.id, i), Anchor.Before);
+ point2 = peritext.point(tick(chunk2.id, j), Anchor.Before);
+ str1 = getText(point1, point2);
+ str2 = chunk1.data!.slice(i);
+ chunk3 = peritext.str.next(chunk1);
+ while (chunk3 && chunk3 !== chunk2) {
+ if (!chunk3.del) {
+ str2 += chunk3.data!;
+ }
+ chunk3 = peritext.str.next(chunk3);
+ }
+ str2 += chunk2.data!.slice(0, j);
+ expect(str1).toBe(str2);
+ point1 = peritext.point(tick(chunk1.id, i), Anchor.After);
+ point2 = peritext.point(tick(chunk2.id, j), Anchor.Before);
+ str1 = getText(point1, point2);
+ str2 = chunk1.data!.slice(i + 1);
+ chunk3 = peritext.str.next(chunk1);
+ while (chunk3 && chunk3 !== chunk2) {
+ if (!chunk3.del) {
+ str2 += chunk3.data!;
+ }
+ chunk3 = peritext.str.next(chunk3);
+ }
+ str2 += chunk2.data!.slice(0, j);
+ expect(str1).toBe(str2);
+ point1 = peritext.point(tick(chunk1.id, i), Anchor.After);
+ point2 = peritext.point(tick(chunk2.id, j), Anchor.After);
+ str1 = getText(point1, point2);
+ str2 = chunk1.data!.slice(i + 1);
+ chunk3 = peritext.str.next(chunk1);
+ while (chunk3 && chunk3 !== chunk2) {
+ if (!chunk3.del) {
+ str2 += chunk3.data!;
+ }
+ chunk3 = peritext.str.next(chunk3);
+ }
+ str2 += chunk2.data!.slice(0, j + 1);
+ expect(str1).toBe(str2);
+ }
+ }
+ }
+ chunk2 = peritext.str.next(chunk2);
+ }
+ chunk1 = peritext.str.next(chunk1);
+ }
+ });
+ };
+
+ describe('with hello world text', () => {
+ const {peritext} = setup();
+ testAllPossibleChunkPointCombinations(peritext);
+ });
+
+ describe('with "integer list" text', () => {
+ const {peritext} = setupNumbersWithTombstonesKit();
+ testAllPossibleChunkPointCombinations(peritext);
+ });
+});
diff --git a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.getOrNextLH.spec.ts b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.getOrNextLH.spec.ts
index 6e779ecf56..4300f963da 100644
--- a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.getOrNextLH.spec.ts
+++ b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.getOrNextLH.spec.ts
@@ -2,7 +2,7 @@ import {Model} from '../../../../json-crdt/model';
import {size} from 'sonic-forest/lib/util';
import {Peritext} from '../../Peritext';
import {Anchor} from '../../rga/constants';
-import {setupNumbersWithTombstones} from '../../__tests__/setup';
+import {setupNumbersWithTombstonesKit} from '../../__tests__/setup';
import {OverlayPoint} from '../OverlayPoint';
import {OverlayRefSliceEnd, OverlayRefSliceStart} from '../refs';
@@ -94,7 +94,7 @@ describe('.getOrNextLower()', () => {
describe('when all text selected, using relative range', () => {
test('can select the starting point', () => {
- const {peritext, editor} = setupNumbersWithTombstones();
+ const {peritext, editor} = setupNumbersWithTombstonesKit();
const range = peritext.range(peritext.pointStart()!, peritext.pointEnd()!);
editor.cursor.setRange(range);
peritext.refresh();
@@ -107,7 +107,7 @@ describe('.getOrNextLower()', () => {
});
test('can select the ending point', () => {
- const {peritext, editor} = setupNumbersWithTombstones();
+ const {peritext, editor} = setupNumbersWithTombstonesKit();
const range = peritext.range(peritext.pointStart()!, peritext.pointEnd()!);
editor.cursor.setRange(range);
peritext.refresh();
@@ -120,7 +120,7 @@ describe('.getOrNextLower()', () => {
describe('when all text selected, using absolute range', () => {
test('can select the starting point', () => {
- const {peritext, editor} = setupNumbersWithTombstones();
+ const {peritext, editor} = setupNumbersWithTombstonesKit();
const range = peritext.range(peritext.pointAbsStart(), peritext.pointAbsEnd());
editor.cursor.setRange(range);
peritext.refresh();
@@ -133,7 +133,7 @@ describe('.getOrNextLower()', () => {
});
test('can select the end point', () => {
- const {peritext, editor} = setupNumbersWithTombstones();
+ const {peritext, editor} = setupNumbersWithTombstonesKit();
const range = peritext.range(peritext.pointAbsStart(), peritext.pointAbsEnd());
editor.cursor.setRange(range);
peritext.refresh();
@@ -189,7 +189,7 @@ describe('.getOrNextHigher()', () => {
describe('when all text selected, using relative range', () => {
test('can select the ending point', () => {
- const {peritext, editor} = setupNumbersWithTombstones();
+ const {peritext, editor} = setupNumbersWithTombstonesKit();
const range = peritext.range(peritext.pointStart()!, peritext.pointEnd()!);
editor.cursor.setRange(range);
peritext.refresh();
@@ -200,7 +200,7 @@ describe('.getOrNextHigher()', () => {
});
test('can select the start point', () => {
- const {peritext, editor} = setupNumbersWithTombstones();
+ const {peritext, editor} = setupNumbersWithTombstonesKit();
const range = peritext.range(peritext.pointStart()!, peritext.pointEnd()!);
editor.cursor.setRange(range);
peritext.refresh();
@@ -215,7 +215,7 @@ describe('.getOrNextHigher()', () => {
describe('when all text selected, using absolute range', () => {
test('can select the ending point', () => {
- const {peritext, editor} = setupNumbersWithTombstones();
+ const {peritext, editor} = setupNumbersWithTombstonesKit();
const range = peritext.range(peritext.pointAbsStart(), peritext.pointAbsEnd());
editor.cursor.setRange(range);
peritext.refresh();
@@ -226,7 +226,7 @@ describe('.getOrNextHigher()', () => {
});
test('can select the start point', () => {
- const {peritext, editor} = setupNumbersWithTombstones();
+ const {peritext, editor} = setupNumbersWithTombstonesKit();
const range = peritext.range(peritext.pointAbsStart(), peritext.pointAbsEnd());
editor.cursor.setRange(range);
peritext.refresh();
diff --git a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.pairs.spec.ts b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.pairs.spec.ts
new file mode 100644
index 0000000000..5d5addca61
--- /dev/null
+++ b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.pairs.spec.ts
@@ -0,0 +1,164 @@
+import {next} from 'sonic-forest/lib/util';
+import {Kit, setupNumbersKit, setupNumbersWithTombstonesKit} from '../../__tests__/setup';
+import {Anchor} from '../../rga/constants';
+import {MarkerOverlayPoint} from '../MarkerOverlayPoint';
+import {OverlayPoint} from '../OverlayPoint';
+
+const runPairsTests = (setup: () => Kit) => {
+ describe('.pairs() full range', () => {
+ test('returns [undef, undef] single pair for an empty overlay', () => {
+ const {peritext} = setup();
+ const overlay = peritext.overlay;
+ overlay.refresh();
+ const pairs = [...overlay.pairs()];
+ expect(pairs).toEqual([[undefined, undefined]]);
+ });
+
+ test('when caret at abs start, returns one pair', () => {
+ const {peritext} = setup();
+ const overlay = peritext.overlay;
+ peritext.editor.cursor.set(peritext.pointAbsStart());
+ overlay.refresh();
+ const pairs = [...overlay.pairs()];
+ const p1 = overlay.first()!;
+ expect(peritext.editor.cursor.start.rightChar()?.view()).toBe('0');
+ expect(pairs).toEqual([[p1, undefined]]);
+ });
+
+ test('when caret at abs end, returns one pair', () => {
+ const {peritext} = setup();
+ const overlay = peritext.overlay;
+ peritext.editor.cursor.set(peritext.pointAbsEnd());
+ overlay.refresh();
+ const pairs = [...overlay.pairs()];
+ const p1 = overlay.first()!;
+ expect(peritext.editor.cursor.start.leftChar()?.view()).toBe('9');
+ expect(pairs).toEqual([[undefined, p1]]);
+ });
+
+ test('for only caret in overlay, returns two edge pairs', () => {
+ const {peritext} = setup();
+ const overlay = peritext.overlay;
+ peritext.editor.cursor.setAt(5);
+ overlay.refresh();
+ const pairs = [...overlay.pairs()];
+ const p1 = overlay.first()!;
+ expect(peritext.editor.cursor.start.leftChar()?.view()).toBe('4');
+ expect(pairs).toEqual([
+ [undefined, p1],
+ [p1, undefined],
+ ]);
+ });
+
+ test('for a cursor selection, returns three pairs', () => {
+ const {peritext} = setup();
+ const overlay = peritext.overlay;
+ peritext.editor.cursor.setAt(3, 3);
+ overlay.refresh();
+ const pairs = [...overlay.pairs()];
+ const p1 = overlay.first()!;
+ const p2 = next(p1)!;
+ expect(peritext.editor.cursor.text()).toBe('345');
+ expect(pairs).toEqual([
+ [undefined, p1],
+ [p1, p2],
+ [p2, undefined],
+ ]);
+ });
+
+ test('for a cursor selection at abs start, returns two pairs', () => {
+ const {peritext} = setup();
+ const overlay = peritext.overlay;
+ const range = peritext.range(peritext.pointAbsStart(), peritext.pointAt(5));
+ peritext.editor.cursor.setRange(range);
+ overlay.refresh();
+ const pairs = [...overlay.pairs()];
+ const p1 = overlay.first()!;
+ const p2 = next(p1)!;
+ expect(peritext.editor.cursor.text()).toBe('01234');
+ expect(pairs).toEqual([
+ [p1, p2],
+ [p2, undefined],
+ ]);
+ });
+
+ test('for a cursor selection at abs end, returns two pairs', () => {
+ const {peritext} = setup();
+ const overlay = peritext.overlay;
+ const range = peritext.range(peritext.pointAt(5, Anchor.Before), peritext.pointAbsEnd());
+ peritext.editor.cursor.setRange(range);
+ overlay.refresh();
+ const pairs = [...overlay.pairs()];
+ const p1 = overlay.first()!;
+ const p2 = next(p1)!;
+ expect(peritext.editor.cursor.text()).toBe('56789');
+ expect(pairs).toEqual([
+ [undefined, p1],
+ [p1, p2],
+ ]);
+ });
+
+ test('for a marker and a slice after the marker, returns 4 pairs', () => {
+ const {peritext} = setup();
+ const {editor, overlay} = peritext;
+ editor.cursor.setAt(3);
+ const [marker] = editor.saved.insMarker('');
+ editor.cursor.setAt(6, 2);
+ editor.extra.insStack('');
+ overlay.refresh();
+ const p1 = overlay.first()!;
+ const p2 = next(p1)!;
+ const p3 = next(p2)!;
+ const pairs = [...overlay.pairs()];
+ expect(peritext.editor.cursor.text()).toBe('56');
+ expect(pairs).toEqual([
+ [undefined, p1],
+ [p1, p2],
+ [p2, p3],
+ [p3, undefined],
+ ]);
+ expect(p1 instanceof MarkerOverlayPoint).toBe(true);
+ expect(p2 instanceof OverlayPoint).toBe(true);
+ expect(p3 instanceof OverlayPoint).toBe(true);
+ expect((p1 as MarkerOverlayPoint).marker).toBe(marker);
+ expect(p2.layers.length).toBe(2);
+ expect(p3.layers.length).toBe(0);
+ expect(p2.refs.length).toBe(2);
+ expect(p3.refs.length).toBe(2);
+ });
+ });
+
+ describe('.pairs() at offset', () => {
+ test('in empty overlay, after caret returns the last edge', () => {
+ const {peritext} = setup();
+ const overlay = peritext.overlay;
+ peritext.editor.cursor.setAt(5);
+ overlay.refresh();
+ const first = overlay.first()!;
+ const pairs = [...overlay.pairs(first)];
+ expect(pairs).toEqual([[first, undefined]]);
+ });
+
+ test('in empty overlay, after selection start returns the selection and the edge', () => {
+ const {peritext} = setup();
+ const overlay = peritext.overlay;
+ peritext.editor.cursor.setAt(2, 4);
+ overlay.refresh();
+ const p1 = overlay.first()!;
+ const p2 = next(p1)!;
+ const list = [...overlay.pairs(p1)];
+ expect(list).toEqual([
+ [p1, p2],
+ [p2, undefined],
+ ]);
+ });
+ });
+};
+
+describe('numbers "0123456789", no edits', () => {
+ runPairsTests(setupNumbersKit);
+});
+
+describe('numbers "0123456789", with default schema and tombstones', () => {
+ runPairsTests(setupNumbersWithTombstonesKit);
+});
diff --git a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.points.spec.ts b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.points.spec.ts
new file mode 100644
index 0000000000..d341897e5a
--- /dev/null
+++ b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.points.spec.ts
@@ -0,0 +1,104 @@
+import {last, next} from 'sonic-forest/lib/util';
+import {Model} from '../../../../json-crdt/model';
+import {Peritext} from '../../Peritext';
+import type {OverlayPoint} from '../OverlayPoint';
+
+const setup = () => {
+ const model = Model.withLogicalClock();
+ const api = model.api;
+ api.root({
+ text: '',
+ slices: [],
+ });
+ api.str(['text']).ins(0, 'wworld');
+ api.str(['text']).ins(0, 'helo ');
+ api.str(['text']).ins(2, 'l');
+ api.str(['text']).del(7, 1);
+ const peritext = new Peritext(model, api.str(['text']).node, api.arr(['slices']).node);
+ const {overlay} = peritext;
+ return {model, peritext, overlay};
+};
+
+const setupWithOverlay = () => {
+ const res = setup();
+ const peritext = res.peritext;
+ peritext.editor.cursor.setAt(6);
+ peritext.editor.saved.insMarker(['p'], '¶');
+ peritext.editor.cursor.setAt(2, 2);
+ peritext.editor.saved.insStack('');
+ peritext.refresh();
+ return res;
+};
+
+describe('.points()', () => {
+ describe('with overlay', () => {
+ test('iterates through all points', () => {
+ const {peritext} = setupWithOverlay();
+ const overlay = peritext.overlay;
+ const points = [...overlay.points()];
+ expect(overlay.first()).not.toBe(undefined);
+ expect(points.length).toBe(3);
+ });
+
+ test('iterates through all points, when points anchored to the same anchor', () => {
+ const {peritext, overlay} = setupWithOverlay();
+ peritext.refresh();
+ expect([...overlay.points()].length).toBe(3);
+ peritext.editor.cursor.setAt(2, 1);
+ peritext.editor.saved.insStack('');
+ peritext.refresh();
+ expect([...overlay.points()].length).toBe(4);
+ expect(overlay.first()).not.toBe(undefined);
+ });
+
+ test('should not return virtual start point, if real start point exists', () => {
+ const {peritext, overlay} = setup();
+ peritext.editor.cursor.setAt(0);
+ peritext.editor.saved.insMarker(['p'], '¶');
+ peritext.refresh();
+ const points = [...overlay.points()];
+ expect(points.length).toBe(2);
+ expect(overlay.first()).toBe(points[0]);
+ });
+
+ test('should not return virtual end point, if real end point exists', () => {
+ const {peritext, overlay} = setup();
+ peritext.editor.cursor.setAt(0, peritext.strApi().view().length);
+ peritext.editor.saved.insStack('bold');
+ peritext.refresh();
+ const points = [...overlay.points()];
+ expect(points.length).toBe(2);
+ expect(overlay.first()).toBe(points[0]);
+ expect(last(overlay.root)).toBe(points[1]);
+ });
+
+ test('can skip points from beginning', () => {
+ const {overlay} = setupWithOverlay();
+ overlay.refresh();
+ const points1 = [...overlay.points()];
+ expect(points1.length).toBe(3);
+ const first = overlay.first()!;
+ const points2 = [...overlay.points(first)];
+ expect(points2.length).toBe(2);
+ const second = next(first)!;
+ const points3 = [...overlay.points(second)];
+ expect(points3.length).toBe(1);
+ const third = next(second);
+ const points4 = [...overlay.points(third)];
+ expect(points4.length).toBe(0);
+ });
+
+ test('can skip the last real point', () => {
+ const {overlay} = setupWithOverlay();
+ overlay.refresh();
+ expect([...overlay.points()].length).toBe(3);
+ const lastPoint = last(overlay.root!);
+ const points: OverlayPoint[] = [];
+ for (const point of overlay.points()) {
+ if (point === lastPoint) break;
+ points.push(point);
+ }
+ expect(points.length).toBe(2);
+ });
+ });
+});
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
index 94d8454aae..8b5fcbb122 100644
--- a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.refresh.spec.ts
+++ b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.refresh.spec.ts
@@ -1,6 +1,6 @@
import {Model, ObjApi} from '../../../../json-crdt/model';
import {Peritext} from '../../Peritext';
-import {setupNumbersWithTombstones} from '../../__tests__/setup';
+import {setupNumbersWithTombstonesKit} from '../../__tests__/setup';
import {Anchor} from '../../rga/constants';
import {SliceBehavior} from '../../slice/constants';
@@ -24,26 +24,26 @@ type Kit = ReturnType;
describe('Overlay.refresh()', () => {
test('can select all text using relative range', () => {
- const {peritext, editor} = setupNumbersWithTombstones();
+ const {peritext, editor} = setupNumbersWithTombstonesKit();
const overlay = peritext.overlay;
const range = peritext.range(peritext.pointStart()!, peritext.pointEnd()!);
editor.cursor.setRange(range);
peritext.refresh();
expect(editor.cursor.text()).toBe('0123456789');
- const overlayPoints = [...overlay];
+ const overlayPoints = [...overlay.points()];
expect(overlayPoints.length).toBe(2);
expect(overlayPoints[0].id.time).toBe(editor.cursor.start.id.time);
expect(overlayPoints[1].id.time).toBe(editor.cursor.end.id.time);
});
test('can select all text using absolute range', () => {
- const {peritext, editor} = setupNumbersWithTombstones();
+ const {peritext, editor} = setupNumbersWithTombstonesKit();
const overlay = peritext.overlay;
const range = peritext.range(peritext.pointAbsStart(), peritext.pointAbsEnd());
editor.cursor.setRange(range);
peritext.refresh();
expect(editor.cursor.text()).toBe('0123456789');
- const overlayPoints = [...overlay];
+ const overlayPoints = [...overlay.points()];
expect(overlayPoints.length).toBe(2);
expect(overlayPoints[0].id.time).toBe(editor.cursor.start.id.time);
expect(overlayPoints[1].id.time).toBe(editor.cursor.end.id.time);
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 32d53ea071..c6d4fe8d9b 100644
--- a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.spec.ts
+++ b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.spec.ts
@@ -21,7 +21,7 @@ const setup = () => {
const markerCount = (peritext: Peritext): number => {
const overlay = peritext.overlay;
- const iterator = overlay.markerIterator();
+ const iterator = overlay.markers0();
let count = 0;
for (let split = iterator(); split; split = iterator()) {
count++;
@@ -43,12 +43,9 @@ describe('markers', () => {
expect(markerCount(peritext)).toBe(0);
peritext.overlay.refresh();
expect(markerCount(peritext)).toBe(1);
- const points = [];
- let point;
- for (const iterator = peritext.overlay.iterator(); (point = iterator()); ) points.push(point);
+ const points = [...peritext.overlay.points()];
expect(points.length).toBe(2);
- point = points[0];
- expect(point.pos()).toBe(5);
+ expect(points[0].pos()).toBe(5);
});
test('can insert two markers', () => {
@@ -77,10 +74,6 @@ describe('markers', () => {
const slice = peritext.editor.insMarker(['p'], '¶');
peritext.refresh();
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(markerCount(peritext)).toBe(0);
@@ -94,10 +87,6 @@ describe('markers', () => {
const slice = peritext.editor.insMarker(['p'], '¶');
peritext.refresh();
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(markerCount(peritext)).toBe(1);
@@ -117,7 +106,7 @@ describe('markers', () => {
expect(markerCount(peritext)).toBe(2);
const points = [];
let point;
- for (const iterator = peritext.overlay.markerIterator(); (point = iterator()); ) points.push(point);
+ for (const iterator = peritext.overlay.markers0(); (point = iterator()); ) points.push(point);
expect(points.length).toBe(2);
expect(points[0].pos()).toBe(2);
expect(points[1].pos()).toBe(11);
@@ -139,9 +128,7 @@ describe('slices', () => {
expect(peritext.overlay.slices.size).toBe(0);
peritext.overlay.refresh();
expect(peritext.overlay.slices.size).toBe(2);
- const points = [];
- let point;
- for (const iterator = peritext.overlay.iterator(); (point = iterator()); ) points.push(point);
+ const points = [...peritext.overlay.points()];
expect(points.length).toBe(2);
expect(points[0].pos()).toBe(6);
expect(points[0].anchor).toBe(Anchor.Before);
@@ -158,9 +145,7 @@ describe('slices', () => {
expect(peritext.overlay.slices.size).toBe(0);
peritext.overlay.refresh();
expect(peritext.overlay.slices.size).toBe(3);
- const points = [];
- let point;
- for (const iterator = peritext.overlay.iterator(); (point = iterator()); ) points.push(point);
+ const points = [...peritext.overlay.points()];
expect(points.length).toBe(4);
});
diff --git a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.tuples.spec.ts b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.tuples.spec.ts
new file mode 100644
index 0000000000..6174e7a26a
--- /dev/null
+++ b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.tuples.spec.ts
@@ -0,0 +1,161 @@
+import {next} from 'sonic-forest/lib/util';
+import {Kit, setupHelloWorldKit, setupHelloWorldWithFewEditsKit} from '../../__tests__/setup';
+import {Anchor} from '../../rga/constants';
+import {MarkerOverlayPoint} from '../MarkerOverlayPoint';
+import {OverlayPoint} from '../OverlayPoint';
+
+const runPairsTests = (setup: () => Kit) => {
+ describe('.tuples() full range', () => {
+ test('returns [START, END] single tuple for an empty overlay', () => {
+ const {peritext} = setup();
+ const overlay = peritext.overlay;
+ overlay.refresh();
+ const list = [...overlay.tuples()];
+ expect(list).toEqual([[overlay.START, overlay.END]]);
+ });
+
+ test('when caret at abs start, returns one [p, END] tuple', () => {
+ const {peritext} = setup();
+ const overlay = peritext.overlay;
+ peritext.editor.cursor.set(peritext.pointAbsStart());
+ overlay.refresh();
+ const list = [...overlay.tuples()];
+ const p1 = overlay.first()!;
+ expect(peritext.editor.cursor.start.rightChar()?.view()).toBe(peritext.strApi().view()[0]);
+ expect(list).toEqual([[p1, overlay.END]]);
+ });
+
+ test('when caret at abs end, returns one [START, p] tuple', () => {
+ const {peritext} = setup();
+ const overlay = peritext.overlay;
+ peritext.editor.cursor.set(peritext.pointAbsEnd());
+ overlay.refresh();
+ const list = [...overlay.tuples()];
+ const p1 = overlay.first()!;
+ expect(peritext.editor.cursor.start.leftChar()?.view()).toBe(peritext.strApi().view().slice(-1));
+ expect(list).toEqual([[overlay.START, p1]]);
+ });
+
+ test('for only caret in overlay, returns two edge tuples', () => {
+ const {peritext} = setup();
+ const overlay = peritext.overlay;
+ peritext.editor.cursor.setAt(5);
+ overlay.refresh();
+ const list = [...overlay.tuples()];
+ const p1 = overlay.first()!;
+ expect(peritext.editor.cursor.start.leftChar()?.view()).toBe(peritext.strApi().view()[4]);
+ expect(list).toEqual([
+ [overlay.START, p1],
+ [p1, overlay.END],
+ ]);
+ });
+
+ test('for a cursor selection, returns three tuples', () => {
+ const {peritext} = setup();
+ const overlay = peritext.overlay;
+ peritext.editor.cursor.setAt(3, 3);
+ overlay.refresh();
+ const list = [...overlay.tuples()];
+ const p1 = overlay.first()!;
+ const p2 = next(p1)!;
+ expect(peritext.editor.cursor.text()).toBe(peritext.strApi().view().slice(3, 6));
+ expect(list).toEqual([
+ [overlay.START, p1],
+ [p1, p2],
+ [p2, overlay.END],
+ ]);
+ });
+
+ test('for a cursor selection at abs start, returns two tuples', () => {
+ const {peritext} = setup();
+ const overlay = peritext.overlay;
+ const range = peritext.range(peritext.pointAbsStart(), peritext.pointAt(5));
+ peritext.editor.cursor.setRange(range);
+ overlay.refresh();
+ const list = [...overlay.tuples()];
+ const p1 = overlay.first()!;
+ const p2 = next(p1)!;
+ expect(list).toEqual([
+ [p1, p2],
+ [p2, overlay.END],
+ ]);
+ });
+
+ test('for a cursor selection at abs end, returns two tuples', () => {
+ const {peritext} = setup();
+ const overlay = peritext.overlay;
+ const range = peritext.range(peritext.pointAt(5, Anchor.Before), peritext.pointAbsEnd());
+ peritext.editor.cursor.setRange(range);
+ overlay.refresh();
+ const list = [...overlay.tuples()];
+ const p1 = overlay.first()!;
+ const p2 = next(p1)!;
+ expect(list).toEqual([
+ [overlay.START, p1],
+ [p1, p2],
+ ]);
+ });
+
+ test('for a marker and a slice after the marker, returns 4 tuples', () => {
+ const {peritext} = setup();
+ const {editor, overlay} = peritext;
+ editor.cursor.setAt(3);
+ const [marker] = editor.saved.insMarker('');
+ editor.cursor.setAt(6, 2);
+ editor.extra.insStack('');
+ overlay.refresh();
+ const p1 = overlay.first()!;
+ const p2 = next(p1)!;
+ const p3 = next(p2)!;
+ const list = [...overlay.tuples()];
+ expect(list).toEqual([
+ [overlay.START, p1],
+ [p1, p2],
+ [p2, p3],
+ [p3, overlay.END],
+ ]);
+ expect(p1 instanceof MarkerOverlayPoint).toBe(true);
+ expect(p2 instanceof OverlayPoint).toBe(true);
+ expect(p3 instanceof OverlayPoint).toBe(true);
+ expect((p1 as MarkerOverlayPoint).marker).toBe(marker);
+ expect(p2.layers.length).toBe(2);
+ expect(p3.layers.length).toBe(0);
+ expect(p2.refs.length).toBe(2);
+ expect(p3.refs.length).toBe(2);
+ });
+ });
+
+ describe('.tuples() at offset', () => {
+ test('in empty overlay, after caret returns the last edge', () => {
+ const {peritext} = setup();
+ const overlay = peritext.overlay;
+ peritext.editor.cursor.setAt(5);
+ overlay.refresh();
+ const first = overlay.first()!;
+ const pairs = [...overlay.tuples(first)];
+ expect(pairs).toEqual([[first, overlay.END]]);
+ });
+
+ test('in empty overlay, after selection start returns the selection and the edge', () => {
+ const {peritext} = setup();
+ const overlay = peritext.overlay;
+ peritext.editor.cursor.setAt(2, 4);
+ overlay.refresh();
+ const p1 = overlay.first()!;
+ const p2 = next(p1)!;
+ const list = [...overlay.tuples(p1)];
+ expect(list).toEqual([
+ [p1, p2],
+ [p2, overlay.END],
+ ]);
+ });
+ });
+};
+
+describe('numbers "hello world", no edits', () => {
+ runPairsTests(setupHelloWorldKit);
+});
+
+describe('numbers "hello world", with default schema and tombstones', () => {
+ runPairsTests(setupHelloWorldWithFewEditsKit);
+});
diff --git a/src/json-crdt-extensions/peritext/overlay/types.ts b/src/json-crdt-extensions/peritext/overlay/types.ts
index 09bccc97bd..47696f1310 100644
--- a/src/json-crdt-extensions/peritext/overlay/types.ts
+++ b/src/json-crdt-extensions/peritext/overlay/types.ts
@@ -1,3 +1,5 @@
+import type {OverlayPoint} from './OverlayPoint';
+
export type BlockTag = [
/**
* Developer specified type of the block. For example, 'title', 'paragraph',
@@ -11,3 +13,19 @@ export type BlockTag = [
*/
attr?: undefined | unknown,
];
+
+/**
+ * Represents a two adjacent overlay points. The first point is the point
+ * that is closer to the start of the document, and the second point is the
+ * point that is closer to the end of the document. When an absolute start is
+ * missing, the `p1` will be `undefined`. When an absolute end is missing, the
+ * `p2` will be `undefined`.
+ */
+export type OverlayPair = [p1: OverlayPoint | undefined, p2: OverlayPoint | undefined];
+
+/**
+ * The *overlay tuple* is similar to the {@link OverlayPair}, but ensures that
+ * both points are defined. The leasing and trailing `undefined` are substituted
+ * by virtual points.
+ */
+export type OverlayTuple = [p1: OverlayPoint, p2: OverlayPoint];
diff --git a/src/util/iterator.ts b/src/util/iterator.ts
index 143506549d..f56305ab92 100644
--- a/src/util/iterator.ts
+++ b/src/util/iterator.ts
@@ -1,5 +1,7 @@
+export type UndefIterator = () => undefined | T;
+
export class UndefEndIter implements IterableIterator {
- constructor(private readonly i: () => T | undefined) {}
+ constructor(private readonly i: UndefIterator) {}
public next(): IteratorResult {
const value = this.i();
@@ -17,3 +19,5 @@ export class IterRes {
public readonly done: boolean,
) {}
}
+
+export const iter = (i: UndefIterator) => new UndefEndIter(i);