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"