diff --git a/src/json-crdt-extensions/peritext/Peritext.ts b/src/json-crdt-extensions/peritext/Peritext.ts index 2005c7e81b..d9fde4c150 100644 --- a/src/json-crdt-extensions/peritext/Peritext.ts +++ b/src/json-crdt-extensions/peritext/Peritext.ts @@ -49,6 +49,7 @@ export class Peritext implements Printable { * * @param pos Position of the character in the text. * @param anchor Whether the point should attach before or after a character. + * Defaults to "before". * @returns The point. */ public pointAt(pos: number, anchor: Anchor = Anchor.Before): Point { diff --git a/src/json-crdt-extensions/peritext/overlay/OverlayPoint.ts b/src/json-crdt-extensions/peritext/overlay/OverlayPoint.ts new file mode 100644 index 0000000000..04fc26575d --- /dev/null +++ b/src/json-crdt-extensions/peritext/overlay/OverlayPoint.ts @@ -0,0 +1,235 @@ +import {Point} from '../rga/Point'; +import {compare} from '../../../json-crdt-patch/clock'; +import {OverlayRef, OverlayRefSliceEnd, OverlayRefSliceStart} from './refs'; +import {printTree} from 'sonic-forest/lib/print/printTree'; +import type {SplitSlice} from '../slice/SplitSlice'; +import type {HeadlessNode} from 'sonic-forest/lib/types'; +import type {Printable} from '../../../util/print/types'; +import type {Slice} from '../slice/types'; + +/** + * A {@link Point} which is indexed in the {@link Overlay} tree. Represents + * sparse locations in the string of the places where annotation slices start, + * end, or are broken down by other intersecting slices. + */ +export class OverlayPoint extends Point implements Printable, HeadlessNode { + /** + * Hash of text contents until the next {@link OverlayPoint}. This field is + * modified by the {@link Overlay} tree. + */ + public hash: number = 0; + + // ------------------------------------------------------------------- layers + + /** + * Sorted list of layers, contains the interval from this point to the next + * one. A *layer* is a part of a slice from the current point to the next one. + * This interval can contain many layers, as the slices can be overlap. + */ + public readonly layers: Slice[] = []; + + /** + * Inserts a slice to the list of layers which contains the area from this + * point until the next one. The operation is idempotent, so inserting the + * same slice twice will not change the state of the point. The layers are + * sorted by the slice ID. + * + * @param slice Slice to add to the layer list. + */ + public addLayer(slice: Slice): void { + const layers = this.layers; + const length = layers.length; + if (!length) { + layers.push(slice); + return; + } + // We attempt to insert from the end of the list, as it is the most likely + // scenario. And `.push()` is more efficient than `.unshift()`. + const lastSlice = layers[length - 1]; + const sliceId = slice.id; + const cmp = compare(lastSlice.id, sliceId); + if (cmp < 0) { + layers.push(slice); + return; + } else if (!cmp) return; + for (let i = length - 2; i >= 0; i--) { + const currSlice = layers[i]; + const cmp = compare(currSlice.id, sliceId); + if (cmp < 0) { + layers.splice(i + 1, 0, slice); + return; + } else if (!cmp) return; + } + layers.unshift(slice); + } + + /** + * Removes a slice from the list of layers, which start from this overlay + * point. + * + * @param slice Slice to remove from the layer list. + */ + public removeLayer(slice: Slice): void { + const layers = this.layers; + const length = layers.length; + for (let i = 0; i < length; i++) { + if (layers[i] === slice) { + layers.splice(i, 1); + return; + } + } + } + + // ------------------------------------------------------------------ markers + + /** + * Collapsed slices - markers (block splits), which represent a single point + * in the text, even if the start and end of the slice are different. + * @deprecated This field might happen to be not necessary. + */ + public readonly markers: Slice[] = []; + + /** + * Inserts a slice to the list of markers which represent a single point in + * the text, even if the start and end of the slice are different. The + * operation is idempotent, so inserting the same slice twice will not change + * the state of the point. The markers are sorted by the slice ID. + * + * @param slice Slice to add to the marker list. + * @deprecated This method might happen to be not necessary. + */ + public addMarker(slice: Slice): void { + /** @deprecated */ + const markers = this.markers; + const length = markers.length; + if (!length) { + markers.push(slice); + return; + } + // We attempt to insert from the end of the list, as it is the most likely + // scenario. And `.push()` is more efficient than `.unshift()`. + const lastSlice = markers[length - 1]; + const sliceId = slice.id; + const cmp = compare(lastSlice.id, sliceId); + if (cmp < 0) { + markers.push(slice); + return; + } else if (!cmp) return; + for (let i = length - 2; i >= 0; i--) { + const currSlice = markers[i]; + const cmp = compare(currSlice.id, sliceId); + if (cmp < 0) { + markers.splice(i + 1, 0, slice); + return; + } else if (!cmp) return; + } + markers.unshift(slice); + } + + /** + * Removes a slice from the list of markers, which represent a single point in + * the text, even if the start and end of the slice are different. + * + * @param slice Slice to remove from the marker list. + * @deprecated This method might happen to be not necessary. + */ + public removeMarker(slice: Slice): void { + /** @deprecated */ + const markers = this.markers; + const length = markers.length; + for (let i = 0; i < length; i++) { + if (markers[i] === slice) { + markers.splice(i, 1); + return; + } + } + } + + // --------------------------------------------------------------------- refs + + /** + * Sorted list of all references to rich-text constructs. + */ + public readonly refs: OverlayRef[] = []; + + /** + * Insert a reference to a marker. + * + * @param slice A marker (split slice). + */ + public addMarkerRef(slice: SplitSlice): void { + this.refs.push(slice); + this.addMarker(slice); + } + + /** + * Insert a layer that starts at this point. + * + * @param slice A slice that starts at this point. + */ + public addLayerStartRef(slice: Slice): void { + this.refs.push(new OverlayRefSliceStart(slice)); + this.addLayer(slice); + } + + /** + * Insert a layer that ends at this point. + * + * @param slice A slice that ends at this point. + */ + public addLayerEndRef(slice: Slice): void { + this.refs.push(new OverlayRefSliceEnd(slice)); + } + + /** + * Removes a reference to a marker or a slice, and remove the corresponding + * layer or marker. + * + * @param slice A slice to remove the reference to. + */ + public removeRef(slice: Slice): void { + const refs = this.refs; + const length = refs.length; + for (let i = 0; i < length; i++) { + const ref = refs[i]; + if (ref === slice) { + refs.splice(i, 1); + this.removeMarker(slice); + return; + } + if ( + (ref instanceof OverlayRefSliceStart && ref.slice === slice) || + (ref instanceof OverlayRefSliceEnd && ref.slice === slice) + ) { + refs.splice(i, 1); + this.removeLayer(slice); + return; + } + } + } + + // ---------------------------------------------------------------- Printable + + public toStringName(tab: string, lite?: boolean): string { + return super.toString(tab, lite); + } + + public toString(tab: string = '', lite?: boolean): string { + const refs = lite ? '' : `, refs = ${this.refs.length}`; + const header = this.toStringName(tab, lite) + refs; + if (lite) return header; + return ( + header + + printTree( + tab, + this.layers.map((slice) => (tab) => slice.toString(tab)), + ) + ); + } + + // ------------------------------------------------------------- HeadlessNode + + public p: OverlayPoint | undefined = undefined; + public l: OverlayPoint | undefined = undefined; + public r: OverlayPoint | undefined = undefined; +} diff --git a/src/json-crdt-extensions/peritext/overlay/__tests__/OverlayPoint.spec.ts b/src/json-crdt-extensions/peritext/overlay/__tests__/OverlayPoint.spec.ts new file mode 100644 index 0000000000..629e972b04 --- /dev/null +++ b/src/json-crdt-extensions/peritext/overlay/__tests__/OverlayPoint.spec.ts @@ -0,0 +1,310 @@ +import {Point} from '../../rga/Point'; +import {setup} from '../../slice/__tests__/setup'; +import {OverlayPoint} from '../OverlayPoint'; +import {OverlayRefSliceEnd, OverlayRefSliceStart} from '../refs'; + +const setupOverlayPoint = () => { + const deps = setup(); + const getPoint = (point: Point) => { + return new OverlayPoint(deps.peritext.str, point.id, point.anchor); + }; + return { + ...deps, + getPoint, + }; +}; + +describe('layers', () => { + test('can add a layer', () => { + const {peritext, getPoint} = setupOverlayPoint(); + const slice = peritext.slices.insOverwrite(peritext.rangeAt(5, 5), ''); + const point = getPoint(slice.start); + expect(point.layers.length).toBe(0); + point.addLayer(slice); + expect(point.layers.length).toBe(1); + expect(point.layers[0]).toBe(slice); + }); + + test('inserting same slice twice is a no-op', () => { + const {peritext, getPoint} = setupOverlayPoint(); + const slice = peritext.slices.insOverwrite(peritext.rangeAt(5, 5), ''); + const point = getPoint(slice.start); + expect(point.layers.length).toBe(0); + point.addLayer(slice); + point.addLayer(slice); + point.addLayer(slice); + expect(point.layers.length).toBe(1); + expect(point.layers[0]).toBe(slice); + }); + + 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 point = getPoint(slice1.start); + expect(point.layers.length).toBe(0); + point.addLayer(slice1); + expect(point.layers.length).toBe(1); + point.addLayer(slice2); + point.addLayer(slice2); + expect(point.layers.length).toBe(2); + expect(point.layers[0]).toBe(slice1); + expect(point.layers[1]).toBe(slice2); + }); + + 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 point = getPoint(slice1.start); + point.addLayer(slice2); + point.addLayer(slice1); + expect(point.layers[0]).toBe(slice1); + expect(point.layers[1]).toBe(slice2); + }); + + 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 point = getPoint(slice1.start); + point.addLayer(slice3); + point.addLayer(slice3); + point.addLayer(slice2); + point.addLayer(slice3); + point.addLayer(slice1); + point.addLayer(slice3); + point.addLayer(slice3); + expect(point.layers.length).toBe(3); + expect(point.layers[0]).toBe(slice1); + expect(point.layers[1]).toBe(slice2); + expect(point.layers[2]).toBe(slice3); + }); + + 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 point = getPoint(slice1.start); + point.addLayer(slice1); + point.addLayer(slice2); + point.addLayer(slice3); + expect(point.layers[0]).toBe(slice1); + expect(point.layers[1]).toBe(slice2); + expect(point.layers[2]).toBe(slice3); + }); + + 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 point = getPoint(slice1.start); + point.addLayer(slice2); + point.addLayer(slice1); + point.addLayer(slice1); + point.addLayer(slice1); + point.addLayer(slice3); + expect(point.layers[0]).toBe(slice1); + expect(point.layers[1]).toBe(slice2); + expect(point.layers[2]).toBe(slice3); + point.removeLayer(slice2); + expect(point.layers[0]).toBe(slice1); + expect(point.layers[1]).toBe(slice3); + point.removeLayer(slice1); + expect(point.layers[0]).toBe(slice3); + point.removeLayer(slice1); + point.removeLayer(slice3); + expect(point.layers.length).toBe(0); + }); +}); + +describe('markers', () => { + test('can add a marker', () => { + const {peritext, getPoint} = setupOverlayPoint(); + const marker = peritext.slices.insSplit(peritext.rangeAt(5, 0), '

'); + const point = getPoint(marker.start); + expect(point.markers.length).toBe(0); + point.addMarker(marker); + expect(point.markers.length).toBe(1); + expect(point.markers[0]).toBe(marker); + }); + + test('inserting same marker twice is a no-op', () => { + const {peritext, getPoint} = setupOverlayPoint(); + const marker = peritext.slices.insSplit(peritext.rangeAt(5, 0), '

'); + const point = getPoint(marker.start); + expect(point.markers.length).toBe(0); + point.addMarker(marker); + point.addMarker(marker); + point.addMarker(marker); + point.addMarker(marker); + expect(point.markers.length).toBe(1); + expect(point.markers[0]).toBe(marker); + }); + + test('can add two markers with the same start position', () => { + const {peritext, getPoint} = setupOverlayPoint(); + const marker1 = peritext.slices.insSplit(peritext.rangeAt(5, 0), '

'); + const marker2 = peritext.slices.insSplit(peritext.rangeAt(5, 0), '

'); + const point = getPoint(marker1.start); + expect(point.markers.length).toBe(0); + point.addMarker(marker1); + expect(point.markers.length).toBe(1); + point.addMarker(marker2); + point.addMarker(marker2); + expect(point.markers.length).toBe(2); + expect(point.markers[0]).toBe(marker1); + expect(point.markers[1]).toBe(marker2); + }); + + test('orders markers by their ID', () => { + const {peritext, getPoint} = setupOverlayPoint(); + const marker1 = peritext.slices.insSplit(peritext.rangeAt(5, 0), '

'); + const marker2 = peritext.slices.insSplit(peritext.rangeAt(5, 0), '

'); + const point = getPoint(marker1.start); + point.addMarker(marker2); + point.addMarker(marker1); + point.addMarker(marker2); + point.addMarker(marker1); + point.addMarker(marker2); + point.addMarker(marker1); + expect(point.markers[0]).toBe(marker1); + expect(point.markers[1]).toBe(marker2); + }); + + test('can add tree markers and sort them correctly', () => { + const {peritext, getPoint} = setupOverlayPoint(); + const marker1 = peritext.slices.insSplit(peritext.rangeAt(5, 0), '

'); + const marker2 = peritext.slices.insSplit(peritext.rangeAt(5, 0), '

'); + const marker3 = peritext.slices.insSplit(peritext.rangeAt(5, 0), '

'); + const point = getPoint(marker1.start); + point.addMarker(marker3); + point.addMarker(marker3); + point.addMarker(marker2); + point.addMarker(marker2); + point.addMarker(marker3); + point.addMarker(marker1); + point.addMarker(marker3); + point.addMarker(marker3); + expect(point.markers.length).toBe(3); + expect(point.markers[0]).toBe(marker1); + expect(point.markers[1]).toBe(marker2); + expect(point.markers[2]).toBe(marker3); + }); + + test('can add tree markers by appending them', () => { + const {peritext, getPoint} = setupOverlayPoint(); + const marker1 = peritext.slices.insSplit(peritext.rangeAt(6, 1), '

'); + const marker2 = peritext.slices.insSplit(peritext.rangeAt(6, 2), '

'); + const marker3 = peritext.slices.insSplit(peritext.rangeAt(6, 3), '

'); + const point = getPoint(marker2.start); + point.addMarker(marker1); + point.addMarker(marker2); + point.addMarker(marker3); + expect(point.markers[0]).toBe(marker1); + expect(point.markers[1]).toBe(marker2); + expect(point.markers[2]).toBe(marker3); + }); + + test('can remove markers', () => { + const {peritext, getPoint} = setupOverlayPoint(); + const marker1 = peritext.slices.insSplit(peritext.rangeAt(6, 1), '

'); + const marker2 = peritext.slices.insSplit(peritext.rangeAt(6, 1), '

'); + const marker3 = peritext.slices.insSplit(peritext.rangeAt(6, 2), '

'); + const point = getPoint(marker1.start); + point.addMarker(marker2); + point.addMarker(marker1); + point.addMarker(marker1); + point.addMarker(marker1); + point.addMarker(marker3); + expect(point.markers[0]).toBe(marker1); + expect(point.markers[1]).toBe(marker2); + expect(point.markers[2]).toBe(marker3); + point.removeMarker(marker2); + expect(point.markers[0]).toBe(marker1); + expect(point.markers[1]).toBe(marker3); + point.removeMarker(marker1); + expect(point.markers[0]).toBe(marker3); + point.removeMarker(marker1); + point.removeMarker(marker3); + expect(point.markers.length).toBe(0); + }); +}); + +describe('refs', () => { + test('can add marker ref', () => { + const {peritext, getPoint} = setupOverlayPoint(); + const marker = peritext.slices.insSplit(peritext.rangeAt(10, 1), '

'); + const point = getPoint(marker.start); + expect(point.markers.length).toBe(0); + expect(point.refs.length).toBe(0); + point.addMarkerRef(marker); + expect(point.markers.length).toBe(1); + expect(point.refs.length).toBe(1); + expect(point.markers[0]).toBe(marker); + expect(point.refs[0]).toBe(marker); + }); + + test('can add layer ref (start)', () => { + const {peritext, getPoint} = setupOverlayPoint(); + const slice = peritext.slices.insErase(peritext.rangeAt(0, 4), 123); + const point = getPoint(slice.start); + expect(point.layers.length).toBe(0); + expect(point.refs.length).toBe(0); + point.addLayerStartRef(slice); + expect(point.layers.length).toBe(1); + expect(point.refs.length).toBe(1); + expect(point.layers[0]).toBe(slice); + expect((point.refs[0] as OverlayRefSliceStart).slice).toBe(slice); + }); + + test('can add layer ref (end)', () => { + const {peritext, getPoint} = setupOverlayPoint(); + const slice = peritext.slices.insErase(peritext.rangeAt(0, 4), 123); + const point = getPoint(slice.end); + expect(point.layers.length).toBe(0); + expect(point.refs.length).toBe(0); + point.addLayerEndRef(slice); + expect(point.layers.length).toBe(0); + expect(point.refs.length).toBe(1); + expect((point.refs[0] as OverlayRefSliceEnd).slice).toBe(slice); + }); + + test('can add marker and layer start', () => { + const {peritext, getPoint} = setupOverlayPoint(); + const marker = peritext.slices.insSplit(peritext.rangeAt(10, 1), '

'); + const slice = peritext.slices.insErase(peritext.rangeAt(10, 4), 123); + const point = getPoint(slice.end); + expect(point.layers.length).toBe(0); + expect(point.markers.length).toBe(0); + expect(point.refs.length).toBe(0); + point.addMarkerRef(marker); + point.addLayerStartRef(slice); + expect(point.layers.length).toBe(1); + expect(point.markers.length).toBe(1); + expect(point.refs.length).toBe(2); + }); + + test('can remove marker and layer', () => { + const {peritext, getPoint} = setupOverlayPoint(); + const marker = peritext.slices.insSplit(peritext.rangeAt(10, 1), '

'); + const slice = peritext.slices.insErase(peritext.rangeAt(10, 4), 123); + const point = getPoint(slice.end); + point.addMarkerRef(marker); + point.addLayerStartRef(slice); + expect(point.layers.length).toBe(1); + expect(point.markers.length).toBe(1); + expect(point.refs.length).toBe(2); + point.removeRef(slice); + expect(point.layers.length).toBe(0); + expect(point.markers.length).toBe(1); + expect(point.refs.length).toBe(1); + point.removeRef(marker); + expect(point.layers.length).toBe(0); + expect(point.markers.length).toBe(0); + expect(point.refs.length).toBe(0); + }); +}); diff --git a/src/json-crdt-extensions/peritext/overlay/refs.ts b/src/json-crdt-extensions/peritext/overlay/refs.ts new file mode 100644 index 0000000000..d8a5eb8217 --- /dev/null +++ b/src/json-crdt-extensions/peritext/overlay/refs.ts @@ -0,0 +1,21 @@ +import type {SplitSlice} from '../slice/SplitSlice'; +import type {Slice} from '../slice/types'; + +/** + * On overlay "ref" is a reference from the {@link Overlay} to a {@link Slice}. + * In case of a *marker* slice, the reference is to the slice itself. In case of + * a regular annotation slice, two references are needed: one to the start slice + * and one to the end slice. + */ +export type OverlayRef = + | SplitSlice // Ref to a *marker* slice + | OverlayRefSliceStart // Ref to the start of an annotation slice + | OverlayRefSliceEnd; // Ref to the end of an annotation slice + +export class OverlayRefSliceStart { + constructor(public readonly slice: Slice) {} +} + +export class OverlayRefSliceEnd { + constructor(public readonly slice: Slice) {} +} diff --git a/src/json-crdt-extensions/peritext/overlay/types.ts b/src/json-crdt-extensions/peritext/overlay/types.ts new file mode 100644 index 0000000000..09bccc97bd --- /dev/null +++ b/src/json-crdt-extensions/peritext/overlay/types.ts @@ -0,0 +1,13 @@ +export type BlockTag = [ + /** + * Developer specified type of the block. For example, 'title', 'paragraph', + * 'image', etc. For performance reasons, it is better to use a number to + * represent the type. + */ + type: number | number[], + + /** + * Any custom attributes that the developer wants to add to the block. + */ + attr?: undefined | unknown, +];