diff --git a/biome.json b/biome.json index c806ca944b..8dee243f6b 100644 --- a/biome.json +++ b/biome.json @@ -45,7 +45,8 @@ "useOptionalChain": "off", "noCommaOperator": "off", "noUselessLabel": "off", - "noBannedTypes": "off" + "noBannedTypes": "off", + "noUselessUndefinedInitialization": "off" }, "security": { "noGlobalEval": "off" diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/Peritext.ts b/packages/json-joy/src/json-crdt-extensions/peritext/Peritext.ts index 5d5497ea04..90422a62e8 100644 --- a/packages/json-joy/src/json-crdt-extensions/peritext/Peritext.ts +++ b/packages/json-joy/src/json-crdt-extensions/peritext/Peritext.ts @@ -7,8 +7,7 @@ import {type 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, tick} from '../../json-crdt-patch/clock'; +import {tick} from '../../json-crdt-patch/clock'; import {Model, type StrApi} from '../../json-crdt/model'; import {CONST, updateNum} from '../../json-hash/hash'; import {SESSION} from '../../json-crdt-patch/constants'; @@ -18,13 +17,12 @@ import {Fragment} from './block/Fragment'; import {updateRga} from '../../json-crdt/hash'; import type {ITimestampStruct} from '../../json-crdt-patch/clock'; import type {Printable} from 'tree-dump/lib/types'; -import type {MarkerSlice} from './slice/MarkerSlice'; import type {SliceSchema, SliceTypeSteps} from './slice/types'; import type {SchemaToJsonNode} from '../../json-crdt/schema/types'; import type {AbstractRga} from '../../json-crdt/nodes/rga'; import type {ChunkSlice} from './util/ChunkSlice'; import type {Stateful} from './types'; -import type {PersistedSlice} from './slice/PersistedSlice'; +import type {Slice} from './slice/Slice'; const EXTRA_SLICES_SCHEMA = s.vec(s.arr([])); const LOCAL_DATA_SCHEMA = EXTRA_SLICES_SCHEMA; @@ -75,7 +73,8 @@ export class Peritext implements Printable, Stateful { } public strApi(): StrApi { - if (this.str instanceof StrNode) return this.model.api.wrap(this.str); + const str = this.str; + if (str instanceof StrNode) return this.model.api.wrap(str); throw new Error('INVALID_STR'); } @@ -98,7 +97,7 @@ export class Peritext implements Printable, Stateful { * * @param pos Position of the character in the text. * @param anchor Whether the point should attach before or after a character. - * Defaults to "before". + * Defaults to "before". * @returns The point. */ public pointAt(pos: number, anchor: Anchor = Anchor.Before): Point { @@ -110,6 +109,27 @@ export class Peritext implements Printable, Stateful { return this.point(id, anchor); } + /** + * Creates a point at a view position in the text, between characters. + * + * @param pos Position between characters in the text. + * @param anchor Whether the point should attach before or after a character. + * Defaults to "after". + * @returns The point. + */ + public pointIn(pos: number, anchor: Anchor = Anchor.After): Point { + const str = this.str; + if (anchor === Anchor.After) { + if (!pos) return this.pointAbsStart(); + const id = str.find(pos - 1); + if (!id) return this.pointEnd() ?? this.pointAbsStart(); + return this.point(id, Anchor.After); + } else { + const id = str.find(pos); + return id ? this.point(id, Anchor.Before) : this.pointAbsEnd(); + } + } + /** * Creates a point which is attached to the start of the text, before the * first character. @@ -299,31 +319,15 @@ export class Peritext implements Printable, Stateful { */ public readonly localSlices: Slices; - public getSlice(id: ITimestampStruct): PersistedSlice | undefined { + public getSlice(id: ITimestampStruct): Slice | undefined { return this.savedSlices.get(id) || this.localSlices.get(id) || this.extraSlices.get(id); } // ------------------------------------------------------------------ markers /** @deprecated Use the method in `Editor` and `Cursor` instead. */ - public insMarker( - after: ITimestampStruct, - type: SliceTypeSteps, - data?: unknown, - char: string = Chars.BlockSplitSentinel, - ): MarkerSlice { - return this.savedSlices.insMarkerAfter(after, type, data, char); - } - - /** @todo This can probably use .del() */ - public delMarker(split: MarkerSlice): void { - const str = this.str; - const api = this.model.api; - const builder = api.builder; - const strChunk = split.start.chunk(); - if (strChunk) builder.del(str.id, [interval(strChunk.id, 0, 1)]); - builder.del(this.savedSlices.set.id, [interval(split.id, 0, 1)]); - api.apply(); + public insMarker(after: ITimestampStruct, type: SliceTypeSteps, data?: unknown): Slice { + return this.savedSlices.insMarkerAfter(after, type, data); } /** ----------------------------------------------------- {@link Printable} */ diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/__tests__/Peritext.cursor.spec.ts b/packages/json-joy/src/json-crdt-extensions/peritext/__tests__/Peritext.cursor.spec.ts index e915fe0e83..f4889dfc55 100644 --- a/packages/json-joy/src/json-crdt-extensions/peritext/__tests__/Peritext.cursor.spec.ts +++ b/packages/json-joy/src/json-crdt-extensions/peritext/__tests__/Peritext.cursor.spec.ts @@ -97,7 +97,6 @@ test('cursor can move across block boundary forwards', () => { expect([...peritext.blocks.root.children[0].texts()][0].attr()[SliceTypeName.Cursor][0]).toBeInstanceOf( InlineAttrStartPoint, ); - editor.cursor.move(1); peritext.refresh(); expect(peritext.blocks.root.children.length).toBe(2); @@ -113,15 +112,14 @@ test('cursor can move across block boundary forwards', () => { expect([...peritext.blocks.root.children[1].texts()][0].attr()).toEqual({}); editor.cursor.move(1); peritext.refresh(); + // console.log(peritext + ''); expect(peritext.blocks.root.children.length).toBe(2); expect([...peritext.blocks.root.children[0].texts()].length).toBe(1); expect([...peritext.blocks.root.children[0].texts()][0].text()).toBe('a'); expect([...peritext.blocks.root.children[0].texts()][0].attr()).toEqual({}); - expect([...peritext.blocks.root.children[1].texts()].length).toBe(2); - expect([...peritext.blocks.root.children[1].texts()][0].text()).toBe(''); - expect([...peritext.blocks.root.children[1].texts()][0].attr()).toEqual({}); - expect([...peritext.blocks.root.children[1].texts()][1].text()).toBe('b'); - expect([...peritext.blocks.root.children[1].texts()][1].attr()[SliceTypeName.Cursor][0]).toBeInstanceOf( + expect([...peritext.blocks.root.children[1].texts()].length).toBe(1); + expect([...peritext.blocks.root.children[1].texts()][0].text()).toBe('b'); + expect([...peritext.blocks.root.children[1].texts()][0].attr()[SliceTypeName.Cursor][0]).toBeInstanceOf( InlineAttrStartPoint, ); editor.cursor.move(1); diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/__tests__/Peritext.point.spec.ts b/packages/json-joy/src/json-crdt-extensions/peritext/__tests__/Peritext.point.spec.ts new file mode 100644 index 0000000000..dfdfa87160 --- /dev/null +++ b/packages/json-joy/src/json-crdt-extensions/peritext/__tests__/Peritext.point.spec.ts @@ -0,0 +1,22 @@ +import {type Kit, runNumbersKitTestSuite} from './setup'; +import {Anchor} from '../rga/constants'; + +const testSuite = (setup: () => Kit): void => { + describe('.pointIn()', () => { + test('can infer point between characters', () => { + const {peritext} = setup(); + for (let i = 0; i <= 9; i++) { + const p0 = peritext.pointIn(i); + expect(p0.anchor).toBe(Anchor.After); + expect(p0.viewPos()).toBe(i); + expect(p0.rightChar()?.view()).toBe(i.toString()); + const p1 = peritext.pointIn(i, Anchor.Before); + expect(p1.anchor).toBe(Anchor.Before); + expect(p1.viewPos()).toBe(i); + expect(p1.rightChar()?.view()).toBe(i.toString()); + } + }); + }); +}; + +runNumbersKitTestSuite(testSuite); diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/__tests__/Peritext.render-block.spec.ts b/packages/json-joy/src/json-crdt-extensions/peritext/__tests__/Peritext.render-block.spec.ts index 184eb08e5e..9658cf4f22 100644 --- a/packages/json-joy/src/json-crdt-extensions/peritext/__tests__/Peritext.render-block.spec.ts +++ b/packages/json-joy/src/json-crdt-extensions/peritext/__tests__/Peritext.render-block.spec.ts @@ -31,14 +31,26 @@ const runInlineSlicesTests = ( const {view, editor} = setup(); editor.cursor.setAt(10); editor.saved.insMarker([['p', 0, {foo: 'bar'}]]); - expect(view()).toMatchSnapshot(); + expect(view()).toMatchInlineSnapshot(` +"<> + <0> + "abcdefghij" {} +

{ foo = "bar" } + "klmnopqrstuvwxyz" {} +" +`); }); test('can insert at the beginning of text', () => { const {view, editor} = setup(); editor.cursor.setAt(0); editor.saved.insMarker([['p', 0, {foo: 'bar'}]]); - expect(view()).toMatchSnapshot(); + expect(view()).toMatchInlineSnapshot(` +"<> +

{ foo = "bar" } + "abcdefghijklmnopqrstuvwxyz" {} +" +`); }); test('nested block data', () => { @@ -53,7 +65,17 @@ const runInlineSlicesTests = ( ['ul', 0, {type: 'tasks'}], ['li', 1, {completed: true}], ]); - expect(view()).toMatchSnapshot(); + expect(view()).toMatchInlineSnapshot(` +"<> + <0> + "abcde" {} +

    { type = "tasks" } +
  • { completed = !f } + "fghi" {} +
  • { completed = !t } + "jklmnopqrstuvwxyz" {} +" +`); }); test('nested block data - 2', () => { @@ -68,14 +90,31 @@ const runInlineSlicesTests = ( ['ul', 1, {type: 'tasks'}], ['li', 0, {completed: true}], ]); - expect(view()).toMatchSnapshot(); + expect(view()).toMatchInlineSnapshot(` +"<> + <0> + "abcde" {} +
      { type = "tasks" } +
    • { completed = !f } + "fghi" {} +
        { type = "tasks" } +
      • { completed = !t } + "jklmnopqrstuvwxyz" {} +" +`); }); test('can insert at the end of text', () => { const {view, editor} = setup(); editor.cursor.setAt(26); editor.saved.insMarker([['unfurl', 0, {link: 'foobar'}]]); - expect(view()).toMatchSnapshot(); + expect(view()).toMatchInlineSnapshot(` +"<> + <0> + "abcdefghijklmnopqrstuvwxyz" {} + { link = "foobar" } +" +`); }); test('can split text after slice', () => { @@ -84,7 +123,16 @@ const runInlineSlicesTests = ( editor.saved.insOne('BOLD'); editor.cursor.setAt(15); editor.saved.insMarker(['paragraph']); - expect(view()).toMatchSnapshot(); + expect(view()).toMatchInlineSnapshot(` +"<> + <0> + "abcde" {} + "fghij" { BOLD = [ !u ] } + "klmno" {} + + "pqrstuvwxyz" {} +" +`); }); test('can split text right after slice', () => { @@ -93,7 +141,16 @@ const runInlineSlicesTests = ( editor.saved.insOne('BOLD'); editor.cursor.setAt(10); editor.saved.insMarker(['paragraph']); - expect(view()).toMatchSnapshot(); + expect(view()).toMatchInlineSnapshot(` +"<> + <0> + "abcde" {} + "fghij" { BOLD = [ !u ] } + "" {} + + "klmnopqrstuvwxyz" {} +" +`); }); test('can split text before slice', () => { @@ -102,7 +159,16 @@ const runInlineSlicesTests = ( editor.saved.insOne('BOLD'); editor.cursor.setAt(10); editor.saved.insMarker(['paragraph']); - expect(view()).toMatchSnapshot(); + expect(view()).toMatchInlineSnapshot(` +"<> + <0> + "abcdefghij" {} + + "klmno" {} + "pqrst" { BOLD = [ !u ] } + "uvwxyz" {} +" +`); }); test('can split text right before slice', () => { @@ -111,7 +177,15 @@ const runInlineSlicesTests = ( editor.saved.insOne('BOLD'); editor.cursor.setAt(15); editor.saved.insMarker(['paragraph']); - expect(view()).toMatchSnapshot(); + expect(view()).toMatchInlineSnapshot(` +"<> + <0> + "abcdefghijklmno" {} + + "pqrst" { BOLD = [ !u ] } + "uvwxyz" {} +" +`); }); test('can split text in the middle of a slice', () => { @@ -120,7 +194,16 @@ const runInlineSlicesTests = ( editor.saved.insOne('BOLD'); editor.cursor.setAt(10); editor.saved.insMarker(['paragraph']); - expect(view()).toMatchSnapshot(); + expect(view()).toMatchInlineSnapshot(` +"<> + <0> + "abcde" {} + "fghij" { BOLD = [ !u ] } + + "klmno" { BOLD = [ !u ] } + "pqrstuvwxyz" {} +" +`); }); test('can annotate with slice over two block splits', () => { @@ -131,7 +214,18 @@ const runInlineSlicesTests = ( editor.saved.insMarker(['p']); editor.cursor.setAt(8, 15); editor.saved.insOne('BOLD'); - expect(view()).toMatchSnapshot(); + expect(view()).toMatchInlineSnapshot(` +"<> + <0> + "abcdefgh" {} + "ij" { BOLD = [ !u ] } +

        + "klmn" { BOLD = [ !u ] } +

        + "opqrstu" { BOLD = [ !u ] } + "vwxyz" {} +" +`); }); test('can insert two blocks', () => { @@ -140,7 +234,16 @@ const runInlineSlicesTests = ( editor.saved.insMarker('p'); editor.cursor.setAt(10 + 10 + 1); editor.saved.insMarker('p'); - expect(view()).toMatchSnapshot(); + expect(view()).toMatchInlineSnapshot(` +"<> + <0> + "abcdefghij" {} +

        + "klmnopqrst" {} +

        + "uvwxyz" {} +" +`); }); }); }; diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/__tests__/Peritext.render-cursor-movement.spec.ts b/packages/json-joy/src/json-crdt-extensions/peritext/__tests__/Peritext.render-cursor-movement.spec.ts index 894ebf441a..3971a6a344 100644 --- a/packages/json-joy/src/json-crdt-extensions/peritext/__tests__/Peritext.render-cursor-movement.spec.ts +++ b/packages/json-joy/src/json-crdt-extensions/peritext/__tests__/Peritext.render-cursor-movement.spec.ts @@ -26,13 +26,31 @@ const runInlineSlicesTests = (desc: string, getKit: () => Kit) => { const {editor, peritext, view} = setup(); editor.cursor.setAt(1); peritext.refresh(); - expect(view()).toMatchSnapshot(); + expect(view()).toMatchInlineSnapshot(` +"<> + <0> + "a" {} + "bcdefghijklmnopqrstuvwxyz" { -1 = [ !u ] } +" +`); editor.cursor.move(1); peritext.refresh(); - expect(view()).toMatchSnapshot(); + expect(view()).toMatchInlineSnapshot(` +"<> + <0> + "ab" {} + "cdefghijklmnopqrstuvwxyz" { -1 = [ !u ] } +" +`); editor.cursor.move(2); peritext.refresh(); - expect(view()).toMatchSnapshot(); + expect(view()).toMatchInlineSnapshot(` +"<> + <0> + "abcd" {} + "efghijklmnopqrstuvwxyz" { -1 = [ !u ] } +" +`); }); test('can move cursor forward - starting the beginning of the string', () => { @@ -40,16 +58,33 @@ const runInlineSlicesTests = (desc: string, getKit: () => Kit) => { editor.cursor.setAt(0); peritext.refresh(); // console.log(view()); - expect(view()).toMatchSnapshot(); + expect(view()).toMatchInlineSnapshot(` +"<> + <0> + "abcdefghijklmnopqrstuvwxyz" { -1 = [ !u ] } +" +`); editor.cursor.move(1); peritext.refresh(); // console.log(view()); // console.log(peritext + ''); - expect(view()).toMatchSnapshot(); + expect(view()).toMatchInlineSnapshot(` +"<> + <0> + "a" {} + "bcdefghijklmnopqrstuvwxyz" { -1 = [ !u ] } +" +`); editor.cursor.move(2); peritext.refresh(); // console.log(view()); - expect(view()).toMatchSnapshot(); + expect(view()).toMatchInlineSnapshot(` +"<> + <0> + "abc" {} + "defghijklmnopqrstuvwxyz" { -1 = [ !u ] } +" +`); }); test('can move cursor backward - starting from middle', () => { diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/__tests__/Peritext.render-inline.spec.ts b/packages/json-joy/src/json-crdt-extensions/peritext/__tests__/Peritext.render-inline.spec.ts index 5207c47b99..d2986f4667 100644 --- a/packages/json-joy/src/json-crdt-extensions/peritext/__tests__/Peritext.render-inline.spec.ts +++ b/packages/json-joy/src/json-crdt-extensions/peritext/__tests__/Peritext.render-inline.spec.ts @@ -21,28 +21,54 @@ const runTests = (_setup: () => Kit) => { test('renders plain text', () => { const {view} = setup(); - expect(view()).toMatchSnapshot(); + expect(view()).toMatchInlineSnapshot(` +"<> + <0> + "0123456789" {} +" +`); }); test('can annotate beginning of text', () => { const {editor, view} = setup(); editor.cursor.setAt(0, 3); editor.saved.insOne('BOLD'); - expect(view()).toMatchSnapshot(); + expect(view()).toMatchInlineSnapshot(` +"<> + <0> + "" {} + "012" { BOLD = [ !u ] } + "3456789" {} +" +`); }); test('can annotate middle of text', () => { const {editor, view} = setup(); editor.cursor.setAt(3, 3); editor.saved.insOne('BOLD'); - expect(view()).toMatchSnapshot(); + expect(view()).toMatchInlineSnapshot(` +"<> + <0> + "012" {} + "345" { BOLD = [ !u ] } + "6789" {} +" +`); }); test('can annotate end of text', () => { const {editor, view} = setup(); editor.cursor.setAt(7, 3); editor.saved.insOne('ITALIC'); - expect(view()).toMatchSnapshot(); + expect(view()).toMatchInlineSnapshot(` +"<> + <0> + "0123456" {} + "789" { ITALIC = [ !u ] } + "" {} +" +`); }); test('can annotate two regions', () => { @@ -51,7 +77,16 @@ const runTests = (_setup: () => Kit) => { editor.saved.insOne('BOLD'); editor.cursor.setAt(5, 3); editor.saved.insOne('ITALIC'); - expect(view()).toMatchSnapshot(); + expect(view()).toMatchInlineSnapshot(` +"<> + <0> + "0" {} + "12" { BOLD = [ !u ] } + "34" {} + "567" { ITALIC = [ !u ] } + "89" {} +" +`); }); test('can annotate two adjacent regions', () => { @@ -60,7 +95,16 @@ const runTests = (_setup: () => Kit) => { editor.saved.insOne('BOLD'); editor.cursor.setAt(2, 3); editor.saved.insOne('ITALIC'); - expect(view()).toMatchSnapshot(); + expect(view()).toMatchInlineSnapshot(` +"<> + <0> + "" {} + "01" { BOLD = [ !u ] } + "" {} + "234" { ITALIC = [ !u ] } + "56789" {} +" +`); }); test('can annotate two adjacent regions at the end of text', () => { @@ -69,7 +113,16 @@ const runTests = (_setup: () => Kit) => { editor.saved.insOne('BOLD'); editor.cursor.setAt(7, 3); editor.saved.insOne('ITALIC'); - expect(view()).toMatchSnapshot(); + expect(view()).toMatchInlineSnapshot(` +"<> + <0> + "01234" {} + "56" { BOLD = [ !u ] } + "" {} + "789" { ITALIC = [ !u ] } + "" {} +" +`); }); test('can annotate overlapping regions at the beginning of text', () => { @@ -78,7 +131,16 @@ const runTests = (_setup: () => Kit) => { editor.saved.insOne('BOLD'); editor.cursor.setAt(1, 2); editor.saved.insOne('ITALIC'); - expect(view()).toMatchSnapshot(); + expect(view()).toMatchInlineSnapshot(` +"<> + <0> + "" {} + "0" { BOLD = [ !u ] } + "1" { BOLD = [ !u ], ITALIC = [ !u ] } + "2" { ITALIC = [ !u ] } + "3456789" {} +" +`); }); test('can annotate overlapping regions in the middle of text', () => { @@ -87,7 +149,16 @@ const runTests = (_setup: () => Kit) => { editor.saved.insOne('BOLD'); editor.cursor.setAt(5, 2); editor.saved.insOne('ITALIC'); - expect(view()).toMatchSnapshot(); + expect(view()).toMatchInlineSnapshot(` +"<> + <0> + "0123" {} + "4" { BOLD = [ !u ] } + "5" { BOLD = [ !u ], ITALIC = [ !u ] } + "6" { ITALIC = [ !u ] } + "789" {} +" +`); }); test('can annotate a contained region at the beginning of text', () => { @@ -96,7 +167,16 @@ const runTests = (_setup: () => Kit) => { editor.saved.insOne('BOLD'); editor.cursor.setAt(1, 2); editor.saved.insOne('ITALIC'); - expect(view()).toMatchSnapshot(); + expect(view()).toMatchInlineSnapshot(` +"<> + <0> + "" {} + "0" { BOLD = [ !u ] } + "12" { BOLD = [ !u ], ITALIC = [ !u ] } + "34" { BOLD = [ !u ] } + "56789" {} +" +`); }); test('can annotate twice contained region in the middle of text', () => { @@ -107,7 +187,18 @@ const runTests = (_setup: () => Kit) => { editor.saved.insOne('ITALIC'); editor.cursor.setAt(6, 1); editor.saved.insOne('UNDERLINE'); - expect(view()).toMatchSnapshot(); + expect(view()).toMatchInlineSnapshot(` +"<> + <0> + "0123" {} + "4" { BOLD = [ !u ] } + "5" { BOLD = [ !u ], ITALIC = [ !u ] } + "6" { BOLD = [ !u ], ITALIC = [ !u ], UNDERLINE = [ !u ] } + "7" { BOLD = [ !u ], ITALIC = [ !u ] } + "8" { BOLD = [ !u ] } + "9" {} +" +`); }); test('can annotate twice contained region at the end of text', () => { @@ -118,7 +209,18 @@ const runTests = (_setup: () => Kit) => { editor.saved.insOne('ITALIC'); editor.cursor.setAt(7, 1); editor.saved.insOne('UNDERLINE'); - expect(view()).toMatchSnapshot(); + expect(view()).toMatchInlineSnapshot(` +"<> + <0> + "01234" {} + "5" { BOLD = [ !u ] } + "6" { BOLD = [ !u ], ITALIC = [ !u ] } + "7" { BOLD = [ !u ], ITALIC = [ !u ], UNDERLINE = [ !u ] } + "8" { BOLD = [ !u ], ITALIC = [ !u ] } + "9" { BOLD = [ !u ] } + "" {} +" +`); }); test('can annotate three intermingled regions', () => { @@ -129,14 +231,31 @@ const runTests = (_setup: () => Kit) => { editor.saved.insOne('ITALIC'); editor.cursor.setAt(4, 5); editor.saved.insOne('UNDERLINE'); - expect(view()).toMatchSnapshot(); + expect(view()).toMatchInlineSnapshot(` +"<> + <0> + "0" {} + "1" { ITALIC = [ !u ] } + "23" { BOLD = [ !u ], ITALIC = [ !u ] } + "45" { BOLD = [ !u ], ITALIC = [ !u ], UNDERLINE = [ !u ] } + "67" { BOLD = [ !u ], UNDERLINE = [ !u ] } + "8" { UNDERLINE = [ !u ] } + "9" {} +" +`); }); test('can insert zero length slice', () => { const {editor, view} = setup(); editor.cursor.setAt(2, 0); editor.saved.insOne('CURSOR'); - expect(view()).toMatchSnapshot(); + expect(view()).toMatchInlineSnapshot(` +"<> + <0> + "01" {} + "23456789" { CURSOR = [ !u ] } +" +`); }); }; diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/__tests__/__snapshots__/Peritext.render-block.spec.ts.snap b/packages/json-joy/src/json-crdt-extensions/peritext/__tests__/__snapshots__/Peritext.render-block.spec.ts.snap deleted file mode 100644 index a043f83f13..0000000000 --- a/packages/json-joy/src/json-crdt-extensions/peritext/__tests__/__snapshots__/Peritext.render-block.spec.ts.snap +++ /dev/null @@ -1,509 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`single text chunk can annotate with slice over two block splits 1`] = ` -"<> - <0> - "abcdefgh" {} - "ij" { BOLD = [ !u ] } -

        - "klmn" { BOLD = [ !u ] } -

        - "opqrstu" { BOLD = [ !u ] } - "vwxyz" {} -" -`; - -exports[`single text chunk can insert at the beginning of text 1`] = ` -"<> -

        { foo = "bar" } - "abcdefghijklmnopqrstuvwxyz" {} -" -`; - -exports[`single text chunk can insert at the end of text 1`] = ` -"<> - <0> - "abcdefghijklmnopqrstuvwxyz" {} - { link = "foobar" } -" -`; - -exports[`single text chunk can insert marker in the middle of text 1`] = ` -"<> - <0> - "abcdefghij" {} -

        { foo = "bar" } - "klmnopqrstuvwxyz" {} -" -`; - -exports[`single text chunk can insert two blocks 1`] = ` -"<> - <0> - "abcdefghij" {} -

        - "klmnopqrst" {} -

        - "uvwxyz" {} -" -`; - -exports[`single text chunk can split text after slice 1`] = ` -"<> - <0> - "abcde" {} - "fghij" { BOLD = [ !u ] } - "klmno" {} - - "pqrstuvwxyz" {} -" -`; - -exports[`single text chunk can split text before slice 1`] = ` -"<> - <0> - "abcdefghij" {} - - "klmno" {} - "pqrst" { BOLD = [ !u ] } - "uvwxyz" {} -" -`; - -exports[`single text chunk can split text in the middle of a slice 1`] = ` -"<> - <0> - "abcde" {} - "fghij" { BOLD = [ !u ] } - - "klmno" { BOLD = [ !u ] } - "pqrstuvwxyz" {} -" -`; - -exports[`single text chunk can split text right after slice 1`] = ` -"<> - <0> - "abcde" {} - "fghij" { BOLD = [ !u ] } - "" {} - - "klmnopqrstuvwxyz" {} -" -`; - -exports[`single text chunk can split text right before slice 1`] = ` -"<> - <0> - "abcdefghijklmno" {} - - "pqrst" { BOLD = [ !u ] } - "uvwxyz" {} -" -`; - -exports[`single text chunk nested block data - 2 1`] = ` -"<> - <0> - "abcde" {} -

          { type = "tasks" } -
        • { completed = !f } - "fghi" {} -
            { type = "tasks" } -
          • { completed = !t } - "jklmnopqrstuvwxyz" {} -" -`; - -exports[`single text chunk nested block data 1`] = ` -"<> - <0> - "abcde" {} -
              { type = "tasks" } -
            • { completed = !f } - "fghi" {} -
            • { completed = !t } - "jklmnopqrstuvwxyz" {} -" -`; - -exports[`text with block split can annotate with slice over two block splits 1`] = ` -"<> - <0> - "abcdefgh" {} - "ij" { BOLD = [ !u ] } -

              - "klmn" { BOLD = [ !u ] } -

              - "opqrstu" { BOLD = [ !u ] } - "vwxyz" {} -" -`; - -exports[`text with block split can insert at the beginning of text 1`] = ` -"<> -

              { foo = "bar" } - "abcdefghijklmnopqrstuvwxyz" {} -" -`; - -exports[`text with block split can insert at the end of text 1`] = ` -"<> - <0> - "abcdefghijklmnopqrstuvwxyz" {} - { link = "foobar" } -" -`; - -exports[`text with block split can insert marker in the middle of text 1`] = ` -"<> - <0> - "abcdefghij" {} -

              { foo = "bar" } - "klmnopqrstuvwxyz" {} -" -`; - -exports[`text with block split can insert two blocks 1`] = ` -"<> - <0> - "abcdefghij" {} -

              - "klmnopqrst" {} -

              - "uvwxyz" {} -" -`; - -exports[`text with block split can split text after slice 1`] = ` -"<> - <0> - "abcde" {} - "fghij" { BOLD = [ !u ] } - "klmno" {} - - "pqrstuvwxyz" {} -" -`; - -exports[`text with block split can split text before slice 1`] = ` -"<> - <0> - "abcdefghij" {} - - "klmno" {} - "pqrst" { BOLD = [ !u ] } - "uvwxyz" {} -" -`; - -exports[`text with block split can split text in the middle of a slice 1`] = ` -"<> - <0> - "abcde" {} - "fghij" { BOLD = [ !u ] } - - "klmno" { BOLD = [ !u ] } - "pqrstuvwxyz" {} -" -`; - -exports[`text with block split can split text right after slice 1`] = ` -"<> - <0> - "abcde" {} - "fghij" { BOLD = [ !u ] } - "" {} - - "klmnopqrstuvwxyz" {} -" -`; - -exports[`text with block split can split text right before slice 1`] = ` -"<> - <0> - "abcdefghijklmno" {} - - "pqrst" { BOLD = [ !u ] } - "uvwxyz" {} -" -`; - -exports[`text with block split nested block data - 2 1`] = ` -"<> - <0> - "abcde" {} -

                { type = "tasks" } -
              • { completed = !f } - "fghi" {} -
                  { type = "tasks" } -
                • { completed = !t } - "jklmnopqrstuvwxyz" {} -" -`; - -exports[`text with block split nested block data 1`] = ` -"<> - <0> - "abcde" {} -
                    { type = "tasks" } -
                  • { completed = !f } - "fghi" {} -
                  • { completed = !t } - "jklmnopqrstuvwxyz" {} -" -`; - -exports[`text with deletes can annotate with slice over two block splits 1`] = ` -"<> - <0> - "abcdefgh" {} - "ij" { BOLD = [ !u ] } -

                    - "klmn" { BOLD = [ !u ] } -

                    - "opqrstu" { BOLD = [ !u ] } - "vwxyz" {} -" -`; - -exports[`text with deletes can insert at the beginning of text 1`] = ` -"<> -

                    { foo = "bar" } - "abcdefghijklmnopqrstuvwxyz" {} -" -`; - -exports[`text with deletes can insert at the end of text 1`] = ` -"<> - <0> - "abcdefghijklmnopqrstuvwxyz" {} - { link = "foobar" } -" -`; - -exports[`text with deletes can insert marker in the middle of text 1`] = ` -"<> - <0> - "abcdefghij" {} -

                    { foo = "bar" } - "klmnopqrstuvwxyz" {} -" -`; - -exports[`text with deletes can insert two blocks 1`] = ` -"<> - <0> - "abcdefghij" {} -

                    - "klmnopqrst" {} -

                    - "uvwxyz" {} -" -`; - -exports[`text with deletes can split text after slice 1`] = ` -"<> - <0> - "abcde" {} - "fghij" { BOLD = [ !u ] } - "klmno" {} - - "pqrstuvwxyz" {} -" -`; - -exports[`text with deletes can split text before slice 1`] = ` -"<> - <0> - "abcdefghij" {} - - "klmno" {} - "pqrst" { BOLD = [ !u ] } - "uvwxyz" {} -" -`; - -exports[`text with deletes can split text in the middle of a slice 1`] = ` -"<> - <0> - "abcde" {} - "fghij" { BOLD = [ !u ] } - - "klmno" { BOLD = [ !u ] } - "pqrstuvwxyz" {} -" -`; - -exports[`text with deletes can split text right after slice 1`] = ` -"<> - <0> - "abcde" {} - "fghij" { BOLD = [ !u ] } - "" {} - - "klmnopqrstuvwxyz" {} -" -`; - -exports[`text with deletes can split text right before slice 1`] = ` -"<> - <0> - "abcdefghijklmno" {} - - "pqrst" { BOLD = [ !u ] } - "uvwxyz" {} -" -`; - -exports[`text with deletes nested block data - 2 1`] = ` -"<> - <0> - "abcde" {} -

                      { type = "tasks" } -
                    • { completed = !f } - "fghi" {} -
                        { type = "tasks" } -
                      • { completed = !t } - "jklmnopqrstuvwxyz" {} -" -`; - -exports[`text with deletes nested block data 1`] = ` -"<> - <0> - "abcde" {} -
                          { type = "tasks" } -
                        • { completed = !f } - "fghi" {} -
                        • { completed = !t } - "jklmnopqrstuvwxyz" {} -" -`; - -exports[`two text chunks can annotate with slice over two block splits 1`] = ` -"<> - <0> - "abcdefgh" {} - "ij" { BOLD = [ !u ] } -

                          - "klmn" { BOLD = [ !u ] } -

                          - "opqrstu" { BOLD = [ !u ] } - "vwxyz" {} -" -`; - -exports[`two text chunks can insert at the beginning of text 1`] = ` -"<> -

                          { foo = "bar" } - "abcdefghijklmnopqrstuvwxyz" {} -" -`; - -exports[`two text chunks can insert at the end of text 1`] = ` -"<> - <0> - "abcdefghijklmnopqrstuvwxyz" {} - { link = "foobar" } -" -`; - -exports[`two text chunks can insert marker in the middle of text 1`] = ` -"<> - <0> - "abcdefghij" {} -

                          { foo = "bar" } - "klmnopqrstuvwxyz" {} -" -`; - -exports[`two text chunks can insert two blocks 1`] = ` -"<> - <0> - "abcdefghij" {} -

                          - "klmnopqrst" {} -

                          - "uvwxyz" {} -" -`; - -exports[`two text chunks can split text after slice 1`] = ` -"<> - <0> - "abcde" {} - "fghij" { BOLD = [ !u ] } - "klmno" {} - - "pqrstuvwxyz" {} -" -`; - -exports[`two text chunks can split text before slice 1`] = ` -"<> - <0> - "abcdefghij" {} - - "klmno" {} - "pqrst" { BOLD = [ !u ] } - "uvwxyz" {} -" -`; - -exports[`two text chunks can split text in the middle of a slice 1`] = ` -"<> - <0> - "abcde" {} - "fghij" { BOLD = [ !u ] } - - "klmno" { BOLD = [ !u ] } - "pqrstuvwxyz" {} -" -`; - -exports[`two text chunks can split text right after slice 1`] = ` -"<> - <0> - "abcde" {} - "fghij" { BOLD = [ !u ] } - "" {} - - "klmnopqrstuvwxyz" {} -" -`; - -exports[`two text chunks can split text right before slice 1`] = ` -"<> - <0> - "abcdefghijklmno" {} - - "pqrst" { BOLD = [ !u ] } - "uvwxyz" {} -" -`; - -exports[`two text chunks nested block data - 2 1`] = ` -"<> - <0> - "abcde" {} -

                            { type = "tasks" } -
                          • { completed = !f } - "fghi" {} -
                              { type = "tasks" } -
                            • { completed = !t } - "jklmnopqrstuvwxyz" {} -" -`; - -exports[`two text chunks nested block data 1`] = ` -"<> - <0> - "abcde" {} -
                                { type = "tasks" } -
                              • { completed = !f } - "fghi" {} -
                              • { completed = !t } - "jklmnopqrstuvwxyz" {} -" -`; diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/__tests__/__snapshots__/Peritext.render-cursor-movement.spec.ts.snap b/packages/json-joy/src/json-crdt-extensions/peritext/__tests__/__snapshots__/Peritext.render-cursor-movement.spec.ts.snap deleted file mode 100644 index 66f8de4a92..0000000000 --- a/packages/json-joy/src/json-crdt-extensions/peritext/__tests__/__snapshots__/Peritext.render-cursor-movement.spec.ts.snap +++ /dev/null @@ -1,283 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`single text chunk can move cursor forward - starting from middle 1`] = ` -"<> - <0> - "a" {} - "bcdefghijklmnopqrstuvwxyz" { -1 = [ !u ] } -" -`; - -exports[`single text chunk can move cursor forward - starting from middle 2`] = ` -"<> - <0> - "ab" {} - "cdefghijklmnopqrstuvwxyz" { -1 = [ !u ] } -" -`; - -exports[`single text chunk can move cursor forward - starting from middle 3`] = ` -"<> - <0> - "abcd" {} - "efghijklmnopqrstuvwxyz" { -1 = [ !u ] } -" -`; - -exports[`single text chunk can move cursor forward - starting the beginning of the string 1`] = ` -"<> - <0> - "abcdefghijklmnopqrstuvwxyz" { -1 = [ !u ] } -" -`; - -exports[`single text chunk can move cursor forward - starting the beginning of the string 2`] = ` -"<> - <0> - "a" {} - "bcdefghijklmnopqrstuvwxyz" { -1 = [ !u ] } -" -`; - -exports[`single text chunk can move cursor forward - starting the beginning of the string 3`] = ` -"<> - <0> - "abc" {} - "defghijklmnopqrstuvwxyz" { -1 = [ !u ] } -" -`; - -exports[`two chunks can move cursor forward - starting from middle 1`] = ` -"<> - <0> - "a" {} - "bcdefghijklmnopqrstuvwxyz" { -1 = [ !u ] } -" -`; - -exports[`two chunks can move cursor forward - starting from middle 2`] = ` -"<> - <0> - "ab" {} - "cdefghijklmnopqrstuvwxyz" { -1 = [ !u ] } -" -`; - -exports[`two chunks can move cursor forward - starting from middle 3`] = ` -"<> - <0> - "abcd" {} - "efghijklmnopqrstuvwxyz" { -1 = [ !u ] } -" -`; - -exports[`two chunks can move cursor forward - starting the beginning of the string 1`] = ` -"<> - <0> - "abcdefghijklmnopqrstuvwxyz" { -1 = [ !u ] } -" -`; - -exports[`two chunks can move cursor forward - starting the beginning of the string 2`] = ` -"<> - <0> - "a" {} - "bcdefghijklmnopqrstuvwxyz" { -1 = [ !u ] } -" -`; - -exports[`two chunks can move cursor forward - starting the beginning of the string 3`] = ` -"<> - <0> - "abc" {} - "defghijklmnopqrstuvwxyz" { -1 = [ !u ] } -" -`; - -exports[`with chunk split can move cursor forward - starting from middle 1`] = ` -"<> - <0> - "a" {} - "bcdefghijklmnopqrstuvwxyz" { -1 = [ !u ] } -" -`; - -exports[`with chunk split can move cursor forward - starting from middle 2`] = ` -"<> - <0> - "ab" {} - "cdefghijklmnopqrstuvwxyz" { -1 = [ !u ] } -" -`; - -exports[`with chunk split can move cursor forward - starting from middle 3`] = ` -"<> - <0> - "abcd" {} - "efghijklmnopqrstuvwxyz" { -1 = [ !u ] } -" -`; - -exports[`with chunk split can move cursor forward - starting the beginning of the string 1`] = ` -"<> - <0> - "abcdefghijklmnopqrstuvwxyz" { -1 = [ !u ] } -" -`; - -exports[`with chunk split can move cursor forward - starting the beginning of the string 2`] = ` -"<> - <0> - "a" {} - "bcdefghijklmnopqrstuvwxyz" { -1 = [ !u ] } -" -`; - -exports[`with chunk split can move cursor forward - starting the beginning of the string 3`] = ` -"<> - <0> - "abc" {} - "defghijklmnopqrstuvwxyz" { -1 = [ !u ] } -" -`; - -exports[`with deletes can move cursor forward - starting from middle 1`] = ` -"<> - <0> - "a" {} - "bcdefghijklmnopqrstuvwxyz" { -1 = [ !u ] } -" -`; - -exports[`with deletes can move cursor forward - starting from middle 2`] = ` -"<> - <0> - "ab" {} - "cdefghijklmnopqrstuvwxyz" { -1 = [ !u ] } -" -`; - -exports[`with deletes can move cursor forward - starting from middle 3`] = ` -"<> - <0> - "abcd" {} - "efghijklmnopqrstuvwxyz" { -1 = [ !u ] } -" -`; - -exports[`with deletes can move cursor forward - starting the beginning of the string 1`] = ` -"<> - <0> - "abcdefghijklmnopqrstuvwxyz" { -1 = [ !u ] } -" -`; - -exports[`with deletes can move cursor forward - starting the beginning of the string 2`] = ` -"<> - <0> - "a" {} - "bcdefghijklmnopqrstuvwxyz" { -1 = [ !u ] } -" -`; - -exports[`with deletes can move cursor forward - starting the beginning of the string 3`] = ` -"<> - <0> - "abc" {} - "defghijklmnopqrstuvwxyz" { -1 = [ !u ] } -" -`; - -exports[`written in reverse can move cursor forward - starting from middle 1`] = ` -"<> - <0> - "a" {} - "bcdefghijklmnopqrstuvwxyz" { -1 = [ !u ] } -" -`; - -exports[`written in reverse can move cursor forward - starting from middle 2`] = ` -"<> - <0> - "ab" {} - "cdefghijklmnopqrstuvwxyz" { -1 = [ !u ] } -" -`; - -exports[`written in reverse can move cursor forward - starting from middle 3`] = ` -"<> - <0> - "abcd" {} - "efghijklmnopqrstuvwxyz" { -1 = [ !u ] } -" -`; - -exports[`written in reverse can move cursor forward - starting the beginning of the string 1`] = ` -"<> - <0> - "abcdefghijklmnopqrstuvwxyz" { -1 = [ !u ] } -" -`; - -exports[`written in reverse can move cursor forward - starting the beginning of the string 2`] = ` -"<> - <0> - "a" {} - "bcdefghijklmnopqrstuvwxyz" { -1 = [ !u ] } -" -`; - -exports[`written in reverse can move cursor forward - starting the beginning of the string 3`] = ` -"<> - <0> - "abc" {} - "defghijklmnopqrstuvwxyz" { -1 = [ !u ] } -" -`; - -exports[`written in reverse with deletes can move cursor forward - starting from middle 1`] = ` -"<> - <0> - "a" {} - "bcdefghijklmnopqrstuvwxyz" { -1 = [ !u ] } -" -`; - -exports[`written in reverse with deletes can move cursor forward - starting from middle 2`] = ` -"<> - <0> - "ab" {} - "cdefghijklmnopqrstuvwxyz" { -1 = [ !u ] } -" -`; - -exports[`written in reverse with deletes can move cursor forward - starting from middle 3`] = ` -"<> - <0> - "abcd" {} - "efghijklmnopqrstuvwxyz" { -1 = [ !u ] } -" -`; - -exports[`written in reverse with deletes can move cursor forward - starting the beginning of the string 1`] = ` -"<> - <0> - "abcdefghijklmnopqrstuvwxyz" { -1 = [ !u ] } -" -`; - -exports[`written in reverse with deletes can move cursor forward - starting the beginning of the string 2`] = ` -"<> - <0> - "a" {} - "bcdefghijklmnopqrstuvwxyz" { -1 = [ !u ] } -" -`; - -exports[`written in reverse with deletes can move cursor forward - starting the beginning of the string 3`] = ` -"<> - <0> - "abc" {} - "defghijklmnopqrstuvwxyz" { -1 = [ !u ] } -" -`; diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/__tests__/__snapshots__/Peritext.render-inline.spec.ts.snap b/packages/json-joy/src/json-crdt-extensions/peritext/__tests__/__snapshots__/Peritext.render-inline.spec.ts.snap deleted file mode 100644 index 1f2e788f9e..0000000000 --- a/packages/json-joy/src/json-crdt-extensions/peritext/__tests__/__snapshots__/Peritext.render-inline.spec.ts.snap +++ /dev/null @@ -1,736 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`numbers "0123456789", no edits can annotate a contained region at the beginning of text 1`] = ` -"<> - <0> - "" {} - "0" { BOLD = [ !u ] } - "12" { BOLD = [ !u ], ITALIC = [ !u ] } - "34" { BOLD = [ !u ] } - "56789" {} -" -`; - -exports[`numbers "0123456789", no edits can annotate beginning of text 1`] = ` -"<> - <0> - "" {} - "012" { BOLD = [ !u ] } - "3456789" {} -" -`; - -exports[`numbers "0123456789", no edits can annotate end of text 1`] = ` -"<> - <0> - "0123456" {} - "789" { ITALIC = [ !u ] } - "" {} -" -`; - -exports[`numbers "0123456789", no edits can annotate middle of text 1`] = ` -"<> - <0> - "012" {} - "345" { BOLD = [ !u ] } - "6789" {} -" -`; - -exports[`numbers "0123456789", no edits can annotate overlapping regions at the beginning of text 1`] = ` -"<> - <0> - "" {} - "0" { BOLD = [ !u ] } - "1" { BOLD = [ !u ], ITALIC = [ !u ] } - "2" { ITALIC = [ !u ] } - "3456789" {} -" -`; - -exports[`numbers "0123456789", no edits can annotate overlapping regions in the middle of text 1`] = ` -"<> - <0> - "0123" {} - "4" { BOLD = [ !u ] } - "5" { BOLD = [ !u ], ITALIC = [ !u ] } - "6" { ITALIC = [ !u ] } - "789" {} -" -`; - -exports[`numbers "0123456789", no edits can annotate three intermingled regions 1`] = ` -"<> - <0> - "0" {} - "1" { ITALIC = [ !u ] } - "23" { BOLD = [ !u ], ITALIC = [ !u ] } - "45" { BOLD = [ !u ], ITALIC = [ !u ], UNDERLINE = [ !u ] } - "67" { BOLD = [ !u ], UNDERLINE = [ !u ] } - "8" { UNDERLINE = [ !u ] } - "9" {} -" -`; - -exports[`numbers "0123456789", no edits can annotate twice contained region at the end of text 1`] = ` -"<> - <0> - "01234" {} - "5" { BOLD = [ !u ] } - "6" { BOLD = [ !u ], ITALIC = [ !u ] } - "7" { BOLD = [ !u ], ITALIC = [ !u ], UNDERLINE = [ !u ] } - "8" { BOLD = [ !u ], ITALIC = [ !u ] } - "9" { BOLD = [ !u ] } - "" {} -" -`; - -exports[`numbers "0123456789", no edits can annotate twice contained region in the middle of text 1`] = ` -"<> - <0> - "0123" {} - "4" { BOLD = [ !u ] } - "5" { BOLD = [ !u ], ITALIC = [ !u ] } - "6" { BOLD = [ !u ], ITALIC = [ !u ], UNDERLINE = [ !u ] } - "7" { BOLD = [ !u ], ITALIC = [ !u ] } - "8" { BOLD = [ !u ] } - "9" {} -" -`; - -exports[`numbers "0123456789", no edits can annotate two adjacent regions 1`] = ` -"<> - <0> - "" {} - "01" { BOLD = [ !u ] } - "" {} - "234" { ITALIC = [ !u ] } - "56789" {} -" -`; - -exports[`numbers "0123456789", no edits can annotate two adjacent regions at the end of text 1`] = ` -"<> - <0> - "01234" {} - "56" { BOLD = [ !u ] } - "" {} - "789" { ITALIC = [ !u ] } - "" {} -" -`; - -exports[`numbers "0123456789", no edits can annotate two regions 1`] = ` -"<> - <0> - "0" {} - "12" { BOLD = [ !u ] } - "34" {} - "567" { ITALIC = [ !u ] } - "89" {} -" -`; - -exports[`numbers "0123456789", no edits can insert zero length slice 1`] = ` -"<> - <0> - "01" {} - "23456789" { CURSOR = [ !u ] } -" -`; - -exports[`numbers "0123456789", no edits renders plain text 1`] = ` -"<> - <0> - "0123456789" {} -" -`; - -exports[`numbers "0123456789", two RGA chunks can annotate a contained region at the beginning of text 1`] = ` -"<> - <0> - "" {} - "0" { BOLD = [ !u ] } - "12" { BOLD = [ !u ], ITALIC = [ !u ] } - "34" { BOLD = [ !u ] } - "56789" {} -" -`; - -exports[`numbers "0123456789", two RGA chunks can annotate beginning of text 1`] = ` -"<> - <0> - "" {} - "012" { BOLD = [ !u ] } - "3456789" {} -" -`; - -exports[`numbers "0123456789", two RGA chunks can annotate end of text 1`] = ` -"<> - <0> - "0123456" {} - "789" { ITALIC = [ !u ] } - "" {} -" -`; - -exports[`numbers "0123456789", two RGA chunks can annotate middle of text 1`] = ` -"<> - <0> - "012" {} - "345" { BOLD = [ !u ] } - "6789" {} -" -`; - -exports[`numbers "0123456789", two RGA chunks can annotate overlapping regions at the beginning of text 1`] = ` -"<> - <0> - "" {} - "0" { BOLD = [ !u ] } - "1" { BOLD = [ !u ], ITALIC = [ !u ] } - "2" { ITALIC = [ !u ] } - "3456789" {} -" -`; - -exports[`numbers "0123456789", two RGA chunks can annotate overlapping regions in the middle of text 1`] = ` -"<> - <0> - "0123" {} - "4" { BOLD = [ !u ] } - "5" { BOLD = [ !u ], ITALIC = [ !u ] } - "6" { ITALIC = [ !u ] } - "789" {} -" -`; - -exports[`numbers "0123456789", two RGA chunks can annotate three intermingled regions 1`] = ` -"<> - <0> - "0" {} - "1" { ITALIC = [ !u ] } - "23" { BOLD = [ !u ], ITALIC = [ !u ] } - "45" { BOLD = [ !u ], ITALIC = [ !u ], UNDERLINE = [ !u ] } - "67" { BOLD = [ !u ], UNDERLINE = [ !u ] } - "8" { UNDERLINE = [ !u ] } - "9" {} -" -`; - -exports[`numbers "0123456789", two RGA chunks can annotate twice contained region at the end of text 1`] = ` -"<> - <0> - "01234" {} - "5" { BOLD = [ !u ] } - "6" { BOLD = [ !u ], ITALIC = [ !u ] } - "7" { BOLD = [ !u ], ITALIC = [ !u ], UNDERLINE = [ !u ] } - "8" { BOLD = [ !u ], ITALIC = [ !u ] } - "9" { BOLD = [ !u ] } - "" {} -" -`; - -exports[`numbers "0123456789", two RGA chunks can annotate twice contained region in the middle of text 1`] = ` -"<> - <0> - "0123" {} - "4" { BOLD = [ !u ] } - "5" { BOLD = [ !u ], ITALIC = [ !u ] } - "6" { BOLD = [ !u ], ITALIC = [ !u ], UNDERLINE = [ !u ] } - "7" { BOLD = [ !u ], ITALIC = [ !u ] } - "8" { BOLD = [ !u ] } - "9" {} -" -`; - -exports[`numbers "0123456789", two RGA chunks can annotate two adjacent regions 1`] = ` -"<> - <0> - "" {} - "01" { BOLD = [ !u ] } - "" {} - "234" { ITALIC = [ !u ] } - "56789" {} -" -`; - -exports[`numbers "0123456789", two RGA chunks can annotate two adjacent regions at the end of text 1`] = ` -"<> - <0> - "01234" {} - "56" { BOLD = [ !u ] } - "" {} - "789" { ITALIC = [ !u ] } - "" {} -" -`; - -exports[`numbers "0123456789", two RGA chunks can annotate two regions 1`] = ` -"<> - <0> - "0" {} - "12" { BOLD = [ !u ] } - "34" {} - "567" { ITALIC = [ !u ] } - "89" {} -" -`; - -exports[`numbers "0123456789", two RGA chunks can insert zero length slice 1`] = ` -"<> - <0> - "01" {} - "23456789" { CURSOR = [ !u ] } -" -`; - -exports[`numbers "0123456789", two RGA chunks renders plain text 1`] = ` -"<> - <0> - "0123456789" {} -" -`; - -exports[`numbers "0123456789", with RGA split can annotate a contained region at the beginning of text 1`] = ` -"<> - <0> - "" {} - "0" { BOLD = [ !u ] } - "12" { BOLD = [ !u ], ITALIC = [ !u ] } - "34" { BOLD = [ !u ] } - "56789" {} -" -`; - -exports[`numbers "0123456789", with RGA split can annotate beginning of text 1`] = ` -"<> - <0> - "" {} - "012" { BOLD = [ !u ] } - "3456789" {} -" -`; - -exports[`numbers "0123456789", with RGA split can annotate end of text 1`] = ` -"<> - <0> - "0123456" {} - "789" { ITALIC = [ !u ] } - "" {} -" -`; - -exports[`numbers "0123456789", with RGA split can annotate middle of text 1`] = ` -"<> - <0> - "012" {} - "345" { BOLD = [ !u ] } - "6789" {} -" -`; - -exports[`numbers "0123456789", with RGA split can annotate overlapping regions at the beginning of text 1`] = ` -"<> - <0> - "" {} - "0" { BOLD = [ !u ] } - "1" { BOLD = [ !u ], ITALIC = [ !u ] } - "2" { ITALIC = [ !u ] } - "3456789" {} -" -`; - -exports[`numbers "0123456789", with RGA split can annotate overlapping regions in the middle of text 1`] = ` -"<> - <0> - "0123" {} - "4" { BOLD = [ !u ] } - "5" { BOLD = [ !u ], ITALIC = [ !u ] } - "6" { ITALIC = [ !u ] } - "789" {} -" -`; - -exports[`numbers "0123456789", with RGA split can annotate three intermingled regions 1`] = ` -"<> - <0> - "0" {} - "1" { ITALIC = [ !u ] } - "23" { BOLD = [ !u ], ITALIC = [ !u ] } - "45" { BOLD = [ !u ], ITALIC = [ !u ], UNDERLINE = [ !u ] } - "67" { BOLD = [ !u ], UNDERLINE = [ !u ] } - "8" { UNDERLINE = [ !u ] } - "9" {} -" -`; - -exports[`numbers "0123456789", with RGA split can annotate twice contained region at the end of text 1`] = ` -"<> - <0> - "01234" {} - "5" { BOLD = [ !u ] } - "6" { BOLD = [ !u ], ITALIC = [ !u ] } - "7" { BOLD = [ !u ], ITALIC = [ !u ], UNDERLINE = [ !u ] } - "8" { BOLD = [ !u ], ITALIC = [ !u ] } - "9" { BOLD = [ !u ] } - "" {} -" -`; - -exports[`numbers "0123456789", with RGA split can annotate twice contained region in the middle of text 1`] = ` -"<> - <0> - "0123" {} - "4" { BOLD = [ !u ] } - "5" { BOLD = [ !u ], ITALIC = [ !u ] } - "6" { BOLD = [ !u ], ITALIC = [ !u ], UNDERLINE = [ !u ] } - "7" { BOLD = [ !u ], ITALIC = [ !u ] } - "8" { BOLD = [ !u ] } - "9" {} -" -`; - -exports[`numbers "0123456789", with RGA split can annotate two adjacent regions 1`] = ` -"<> - <0> - "" {} - "01" { BOLD = [ !u ] } - "" {} - "234" { ITALIC = [ !u ] } - "56789" {} -" -`; - -exports[`numbers "0123456789", with RGA split can annotate two adjacent regions at the end of text 1`] = ` -"<> - <0> - "01234" {} - "56" { BOLD = [ !u ] } - "" {} - "789" { ITALIC = [ !u ] } - "" {} -" -`; - -exports[`numbers "0123456789", with RGA split can annotate two regions 1`] = ` -"<> - <0> - "0" {} - "12" { BOLD = [ !u ] } - "34" {} - "567" { ITALIC = [ !u ] } - "89" {} -" -`; - -exports[`numbers "0123456789", with RGA split can insert zero length slice 1`] = ` -"<> - <0> - "01" {} - "23456789" { CURSOR = [ !u ] } -" -`; - -exports[`numbers "0123456789", with RGA split renders plain text 1`] = ` -"<> - <0> - "0123456789" {} -" -`; - -exports[`numbers "0123456789", with default schema and tombstones can annotate a contained region at the beginning of text 1`] = ` -"<> - <0> - "" {} - "0" { BOLD = [ !u ] } - "12" { BOLD = [ !u ], ITALIC = [ !u ] } - "34" { BOLD = [ !u ] } - "56789" {} -" -`; - -exports[`numbers "0123456789", with default schema and tombstones can annotate beginning of text 1`] = ` -"<> - <0> - "" {} - "012" { BOLD = [ !u ] } - "3456789" {} -" -`; - -exports[`numbers "0123456789", with default schema and tombstones can annotate end of text 1`] = ` -"<> - <0> - "0123456" {} - "789" { ITALIC = [ !u ] } - "" {} -" -`; - -exports[`numbers "0123456789", with default schema and tombstones can annotate middle of text 1`] = ` -"<> - <0> - "012" {} - "345" { BOLD = [ !u ] } - "6789" {} -" -`; - -exports[`numbers "0123456789", with default schema and tombstones can annotate overlapping regions at the beginning of text 1`] = ` -"<> - <0> - "" {} - "0" { BOLD = [ !u ] } - "1" { BOLD = [ !u ], ITALIC = [ !u ] } - "2" { ITALIC = [ !u ] } - "3456789" {} -" -`; - -exports[`numbers "0123456789", with default schema and tombstones can annotate overlapping regions in the middle of text 1`] = ` -"<> - <0> - "0123" {} - "4" { BOLD = [ !u ] } - "5" { BOLD = [ !u ], ITALIC = [ !u ] } - "6" { ITALIC = [ !u ] } - "789" {} -" -`; - -exports[`numbers "0123456789", with default schema and tombstones can annotate three intermingled regions 1`] = ` -"<> - <0> - "0" {} - "1" { ITALIC = [ !u ] } - "23" { BOLD = [ !u ], ITALIC = [ !u ] } - "45" { BOLD = [ !u ], ITALIC = [ !u ], UNDERLINE = [ !u ] } - "67" { BOLD = [ !u ], UNDERLINE = [ !u ] } - "8" { UNDERLINE = [ !u ] } - "9" {} -" -`; - -exports[`numbers "0123456789", with default schema and tombstones can annotate twice contained region at the end of text 1`] = ` -"<> - <0> - "01234" {} - "5" { BOLD = [ !u ] } - "6" { BOLD = [ !u ], ITALIC = [ !u ] } - "7" { BOLD = [ !u ], ITALIC = [ !u ], UNDERLINE = [ !u ] } - "8" { BOLD = [ !u ], ITALIC = [ !u ] } - "9" { BOLD = [ !u ] } - "" {} -" -`; - -exports[`numbers "0123456789", with default schema and tombstones can annotate twice contained region in the middle of text 1`] = ` -"<> - <0> - "0123" {} - "4" { BOLD = [ !u ] } - "5" { BOLD = [ !u ], ITALIC = [ !u ] } - "6" { BOLD = [ !u ], ITALIC = [ !u ], UNDERLINE = [ !u ] } - "7" { BOLD = [ !u ], ITALIC = [ !u ] } - "8" { BOLD = [ !u ] } - "9" {} -" -`; - -exports[`numbers "0123456789", with default schema and tombstones can annotate two adjacent regions 1`] = ` -"<> - <0> - "" {} - "01" { BOLD = [ !u ] } - "" {} - "234" { ITALIC = [ !u ] } - "56789" {} -" -`; - -exports[`numbers "0123456789", with default schema and tombstones can annotate two adjacent regions at the end of text 1`] = ` -"<> - <0> - "01234" {} - "56" { BOLD = [ !u ] } - "" {} - "789" { ITALIC = [ !u ] } - "" {} -" -`; - -exports[`numbers "0123456789", with default schema and tombstones can annotate two regions 1`] = ` -"<> - <0> - "0" {} - "12" { BOLD = [ !u ] } - "34" {} - "567" { ITALIC = [ !u ] } - "89" {} -" -`; - -exports[`numbers "0123456789", with default schema and tombstones can insert zero length slice 1`] = ` -"<> - <0> - "01" {} - "23456789" { CURSOR = [ !u ] } -" -`; - -exports[`numbers "0123456789", with default schema and tombstones renders plain text 1`] = ` -"<> - <0> - "0123456789" {} -" -`; - -exports[`numbers "0123456789", with multiple deletes can annotate a contained region at the beginning of text 1`] = ` -"<> - <0> - "" {} - "0" { BOLD = [ !u ] } - "12" { BOLD = [ !u ], ITALIC = [ !u ] } - "34" { BOLD = [ !u ] } - "56789" {} -" -`; - -exports[`numbers "0123456789", with multiple deletes can annotate beginning of text 1`] = ` -"<> - <0> - "" {} - "012" { BOLD = [ !u ] } - "3456789" {} -" -`; - -exports[`numbers "0123456789", with multiple deletes can annotate end of text 1`] = ` -"<> - <0> - "0123456" {} - "789" { ITALIC = [ !u ] } - "" {} -" -`; - -exports[`numbers "0123456789", with multiple deletes can annotate middle of text 1`] = ` -"<> - <0> - "012" {} - "345" { BOLD = [ !u ] } - "6789" {} -" -`; - -exports[`numbers "0123456789", with multiple deletes can annotate overlapping regions at the beginning of text 1`] = ` -"<> - <0> - "" {} - "0" { BOLD = [ !u ] } - "1" { BOLD = [ !u ], ITALIC = [ !u ] } - "2" { ITALIC = [ !u ] } - "3456789" {} -" -`; - -exports[`numbers "0123456789", with multiple deletes can annotate overlapping regions in the middle of text 1`] = ` -"<> - <0> - "0123" {} - "4" { BOLD = [ !u ] } - "5" { BOLD = [ !u ], ITALIC = [ !u ] } - "6" { ITALIC = [ !u ] } - "789" {} -" -`; - -exports[`numbers "0123456789", with multiple deletes can annotate three intermingled regions 1`] = ` -"<> - <0> - "0" {} - "1" { ITALIC = [ !u ] } - "23" { BOLD = [ !u ], ITALIC = [ !u ] } - "45" { BOLD = [ !u ], ITALIC = [ !u ], UNDERLINE = [ !u ] } - "67" { BOLD = [ !u ], UNDERLINE = [ !u ] } - "8" { UNDERLINE = [ !u ] } - "9" {} -" -`; - -exports[`numbers "0123456789", with multiple deletes can annotate twice contained region at the end of text 1`] = ` -"<> - <0> - "01234" {} - "5" { BOLD = [ !u ] } - "6" { BOLD = [ !u ], ITALIC = [ !u ] } - "7" { BOLD = [ !u ], ITALIC = [ !u ], UNDERLINE = [ !u ] } - "8" { BOLD = [ !u ], ITALIC = [ !u ] } - "9" { BOLD = [ !u ] } - "" {} -" -`; - -exports[`numbers "0123456789", with multiple deletes can annotate twice contained region in the middle of text 1`] = ` -"<> - <0> - "0123" {} - "4" { BOLD = [ !u ] } - "5" { BOLD = [ !u ], ITALIC = [ !u ] } - "6" { BOLD = [ !u ], ITALIC = [ !u ], UNDERLINE = [ !u ] } - "7" { BOLD = [ !u ], ITALIC = [ !u ] } - "8" { BOLD = [ !u ] } - "9" {} -" -`; - -exports[`numbers "0123456789", with multiple deletes can annotate two adjacent regions 1`] = ` -"<> - <0> - "" {} - "01" { BOLD = [ !u ] } - "" {} - "234" { ITALIC = [ !u ] } - "56789" {} -" -`; - -exports[`numbers "0123456789", with multiple deletes can annotate two adjacent regions at the end of text 1`] = ` -"<> - <0> - "01234" {} - "56" { BOLD = [ !u ] } - "" {} - "789" { ITALIC = [ !u ] } - "" {} -" -`; - -exports[`numbers "0123456789", with multiple deletes can annotate two regions 1`] = ` -"<> - <0> - "0" {} - "12" { BOLD = [ !u ] } - "34" {} - "567" { ITALIC = [ !u ] } - "89" {} -" -`; - -exports[`numbers "0123456789", with multiple deletes can insert zero length slice 1`] = ` -"<> - <0> - "01" {} - "23456789" { CURSOR = [ !u ] } -" -`; - -exports[`numbers "0123456789", with multiple deletes renders plain text 1`] = ` -"<> - <0> - "0123456789" {} -" -`; diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/block/Block.ts b/packages/json-joy/src/json-crdt-extensions/peritext/block/Block.ts index babd13262c..7e79b04237 100644 --- a/packages/json-joy/src/json-crdt-extensions/peritext/block/Block.ts +++ b/packages/json-joy/src/json-crdt-extensions/peritext/block/Block.ts @@ -1,13 +1,12 @@ import {printTree} from 'tree-dump/lib/printTree'; import {CONST, updateJson, updateNum} from '../../../json-hash/hash'; -import {MarkerOverlayPoint} from '../overlay/MarkerOverlayPoint'; import {UndEndIterator, type UndEndNext} from '../../../util/iterator'; import {Inline} from './Inline'; import {formatStep, getTag} from '../slice/util'; import {Range} from '../rga/Range'; import {toLine} from 'pojo-dump/lib/toLine'; +import {OverlayPoint} from '../overlay/OverlayPoint'; import type {Point} from '../rga/Point'; -import type {OverlayPoint} from '../overlay/OverlayPoint'; import type {Printable} from 'tree-dump'; import type {Peritext} from '../Peritext'; import type {Stateful} from '../types'; @@ -28,7 +27,7 @@ export class Block extends Range implements IBloc constructor( public readonly txt: Peritext, public readonly path: SliceTypeSteps, - public readonly marker: MarkerOverlayPoint | undefined, + public readonly marker: OverlayPoint | undefined, public start: Point, public end: Point, ) { @@ -79,7 +78,7 @@ export class Block extends Range implements IBloc if (closed) return; const point = iterator(); if (!point) return; - if (point instanceof MarkerOverlayPoint) { + if (point.isMarker()) { closed = true; return; } @@ -101,7 +100,8 @@ export class Block extends Range implements IBloc let pair = iterator(); while (!marker && pair && pair[1] && pair[1].cmpSpatial(this.start) < 0) pair = iterator(); if (!pair) return (closed = true), void 0; - if (!pair[1] || pair[1] instanceof MarkerOverlayPoint) closed = true; + const second = pair[1]; + if (!second || (second instanceof OverlayPoint && second.isMarker())) closed = true; return pair; }; } @@ -116,7 +116,6 @@ export class Block extends Range implements IBloc const start = this.start; const end = this.end; const startIsMarker = overlay.isMarker(start.id); - const endIsMarker = overlay.isMarker(end.id); let isFirst = true; let next = iterator(); let closed = false; @@ -134,12 +133,11 @@ export class Block extends Range implements IBloc if (startIsMarker) { point1 = point1.clone(); point1.step(1); - // Skip condition when inline annotations tarts immediately at th - // beginning of the block. - if (point1.cmp(point2) === 0) return newIterator(); + if (point1.cmpSpatial(point2) >= 0) return newIterator(); } } - if (!endIsMarker && end.cmpSpatial(overlayPoint2) < 0) { + // TODO: PERF: Speed up this check. Do the check only if necessary. + if (end.cmpSpatial(overlayPoint2) < 0) { closed = true; point2 = end; } @@ -185,7 +183,7 @@ export class Block extends Range implements IBloc state = updateJson(state, path); const marker = this.marker; if (marker) { - state = updateNum(state, marker.marker.refresh()); + state = updateNum(state, marker.markers[0].refresh()); state = updateNum(state, marker.textHash); } else { state = updateNum(state, this.txt.overlay.leadingTextHash); diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/block/Fragment.ts b/packages/json-joy/src/json-crdt-extensions/peritext/block/Fragment.ts index 4b54012be4..cec5b5425f 100644 --- a/packages/json-joy/src/json-crdt-extensions/peritext/block/Fragment.ts +++ b/packages/json-joy/src/json-crdt-extensions/peritext/block/Fragment.ts @@ -4,12 +4,12 @@ import {printTree} from 'tree-dump/lib/printTree'; import {LeafBlock} from './LeafBlock'; import {Range} from '../rga/Range'; import {CommonSliceType, type SliceTypeSteps} from '../slice'; -import type {MarkerOverlayPoint} from '../overlay/MarkerOverlayPoint'; import type {Stateful} from '../types'; import type {Printable} from 'tree-dump/lib/types'; import type {Peritext} from '../Peritext'; import type {Point} from '../rga/Point'; import type {PeritextMlElement} from './types'; +import type {OverlayPoint} from '../overlay/OverlayPoint'; /** * A *fragment* represents a structural slice of a rich-text document. A @@ -55,7 +55,7 @@ export class Fragment extends Range implements Printable, Statefu private insertBlock( parent: Block, path: SliceTypeSteps, - marker: undefined | MarkerOverlayPoint, + marker: undefined | OverlayPoint, end: Point = this.end, ): Block { const txt = this.txt; diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/block/Inline.ts b/packages/json-joy/src/json-crdt-extensions/peritext/block/Inline.ts index ed306d5365..5c8cb57796 100644 --- a/packages/json-joy/src/json-crdt-extensions/peritext/block/Inline.ts +++ b/packages/json-joy/src/json-crdt-extensions/peritext/block/Inline.ts @@ -11,8 +11,8 @@ import type {OverlayPoint} from '../overlay/OverlayPoint'; import type {Printable} from 'tree-dump/lib/types'; import type {PathStep} from '@jsonjoy.com/json-pointer'; import type {Peritext} from '../Peritext'; -import type {Slice} from '../slice/types'; import type {PeritextMlAttributes, PeritextMlNode} from './types'; +import type {Slice} from '../slice/Slice'; export abstract class AbstractInlineAttr { constructor(public slice: Slice) {} diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/block/__tests__/Block.iteration.spec.ts b/packages/json-joy/src/json-crdt-extensions/peritext/block/__tests__/Block.iteration.spec.ts index 3ae95541c3..6c8760291f 100644 --- a/packages/json-joy/src/json-crdt-extensions/peritext/block/__tests__/Block.iteration.spec.ts +++ b/packages/json-joy/src/json-crdt-extensions/peritext/block/__tests__/Block.iteration.spec.ts @@ -1,5 +1,4 @@ import {setupHelloWorldKit} from '../../__tests__/setup'; -import {MarkerOverlayPoint} from '../../overlay/MarkerOverlayPoint'; import {OverlayPoint} from '../../overlay/OverlayPoint'; const setupTwoBlockDocument = () => { @@ -88,7 +87,8 @@ describe('points', () => { expect(points2.length).toBe(5); expect(points1[0]).toBeInstanceOf(OverlayPoint); expect(points1[0]).toBe(peritext.overlay.START); - expect(points2[0]).toBeInstanceOf(MarkerOverlayPoint); + expect(points2[0]).toBeInstanceOf(OverlayPoint); + expect(points2[0]?.isMarker()).toBe(true); }); }); diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/block/__tests__/Fragment-export.spec.ts b/packages/json-joy/src/json-crdt-extensions/peritext/block/__tests__/Fragment-export.spec.ts index 40ba3723e8..3df84b021c 100644 --- a/packages/json-joy/src/json-crdt-extensions/peritext/block/__tests__/Fragment-export.spec.ts +++ b/packages/json-joy/src/json-crdt-extensions/peritext/block/__tests__/Fragment-export.spec.ts @@ -32,9 +32,9 @@ const runTests = (setup: () => Kit) => { 0, null, 'ef', - [-3, {inline: true}, 'g'], - [-4, {inline: true}, [-3, {inline: true}, 'h']], - [-4, {inline: true}, 'i'], + [CommonSliceType.b, {inline: true}, 'g'], + [CommonSliceType.i, {inline: true}, [CommonSliceType.b, {inline: true}, 'h']], + [CommonSliceType.i, {inline: true}, 'i'], 'j', ], [0, null, 'klm'], diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/block/__tests__/Fragment.spec.ts b/packages/json-joy/src/json-crdt-extensions/peritext/block/__tests__/Fragment.spec.ts index 6e952dfa31..94834637c9 100644 --- a/packages/json-joy/src/json-crdt-extensions/peritext/block/__tests__/Fragment.spec.ts +++ b/packages/json-joy/src/json-crdt-extensions/peritext/block/__tests__/Fragment.spec.ts @@ -1,5 +1,4 @@ import {setupHelloWorldKit} from '../../__tests__/setup'; -import {MarkerOverlayPoint} from '../../overlay/MarkerOverlayPoint'; import {Block} from '../Block'; import {LeafBlock} from '../LeafBlock'; import {CommonSliceType} from '../../slice'; @@ -32,7 +31,7 @@ test('can construct a two-paragraph document', () => { expect(paragraph1.path).toEqual([0]); expect(paragraph2.path).toEqual(['p']); expect(paragraph1.marker).toBe(undefined); - expect(paragraph2.marker instanceof MarkerOverlayPoint).toBe(true); + expect(paragraph2.marker?.isMarker()).toBe(true); }); test('first inline element does not contain marker text', () => { diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/constants.ts b/packages/json-joy/src/json-crdt-extensions/peritext/constants.ts index d23d8d1123..6864e315d2 100644 --- a/packages/json-joy/src/json-crdt-extensions/peritext/constants.ts +++ b/packages/json-joy/src/json-crdt-extensions/peritext/constants.ts @@ -2,10 +2,6 @@ import {type nodes, s} from '../../json-crdt-patch'; import {ExtensionId, ExtensionName} from '../constants'; import type {SliceSchema} from './slice/types'; -export enum Chars { - BlockSplitSentinel = '\n', -} - export enum Position { /** * Specifies the absolute start of the text, i.e. the position before the diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/editor/Cursor.ts b/packages/json-joy/src/json-crdt-extensions/peritext/editor/Cursor.ts index 3c475ad26d..5cdd5d09fb 100644 --- a/packages/json-joy/src/json-crdt-extensions/peritext/editor/Cursor.ts +++ b/packages/json-joy/src/json-crdt-extensions/peritext/editor/Cursor.ts @@ -1,6 +1,6 @@ import {printTs} from '../../../json-crdt-patch'; import {CursorAnchor} from '../slice/constants'; -import {PersistedSlice} from '../slice/PersistedSlice'; +import {Slice} from '../slice/Slice'; import type {Point} from '../rga/Point'; /** @@ -21,7 +21,7 @@ import type {Point} from '../rga/Point'; * side is the one that stays in place when the user presses the arrow keys. The * side of the anchor is determined by the {@link Cursor#anchorSide} property. */ -export class Cursor extends PersistedSlice { +export class Cursor extends Slice { /** * @todo Remove getter `get` here. */ diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/editor/Editor.ts b/packages/json-joy/src/json-crdt-extensions/peritext/editor/Editor.ts index 772b8509ac..36c20b54dc 100644 --- a/packages/json-joy/src/json-crdt-extensions/peritext/editor/Editor.ts +++ b/packages/json-joy/src/json-crdt-extensions/peritext/editor/Editor.ts @@ -1,26 +1,25 @@ import {Cursor} from './Cursor'; import {Anchor} from '../rga/constants'; -import {formatType} from '../slice/util'; +import {formatStep} from '../slice/util'; import {EditorSlices} from './EditorSlices'; import {next, prev} from 'sonic-forest/lib/util'; import {printTree} from 'tree-dump/lib/printTree'; import {SliceRegistry} from '../registry/SliceRegistry'; -import {PersistedSlice} from '../slice/PersistedSlice'; +import {Slice} from '../slice/Slice'; import {toLine} from 'pojo-dump/lib/toLine'; import {CommonSliceType, type SliceTypeSteps, type SliceType, type SliceTypeStep} from '../slice'; import {isLetter, isPunctuation, isWhitespace, stepsEqual} from './util'; import {ValueSyncStore} from '../../../util/events/sync-store'; -import {MarkerOverlayPoint} from '../overlay/MarkerOverlayPoint'; import {UndEndIterator, type UndEndNext} from '../../../util/iterator'; -import {tick, Timespan, type ITimespanStruct} from '../../../json-crdt-patch'; +import {type Patch, tick, Timespan, type ITimespanStruct, tss, PatchBuilder, s} from '../../../json-crdt-patch'; import {CursorAnchor, SliceStacking, SliceHeaderMask, SliceHeaderShift, SliceTypeCon} from '../slice/constants'; import {ArrApi} from '../../../json-crdt/model'; +import * as schema from '../slice/schema'; import type {Point} from '../rga/Point'; import type {Range} from '../rga/Range'; import type {Printable} from 'tree-dump'; import type {Peritext} from '../Peritext'; import type {ChunkSlice} from '../util/ChunkSlice'; -import type {MarkerSlice} from '../slice/MarkerSlice'; import type { CharIterator, CharPredicate, @@ -34,6 +33,8 @@ import type { MarkerUpdateTarget, } from './types'; import type {ApiOperation} from '../../../json-crdt/model/api/types'; +import {JsonCrdtDiff} from '../../../json-crdt-diff/JsonCrdtDiff'; +import {OverlayPoint} from '../overlay/OverlayPoint'; /** * For inline boolean ("Overwrite") slices, both range endpoints should be @@ -270,8 +271,7 @@ export class Editor implements Printable { const txt = this.txt; const overlay = txt.overlay; const contained = overlay.findContained(range); - for (const slice of contained) - if (slice instanceof PersistedSlice && slice.stacking !== SliceStacking.Cursor) slice.del(); + for (const slice of contained) if (slice instanceof Slice && slice.stacking !== SliceStacking.Cursor) slice.del(); txt.delStr(range); } @@ -467,8 +467,8 @@ export class Editor implements Printable { let overlayPoint = overlay.getOrNextHigher(point); if (!overlayPoint) return this.end(); if (point.cmp(overlayPoint) === 0) overlayPoint = next(overlayPoint); - while (!(overlayPoint instanceof MarkerOverlayPoint) && overlayPoint) overlayPoint = next(overlayPoint); - if (overlayPoint instanceof MarkerOverlayPoint) { + while (overlayPoint instanceof OverlayPoint && !overlayPoint.isMarker()) overlayPoint = next(overlayPoint); + if (overlayPoint instanceof OverlayPoint && overlayPoint.isMarker()) { const point = overlayPoint.clone(); point.refAfter(); return point; @@ -486,8 +486,8 @@ export class Editor implements Printable { if (point.isAbsStart()) return point; let overlayPoint = overlay.getOrNextLower(point); if (!overlayPoint) return this.start(); - while (!(overlayPoint instanceof MarkerOverlayPoint) && overlayPoint) overlayPoint = prev(overlayPoint); - if (overlayPoint instanceof MarkerOverlayPoint) { + while (overlayPoint instanceof OverlayPoint && !overlayPoint.isMarker()) overlayPoint = prev(overlayPoint); + if (overlayPoint instanceof OverlayPoint && overlayPoint.isMarker()) { const point = overlayPoint.clone(); point.refBefore(); return point; @@ -652,7 +652,7 @@ export class Editor implements Printable { overlay.refresh(); const contained = overlay.findContained(range); for (const slice of contained) { - if (slice instanceof PersistedSlice) { + if (slice instanceof Slice) { switch (slice.stacking) { case SliceStacking.One: case SliceStacking.Many: @@ -701,7 +701,7 @@ export class Editor implements Printable { const needToRemoveFormatting = complete.has(type); makeRangeExtendable(range); const contained = overlay.findContained(range); - for (const slice of contained) if (slice instanceof PersistedSlice && slice.type() === type) slice.del(); + for (const slice of contained) if (slice instanceof Slice && slice.type() === type) slice.del(); if (needToRemoveFormatting) { overlay.refresh(); const [complete2, partial2] = overlay.stat(range, 1e6); @@ -732,13 +732,13 @@ export class Editor implements Printable { ): void { // TODO: handle mutually exclusive slices (, ) this.txt.overlay.refresh(); - for (const range of selection) { + SELECTION: for (const range of selection) { if (range.isCollapsed()) { const pending = this.pending.value ?? new Map(); if (pending.has(type)) pending.delete(type); else pending.set(type, data); this.pending.next(pending); - continue; + continue SELECTION; } this.toggleRangeExclFmt(range, type, data, store); } @@ -752,8 +752,8 @@ export class Editor implements Printable { * @param point The point to get the marker at. * @returns The split marker at the point, if any. */ - public getMarker(point: Point): MarkerSlice | undefined { - return this.txt.overlay.getOrNextLowerMarker(point)?.marker; + public getMarker(point: Point): Slice | undefined { + return this.txt.overlay.getOrNextLowerMarker(point)?.markers[0]; } /** @@ -763,7 +763,7 @@ export class Editor implements Printable { * @param point The point to get the block type at. * @returns Current block type at the point. */ - public getBlockType(point: Point): [type: SliceTypeSteps, marker?: MarkerSlice | undefined] { + public getBlockType(point: Point): [type: SliceTypeSteps, marker?: Slice | undefined] { const marker = this.getMarker(point); if (!marker) return [[SliceTypeCon.p]]; let steps = marker?.type() ?? [SliceTypeCon.p]; @@ -780,7 +780,7 @@ export class Editor implements Printable { * @param type The type of the marker. * @returns The inserted marker slice. */ - public insStartMarker(type: SliceTypeSteps): MarkerSlice { + public insStartMarker(type: SliceTypeSteps): Slice { const txt = this.txt; const start = txt.pointStart() ?? txt.pointAbsStart(); start.refAfter(); @@ -797,7 +797,7 @@ export class Editor implements Printable { * @param type The new block type. * @returns The marker slice at the point, or a new marker slice if there is none. */ - public setBlockType(point: Point, type: SliceTypeSteps): MarkerSlice { + public setBlockType(point: Point, type: SliceTypeSteps): Slice { const marker = this.getMarker(point); if (marker) { marker.update({type}); @@ -897,7 +897,7 @@ export class Editor implements Printable { } } - public setStartMarker(type: SliceTypeSteps, data?: unknown, slices: EditorSlices = this.saved): MarkerSlice { + public setStartMarker(type: SliceTypeSteps, data?: unknown, slices: EditorSlices = this.saved): Slice { const after = this.txt.pointStart() ?? this.txt.pointAbsStart(); after.refAfter(); return slices.slices.insMarkerAfter(after.id, type, data); @@ -913,7 +913,7 @@ export class Editor implements Printable { const overlay = this.txt.overlay; const markerPoint = overlay.getOrNextLowerMarker(point); if (markerPoint) { - const marker = markerPoint.marker; + const marker = markerPoint.markers[0]; const markerTag = marker.nestedType().tag().name(); const tagStep = type[type.length - 1]; const tag = Array.isArray(tagStep) ? tagStep[0] : tagStep; @@ -939,19 +939,24 @@ export class Editor implements Printable { for (const range of selection) this.tglMarkerAt(range.start, type, data, slices, def); } - public delMarker(selection: Range[] | IterableIterator> = this.cursors()): void { - const markerPoints = new Set>(); + public delMarker(split: Slice): void { + const boundary = split.boundary(); + this.delRange(boundary); + } + + public delMarkerSelection(selection: Range[] | IterableIterator> = this.cursors()): void { + const markerPoints = new Set>(); for (const range of selection) { const markerPoint = this.txt.overlay.getOrNextLowerMarker(range.start); if (markerPoint) markerPoints.add(markerPoint); } - for (const markerPoint of markerPoints) { - const boundary = markerPoint.marker.boundary(); - this.delRange(boundary); - } + for (const markerPoint of markerPoints) this.delMarker(markerPoint.markers[0]); } - public updMarkerSlice(marker: MarkerSlice, target: MarkerUpdateTarget, ops: ApiOperation[]): void { + /** + * @todo This detailed update operation should move to the {@link Slice} class. + */ + public updMarkerSlice(marker: Slice, target: MarkerUpdateTarget, ops: ApiOperation[]): void { const node = target === 'type' ? marker.nestedType().asArr() @@ -973,7 +978,7 @@ export class Editor implements Printable { ): void { const overlay = this.txt.overlay; const markerPoint = overlay.getOrNextLowerMarker(point); - const marker: MarkerSlice = markerPoint?.marker ?? this.setStartMarker([SliceTypeCon.p], void 0, slices); + const marker: Slice = markerPoint?.markers[0] ?? this.setStartMarker([SliceTypeCon.p], void 0, slices); this.updMarkerSlice(marker, target, ops); } @@ -988,7 +993,21 @@ export class Editor implements Printable { // ---------------------------------------------------------- export / import - public export(range: Range): ViewRange { + public exportSlice(slice: Slice): ViewSlice { + const {stacking, start, end} = slice; + const header: number = + (stacking << SliceHeaderShift.Stacking) + + (start.anchor << SliceHeaderShift.X1Anchor) + + (end.anchor << SliceHeaderShift.X2Anchor); + const viewSlice: ViewSlice = [header, start.viewPos(), end.viewPos(), slice.type()]; + const data = slice.data(); + if (data !== void 0) viewSlice.push(data); + return viewSlice; + } + + public export(range?: Range): ViewRange { + if (!range) range = this.txt.rangeAll(); + if (!range) return ['', 0, []]; const r = range.range(); r.start.refBefore(); r.end.refAfter(); @@ -998,8 +1017,10 @@ export class Editor implements Printable { const view: ViewRange = [text, offset, viewSlices]; const txt = this.txt; const overlay = txt.overlay; - const slices = overlay.findOverlapping(r); + const slices = Array.from(overlay.findOverlapping(r)); + slices.sort((a, b) => (a instanceof Slice && b instanceof Slice ? a.pos() - b.pos() : 0)); for (const slice of slices) { + if (!(slice instanceof Slice) || !slice.isSaved()) continue; const isSavedSlice = slice.id.sid === txt.model.clock.sid; if (!isSavedSlice) continue; const stacking = slice.stacking; @@ -1008,14 +1029,7 @@ export class Editor implements Printable { case SliceStacking.Many: case SliceStacking.Erase: case SliceStacking.Marker: { - const {stacking, start, end} = slice; - const header: number = - (stacking << SliceHeaderShift.Stacking) + - (start.anchor << SliceHeaderShift.X1Anchor) + - (end.anchor << SliceHeaderShift.X2Anchor); - const viewSlice: ViewSlice = [header, start.viewPos(), end.viewPos(), slice.type()]; - const data = slice.data(); - if (data !== void 0) viewSlice.push(data); + const viewSlice = this.exportSlice(slice); viewSlices.push(viewSlice); } } @@ -1030,7 +1044,9 @@ export class Editor implements Printable { * @param range Range copy formatting from, normally a single visible character. * @returns A list of serializable inline formatting applied to the selected range. */ - public exportStyle(range: Range): ViewStyle[] { + public exportStyle(range?: Range): ViewStyle[] { + if (!range) range = this.txt.rangeAll(); + if (!range) return []; const formatting: ViewStyle[] = []; const txt = this.txt; const overlay = txt.overlay; @@ -1153,6 +1169,74 @@ export class Editor implements Printable { } } + // ----------------------------------------------------- diff / patch / merge + + /** + * Merges the given destination view range with the current document, computes + * a minimal patch and immediately applies it to the document. + * + * @param dst Destination view range to merge with the current document. + * @returns Returns two patches: the first one for the text contents, the + * second one for the rich-text slices. + */ + public merge(dst: ViewRange): [patch1: Patch | undefined, patch2: Patch | undefined, patch3: Patch | undefined] { + const [text, offset, slices] = dst; + if (offset !== 0) throw new Error('DOC'); // Only full document diff supported. + const txt = this.txt; + const str = txt.str; + const startTime = txt.model.clock.time; + const patch1 = txt.strApi().merge(text); + + let patch2: Patch | undefined; + { + // Block split `'\n'` new line characters must be standalone RGA chunks. + // Here we enforce that by removing all newly inserted `'\n'` characters + // and replacing them with a single `'\n'` character, which we ensure is + // always a standalone RGA chunk. + const model = txt.model; + const builder = new PatchBuilder(model.clock.clone()); + let hasChange = false; + for (const slice of slices) { + const stacking: SliceStacking = (slice[0] & SliceHeaderMask.Stacking) >>> SliceHeaderShift.Stacking; + if (stacking === SliceStacking.Marker) { + const pos = slice[1]; + const id = str.find(pos); + if (id && id.time + 1 > startTime) { + builder.insStr(str.id, id, '\n'); + builder.nop(1); + builder.del(str.id, [tss(id.sid, id.time, 1)]); + hasChange = true; + } + } + } + if (hasChange) { + patch2 = builder.flush(); + model.applyPatch(patch2); + } + } + + let patch3: Patch | undefined; + { + const dstSlices: ReturnType[] = []; + for (const [header, start, end, type, data] of slices) { + const stacking: SliceStacking = (header & SliceHeaderMask.Stacking) >>> SliceHeaderShift.Stacking; + const anchor1: Anchor = (header & SliceHeaderMask.X1Anchor) >>> SliceHeaderShift.X1Anchor; + const anchor2: Anchor = (header & SliceHeaderMask.X2Anchor) >>> SliceHeaderShift.X2Anchor; + const startPoint = txt.pointIn(start, anchor1); + const endPoint = txt.pointIn(end, anchor2); + const range = txt.range(startPoint, endPoint); + const sliceSchema = schema.slice(range, stacking, type, data); + dstSlices.push(sliceSchema); + } + const differ = new JsonCrdtDiff(txt.model); + patch3 = differ.diff(txt.savedSlices.set, s.arr(dstSlices)); + if (patch3.ops.length) txt.model.applyPatch(patch3); + else patch3 = void 0; + } + + return [patch1, patch2, patch3]; + } + // ------------------------------------------------------------------ various public pos2point(at: EditorPosition): Point { @@ -1190,7 +1274,7 @@ export class Editor implements Printable { public toString(tab: string = ''): string { const pending = this.pending.value; const pendingFormatted = {} as any; - if (pending) for (const [type, data] of pending) pendingFormatted[formatType(type)] = data; + if (pending) for (const [type, data] of pending) pendingFormatted[formatStep(type)] = data; return ( 'Editor' + printTree(tab, [ diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/editor/EditorSlices.ts b/packages/json-joy/src/json-crdt-extensions/peritext/editor/EditorSlices.ts index c830cdfe26..a05cbdfe7b 100644 --- a/packages/json-joy/src/json-crdt-extensions/peritext/editor/EditorSlices.ts +++ b/packages/json-joy/src/json-crdt-extensions/peritext/editor/EditorSlices.ts @@ -1,12 +1,11 @@ -import {PersistedSlice} from '../slice/PersistedSlice'; +import {Slice} from '../slice/Slice'; import type {Peritext} from '../Peritext'; import type {SliceType} from '../slice/types'; -import type {MarkerSlice} from '../slice/MarkerSlice'; import type {Slices} from '../slice/Slices'; import type {ITimestampStruct} from '../../../json-crdt-patch'; import type {Range} from '../rga/Range'; -const forEachRange = >( +const forEachRange = >( selection: Range[] | IterableIterator>, callback: (range: Range) => S, ): S[] => { @@ -31,7 +30,7 @@ export class EditorSlices { type: SliceType, data?: unknown | ITimestampStruct, selection?: Range[] | IterableIterator>, - ): PersistedSlice[] { + ): Slice[] { const {slices, txt} = this; selection ||= txt.editor.cursors(); return forEachRange(selection, (range) => slices.insStack(range.range(), type, data)); @@ -41,7 +40,7 @@ export class EditorSlices { type: SliceType, data?: unknown | ITimestampStruct, selection?: Range[] | IterableIterator>, - ): PersistedSlice[] { + ): Slice[] { const {slices, txt} = this; selection ||= txt.editor.cursors(); return forEachRange(selection, (range) => slices.insOne(range.range(), type, data)); @@ -51,18 +50,13 @@ export class EditorSlices { type: SliceType, data?: unknown | ITimestampStruct, selection?: Range[] | IterableIterator>, - ): PersistedSlice[] { + ): Slice[] { const {slices, txt} = this; selection ||= txt.editor.cursors(); return forEachRange(selection, (range) => slices.insErase(range.range(), type, data)); } - public insMarker( - type: SliceType, - data?: unknown, - separator?: string, - selection?: Range[] | IterableIterator>, - ): MarkerSlice[] { + public insMarker(type: SliceType, data?: unknown, selection?: Range[] | IterableIterator>): Slice[] { const {slices, txt} = this; const editor = txt.editor; selection ||= txt.editor.cursors(); @@ -70,12 +64,12 @@ export class EditorSlices { editor.collapseCursor(range); const after = range.start.clone(); after.refAfter(); - const marker = slices.insMarkerAfter(after.id, type, data, separator); + const marker = slices.insMarkerAfter(after.id, type, data); return marker; }); } - public del(sliceOrId: PersistedSlice | ITimestampStruct): void { - this.slices.del(sliceOrId instanceof PersistedSlice ? sliceOrId.id : sliceOrId); + public del(sliceOrId: Slice | ITimestampStruct): void { + this.slices.del(sliceOrId instanceof Slice ? sliceOrId.id : sliceOrId); } } diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/editor/__tests__/Editor-export.spec.ts b/packages/json-joy/src/json-crdt-extensions/peritext/editor/__tests__/Editor-export.spec.ts index e433129695..3452a79f96 100644 --- a/packages/json-joy/src/json-crdt-extensions/peritext/editor/__tests__/Editor-export.spec.ts +++ b/packages/json-joy/src/json-crdt-extensions/peritext/editor/__tests__/Editor-export.spec.ts @@ -104,8 +104,8 @@ const testSuite = (setup: () => Kit) => { 'ij\nklmno\npqr', 8, [ - [pHeader, 10, 10, CommonSliceType.p], [iHeader, 12, 14, CommonSliceType.i], + [pHeader, 10, 10, CommonSliceType.p], [blockquoteHeader, 16, 16, CommonSliceType.blockquote], ], ]); diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/editor/__tests__/Editor-merge.fuzzer.spec.ts b/packages/json-joy/src/json-crdt-extensions/peritext/editor/__tests__/Editor-merge.fuzzer.spec.ts new file mode 100644 index 0000000000..458ed4ec5f --- /dev/null +++ b/packages/json-joy/src/json-crdt-extensions/peritext/editor/__tests__/Editor-merge.fuzzer.spec.ts @@ -0,0 +1,51 @@ +import type {ViewRange} from '../types'; +import {ViewRangeGenerator, type ViewRangeGeneratorOpts} from './fuzzing'; +import {ModelWithExt, ext} from '../../../ModelWithExt'; +import {assertCanMergeInto, assertCanMergeIntoEmptyDocument} from './merge'; + +test('can merge into empty document', () => { + for (let i = 0; i < 50; i++) { + const view = ViewRangeGenerator.generate({ + // text: 'abcdefghijklmnopqrstuvwxyz', + // text: '012345', + // formattingCount: 2, + // markerCount: 2, + }); + // logTree(view); + assertCanMergeIntoEmptyDocument(view); + } +}); + +describe('can merge in random documents', () => { + const mergeRandomDocs = (count: number, opts?: Partial) => { + const model = ModelWithExt.create(ext.peritext.new('')); + const views: ViewRange[] = []; + for (let i = 0; i < count; i++) { + const view = ViewRangeGenerator.generate(opts); + views.push(view); + try { + assertCanMergeInto(model, view); + } catch (error) { + console.log(`VIEWS (${views.length}):`, JSON.stringify(views)); + throw error; + } + } + }; + + test('standardized text', () => { + for (let i = 0; i < 10; i++) { + mergeRandomDocs(5, { + text: 'abcdefghijklmnopqrstuvwxyz', + // text: '012345', + // formattingCount: 2, + // markerCount: 2, + }); + } + }); + + test('random text', () => { + for (let i = 0; i < 10; i++) { + mergeRandomDocs(5); + } + }); +}); diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/editor/__tests__/Editor-merge.snapshots.spec.ts b/packages/json-joy/src/json-crdt-extensions/peritext/editor/__tests__/Editor-merge.snapshots.spec.ts new file mode 100644 index 0000000000..e1d82abad0 --- /dev/null +++ b/packages/json-joy/src/json-crdt-extensions/peritext/editor/__tests__/Editor-merge.snapshots.spec.ts @@ -0,0 +1,125 @@ +import type {ViewRange} from '../types'; +import {assertCanMergeIntoEmptyDocument, assertCanMergeViewTrain} from './merge'; + +test('fuzzer 1', () => { + const view: ViewRange = [ + 'abc\ndefghijklmn\nopqrstuvwxyz', + 0, + [ + [0, 15, 15, 'K>e'], + [0, 3, 3, 13], + [5, 12, 15, -4], + ], + ]; + // logTree(view); + assertCanMergeIntoEmptyDocument(view); +}); + +test('fuzzer 2', () => { + const views: ViewRange[] = [ + [ + '012\n3\n45', + 0, + [ + [ + 0, + 3, + 3, + '*dm|', + { + 'SkZQ&p=': 1, + }, + ], + [ + 0, + 5, + 5, + [ + [-34, 0, {}], + ['z*', 2, {}], + [47, 0, {}], + ], + ], + [10, 3, 4, '4'], + [11, 2, 5, '7APyK'], + ], + ], + [ + '01\n2345\n', + 0, + [ + [0, 2, 2, ['vCKstZZ', 'T/5F*B}', ['E:T', 1]]], + [0, 7, 7, 41], + [5, 2, 4, 'Yyf*'], + [4, 5, 7, "ZZY4V'"], + ], + ], + ]; + assertCanMergeViewTrain(views); +}); + +test('fuzzer 3', () => { + const views: ViewRange[] = [ + [ + 'abcdefghi\njk\nlmnopqrs\ntuvwxyz\n', + 0, + [ + [0, 9, 9, 26, {}], + [0, 12, 12, 46], + [0, 21, 21, ['tag'], {}], + [0, 29, 29, [-45, 'ul', ':"v', ['l[']]], + [4, 13, 16, 'code'], + [10, 26, 26, 'abbr'], + ], + ], + [ + 'ab\nc\ndefghij\nklm\nno\npqrstuv\nwxy\nz', + 0, + [ + [0, 2, 2, 'tag'], + [0, 4, 4, [['q'], 'p'], {}], + [0, 12, 12, -35], + [0, 16, 16, [[50, 3, {a: 123}], [-26, 1], 24, [-29]]], + [0, 19, 19, 35], + [0, 27, 27, 'p'], + [9, 27, 31, 38], + ], + ], + ] as any; + assertCanMergeViewTrain(views); +}); + +test('fuzzer 4', () => { + const views: ViewRange[] = [ + [ + 'abcdefghijklmnopqrstuvwxyz', + 0, + [ + [10, 25, 25, 'EOkYd'], + [6, 25, 25, 48, {}], + ], + ], + [ + 'abcdefghijklmnopq\nrstuvwxyz', + 0, + [ + [0, 17, 17, [[-46, 0, {}], [-40], ['0i;/z)vL'], ['A;h必Tp', 2, {}]], {}], + [9, 8, 11, -32, {}], + [7, 18, 21, 'Iq'], + ], + ], + [ + 'ab\nc\nde\nfghij\nklm\nnopqrstuvwxyz', + 0, + [ + [0, 2, 2, ['p]g', -42, [-56, 0, {}], ['U']]], + [0, 4, 4, [['&Uxa,.', 3], [-58], [16], ['#FLbURE']]], + [0, 7, 7, ['l1', 'W Kit) => { + describe('.merge()', () => { + const assertMergeEqual = (view: ViewRange) => { + const {editor, peritext} = setup(); + editor.merge(view)!; + peritext.refresh(); + const view2 = editor.export(peritext.rangeAll()!); + expect(view2).toEqual(view); + }; + + test('preserves formatting order', () => { + assertMergeEqual([ + 'text!', + 0, + [ + [10, 1, 5, 'em'], + [10, 1, 5, 'strong'], + ], + ]); + assertMergeEqual([ + 'text!', + 0, + [ + [10, 1, 5, 'strong'], + [10, 1, 5, 'em'], + ], + ]); + }); + + describe('block splits', () => { + test('can update text-only after block split', () => { + const {editor, peritext} = setup(); + editor.cursor.setAt(10); + editor.saved.insMarker(CommonSliceType.blockquote); + peritext.refresh(); + const view = editor.export(peritext.rangeAll()!); + const text = view[0]; + const newText = (view[0] = text.slice(0, 15) + '__inserted_text__' + text.slice(15)); + const patch = editor.merge(view)!; + expect(patch[1]).toBe(undefined); + expect(patch[0]!.ops.length).toBe(1); + expect(patch[0]!.ops[0].name()).toBe('ins_str'); + expect(editor.export(peritext.rangeAll()!)[0]).toBe(newText); + view[0] = text; // restore original text + const patch2 = editor.merge(view)!; + expect(patch2[1]).toBe(undefined); + expect(patch2[0]!.ops.length).toBe(1); + expect(patch2[0]!.ops[0].name()).toBe('del'); + expect(editor.export(peritext.rangeAll()!)).toEqual(view); + }); + + test('can update text-only before block split', () => { + const {editor, peritext} = setup(); + editor.cursor.setAt(10); + editor.saved.insMarker(CommonSliceType.blockquote); + peritext.refresh(); + const view = editor.export(peritext.rangeAll()!); + const text = view[0]; + const newText = text.slice(0, 3) + '_0_' + text.slice(3); + view[0] = newText; + (view)[2][0][1] += 3; + (view)[2][0][2] += 3; + const patch = editor.merge(view)!; + expect(patch[1]).toBe(undefined); + expect(patch[0]!.ops.length).toBe(1); + expect(patch[0]!.ops[0].name()).toBe('ins_str'); + expect(editor.export(peritext.rangeAll()!)[0]).toBe(newText); + view[0] = text; // restore original text + (view)[2][0][1] -= 3; + (view)[2][0][2] -= 3; + const patch2 = editor.merge(view)!; + expect(patch2[1]).toBe(undefined); + expect(patch2[0]!.ops.length).toBe(1); + expect(patch2[0]!.ops[0].name()).toBe('del'); + expect(editor.export(peritext.rangeAll()!)[0]).toBe(text); + }); + + test('can handle block split type change and block split delete', () => { + const {editor, peritext} = setup(); + editor.cursor.setAt(10); + editor.saved.insMarker(CommonSliceType.blockquote); + editor.cursor.setAt(20); + editor.saved.insMarker([[CommonSliceType.p, 0, {indent: 1}]]); + editor.cursor.setAt(25); + editor.saved.insMarker([[CommonSliceType.p, 0, {indent: 2}]]); + peritext.refresh(); + const view = editor.export(peritext.rangeAll()!); + (view)[2][0][3] = 'blockquote'; + view[2].splice(2, 1); + const patch = editor.merge(view); + peritext.refresh(); + const view2 = editor.export(peritext.rangeAll()!); + expect(view2).toEqual(view); + expect(patch[0]).toBe(undefined); + }); + + test('block insert', () => { + const {editor, peritext} = setup(); + const {peritext: peritext2} = setup(); + editor.cursor.setAt(10); + editor.saved.insMarker(CommonSliceType.blockquote); + peritext2.editor.cursor.setAt(10); + peritext2.editor.saved.insMarker(CommonSliceType.blockquote); + peritext2.editor.cursor.setAt(20); + peritext2.editor.saved.insMarker(CommonSliceType.p); + peritext.refresh(); + peritext2.refresh(); + const view2 = peritext2.editor.export(peritext2.rangeAll()!); + const patch = editor.merge(view2); + expect(patch[0]?.ops.length).toBe(1); + expect(patch[0]?.ops[0].name()).toBe('ins_str'); + peritext.refresh(); + const view3 = editor.export(peritext.rangeAll()!); + expect(view3).toEqual(view2); + }); + + test('complex block inserts (and moves)', () => { + const {editor, peritext} = setup(); + const {peritext: peritext2} = setup(); + editor.cursor.setAt(10); + editor.saved.insMarker(CommonSliceType.blockquote); + peritext2.editor.cursor.setAt(11); + peritext2.editor.saved.insMarker(CommonSliceType.blockquote); + peritext2.editor.cursor.setAt(15); + peritext2.editor.saved.insMarker([ + [SliceTypeCon.ul, 0, {type: 'tasks'}], + [SliceTypeCon.li, 0, {checked: true}], + ]); + peritext2.editor.cursor.setAt(20); + peritext2.editor.saved.insMarker(CommonSliceType.p); + peritext.refresh(); + peritext2.refresh(); + const view = peritext2.editor.export(peritext2.rangeAll()!); + editor.merge(view); + peritext.refresh(); + const view3 = editor.export(peritext.rangeAll()!); + expect(view3).toEqual(view); + }); + + test('change marker type', () => { + const {editor, peritext} = setup(); + editor.cursor.setAt(10); + editor.saved.insMarker(CommonSliceType.h1); + peritext.refresh(); + const view = editor.export(peritext.rangeAll()!); + view[2][0][3] = CommonSliceType.h2; + const patch = editor.merge(view); + expect(patch[0]).toBe(undefined); + expect(patch[2]!.ops.length < 5).toBe(true); + expect(patch[2]!.ops.length > 1).toBe(true); + peritext.refresh(); + const slices = [...peritext.overlay.markers()]; + expect(slices.length).toBe(1); + const slice = slices[0]; + expect(slice.type()).toEqual(CommonSliceType.h2); + view[2][0][3] = [SliceTypeCon.ul, SliceTypeCon.li]; + const patch2 = editor.merge(view); + expect(patch2[0]).toBe(undefined); + peritext.refresh(); + const slices2 = [...peritext.overlay.markers()]; + expect(slices2.length).toBe(1); + const slice2 = slices2[0]; + expect(slice2.type()).toEqual([SliceTypeCon.ul, SliceTypeCon.li]); + }); + }); + + describe('inline formatting', () => { + const assertCanMerge = (src: Peritext, dst: Peritext) => { + src.refresh(); + dst.refresh(); + const view = dst.editor.export(dst.rangeAll()!); + src.editor.merge(view); + src.refresh(); + const view2 = src.editor.export(src.rangeAll()!); + expect(view2).toEqual(view); + }; + + test('can add a single slice', () => { + const {peritext: txt1} = setup(); + const {peritext: txt2} = setup(); + txt2.editor.cursor.setAt(10, 5); + txt2.editor.saved.insOne('bold'); + assertCanMerge(txt1, txt2); + }); + + test('can add two overlapping slices', () => { + const {peritext: txt1} = setup(); + const {peritext: txt2} = setup(); + txt2.editor.cursor.setAt(10, 10); + txt2.editor.saved.insOne('bold'); + txt2.editor.cursor.setAt(15, 10); + txt2.editor.saved.insOne('italic'); + assertCanMerge(txt1, txt2); + }); + + test('can remove a single slice', () => { + const {peritext: txt1} = setup(); + const {peritext: txt2} = setup(); + txt1.editor.cursor.setAt(10, 10); + txt1.editor.saved.insOne('bold'); + assertCanMerge(txt1, txt2); + }); + + test('can change slice type', () => { + const {peritext: txt1} = setup(); + const {peritext: txt2} = setup(); + txt1.editor.cursor.setAt(10, 10); + txt1.editor.saved.insOne('bold'); + txt2.editor.cursor.setAt(10, 10); + txt2.editor.saved.insOne('italic'); + assertCanMerge(txt1, txt2); + }); + + test('returns empty patch for equal documents', () => { + const {peritext: txt1} = setup(); + const {peritext: txt2} = setup(); + txt1.editor.cursor.setAt(10, 10); + txt1.editor.saved.insOne('bold'); + txt2.editor.cursor.setAt(10, 10); + txt2.editor.saved.insOne('bold'); + assertCanMerge(txt1, txt2); + }); + }); + + describe('scenarios', () => { + const assertCanMergeFromTo = (from: ViewRange, to: ViewRange) => { + const kit = setupKit(); + kit.peritext.refresh(); + // kit.editor.import(0, from); + kit.editor.merge(from); + kit.peritext.refresh(); + // console.log(toTree(from)); + // expect(kit.editor.export()).toEqual(from); + kit.editor.merge(to); + kit.peritext.refresh(); + // console.log(toTree(to)); + // console.log(toTree(kit.editor.export())); + expect(kit.editor.export()).toEqual(to); + }; + + test('example from ProseMirror tests', () => { + assertCanMergeFromTo(fixtures.view1, fixtures.view2); + assertCanMergeFromTo(fixtures.view2, fixtures.view1); + assertCanMergeFromTo(fixtures.view1, fixtures.view3); + assertCanMergeFromTo(fixtures.view3, fixtures.view1); + assertCanMergeFromTo(fixtures.view2, fixtures.view3); + assertCanMergeFromTo(fixtures.view3, fixtures.view2); + }); + }); + }); +}; + +// runAlphabetKitTestSuite(testSuite); +testSuite(setupAlphabetKit); diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/editor/__tests__/fixtures.ts b/packages/json-joy/src/json-crdt-extensions/peritext/editor/__tests__/fixtures.ts new file mode 100644 index 0000000000..6bd14042ec --- /dev/null +++ b/packages/json-joy/src/json-crdt-extensions/peritext/editor/__tests__/fixtures.ts @@ -0,0 +1,65 @@ +import type {ViewRange} from '../types'; + +export const view1: ViewRange = [ + '\nThis is a paragraph with emphasized text, strong text, and a link to example.com.', + 0, + [ + [0, 0, 0, [['paragraph', 0, {}]]], + [10, 26, 41, 'em'], + [10, 43, 54, 'strong'], + [ + 6, + 70, + 81, + 'link', + { + href: 'https://example.com', + title: null, + }, + ], + ], +]; + +export const view2: ViewRange = [ + '\nThis is a paragraph with nested inline styles and a link to example.com.', + 0, + [ + [0, 0, 0, [['paragraph', 0, {}]]], + [10, 26, 33, 'em'], + [10, 33, 46, 'em'], + [10, 33, 46, 'strong'], + [ + 6, + 61, + 72, + 'link', + { + href: 'https://example.com', + title: null, + }, + ], + [10, 61, 72, 'strong'], + ], +]; + +export const view3: ViewRange = [ + '\nThis is a paragraph with nested inline styles and a link to example.com.', + 0, + [ + [0, 0, 0, [['paragraph', 0, {}]]], + [10, 26, 33, 'em'], + [10, 33, 46, 'em'], + [10, 33, 46, 'strong'], + [10, 61, 72, 'strong'], + [ + 6, + 61, + 72, + 'link', + { + href: 'https://example.com', + title: null, + }, + ], + ], +]; diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/editor/__tests__/fuzzing.ts b/packages/json-joy/src/json-crdt-extensions/peritext/editor/__tests__/fuzzing.ts new file mode 100644 index 0000000000..9ec20b2b21 --- /dev/null +++ b/packages/json-joy/src/json-crdt-extensions/peritext/editor/__tests__/fuzzing.ts @@ -0,0 +1,113 @@ +import {Fuzzer} from '@jsonjoy.com/util/lib/Fuzzer'; +import {RandomJson} from '@jsonjoy.com/json-random'; +import type {ViewRange, ViewSlice} from '../types'; +import {SliceHeaderShift, SliceStacking} from '../../slice/constants'; +import {Anchor} from '../../rga/constants'; +import type {SliceType, SliceTypeCompositeStep, SliceTypeStep, TypeTag} from '../../slice'; + +export interface ViewRangeGeneratorOpts { + text: string; + formattingCount: number; + markerCount: number; +} + +export class ViewRangeGenerator { + public static readonly generate = (opts: Partial = {}): ViewRange => { + const generator = new ViewRangeGenerator(opts); + return generator.generate(); + }; + + public readonly opts: ViewRangeGeneratorOpts; + public text: string; + + constructor(opts: Partial) { + this.opts = { + text: opts.text ?? RandomJson.genString(Fuzzer.randomInt(20, 100)), + formattingCount: opts.formattingCount ?? Fuzzer.randomInt(0, 10), + markerCount: opts.markerCount ?? Fuzzer.randomInt(0, 10), + }; + this.text = this.opts.text; + } + + public generateFormatting(): ViewSlice { + const length = this.text.length; + let start = Fuzzer.randomInt(0, length - 1); + let end = Fuzzer.randomInt(start + 1, length); + const stacking = Fuzzer.pick([SliceStacking.One, SliceStacking.Many]); + let anchorStart = Anchor.Before; + if (start > 0 && Fuzzer.randomInt(0, 1)) { + anchorStart = Anchor.After; + start--; + } + let anchorEnd = Anchor.After; + if (end < length && Fuzzer.randomInt(0, 1)) { + anchorEnd = Anchor.Before; + end++; + } + const header: number = + (stacking << SliceHeaderShift.Stacking) + + (anchorStart << SliceHeaderShift.X1Anchor) + + (anchorEnd << SliceHeaderShift.X2Anchor); + const tag: TypeTag = this.generateTag(); + const slice: ViewSlice = [header, start, end - 1, tag]; + if (Fuzzer.randomInt(0, 1)) slice.push(this.generateData()); + return slice; + } + + public generateMarker(pos: number): ViewSlice { + this.text = this.text.slice(0, pos) + '\n' + this.text.slice(pos); + const header: number = + (SliceStacking.Marker << SliceHeaderShift.Stacking) + + (Anchor.Before << SliceHeaderShift.X1Anchor) + + (Anchor.Before << SliceHeaderShift.X2Anchor); + const type: SliceType = Fuzzer.pick([ + () => this.generateTag(), + () => { + const length = Fuzzer.randomInt(1, 4); + const steps: SliceTypeStep[] = []; + for (let i = 0; i < length; i++) { + const step: SliceTypeStep = Fuzzer.pick([ + () => this.generateTag(), + () => { + const tag = this.generateTag(); + const step: SliceTypeCompositeStep = [tag] as any; + if (Fuzzer.randomInt(0, 1)) { + step.push(Fuzzer.randomInt(0, 3)); + if (Fuzzer.randomInt(0, 1)) step.push(this.generateData() as any); + } + return step; + }, + ])(); + steps.push(step); + } + return steps; + }, + ])(); + const slice: ViewSlice = [header, pos, pos, type]; + if (Fuzzer.randomInt(0, 1)) slice.push(this.generateData()); + return slice; + } + + protected generateTag(): TypeTag { + const tag: TypeTag = Fuzzer.pick([ + () => Fuzzer.randomInt(-64, 64), + () => RandomJson.genString(Fuzzer.randomInt(1, 8)), + ])(); + return tag; + } + + protected generateData(): unknown { + return RandomJson.generate({nodeCount: Fuzzer.randomInt(1, 4)}); + } + + public generate(): ViewRange { + const slices: ViewRange[2] = []; + const maxMarkerCount = this.opts.markerCount; + let positions: number[] = []; + for (let i = 0; i < maxMarkerCount; i++) positions.push(Fuzzer.randomInt(0, this.text.length)); + positions = [...new Set(positions)].sort((a, b) => a - b); + for (let i = 0; i < positions.length; i++) slices.push(this.generateMarker(positions[i] + i)); + for (let i = 0; i < this.opts.formattingCount; i++) slices.push(this.generateFormatting()); + return [this.text, 0, slices]; + } +} diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/editor/__tests__/merge.ts b/packages/json-joy/src/json-crdt-extensions/peritext/editor/__tests__/merge.ts new file mode 100644 index 0000000000..28db2c6b15 --- /dev/null +++ b/packages/json-joy/src/json-crdt-extensions/peritext/editor/__tests__/merge.ts @@ -0,0 +1,53 @@ +import type {ViewRange} from '../types'; +// import {logTree, toTree} from 'pojo-dump'; +import {ModelWithExt, ext} from '../../../ModelWithExt'; +import type {PeritextNode} from '../../PeritextNode'; +import type {Model} from '../../../../json-crdt/model'; +import type {ExtensionNode} from '../../../../json-crdt/schema/types'; + +// const normalizeViewRange = (view: ViewRange): void => { +// const [, offset, slices] = view; +// const length = slices.length; +// if (offset > 0) { +// view[1] = 0; +// for (let i = 0; i < length; i++) { +// const slice = slices[i]; +// slice[1] += offset; +// slice[2] += offset; +// } +// } +// slices.sort((a, b) => a[1] - b[1] || a[2] - b[2] || 1); +// }; + +export const assertCanMergeInto = (model: Model>, view: ViewRange): void => { + // logTree(view); + try { + const txt = model.s.toExt().txt; + txt.editor.merge(view); + txt.refresh(); + const view2 = txt.editor.export(); + // logTree(view2); + // console.log(txt + ''); + // console.log(model + ''); + // console.log(txt + ''); + // expect(toTree(view2)).toBe(toTree(view)); + expect(view2).toEqual(view); + } catch (error) { + // console.log(toTree(view)); + console.log(JSON.stringify(view)); + throw error; + } +}; + +export const assertCanMergeIntoEmptyDocument = (view: ViewRange): void => { + const model = ModelWithExt.create(ext.peritext.new('')); + assertCanMergeInto(model, view); +}; + +export const assertCanMergeViewTrain = (views: ViewRange[]): void => { + const model = ModelWithExt.create(ext.peritext.new(''), 1234567890123); + for (const view of views) { + // console.log(`Merging view ${i++} of ${views.length}`); + assertCanMergeInto(model, view); + } +}; diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/editor/types.ts b/packages/json-joy/src/json-crdt-extensions/peritext/editor/types.ts index 195b859988..c2d90db887 100644 --- a/packages/json-joy/src/json-crdt-extensions/peritext/editor/types.ts +++ b/packages/json-joy/src/json-crdt-extensions/peritext/editor/types.ts @@ -13,9 +13,75 @@ export type EditorSelection = Range | [start: EditorPosition, export type TextRangeUnit = 'point' | 'char' | 'word' | 'line' | 'vline' | 'vert' | 'block' | 'all'; -export type ViewRange = [text: string, textPosition: number, slices: ViewSlice[]]; +/** + * Represents an exported view of any selected range (fragment) of the document. + * + * @todo Rename to `PeritextView`. + */ +export type ViewRange = [ + /** + * The total text of the exported fragment. + */ + text: string, + /** + * The start position of the exported fragment in the document text. The `x1` + * and `x2` range values in slices are also absolute positions as they are + * stored in the document. To compute the positions relative to the fragment + * `text`, subtract this value from them. + */ + textPosition: number, + /** + * List of all formattings and block splits in the exported fragment. + */ + slices: ViewSlice[], +]; -export type ViewSlice = [header: number, x1: number, x2: number, type: SliceType, data?: unknown]; +/** + * Represents and exported view of a single rich-text formatting annotation or + * a block split in the document. + */ +export type ViewSlice = [ + /** Contains the behavior and edge anchor point selections. */ + header: number, + /** + * Start character position of the formatting, or the newline character `\n` + * position if this slice is a block split. The position is absolute to the + * document text, use the `textPosition` field from the {@link ViewRange} + * interface to compute the relative position. + */ + x1: number, + /** + * End character position of the formatting, or the newline character `\n` + * position if this slice is a block split. The position is absolute to the + * document text, use the `textPosition` field from the {@link ViewRange} + * interface to compute the relative position. + * + * @todo Maybe remove this field in `Marker` slices, as it is always equal + * to `x1` in such cases. Even `x1` could be stored in `header` as well. + * Or store `x1` anchor point as the lowest bit in `x1` and that way + * `header` would be purely a behavior field. Actually, best encoding + * would be: `[header, type, x1, x2?, data?]` for formatting slices and + * `[header, type, x1, data?]` for marker slices. This way the head + * `[header, type, x1]` is always the same. Also, the `x2` should be + * `length` instead, this way it will consume less space when serialized + * to JSON, so formatting: `[header, type, pos, length, data?]`. And + * markers: `[header, type, pos, data?]`. + */ + x2: number, + /** + * The user selected type of the slice. In case of an inline formattting, + * this is the tag name, such as "bold", "italic", etc., expressed as a + * string or a number. In case of a block split, this can also be an + * array of nesting *steps* that describe the block type. Each step is either + * a string or a number, or a 3-tuple of the form `[tag, discriminant, data]`. + */ + type: SliceType, + /** + * Additional data associated with the slice when it is an inline + * formatting. Usually this is an object. + */ + data?: unknown, +]; export type ViewStyle = [stacking: SliceStacking, type: SliceType, data?: unknown]; diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/events/__tests__/marker.spec.ts b/packages/json-joy/src/json-crdt-extensions/peritext/events/__tests__/marker.spec.ts index dffc3eef2c..230173c395 100644 --- a/packages/json-joy/src/json-crdt-extensions/peritext/events/__tests__/marker.spec.ts +++ b/packages/json-joy/src/json-crdt-extensions/peritext/events/__tests__/marker.spec.ts @@ -75,6 +75,7 @@ const testSuite = (getKit: () => Kit) => { const slice = kit.peritext.savedSlices.each().find((slice) => slice.type() === SliceTypeCon.p); kit.et.cursor({clear: true}); kit.et.marker({action: 'del', slice}); + kit.peritext.refresh(); expect(kit.toHtml()).toBe('

                                abcdefghi

                                jklmnopqrstuvwxyz

                                '); expect(kit.peritext.savedSlices.size()).toBe(1); const slice2 = kit.peritext.savedSlices.each().find((slice) => true)!; @@ -90,18 +91,21 @@ const testSuite = (getKit: () => Kit) => { const kit = setup(); kit.et.cursor({at: [8]}); kit.et.marker({action: 'ins', type: SliceTypeCon.blockquote}); + kit.peritext.refresh(); expect(kit.toHtml()).toBe('

                                abcdefgh

                                ijklmnopqrstuvwxyz
                                '); kit.et.marker({ action: 'upd', target: 'type', ops: [['add', '/-', SliceTypeCon.p]], }); + kit.peritext.refresh(); expect(kit.toHtml()).toBe('

                                abcdefgh

                                ijklmnopqrstuvwxyz

                                '); kit.et.marker({ action: 'upd', target: 'type', ops: [['add', '/2', [[SliceTypeCon.ul, 0, {type: 'tasks'}], SliceTypeCon.li]]], }); + kit.peritext.refresh(); expect(kit.toHtml()).toBe( '

                                abcdefgh

                                • ijklmnopqrstuvwxyz

                                ', ); @@ -248,6 +252,7 @@ const testSuite = (getKit: () => Kit) => { expect(html1).toBe('

                                abcde

                                fghijklmnopqrstuvwxyz

                                '); expect(kit.peritext.blocks.root.children.length).toBe(2); expect(kit.peritext.blocks.root.children[1].children.length).toBe(1); + kit.peritext.refresh(); kit.et.cursor({at: [10]}); kit.et.marker({action: 'ins'}); kit.peritext.refresh(); diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/events/defaults/PeritextEventDefaults.ts b/packages/json-joy/src/json-crdt-extensions/peritext/events/defaults/PeritextEventDefaults.ts index 2f33f2b6da..33df55aa49 100644 --- a/packages/json-joy/src/json-crdt-extensions/peritext/events/defaults/PeritextEventDefaults.ts +++ b/packages/json-joy/src/json-crdt-extensions/peritext/events/defaults/PeritextEventDefaults.ts @@ -2,8 +2,7 @@ import {Anchor} from '../../../../json-crdt-extensions/peritext/rga/constants'; import {placeCursor} from './annals'; import {Cursor} from '../../../../json-crdt-extensions/peritext/editor/Cursor'; import {CursorAnchor, type Peritext} from '../../../../json-crdt-extensions/peritext'; -import {PersistedSlice} from '../../../../json-crdt-extensions/peritext/slice/PersistedSlice'; -import {MarkerSlice} from '../../slice/MarkerSlice'; +import {Slice} from '../../../../json-crdt-extensions/peritext/slice/Slice'; import type {Range} from '../../../../json-crdt-extensions/peritext/rga/Range'; import type {PeritextDataTransfer} from '../../../../json-crdt-extensions/peritext/transfer/PeritextDataTransfer'; import type {PeritextEventHandlerMap, PeritextEventTarget} from '../PeritextEventTarget'; @@ -235,8 +234,8 @@ export class PeritextEventDefaults implements PeritextEventHandlerMap { case 'del': { const {slice} = detail; if (!tag && slice) { - const persistedSlice = slice instanceof PersistedSlice ? slice : this.txt.getSlice(slice); - if (persistedSlice instanceof PersistedSlice && !persistedSlice.isSplit()) { + const persistedSlice = slice instanceof Slice ? slice : this.txt.getSlice(slice); + if (persistedSlice instanceof Slice && !persistedSlice.isMarker()) { persistedSlice.del(); } } else { @@ -253,8 +252,8 @@ export class PeritextEventDefaults implements PeritextEventHandlerMap { case 'set': { const {slice} = detail; if (slice) { - const persistedSlice = slice instanceof PersistedSlice ? slice : this.txt.getSlice(slice); - if (persistedSlice instanceof PersistedSlice) { + const persistedSlice = slice instanceof Slice ? slice : this.txt.getSlice(slice); + if (persistedSlice instanceof Slice) { if (action === 'set') persistedSlice.setData(detail.data); else persistedSlice.mergeData(detail.data); } @@ -278,18 +277,16 @@ export class PeritextEventDefaults implements PeritextEventHandlerMap { case 'del': { const {slice} = detail; if (slice) { - const persistedSlice = slice instanceof PersistedSlice ? slice : this.txt.getSlice(slice); - if (persistedSlice instanceof MarkerSlice) persistedSlice.del(); + const persistedSlice = slice instanceof Slice ? slice : this.txt.getSlice(slice); + if (persistedSlice?.isMarker()) persistedSlice.del(); } else { - editor.delMarker(selection); + editor.delMarkerSelection(selection); } break; } case 'upd': { const {target, ops} = detail; - if (target && ops) { - editor.updMarker(target, ops, selection); - } + if (target && ops) editor.updMarker(target, ops, selection); break; } } diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/events/types.ts b/packages/json-joy/src/json-crdt-extensions/peritext/events/types.ts index 583c134666..a7dc3f557a 100644 --- a/packages/json-joy/src/json-crdt-extensions/peritext/events/types.ts +++ b/packages/json-joy/src/json-crdt-extensions/peritext/events/types.ts @@ -4,7 +4,7 @@ import type {SliceTypeSteps, TypeTag} from '../../../json-crdt-extensions/perite import type {ITimestampStruct, Patch} from '../../../json-crdt-patch'; import type {Cursor} from '../../../json-crdt-extensions/peritext/editor/Cursor'; import type {Range} from '../../../json-crdt-extensions/peritext/rga/Range'; -import type {PersistedSlice} from '../slice/PersistedSlice'; +import type {Slice} from '../slice/Slice'; import type {ApiOperation} from '../../../json-crdt/model/api/types'; /** @@ -121,10 +121,10 @@ export type SelectionMoveInstruction = [ */ export interface SliceDetailPart { /** - * An instance of {@link PersistedSlice} or its ID {@link ITimestampStruct} used + * An instance of {@link Slice} or its ID {@link ITimestampStruct} used * to retrieve the slice from the document. */ - slice?: PersistedSlice | ITimestampStruct; + slice?: Slice | ITimestampStruct; } /** @@ -388,7 +388,7 @@ export interface FormatDetail extends RangeEventDetail, SliceDetailPart { * {action: 'del'} * ``` * - * To remove a specific marker identified by its {@link PersistedSlice} reference + * To remove a specific marker identified by its {@link Slice} reference * pass the slice or its ID in the `slice` field: * * ```ts diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/overlay/MarkerOverlayPoint.ts b/packages/json-joy/src/json-crdt-extensions/peritext/overlay/MarkerOverlayPoint.ts deleted file mode 100644 index 2d8be712ab..0000000000 --- a/packages/json-joy/src/json-crdt-extensions/peritext/overlay/MarkerOverlayPoint.ts +++ /dev/null @@ -1,63 +0,0 @@ -import {printTree} from 'tree-dump/lib/printTree'; -import {OverlayPoint} from './OverlayPoint'; -import type {HeadlessNode2} from 'sonic-forest/lib/types2'; -import type {SliceType} from '../slice/types'; -import type {Anchor} from '../rga/constants'; -import type {AbstractRga} from '../../../json-crdt/nodes/rga'; -import type {ITimestampStruct} from '../../../json-crdt-patch/clock'; -import type {MarkerSlice} from '../slice/MarkerSlice'; - -export class MarkerOverlayPoint extends OverlayPoint implements HeadlessNode2 { - /** - * Hash value of the following text contents, up until the next marker. - */ - public textHash: number = 0; - - constructor( - protected readonly rga: AbstractRga, - id: ITimestampStruct, - anchor: Anchor, - public readonly marker: MarkerSlice, - ) { - super(rga, id, anchor); - } - - public type(): SliceType { - return this.marker && this.marker.type(); - } - - public data(): unknown { - return this.marker && this.marker.data(); - } - - // ---------------------------------------------------------------- Printable - - public toStringName(): string { - return 'MarkerOverlayPoint'; - } - - public toStringHeader(tab: string, lite?: boolean): string { - const hash = lite ? '' : `#${this.textHash.toString(36).slice(-4)}`; - const tag = lite ? '' : `, type = ${JSON.stringify(this.type() as any)}`; - return `${super.toStringHeader(tab, lite)}${lite ? '' : ' '}${hash}${tag}`; - } - - public toString(tab: string = '', lite?: boolean): string { - return ( - this.toStringHeader(tab, lite) + - (lite - ? '' - : printTree(tab, [ - (tab) => this.marker.toString(tab), - ...this.layers.map((slice) => (tab: string) => slice.toString(tab)), - ...this.markers.map((slice) => (tab: string) => slice.toString(tab)), - ])) - ); - } - - // ------------------------------------------------------------ HeadlessNode2 - - public p2: MarkerOverlayPoint | undefined; - public l2: MarkerOverlayPoint | undefined; - public r2: MarkerOverlayPoint | undefined; -} diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/overlay/Overlay.ts b/packages/json-joy/src/json-crdt-extensions/peritext/overlay/Overlay.ts index eb3d1c14bb..c7d17eac72 100644 --- a/packages/json-joy/src/json-crdt-extensions/peritext/overlay/Overlay.ts +++ b/packages/json-joy/src/json-crdt-extensions/peritext/overlay/Overlay.ts @@ -5,11 +5,9 @@ import {first2, insert2, last2, next2, prev2, remove2} from 'sonic-forest/lib/ut import {splay} from 'sonic-forest/lib/splay/util'; import {Anchor} from '../rga/constants'; import {OverlayPoint} from './OverlayPoint'; -import {MarkerOverlayPoint} from './MarkerOverlayPoint'; -import {OverlayRefSliceEnd, OverlayRefSliceStart} from './refs'; import {compare, type ITimestampStruct} from '../../../json-crdt-patch/clock'; import {CONST, updateNum} from '../../../json-hash/hash'; -import {MarkerSlice} from '../slice/MarkerSlice'; +import {Slice} from '../slice/Slice'; import {UndEndIterator, type UndEndNext} from '../../../util/iterator'; import {SliceStacking} from '../slice/constants'; import type {Point} from '../rga/Point'; @@ -18,12 +16,59 @@ import type {Chunk} from '../../../json-crdt/nodes/rga'; import type {Peritext} from '../Peritext'; import type {Stateful} from '../types'; import type {Printable} from 'tree-dump/lib/types'; -import type {MutableSlice, Slice, SliceType} from '../slice/types'; -import type {Slices} from '../slice/Slices'; -import type {MarkerOverlayPair, OverlayPair, OverlayTuple} from './types'; -import type {Comparator} from 'sonic-forest/lib/types'; - -const spatialComparator: Comparator = (a: OverlayPoint, b: OverlayPoint) => a.cmpSpatial(b); +import type {OverlayPair, OverlayTuple} from './types'; +import type {Comparator, HeadlessNode} from 'sonic-forest/lib/types'; +import type {SliceType} from '../slice'; +import type {HeadlessNode2} from 'sonic-forest/lib/types2'; + +const find = ( + root: N | undefined, + comparison: T, + comparator: Comparator, +): N | undefined => { + let curr: N | undefined = root; + while (curr) { + const cmp = comparator(comparison, curr); + if (cmp === 0) return curr; + curr = cmp < 0 ? (curr.l as N | undefined) : (curr.r as N | undefined); + } + return curr; +}; + +const find2 = ( + root: N | undefined, + comparison: T, + comparator: Comparator, +): N | undefined => { + let curr: N | undefined = root; + while (curr) { + const cmp = comparator(comparison, curr); + if (cmp === 0) return curr; + curr = cmp < 0 ? (curr.l2 as N | undefined) : (curr.r2 as N | undefined); + } + return curr; +}; + +export const insert = ( + root: N | undefined, + node: N, + comparator: Comparator, +): N | undefined => { + if (!root) return node; + let curr: N | undefined = root; + while (curr) { + const cmp = comparator(node, curr); + const next: N | undefined = cmp < 0 ? (curr.l as N | undefined) : (curr.r as N | undefined); + if (!next) { + if (cmp < 0) insertLeft(node, curr); + else insertRight(node, curr); + break; + } else curr = next; + } + return root; +}; + +const spatialComparator: Comparator> = (a: OverlayPoint, b: OverlayPoint) => a.cmpSpatial(b); /** * Overlay is a tree structure that represents all the intersections of slices @@ -33,8 +78,11 @@ const spatialComparator: Comparator = (a: OverlayPoint, b: Overlay * based on the current state of the text and slices. */ export class Overlay implements Printable, Stateful { + /** + * @todo Make it an AVL tree. + */ public root: OverlayPoint | undefined = undefined; - public root2: MarkerOverlayPoint | undefined = undefined; + public root2: OverlayPoint | undefined = undefined; /** A virtual absolute start point, used when the absolute start is missing. */ public readonly START: OverlayPoint; @@ -52,10 +100,6 @@ export class Overlay implements Printable, Stateful { return new OverlayPoint(this.txt.str, id, anchor); } - private mPoint(marker: MarkerSlice, anchor: Anchor): MarkerOverlayPoint { - return new MarkerOverlayPoint(this.txt.str, marker.start.id, anchor, marker); - } - public first(): OverlayPoint | undefined { return this.root ? first(this.root) : undefined; } @@ -64,14 +108,22 @@ export class Overlay implements Printable, Stateful { return this.root ? last(this.root) : undefined; } - public firstMarker(): MarkerOverlayPoint | undefined { + public firstMarker(): OverlayPoint | undefined { return this.root2 ? first2(this.root2) : undefined; } - public lastMarker(): MarkerOverlayPoint | undefined { + public lastMarker(): OverlayPoint | undefined { return this.root2 ? last2(this.root2) : undefined; } + public get(point: Point): OverlayPoint | undefined { + return find, OverlayPoint>(this.root, point, spatialComparator as Comparator>); + } + + public getMarker(point: Point): OverlayPoint | undefined { + return find2, OverlayPoint>(this.root2, point, spatialComparator as Comparator>); + } + /** * Retrieve overlay point or the previous one, measured in spacial dimension. */ @@ -82,7 +134,7 @@ export class Overlay implements Printable, Stateful { return first.isAbsStart() ? first : void 0; } else if (point.isAbsEnd()) return this.last(); let curr: OverlayPoint | undefined = this.root; - let result: OverlayPoint | undefined; + let result: OverlayPoint | undefined = undefined; while (curr) { const cmp = curr.cmpSpatial(point); if (cmp === 0) return curr; @@ -107,7 +159,7 @@ export class Overlay implements Printable, Stateful { return last.isAbsEnd() ? last : void 0; } else if (point.isAbsStart()) return this.first(); let curr: OverlayPoint | undefined = this.root; - let result: OverlayPoint | undefined; + let result: OverlayPoint | undefined = undefined; while (curr) { const cmp = curr.cmpSpatial(point); if (cmp === 0) return curr; @@ -123,17 +175,17 @@ export class Overlay implements Printable, Stateful { } /** - * Retrieve a {@link MarkerOverlayPoint} at the specified point or the + * Retrieve a {@link OverlayPoint} at the specified point or the * previous one, measured in spacial dimension. */ - public getOrNextLowerMarker(point: Point): MarkerOverlayPoint | undefined { + public getOrNextLowerMarker(point: Point): OverlayPoint | undefined { if (point.isAbsStart()) { const first = this.firstMarker(); if (!first) return; return first.isAbsStart() ? first : void 0; } else if (point.isAbsEnd()) return this.lastMarker(); - let curr: MarkerOverlayPoint | undefined = this.root2; - let result: MarkerOverlayPoint | undefined; + let curr: OverlayPoint | undefined = this.root2; + let result: OverlayPoint | undefined = undefined; while (curr) { const cmp = curr.cmpSpatial(point); if (cmp === 0) return curr; @@ -210,7 +262,7 @@ export class Overlay implements Printable, Stateful { } /** - * Returns all {@link MarkerOverlayPoint} instances in the overlay, starting + * Returns all {@link OverlayPoint} instances in the overlay, starting * from the given marker point, not including the marker point itself. * * If the `after` parameter is not provided, the iteration starts from the @@ -220,7 +272,7 @@ export class Overlay implements Printable, Stateful { * @returns All marker points in the overlay, starting from the given marker * point. */ - public markers0(after: undefined | MarkerOverlayPoint): UndEndNext> { + public markers0(after: undefined | OverlayPoint): UndEndNext> { let curr = after ? next2(after) : first2(this.root2); return () => { const ret = curr; @@ -229,12 +281,12 @@ export class Overlay implements Printable, Stateful { }; } - public markers(after?: undefined | MarkerOverlayPoint): UndEndIterator> { + public markers(after?: undefined | OverlayPoint): UndEndIterator> { return new UndEndIterator(this.markers0(after)); } /** - * Returns all {@link MarkerOverlayPoint} instances in the overlay, starting + * Returns all {@link OverlayPoint} instances in the overlay, starting * from a give {@link Point}, including any marker overlay points that are * at the same position as the given point. * @@ -242,7 +294,7 @@ export class Overlay implements Printable, Stateful { * @returns All marker points in the overlay, starting from the given marker * point. */ - public markersFrom0(point: Point): UndEndNext> { + public markersFrom0(point: Point): UndEndNext> { if (point.isAbsStart()) return this.markers0(undefined); let after = this.getOrNextLowerMarker(point); if (after && after.cmp(point) === 0) after = prev2(after); @@ -261,11 +313,11 @@ export class Overlay implements Printable, Stateful { * continues until the end of the overlay. * @returns Iterator that returns pairs of overlay points. */ - public markerPairs0(start: Point, end?: Point): UndEndNext> { + public markerPairs0(start: Point, end?: Point): UndEndNext> { const i = this.markersFrom0(start); let closed = false; - let p1: MarkerOverlayPoint | undefined; - let p2: MarkerOverlayPoint | undefined = i(); + let p1: OverlayPoint | undefined; + let p2: OverlayPoint | undefined = i(); if (p2) { if (p2.isAbsStart() || !p2.cmp(start)) { p1 = p2; @@ -286,14 +338,14 @@ export class Overlay implements Printable, Stateful { return [p1, cmp ? void 0 : p2]; } } - const result: MarkerOverlayPair = [p1, p2]; + const result: OverlayPair = [p1, p2]; p1 = p2; p2 = i(); return result; }; } - public pairs0(after: undefined | OverlayPoint): UndEndNext> { + public pairs0(after: undefined | OverlayPoint, inclusive?: boolean): UndEndNext> { const isEmpty = !this.root; if (isEmpty) { const u = undefined; @@ -302,7 +354,7 @@ export class Overlay implements Printable, Stateful { } let p1: OverlayPoint | undefined; let p2: OverlayPoint | undefined = after; - const iterator = this.points0(after); + const iterator = this.points0(after, inclusive); return () => { const next = iterator(); const isEnd = !next; @@ -324,12 +376,12 @@ export class Overlay implements Printable, Stateful { }; } - public pairs(after?: undefined | OverlayPoint): IterableIterator> { - return new UndEndIterator(this.pairs0(after)); + public pairs(after?: undefined | OverlayPoint, inclusive?: boolean): IterableIterator> { + return new UndEndIterator(this.pairs0(after, inclusive)); } - public tuples0(after: undefined | OverlayPoint): UndEndNext> { - const iterator = this.pairs0(after); + public tuples0(after: undefined | OverlayPoint, inclusive?: boolean): UndEndNext> { + const iterator = this.pairs0(after, inclusive); return () => { const pair = iterator(); if (!pair) return; @@ -339,8 +391,8 @@ export class Overlay implements Printable, Stateful { }; } - public tuples(after?: undefined | OverlayPoint): IterableIterator> { - return new UndEndIterator(this.tuples0(after)); + public tuples(after?: undefined | OverlayPoint, inclusive?: boolean): IterableIterator> { + return new UndEndIterator(this.tuples0(after, inclusive)); } /** @@ -380,8 +432,8 @@ export class Overlay implements Printable, Stateful { const slice = slices[i]; if (!result.has(slice) && range.contains(slice)) result.add(slice); } - if (point instanceof MarkerOverlayPoint) { - const marker = point.marker; + if (point instanceof OverlayPoint && point.isMarker()) { + const marker = point.markers[0]; if (marker && !result.has(marker) && range.contains(marker)) result.add(marker); } } while (point && (point = next(point)) && range.containsPoint(point)); @@ -404,8 +456,8 @@ export class Overlay implements Printable, Stateful { const slices = point.layers; const length = slices.length; for (let i = 0; i < length; i++) result.add(slices[i]); - if (point instanceof MarkerOverlayPoint) { - const marker = point.marker; + if (point instanceof OverlayPoint && point.isMarker()) { + const marker = point.markers[0]; if (marker) result.add(marker); } } while (point && (point = next(point)) && range.containsPoint(point)); @@ -442,34 +494,34 @@ export class Overlay implements Printable, Stateful { let partial: Set = new Set(); let isFirst = true; let markerCount = 0; - for (let point = iterator(); point && point.cmpSpatial(end) < 0; point = iterator()) { - if (point instanceof MarkerOverlayPoint) { + OVERLAY: for (let point = iterator(); point && point.cmpSpatial(end) < 0; point = iterator()) { + if (point instanceof OverlayPoint && point.isMarker()) { markerCount++; if (markerCount >= endOnMarker) break; - continue; + continue OVERLAY; } const current = new Set(); const layers = point.layers; const length = layers.length; - for (let i = 0; i < length; i++) { + LAYERS: for (let i = 0; i < length; i++) { const slice = layers[i]; const type = slice.type(); - if (typeof type === 'object') continue; + if (typeof type === 'object') continue LAYERS; const stacking = slice.stacking; - switch (stacking) { + STACKING: switch (stacking) { case SliceStacking.One: current.add(type); - break; + break STACKING; case SliceStacking.Erase: current.delete(type); - break; + break STACKING; } } if (isFirst) { isFirst = false; if (hasLeadingPoint) complete = current; else partial = current; - continue; + continue OVERLAY; } for (const type of complete) if (!current.has(type)) { @@ -490,7 +542,7 @@ export class Overlay implements Printable, Stateful { public isMarker(id: ITimestampStruct): boolean { const p = this.txt.point(id, Anchor.Before); const op = this.getOrNextLower(p); - return op instanceof MarkerOverlayPoint && op.id.time === id.time && op.id.sid === id.sid; + return op instanceof OverlayPoint && op.isMarker() && op.id.time === id.time && op.id.sid === id.sid; } public skipMarkers(point: Point, direction: -1 | 1): boolean { @@ -503,18 +555,87 @@ export class Overlay implements Printable, Stateful { return false; } - // ----------------------------------------------------------------- Stateful + /** ------------------------------------------------------ {@link Stateful} */ public hash: number = 0; + private clear(): void { + this.root = void 0; + this.root2 = void 0; + this.slices.clear(); + } + public refresh(slicesOnly: boolean = false): number { - const txt = this.txt; + const {txt, slices} = this; let hash: number = CONST.START_STATE; - hash = this.refreshSlices(hash, txt.savedSlices); - hash = this.refreshSlices(hash, txt.extraSlices); - hash = this.refreshSlices(hash, txt.localSlices); - - // TODO: Move test hash calculation out of the overlay. + { + const {savedSlices, extraSlices, localSlices} = txt; + const savedSlicesOldHash = savedSlices.hash; + const savedSlicesHash = savedSlices.refresh(); + const savedSlicesChanged = savedSlicesOldHash !== savedSlicesHash; + hash = updateNum(hash, savedSlicesHash); + const extraSlicesOldHash = extraSlices.hash; + const extraSlicesHash = extraSlices.refresh(); + const extraSlicesChanged = extraSlicesOldHash !== extraSlicesHash; + hash = updateNum(hash, extraSlicesHash); + const localSlicesOldHash = localSlices.hash; + const localSlicesHash = localSlices.refresh(); + const localSlicesChanged = localSlicesOldHash !== localSlicesHash; + hash = updateNum(hash, localSlicesHash); + if (savedSlicesChanged || extraSlicesChanged || localSlicesChanged) { + // this.clear(); + // savedSlices.forEach((slice) => { + // if (slice.isSplit()) this.insMarker(slice); + // else this.insSlice(slice); + // }); + // extraSlices.forEach((slice) => { + // if (slice.isSplit()) this.insMarker(slice); + // else this.insSlice(slice); + // }); + // localSlices.forEach((slice) => { + // if (slice.isSplit()) this.insMarker(slice); + // else this.insSlice(slice); + // }); + + if (savedSlicesChanged || extraSlicesChanged) { + this.clear(); + // TODO: Implement complete rebuild routine from scratch. It should + // be efficient and fast: (1) retrieve all slices from all + // sources, (2) sort them by start point, (3) insert them into + // the overlay and update hashes in one go. + } else if (localSlicesChanged) { + slices.forEach((tuple, slice) => { + const mutSlice = slice as Slice; + if (mutSlice.isDel?.()) this.delSlice(slice, tuple); + }); + } + if (savedSlicesChanged || extraSlicesChanged) { + savedSlices.forEach((slice) => { + if (slice.isMarker()) this.upsertSlice(slice); + else this.upsertSlice(slice); + }); + extraSlices.forEach((slice) => { + if (slice.isMarker()) this.upsertSlice(slice); + else this.upsertSlice(slice); + }); + } + if (localSlicesChanged || savedSlicesChanged || extraSlicesChanged) { + const sliceSet = this.slices; + localSlices.forEach((slice) => { + const tuple = sliceSet.get(slice); + if (tuple) { + const positionMoved = tuple[0].cmp(slice.start) !== 0 || tuple[1].cmp(slice.end) !== 0; + if (positionMoved) this.delSlice(slice, tuple); + } + }); + localSlices.forEach((slice) => { + const tuple = slice.isMarker() ? this.upsertSlice(slice) : this.upsertSlice(slice); + this.slices.set(slice, tuple); + }); + } + } + } + // TODO: Move text hash calculation out of the overlay. if (!slicesOnly) { // hash = updateRga(hash, txt.str); hash = this.refreshTextSlices(hash); @@ -524,47 +645,43 @@ export class Overlay implements Printable, Stateful { public readonly slices = new Map, [start: OverlayPoint, end: OverlayPoint]>(); - 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; + private upsertSlice(slice: Slice): [start: OverlayPoint, end: OverlayPoint] { + if (slice.isMarker()) { + const start = slice.start; + const overlayPointOrLower = this.getOrNextLower(start); + if (overlayPointOrLower) { + const isStart = !overlayPointOrLower.cmp(start); + if (isStart) { + overlayPointOrLower.addMarkerRef(slice); + const markerOverlayPoint = this.getMarker(start); + if (!markerOverlayPoint) this.root2 = insert2(this.root2, overlayPointOrLower, spatialComparator); + return [overlayPointOrLower, overlayPointOrLower]; + } + } + const markerOverlayPointOrLower = this.getOrNextLowerMarker(start); + if (markerOverlayPointOrLower) { + const isStart = !markerOverlayPointOrLower.cmp(start); + if (isStart) { + markerOverlayPointOrLower.addMarkerRef(slice); + this.root = insert(this.root, markerOverlayPointOrLower, spatialComparator); + return [markerOverlayPointOrLower, markerOverlayPointOrLower]; } - tuple = slice instanceof MarkerSlice ? this.insMarker(slice) : 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 point = new OverlayPoint(this.txt.str, start.id, Anchor.Before); + point.addMarkerRef(slice); + this.root = insert(this.root, point, spatialComparator); + this.root2 = insert2(this.root2, point, spatialComparator); + const prevPoint = prev(point); + if (prevPoint) point.layers.push(...prevPoint.layers); + return [point, point]; } - return state; - } - - private insSlice(slice: Slice): [start: OverlayPoint, end: OverlayPoint] { const x0 = slice.start; const x1 = slice.end; - const [start, isStartNew] = this.upsertPoint(x0); - const [end, isEndNew] = this.upsertPoint(x1); + const [start, isStartNew] = this.upsertPoint(slice.start); + const [end, isEndNew] = this.upsertPoint(slice.end); const isCollapsed = x0.cmp(x1) === 0; - start.refs.push(new OverlayRefSliceStart(slice)); - end.refs.push(new OverlayRefSliceEnd(slice)); + start.upsertStartRef(slice); + end.upsertEndRef(slice); if (isStartNew) { const beforeStartPoint = prev(start); if (beforeStartPoint) start.layers.push(...beforeStartPoint.layers); @@ -581,69 +698,49 @@ export class Overlay implements Printable, Stateful { return [start, end]; } - private insMarker(slice: MarkerSlice): [start: OverlayPoint, end: OverlayPoint] { - const point = this.mPoint(slice, Anchor.Before); - const pivot = this.insPoint(point); - if (!pivot) { - point.refs.push(slice); - const prevPoint = prev(point); - if (prevPoint) point.layers.push(...prevPoint.layers); - } - return [point, point]; - } - private delSlice(slice: Slice, [start, end]: [start: OverlayPoint, end: OverlayPoint]): void { - this.slices.delete(slice); - let curr: OverlayPoint | undefined = start; - do { - curr.removeLayer(slice); - curr.removeMarker(slice); - curr = next(curr); - } while (curr && curr !== end); - start.removeRef(slice); - end.removeRef(slice); - if (!start.refs.length) this.delPoint(start); - if (!end.refs.length && start !== end) this.delPoint(end); + if (slice instanceof Slice && slice.isMarker()) { + this.slices.delete(slice); + this.root2 = remove2(this.root2, start as OverlayPoint); + this.root = remove(this.root, start); + } else { + this.slices.delete(slice); + let curr: OverlayPoint | undefined = start; + do { + curr.removeLayer(slice); + curr.removeMarker(slice); + curr = next(curr); + } while (curr && curr !== end); + start.removeRef(slice); + end.removeRef(slice); + if (!start.refs.length) this.delPoint(start); + if (!end.refs.length && start !== end) this.delPoint(end); + } } /** + * Inserts a point into the tree, sorted by spatial dimension. * Retrieve an existing {@link OverlayPoint} or create a new one, inserted * in the tree, sorted by spatial dimension. - */ - private upsertPoint(point: Point): [point: OverlayPoint, isNew: boolean] { - const newPoint = this.point(point.id, point.anchor); - const pivot = this.insPoint(newPoint); - if (pivot) return [pivot, false]; - return [newPoint, true]; - } - - /** - * Inserts a point into the tree, sorted by spatial dimension. + * * @param point Point to insert. * @returns Returns the existing point if it was already in the tree. */ - private insPoint(point: OverlayPoint): OverlayPoint | undefined { - if (point instanceof MarkerOverlayPoint) { - this.root2 = insert2(this.root2, point, spatialComparator); - // if (this.root2 !== point) this.root2 = splay2(this.root2!, point, 10); - } - let pivot = this.getOrNextLower(point); - if (!pivot) pivot = first(this.root); - if (!pivot) { - this.root = point; - return; - } else { - if (pivot.cmp(point) === 0) return pivot; - const cmp = pivot.cmpSpatial(point); - if (cmp < 0) insertRight(point, pivot); - else insertLeft(point, pivot); - } - if (this.root !== point) this.root = splay(this.root!, point, 10); - return; + private upsertPoint(point: Point): [point: OverlayPoint, isNew: boolean] { + let overlayPointOrLower = this.getOrNextLower(point); + overlayPointOrLower ??= first(this.root); + if (!overlayPointOrLower) return [(this.root = this.point(point.id, point.anchor)), true]; + if (overlayPointOrLower.cmp(point) === 0) return [overlayPointOrLower, false]; + const cmp = overlayPointOrLower.cmpSpatial(point); + const overlayPoint = this.getMarker(point) ?? this.point(point.id, point.anchor); + if (cmp < 0) insertRight(overlayPoint, overlayPointOrLower); + else insertLeft(overlayPoint, overlayPointOrLower); + if (this.root !== overlayPoint) this.root = splay(this.root!, overlayPoint, 10); + return [overlayPoint, true]; } private delPoint(point: OverlayPoint): void { - if (point instanceof MarkerOverlayPoint) this.root2 = remove2(this.root2, point); + if (point.p2) this.root2 = remove2(this.root2, point); this.root = remove(this.root, point); } @@ -655,7 +752,7 @@ export class Overlay implements Printable, Stateful { const firstChunk = str.first(); if (!firstChunk) return stateTotal; let chunk: Chunk | undefined = firstChunk; - let marker: MarkerOverlayPoint | undefined; + let marker: OverlayPoint | undefined = undefined; const i = this.tuples0(undefined); let state: number = CONST.START_STATE; for (let pair = i(); pair; pair = i()) { @@ -673,7 +770,7 @@ export class Overlay implements Printable, Stateful { for (const slice of p1.markers) state = updateNum(state, slice.hash); p1.hash = overlayPointHash; stateTotal = updateNum(stateTotal, overlayPointHash); - if (p2 instanceof MarkerOverlayPoint) { + if (p2 && p2.isMarker()) { if (marker) { marker.textHash = state; } else { @@ -684,15 +781,15 @@ export class Overlay implements Printable, Stateful { marker = p2; } } - if ((marker as any) instanceof MarkerOverlayPoint) { - (marker as any as MarkerOverlayPoint).textHash = state; + if (marker && marker.isMarker()) { + marker.textHash = state; } else { this.leadingTextHash = state; } return stateTotal; } - // ---------------------------------------------------------------- Printable + /** ----------------------------------------------------- {@link Printable} */ public toString(tab: string = ''): string { const printPoint = (tab: string, point: OverlayPoint): string => { @@ -704,7 +801,7 @@ export class Overlay implements Printable, Stateful { ]) ); }; - const printMarkerPoint = (tab: string, point: MarkerOverlayPoint): string => { + const printMarkerPoint = (tab: string, point: OverlayPoint): string => { return ( point.toString(tab) + printBinary(tab, [ diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/overlay/OverlayPoint.ts b/packages/json-joy/src/json-crdt-extensions/peritext/overlay/OverlayPoint.ts index 072fb5be6e..24d82b7eea 100644 --- a/packages/json-joy/src/json-crdt-extensions/peritext/overlay/OverlayPoint.ts +++ b/packages/json-joy/src/json-crdt-extensions/peritext/overlay/OverlayPoint.ts @@ -2,23 +2,37 @@ import {Point} from '../rga/Point'; import {compare} from '../../../json-crdt-patch/clock'; import {type OverlayRef, OverlayRefSliceEnd, OverlayRefSliceStart} from './refs'; import {printTree} from 'tree-dump/lib/printTree'; -import type {MarkerSlice} from '../slice/MarkerSlice'; +import {formatType} from '../slice/util'; +import type {SliceType} from '../slice/types'; +import type {Slice} from '../slice/Slice'; import type {HeadlessNode} from 'sonic-forest/lib/types'; import type {PrintChild, Printable} from 'tree-dump/lib/types'; -import type {Slice} from '../slice/types'; +import type {HeadlessNode2} from 'sonic-forest/lib/types2'; /** * 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 { +export class OverlayPoint extends Point implements Printable, HeadlessNode, HeadlessNode2 { /** * Hash of text contents until the next {@link OverlayPoint}. This field is * modified by the {@link Overlay} tree. */ public hash: number = 0; + /** -------------------------------------------------- {@link HeadlessNode} */ + + public p: OverlayPoint | undefined = undefined; + public l: OverlayPoint | undefined = undefined; + public r: OverlayPoint | undefined = undefined; + + /** ------------------------------------------------- {@link HeadlessNode2} */ + + public p2: OverlayPoint | undefined = undefined; + public l2: OverlayPoint | undefined = undefined; + public r2: OverlayPoint | undefined = undefined; + // ------------------------------------------------------------------- layers /** @@ -80,14 +94,122 @@ export class OverlayPoint extends Point implements Printable, Hea } } + // --------------------------------------------------------------------- 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: Slice): void { + const markers = this.markers; + const length = markers.length; + for (let i = 0; i < length; i++) if (markers[i] === slice) return; + this.refs.push(slice); + this.addMarker(slice); + } + + public upsertStartRef(slice: Slice): OverlayRefSliceStart { + const refs = this.refs; + const length = refs.length; + for (let i = 0; i < length; i++) { + const ref = refs[i]; + if (ref instanceof OverlayRefSliceStart && ref.slice === slice) return ref; + } + const ref = new OverlayRefSliceStart(slice); + this.refs.push(ref); + return ref; + } + + public upsertEndRef(slice: Slice): OverlayRefSliceEnd { + const refs = this.refs; + const length = refs.length; + for (let i = 0; i < length; i++) { + const ref = refs[i]; + if (ref instanceof OverlayRefSliceEnd && ref.slice === slice) return ref; + } + const ref = new OverlayRefSliceEnd(slice); + this.refs.push(ref); + return ref; + } + + /** + * 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; + } + } + } + // ------------------------------------------------------------------ markers + /** + * Hash value of the following text contents, up until the next marker. + */ + public textHash: number = 0; + + /** + * @deprecated Use `this.marker().type()` instead. + */ + public type(): SliceType { + return this.markers[0] && this.markers[0].type(); + } + + /** + * @deprecated Use `this.marker().data()` instead. + */ + public data(): unknown { + return this.markers[0] && this.markers[0].data(); + } + /** * Collapsed slices - markers (block splits), which represent a single point * in the text, even if the start and end of the slice are different. + * + * @todo This normally should never be a list, but a single item. Enforce? */ public readonly markers: Slice[] = []; + /** + * @deprecated Make this a method. + */ + get marker(): Slice { + const marker = this.markers[0]; + if (!marker) throw new Error('NO_MARKER'); + return marker; + } + + public isMarker(): boolean { + const markers = this.markers; + const length = markers.length; + for (let i = 0; i < length; i++) if (markers[i].isMarker()) return true; + return false; + } + /** * 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 @@ -95,6 +217,8 @@ export class OverlayPoint extends Point implements Printable, Hea * the state of the point. The markers are sorted by the slice ID. * * @param slice Slice to add to the marker list. + * + * @todo Make this method private. */ public addMarker(slice: Slice): void { const markers = this.markers; @@ -140,80 +264,35 @@ export class OverlayPoint extends Point implements Printable, Hea } } - // --------------------------------------------------------------------- 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: MarkerSlice): 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 + /** ----------------------------------------------------- {@link Printable} */ public toStringName(): string { - return 'OverlayPoint'; + return 'OverlayPoint' + (this.isMarker() ? '::Marker' : ''); } public toStringHeader(tab: string = '', lite?: boolean): string { - return super.toString(tab, lite); + const header = super.toString(tab, lite); + if (this.isMarker()) { + const hash = lite ? '' : `#${this.textHash.toString(36).slice(-4)}`; + const typeFormatted = formatType(this.type()); + const typeFormatted2 = lite ? '' : ' ' + typeFormatted; + return `${header}${lite ? '' : ' '}${hash}${typeFormatted2}`; + } + return header; } public toString(tab: string = '', lite?: boolean): string { + if (this.isMarker()) { + return ( + this.toStringHeader(tab, lite) + + (lite + ? '' + : printTree( + tab, + this.markers.map((slice) => (tab: string) => slice.toString(tab)), + )) + ); + } const refs = lite ? '' : `, refs = ${this.refs.length}`; const header = this.toStringHeader(tab, lite) + refs; if (lite) return header; @@ -226,10 +305,4 @@ export class OverlayPoint extends Point implements Printable, Hea for (let i = 0; i < markerLength; i++) children.push((tab) => markers[i].toString(tab)); return header + printTree(tab, children); } - - // ------------------------------------------------------------- HeadlessNode - - public p: OverlayPoint | undefined = undefined; - public l: OverlayPoint | undefined = undefined; - public r: OverlayPoint | undefined = undefined; } diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.markers.spec.ts b/packages/json-joy/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.markers.spec.ts index 2c47ead98a..9899198e63 100644 --- a/packages/json-joy/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.markers.spec.ts +++ b/packages/json-joy/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.markers.spec.ts @@ -1,7 +1,7 @@ import {UndEndIterator} from '../../../../util/iterator'; import {type Kit, runNumbersKitTestSuite} from '../../__tests__/setup'; import type {Point} from '../../rga/Point'; -import {MarkerOverlayPoint} from '../MarkerOverlayPoint'; +import {OverlayPoint} from '../OverlayPoint'; const runMarkersTests = (setup: () => Kit) => { describe('.markers()', () => { @@ -19,7 +19,7 @@ const runMarkersTests = (setup: () => Kit) => { peritext.overlay.refresh(); const list = [...peritext.overlay.markers()]; expect(list.length).toBe(1); - expect(list[0] instanceof MarkerOverlayPoint).toBe(true); + expect(list[0].isMarker()).toBe(true); }); test('can iterate through multiple markers', () => { @@ -35,10 +35,10 @@ const runMarkersTests = (setup: () => Kit) => { peritext.overlay.refresh(); const list = [...peritext.overlay.markers()]; expect(list.length).toBe(3); - for (const m of list) expect(m instanceof MarkerOverlayPoint).toBe(true); - expect(list[0].marker).toBe(m1); - expect(list[1].marker).toBe(m2); - expect(list[2].marker).toBe(m3); + for (const m of list) expect(m.isMarker()).toBe(true); + expect(list[0].markers[0]).toBe(m1); + expect(list[1].markers[0]).toBe(m2); + expect(list[2].markers[0]).toBe(m3); }); test('can delete markers', () => { @@ -120,7 +120,7 @@ const runMarkersTests = (setup: () => Kit) => { const point = peritext.pointAt(3); const list = [...new UndEndIterator(peritext.overlay.markersFrom0(point))]; expect(list.length).toBe(1); - expect(list[0] instanceof MarkerOverlayPoint).toBe(true); + expect(list[0].isMarker()).toBe(true); }); test('returns a single marker (when point is before marker position)', () => { @@ -131,7 +131,7 @@ const runMarkersTests = (setup: () => Kit) => { const point = peritext.pointAt(1); const list = [...new UndEndIterator(peritext.overlay.markersFrom0(point))]; expect(list.length).toBe(1); - expect(list[0] instanceof MarkerOverlayPoint).toBe(true); + expect(list[0].isMarker()).toBe(true); }); test('can iterate through multiple markers', () => { @@ -148,7 +148,7 @@ const runMarkersTests = (setup: () => Kit) => { const point = peritext.pointAt(1); const list = [...new UndEndIterator(peritext.overlay.markersFrom0(point))]; expect(list.length).toBe(3); - for (const m of list) expect(m instanceof MarkerOverlayPoint).toBe(true); + for (const m of list) expect(m.isMarker()).toBe(true); expect(list[0].marker).toBe(m1); expect(list[1].marker).toBe(m2); expect(list[2].marker).toBe(m3); @@ -168,7 +168,7 @@ const runMarkersTests = (setup: () => Kit) => { const point = peritext.pointAbsStart(); const list = [...new UndEndIterator(peritext.overlay.markersFrom0(point))]; expect(list.length).toBe(3); - for (const m of list) expect(m instanceof MarkerOverlayPoint).toBe(true); + for (const m of list) expect(m.isMarker()).toBe(true); expect(list[0].marker).toBe(m1); expect(list[1].marker).toBe(m2); expect(list[2].marker).toBe(m3); @@ -196,7 +196,7 @@ const runMarkersTests = (setup: () => Kit) => { const point = peritext.pointAbsStart(); const list = [...new UndEndIterator(peritext.overlay.markersFrom0(point))]; expect(list.length).toBe(1); - expect(list[0].marker).toBe(marker); + expect(list[0].markers[0]).toBe(marker); editor.extra.del(marker); peritext.overlay.refresh(); expect([...peritext.overlay.markers()].length).toBe(0); @@ -335,8 +335,10 @@ const runMarkersTests = (setup: () => Kit) => { const list = [...new UndEndIterator(peritext.overlay.markerPairs0(point))]; expect(list.length).toBe(2); expect(list[0][0]).toBe(undefined); - expect(list[0][1]).toBeInstanceOf(MarkerOverlayPoint); - expect(list[1][0]).toBeInstanceOf(MarkerOverlayPoint); + expect(list[0][1]).toBeInstanceOf(OverlayPoint); + expect(list[0][1]?.isMarker()).toBe(true); + expect(list[1][0]).toBeInstanceOf(OverlayPoint); + expect(list[1][0]?.isMarker()).toBe(true); expect(list[1][1]).toBe(undefined); expect(list[0][1]).toBe(list[1][0]); }); @@ -346,7 +348,8 @@ const runMarkersTests = (setup: () => Kit) => { const point = peritext.pointAt(5); const list = [...new UndEndIterator(peritext.overlay.markerPairs0(point))]; expect(list.length).toBe(1); - expect(list[0][0]).toBeInstanceOf(MarkerOverlayPoint); + expect(list[0][0]).toBeInstanceOf(OverlayPoint); + expect(list[0][0]?.isMarker()).toBe(true); expect(list[0][1]).toBe(undefined); }); @@ -378,8 +381,10 @@ const runMarkersTests = (setup: () => Kit) => { const list = [...new UndEndIterator(iterator)]; expect(list.length).toBe(2); expect(list[0][0]).toBe(undefined); - expect(list[0][1]).toBeInstanceOf(MarkerOverlayPoint); - expect(list[1][0]).toBeInstanceOf(MarkerOverlayPoint); + expect(list[0][1]).toBeInstanceOf(OverlayPoint); + expect(list[0][1]?.isMarker()).toBe(true); + expect(list[1][0]).toBeInstanceOf(OverlayPoint); + expect(list[1][0]?.isMarker()).toBe(true); expect(list[1][1]).toBe(undefined); }); @@ -391,8 +396,10 @@ const runMarkersTests = (setup: () => Kit) => { const list = [...new UndEndIterator(iterator)]; expect(list.length).toBe(2); expect(list[0][0]).toBe(undefined); - expect(list[0][1]).toBeInstanceOf(MarkerOverlayPoint); - expect(list[1][0]).toBeInstanceOf(MarkerOverlayPoint); + expect(list[0][1]).toBeInstanceOf(OverlayPoint); + expect(list[0][1]?.isMarker()).toBe(true); + expect(list[1][0]).toBeInstanceOf(OverlayPoint); + expect(list[1][0]?.isMarker()).toBe(true); expect(list[1][1]).toBe(undefined); }); @@ -404,8 +411,10 @@ const runMarkersTests = (setup: () => Kit) => { const list = [...new UndEndIterator(iterator)]; expect(list.length).toBe(2); expect(list[0][0]).toBe(undefined); - expect(list[0][1]).toBeInstanceOf(MarkerOverlayPoint); - expect(list[1][0]).toBeInstanceOf(MarkerOverlayPoint); + expect(list[0][1]).toBeInstanceOf(OverlayPoint); + expect(list[0][1]?.isMarker()).toBe(true); + expect(list[1][0]).toBeInstanceOf(OverlayPoint); + expect(list[1][0]?.isMarker()).toBe(true); expect(list[1][1]).toBe(undefined); }); @@ -449,7 +458,8 @@ const runMarkersTests = (setup: () => Kit) => { const iterator = peritext.overlay.markerPairs0(start, end); const list = [...new UndEndIterator(iterator)]; expect(list.length).toBe(1); - expect(list[0][0]).toBeInstanceOf(MarkerOverlayPoint); + expect(list[0][0]).toBeInstanceOf(OverlayPoint); + expect(list[0][0]?.isMarker()).toBe(true); expect(list[0][1]).toBe(undefined); }); diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.pairs.spec.ts b/packages/json-joy/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.pairs.spec.ts index 0b1c51aaf3..b71ca7615f 100644 --- a/packages/json-joy/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.pairs.spec.ts +++ b/packages/json-joy/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.pairs.spec.ts @@ -8,8 +8,7 @@ import { setupNumbersWithTwoChunksKit, } from '../../__tests__/setup'; import {Anchor} from '../../rga/constants'; -import {MarkerOverlayPoint} from '../MarkerOverlayPoint'; -import {OverlayPoint} from '../OverlayPoint'; +import type {OverlayPoint} from '../OverlayPoint'; const runPairsTests = (setup: () => Kit) => { describe('.pairs() full range', () => { @@ -124,10 +123,10 @@ const runPairsTests = (setup: () => Kit) => { [p2, p3], [p3, undefined], ]); - expect(p1 instanceof MarkerOverlayPoint).toBe(true); - expect(p2 instanceof OverlayPoint).toBe(true); - expect(p3 instanceof OverlayPoint).toBe(true); - expect((p1 as MarkerOverlayPoint).marker).toBe(marker); + expect(p1.isMarker()).toBe(true); + expect(!p2.isMarker()).toBe(true); + expect(!p3.isMarker()).toBe(true); + expect((p1 as OverlayPoint).marker).toBe(marker); expect(p2.layers.length).toBe(2); expect(p3.layers.length).toBe(0); expect(p2.refs.length).toBe(2); diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.spec.ts b/packages/json-joy/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.spec.ts index 7543541a7c..3fec2de270 100644 --- a/packages/json-joy/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.spec.ts +++ b/packages/json-joy/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.spec.ts @@ -1,8 +1,7 @@ import {Model} from '../../../../json-crdt/model'; -import {first, next} from 'sonic-forest/lib/util'; +import {first, next, size} from 'sonic-forest/lib/util'; import {Peritext} from '../../Peritext'; import {Anchor} from '../../rga/constants'; -import {MarkerOverlayPoint} from '../MarkerOverlayPoint'; const setup = () => { const model = Model.create(); @@ -73,8 +72,8 @@ describe('markers', () => { peritext.editor.cursor.set(marker.start.clone()); peritext.overlay.refresh(); const overlayMarkerPoint = peritext.overlay.root2!; - expect(overlayMarkerPoint instanceof MarkerOverlayPoint).toBe(true); - expect(overlayMarkerPoint.markers.length).toBe(1); + expect(overlayMarkerPoint.isMarker()).toBe(true); + expect(overlayMarkerPoint.markers.length).toBe(2); expect(overlayMarkerPoint.markers.find((m) => m === peritext.editor.cursor)).toBe(peritext.editor.cursor); }); }); @@ -86,7 +85,7 @@ describe('markers', () => { const [slice] = peritext.editor.saved.insMarker(['p'], '¶'); peritext.refresh(); expect(markerCount(peritext)).toBe(1); - peritext.delMarker(slice); + peritext.editor.delMarker(slice); peritext.refresh(); expect(markerCount(peritext)).toBe(0); }); @@ -99,7 +98,7 @@ describe('markers', () => { const [slice] = peritext.editor.saved.insMarker(['p'], '¶'); peritext.refresh(); expect(markerCount(peritext)).toBe(2); - peritext.delMarker(slice); + peritext.editor.delMarker(slice); peritext.refresh(); expect(markerCount(peritext)).toBe(1); }); @@ -137,9 +136,9 @@ describe('slices', () => { const {peritext} = setup(); peritext.editor.cursor.setAt(6, 2); peritext.editor.saved.insStack('em', {emphasis: true}); - expect(peritext.overlay.slices.size).toBe(0); + expect(size(peritext.overlay.root)).toBe(0); peritext.overlay.refresh(); - expect(peritext.overlay.slices.size).toBe(2); + expect(size(peritext.overlay.root)).toBe(2); const points = [...peritext.overlay.points()]; expect(points.length).toBe(2); expect(points[0].pos()).toBe(6); @@ -154,9 +153,9 @@ describe('slices', () => { peritext.editor.saved.insStack('em', {emphasis: true}); peritext.editor.cursor.setAt(4, 8); peritext.editor.saved.insStack('strong', {bold: true}); - expect(peritext.overlay.slices.size).toBe(0); + expect(size(peritext.overlay.root)).toBe(0); peritext.overlay.refresh(); - expect(peritext.overlay.slices.size).toBe(3); + expect(size(peritext.overlay.root)).toBe(4); const points = [...peritext.overlay.points()]; expect(points.length).toBe(4); }); @@ -214,7 +213,7 @@ describe('slices', () => { peritext.editor.cursor.setAt(6); peritext.editor.saved.insMarker(['p']); peritext.refresh(); - const point = peritext.overlay.find((point) => point instanceof MarkerOverlayPoint)!; + const point = peritext.overlay.find((point) => point.isMarker())!; expect(point.layers.length).toBe(0); peritext.editor.cursor.setAt(2, 2); peritext.editor.saved.insStack(''); @@ -234,11 +233,14 @@ describe('slices', () => { const [slice] = peritext.editor.saved.insStack('em', {emphasis: true}); expect(peritext.overlay.slices.size).toBe(0); peritext.overlay.refresh(); - expect(peritext.overlay.slices.size).toBe(2); + expect(size(peritext.overlay.root)).toBe(2); peritext.savedSlices.del(slice.id); - expect(peritext.overlay.slices.size).toBe(2); + expect(size(peritext.overlay.root)).toBe(2); + peritext.overlay.refresh(); + expect(size(peritext.overlay.root)).toBe(2); + peritext.editor.delCursors(); peritext.overlay.refresh(); - expect(peritext.overlay.slices.size).toBe(1); + expect(size(peritext.overlay.root)).toBe(0); }); }); }); diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.tuples.spec.ts b/packages/json-joy/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.tuples.spec.ts index 3135230252..44b638377e 100644 --- a/packages/json-joy/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.tuples.spec.ts +++ b/packages/json-joy/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.tuples.spec.ts @@ -1,8 +1,7 @@ import {next} from 'sonic-forest/lib/util'; import {type Kit, setupHelloWorldKit, setupHelloWorldWithFewEditsKit} from '../../__tests__/setup'; import {Anchor} from '../../rga/constants'; -import {MarkerOverlayPoint} from '../MarkerOverlayPoint'; -import {OverlayPoint} from '../OverlayPoint'; +import type {OverlayPoint} from '../OverlayPoint'; const runPairsTests = (setup: () => Kit) => { describe('.tuples() full range', () => { @@ -115,10 +114,10 @@ const runPairsTests = (setup: () => Kit) => { [p2, p3], [p3, overlay.END], ]); - expect(p1 instanceof MarkerOverlayPoint).toBe(true); - expect(p2 instanceof OverlayPoint).toBe(true); - expect(p3 instanceof OverlayPoint).toBe(true); - expect((p1 as MarkerOverlayPoint).marker).toBe(marker); + expect(p1.isMarker()).toBe(true); + expect(!p2.isMarker()).toBe(true); + expect(!p3.isMarker()).toBe(true); + expect((p1 as OverlayPoint).marker).toBe(marker); expect(p2.layers.length).toBe(2); expect(p3.layers.length).toBe(0); expect(p2.refs.length).toBe(2); diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/overlay/__tests__/OverlayPoint.spec.ts b/packages/json-joy/src/json-crdt-extensions/peritext/overlay/__tests__/OverlayPoint.spec.ts index b39a3681b1..ac3e6e3cda 100644 --- a/packages/json-joy/src/json-crdt-extensions/peritext/overlay/__tests__/OverlayPoint.spec.ts +++ b/packages/json-joy/src/json-crdt-extensions/peritext/overlay/__tests__/OverlayPoint.spec.ts @@ -127,7 +127,7 @@ describe('markers', () => { const marker = peritext.savedSlices.insMarker(peritext.rangeAt(5, 0), '

                                '); const point = getPoint(marker.start); expect(point.markers.length).toBe(0); - point.addMarker(marker); + point.addMarkerRef(marker); expect(point.markers.length).toBe(1); expect(point.markers[0]).toBe(marker); }); @@ -137,10 +137,10 @@ describe('markers', () => { const marker = peritext.savedSlices.insMarker(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); + point.addMarkerRef(marker); + point.addMarkerRef(marker); + point.addMarkerRef(marker); + point.addMarkerRef(marker); expect(point.markers.length).toBe(1); expect(point.markers[0]).toBe(marker); }); @@ -151,10 +151,10 @@ describe('markers', () => { const marker2 = peritext.savedSlices.insMarker(peritext.rangeAt(5, 0), '

                                '); const point = getPoint(marker1.start); expect(point.markers.length).toBe(0); - point.addMarker(marker1); + point.addMarkerRef(marker1); expect(point.markers.length).toBe(1); - point.addMarker(marker2); - point.addMarker(marker2); + point.addMarkerRef(marker2); + point.addMarkerRef(marker2); expect(point.markers.length).toBe(2); expect(point.markers[0]).toBe(marker1); expect(point.markers[1]).toBe(marker2); @@ -165,12 +165,12 @@ describe('markers', () => { 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); - point.addMarker(marker2); - point.addMarker(marker1); - point.addMarker(marker2); - point.addMarker(marker1); + point.addMarkerRef(marker2); + point.addMarkerRef(marker1); + point.addMarkerRef(marker2); + point.addMarkerRef(marker1); + point.addMarkerRef(marker2); + point.addMarkerRef(marker1); expect(point.markers[0]).toBe(marker1); expect(point.markers[1]).toBe(marker2); }); @@ -181,14 +181,14 @@ describe('markers', () => { 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); - point.addMarker(marker2); - point.addMarker(marker2); - point.addMarker(marker3); - point.addMarker(marker1); - point.addMarker(marker3); - point.addMarker(marker3); + point.addMarkerRef(marker3); + point.addMarkerRef(marker3); + point.addMarkerRef(marker2); + point.addMarkerRef(marker2); + point.addMarkerRef(marker3); + point.addMarkerRef(marker1); + point.addMarkerRef(marker3); + point.addMarkerRef(marker3); expect(point.markers.length).toBe(3); expect(point.markers[0]).toBe(marker1); expect(point.markers[1]).toBe(marker2); @@ -201,9 +201,9 @@ describe('markers', () => { 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); - point.addMarker(marker3); + point.addMarkerRef(marker1); + point.addMarkerRef(marker2); + point.addMarkerRef(marker3); expect(point.markers[0]).toBe(marker1); expect(point.markers[1]).toBe(marker2); expect(point.markers[2]).toBe(marker3); @@ -215,11 +215,11 @@ describe('markers', () => { 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); - point.addMarker(marker1); - point.addMarker(marker1); - point.addMarker(marker3); + point.addMarkerRef(marker2); + point.addMarkerRef(marker1); + point.addMarkerRef(marker1); + point.addMarkerRef(marker1); + point.addMarkerRef(marker3); expect(point.markers[0]).toBe(marker1); expect(point.markers[1]).toBe(marker2); expect(point.markers[2]).toBe(marker3); @@ -254,7 +254,8 @@ describe('refs', () => { const point = getPoint(slice.start); expect(point.layers.length).toBe(0); expect(point.refs.length).toBe(0); - point.addLayerStartRef(slice); + point.upsertStartRef(slice); + point.addLayer(slice); expect(point.layers.length).toBe(1); expect(point.refs.length).toBe(1); expect(point.layers[0]).toBe(slice); @@ -267,7 +268,7 @@ describe('refs', () => { const point = getPoint(slice.end); expect(point.layers.length).toBe(0); expect(point.refs.length).toBe(0); - point.addLayerEndRef(slice); + point.upsertEndRef(slice); expect(point.layers.length).toBe(0); expect(point.refs.length).toBe(1); expect((point.refs[0] as OverlayRefSliceEnd).slice).toBe(slice); @@ -282,7 +283,8 @@ describe('refs', () => { expect(point.markers.length).toBe(0); expect(point.refs.length).toBe(0); point.addMarkerRef(marker); - point.addLayerStartRef(slice); + point.upsertStartRef(slice); + point.addLayer(slice); expect(point.layers.length).toBe(1); expect(point.markers.length).toBe(1); expect(point.refs.length).toBe(2); @@ -294,7 +296,8 @@ describe('refs', () => { const slice = peritext.savedSlices.insErase(peritext.rangeAt(10, 4), 123); const point = getPoint(slice.end); point.addMarkerRef(marker); - point.addLayerStartRef(slice); + point.upsertStartRef(slice); + point.addLayer(slice); expect(point.layers.length).toBe(1); expect(point.markers.length).toBe(1); expect(point.refs.length).toBe(2); diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/overlay/refs.ts b/packages/json-joy/src/json-crdt-extensions/peritext/overlay/refs.ts index 45748b540c..6fbd875377 100644 --- a/packages/json-joy/src/json-crdt-extensions/peritext/overlay/refs.ts +++ b/packages/json-joy/src/json-crdt-extensions/peritext/overlay/refs.ts @@ -1,5 +1,4 @@ -import type {MarkerSlice} from '../slice/MarkerSlice'; -import type {Slice} from '../slice/types'; +import type {Slice} from '../slice/Slice'; /** * On overlay "ref" is a reference from the {@link Overlay} to a {@link Slice}. @@ -8,7 +7,7 @@ import type {Slice} from '../slice/types'; * and one to the end slice. */ export type OverlayRef = - | MarkerSlice // Ref to a *marker* + | Slice // Ref to a *marker* | OverlayRefSliceStart // Ref to the start of an annotation slice | OverlayRefSliceEnd; // Ref to the end of an annotation slice diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/overlay/types.ts b/packages/json-joy/src/json-crdt-extensions/peritext/overlay/types.ts index 8e8b42c0ab..618631170f 100644 --- a/packages/json-joy/src/json-crdt-extensions/peritext/overlay/types.ts +++ b/packages/json-joy/src/json-crdt-extensions/peritext/overlay/types.ts @@ -1,4 +1,3 @@ -import type {MarkerOverlayPoint} from './MarkerOverlayPoint'; import type {OverlayPoint} from './OverlayPoint'; /** @@ -16,13 +15,3 @@ export type OverlayPair = [p1: OverlayPoint | undefined, p2: OverlayPoint< * by virtual points. */ export type OverlayTuple = [p1: OverlayPoint, p2: OverlayPoint]; - -/** - * Represents a two adjacent marker overlay points. The first point is the point - * that is closer to the start of the document, and the second point is the - * point that is closer to the end of the document. - * - * When point is `undefined`, it means the point represents the end of range. - * In the complete document it is ABS start or ABS end of the document. - */ -export type MarkerOverlayPair = [p1: MarkerOverlayPoint | undefined, p2: MarkerOverlayPoint | undefined]; diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/registry/SliceBehavior.ts b/packages/json-joy/src/json-crdt-extensions/peritext/registry/SliceBehavior.ts index 63f8b8015a..6f268f464c 100644 --- a/packages/json-joy/src/json-crdt-extensions/peritext/registry/SliceBehavior.ts +++ b/packages/json-joy/src/json-crdt-extensions/peritext/registry/SliceBehavior.ts @@ -1,5 +1,5 @@ import {SliceStacking} from '../slice/constants'; -import {formatType} from '../slice/util'; +import {formatStep} from '../slice/util'; import type {PeritextMlElement} from '../block/types'; import type {NodeBuilder} from '../../../json-crdt-patch'; import type {FromHtmlConverter, ToHtmlConverter} from './types'; @@ -129,6 +129,6 @@ export class SliceBehavior< /** ----------------------------------------------------- {@link Printable} */ public toString(tab: string = ''): string { - return `${formatType(this.tag)} (${this.stacking}) ${JSON.stringify(Object.keys(this.data))}`; + return `${formatStep(this.tag)} (${this.stacking}) ${JSON.stringify(Object.keys(this.data))}`; } } diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/registry/SliceRegistry.ts b/packages/json-joy/src/json-crdt-extensions/peritext/registry/SliceRegistry.ts index 18dfcaf612..34d682cdd9 100644 --- a/packages/json-joy/src/json-crdt-extensions/peritext/registry/SliceRegistry.ts +++ b/packages/json-joy/src/json-crdt-extensions/peritext/registry/SliceRegistry.ts @@ -25,7 +25,8 @@ export class SliceRegistry implements Printable { public static readonly withCommon = (): SliceRegistry => { const undefSchema = s.con(undefined); const registry = new SliceRegistry(); - //------------------------------ Inline elements with "One" stacking behavior + + //----------------------------- Inline elements with "One" stacking behavior const i0 = ( tag: Tag, name: string, @@ -168,7 +169,7 @@ export class SliceRegistry implements Printable { _fromHtml.set(htmlTag, converters); } } - const tagStr = CommonSliceType[tag as TAG]; + const tagStr = CommonSliceType[tag as any]; if (tagStr && typeof tagStr === 'string' && (!fromHtml || !(tagStr in fromHtml))) _fromHtml.set(tagStr, [[entry, () => [tag, null]]]); } diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/rga/Point.ts b/packages/json-joy/src/json-crdt-extensions/peritext/rga/Point.ts index 71e21365b6..659d71755b 100644 --- a/packages/json-joy/src/json-crdt-extensions/peritext/rga/Point.ts +++ b/packages/json-joy/src/json-crdt-extensions/peritext/rga/Point.ts @@ -144,9 +144,9 @@ export class Point implements Pick, Printable { * character, 1 is after the first character, etc.). */ public viewPos(): number { - const pos = this.pos(); const isAbs = equal(this.rga.id, this.id); if (isAbs) return this.anchor === Anchor.After ? 0 : this.rga.length(); + const pos = this.pos(); return this.anchor === Anchor.Before ? pos : pos + 1; } diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/slice/MarkerSlice.ts b/packages/json-joy/src/json-crdt-extensions/peritext/slice/MarkerSlice.ts deleted file mode 100644 index 19f93ff0bc..0000000000 --- a/packages/json-joy/src/json-crdt-extensions/peritext/slice/MarkerSlice.ts +++ /dev/null @@ -1,32 +0,0 @@ -import {Anchor} from '../rga/constants'; -import {PersistedSlice} from './PersistedSlice'; -import type {Range} from '../rga/Range'; - -/** - * Represents a block split in the text, i.e. it is a *marker* that shows - * where a block was split. Markers also insert one "\n" new line character. - * Both marker ends are attached to the "before" anchor fo the "\n" new line - * character, i.e. it is *collapsed* to the "before" anchor. - */ -export class MarkerSlice extends PersistedSlice { - /** - * Returns the {@link Range} which exactly contains the block boundary of this - * marker. - */ - public boundary(): Range { - const start = this.start; - const end = start.clone(); - end.anchor = Anchor.After; - return this.txt.range(start, end); - } - - public del(): void { - super.del(); - const txt = this.txt; - const range = txt.range( - this.start, - this.start.copy((p) => (p.anchor = Anchor.After)), - ); - txt.delStr(range); - } -} diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/slice/NestedType.ts b/packages/json-joy/src/json-crdt-extensions/peritext/slice/NestedType.ts index 663305c8d7..c591c2a1f5 100644 --- a/packages/json-joy/src/json-crdt-extensions/peritext/slice/NestedType.ts +++ b/packages/json-joy/src/json-crdt-extensions/peritext/slice/NestedType.ts @@ -1,11 +1,11 @@ import type {ArrApi} from '../../../json-crdt/model'; import {NestedTag} from './NestedTag'; -import type {PersistedSlice} from './PersistedSlice'; import * as schema from './schema'; +import type {Slice} from './Slice'; import type {SliceTypeSteps} from './types'; export class NestedType { - constructor(protected slice: PersistedSlice) {} + constructor(protected slice: Slice) {} /** Enforces slice type to be an "arr" of tags. */ public asArr(): ArrApi { diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts b/packages/json-joy/src/json-crdt-extensions/peritext/slice/Slice.ts similarity index 68% rename from packages/json-joy/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts rename to packages/json-joy/src/json-crdt-extensions/peritext/slice/Slice.ts index f8d8054525..3f3708db9c 100644 --- a/packages/json-joy/src/json-crdt-extensions/peritext/slice/PersistedSlice.ts +++ b/packages/json-joy/src/json-crdt-extensions/peritext/slice/Slice.ts @@ -3,28 +3,27 @@ import {Point} from '../rga/Point'; import {Range} from '../rga/Range'; import {updateNode} from '../../../json-crdt/hash'; import {printTree} from 'tree-dump/lib/printTree'; -import type {Anchor} from '../rga/constants'; import { SliceHeaderMask, SliceHeaderShift, SliceStacking, SliceTupleIndex, SliceStackingName, - SliceTypeName, SliceTypeCon, } from './constants'; import {CONST} from '../../../json-hash/hash'; import {Timestamp} from '../../../json-crdt-patch/clock'; import {prettyOneLine} from '../../../json-pretty'; -import {validateType} from './util'; +import {formatType, validateType} from './util'; import {NodeBuilder, s} from '../../../json-crdt-patch'; import {JsonCrdtDiff} from '../../../json-crdt-diff/JsonCrdtDiff'; +import {NestedType} from './NestedType'; +import {Anchor} from '../rga/constants'; import {type Model, type NodeApi, ObjApi} from '../../../json-crdt/model'; import type {ObjNode, VecNode} from '../../../json-crdt/nodes'; -import {NestedType} from './NestedType'; import type {ITimestampStruct} from '../../../json-crdt-patch/clock'; -import type {ArrChunk, JsonNode} from '../../../json-crdt/nodes'; -import type {MutableSlice, SliceView, SliceType, SliceUpdateParams, SliceTypeSteps} from './types'; +import type {ArrChunk, ArrNode, JsonNode} from '../../../json-crdt/nodes'; +import type {SliceView, SliceType, SliceUpdateParams, SliceTypeSteps} from './types'; import type {Stateful} from '../types'; import type {Printable} from 'tree-dump/lib/types'; import type {AbstractRga} from '../../../json-crdt/nodes/rga'; @@ -32,13 +31,24 @@ import type {Peritext} from '../Peritext'; import type {Slices} from './Slices'; /** - * A persisted slice is a slice that is stored in a {@link Model}. It is used for - * rich-text formatting and annotations. + * A slice is stored in a {@link Model} as a "vec" node. It is used for + * rich-text formatting annotations and block splits. * - * @todo Maybe rename to "saved", "stored", "mutable". + * Slices represent Peritext's rich-text formatting/splits. The "slice" + * concept captures both: (1) range annotations; as well as, (2) *markers*, + * which are a single-point annotations. The markers are used as block splits, + * e.g. paragraph, heading, blockquote, etc. In markers, the start and end + * positions of the range are normally the same, but could also wrap around + * a single RGA chunk. */ -export class PersistedSlice extends Range implements MutableSlice, Stateful, Printable { - public static deserialize(model: Model, txt: Peritext, chunk: ArrChunk, tuple: VecNode): PersistedSlice { +export class Slice extends Range implements Stateful, Printable { + public static deserialize( + model: Model, + txt: Peritext, + arr: ArrNode, + chunk: ArrChunk, + tuple: VecNode, + ): Slice { const header = +(tuple.get(0)!.view() as SliceView[0]); const id1 = tuple.get(1)!.view() as ITimestampStruct; const id2 = (tuple.get(2)!.view() || id1) as ITimestampStruct; @@ -51,7 +61,7 @@ export class PersistedSlice extends Range implements MutableSlice const rga = txt.str as unknown as AbstractRga; const p1 = new Point(rga, id1, anchor1); const p2 = new Point(rga, id2, anchor2); - const slice = new PersistedSlice(model, txt, chunk, tuple, stacking, p1, p2); + const slice = new Slice(model, txt, arr, chunk, tuple, stacking, p1, p2); validateType(slice.type()); return slice; } @@ -59,11 +69,27 @@ export class PersistedSlice extends Range implements MutableSlice /** @todo Use API node here. */ protected readonly rga: AbstractRga; + /** + * ID of the slice. ID is used for layer sorting. + */ + public readonly id: ITimestampStruct; + + /** + * The low-level stacking behavior of the slice. Specifies whether the + * slice is a split, i.e. a "marker" for a block split, in which case it + * represents a single place in the text where text is split into blocks. + * Otherwise, specifies the low-level behavior or the rich-text formatting + * of the slice. + */ + public stacking: SliceStacking; + constructor( /** The `Model` where the slice is stored. */ protected readonly model: Model, /** The Peritext context. */ protected readonly txt: Peritext, + /** The "arr" node where the slice is stored. */ + protected readonly arr: ArrNode, /** The `arr` chunk of `arr` where the slice is stored. */ protected readonly chunk: ArrChunk, /** The `vec` node which stores the serialized contents of this slice. */ @@ -74,23 +100,39 @@ export class PersistedSlice extends Range implements MutableSlice ) { super(txt.str as unknown as AbstractRga, start, end); this.rga = txt.str as unknown as AbstractRga; + // TODO: Chunk could potentially contain multiple entries, handle that case. this.id = chunk.id; this.stacking = stacking; } - public isSplit(): boolean { + /** + * Represents a block split in the text, i.e. it is a *marker* that shows + * where a block was split. Markers also insert one "\n" new line character. + * Both marker ends are attached to the "before" anchor fo the "\n" new line + * character, i.e. it is *collapsed* to the "before" anchor. + */ + public isMarker(): boolean { return this.stacking === SliceStacking.Marker; } - protected tupleApi() { + public tupleApi() { return this.model.api.wrap(this.tuple); } - // protected setType(schema: NodeBuilder): void { - // this.tupleApi().set([ - // [SliceTupleIndex.Type, schema.type(type as SliceTypeSteps)], - // ]); - // } + public pos(): number { + return this.arr.posById(this.id) || 0; + } + + /** + * Returns the {@link Range} which exactly contains the block boundary of this + * marker. + */ + public boundary(): Range { + const start = this.start; + const end = start.clone(); + end.anchor = Anchor.After; + return this.txt.range(start, end); + } // ---------------------------------------------------------------- mutations @@ -108,11 +150,6 @@ export class PersistedSlice extends Range implements MutableSlice this.update({range: this}); } - /** -------------------------------------------------- {@link MutableSlice} */ - - public readonly id: ITimestampStruct; - public stacking: SliceStacking; - public update(params: SliceUpdateParams): void { let updateHeader = false; const changes: [number, unknown][] = []; @@ -142,6 +179,10 @@ export class PersistedSlice extends Range implements MutableSlice this.tupleApi().set(changes); } + public isSaved(): boolean { + return this.tuple.id.sid === this.txt.model.clock.sid; + } + public getStore(): Slices | undefined { const txt = this.txt; const sid = this.id.sid; @@ -154,12 +195,26 @@ export class PersistedSlice extends Range implements MutableSlice return; } + /** + * Delete this slice from its backing store. + */ public del(): void { const store = this.getStore(); if (!store) return; store.del(this.id); + if (this.isMarker()) { + const txt = this.txt; + const range = txt.range( + this.start, + this.start.copy((p) => (p.anchor = Anchor.After)), + ); + txt.delStr(range); + } } + /** + * Whether the slice is deleted. + */ public isDel(): boolean { return this.chunk.del; } @@ -180,6 +235,17 @@ export class PersistedSlice extends Range implements MutableSlice return new NestedType(this); } + /** + * The high-level behavior identifier of the slice. Specifies the + * user-defined type of the slice, e.g. paragraph, heading, blockquote, etc. + * + * Usually the type is a number or string primitive, in which case it is + * referred to as *tag*. + * + * The type is a list only for nested blocks, e.g. `['ul', 'li']`, in which + * case the type is a list of tags. The last tag in the list is the + * "leaf" tag, which is the type of the leaf block element. + */ public type(): SliceType { return this.typeNode()?.view() as SliceType; } @@ -191,6 +257,10 @@ export class PersistedSlice extends Range implements MutableSlice // -------------------------------------------------- slice data manipulation + /** + * High-level user-defined metadata of the slice, which accompanies the slice + * type. + */ public data(): unknown | undefined { return this.tuple.get(SliceTupleIndex.Data)?.view(); } @@ -245,7 +315,7 @@ export class PersistedSlice extends Range implements MutableSlice this.hash = state; if (changed) { const tuple = this.tuple; - const slice = PersistedSlice.deserialize(this.model, this.txt, this.chunk, tuple); + const slice = Slice.deserialize(this.model, this.txt, this.arr, this.chunk, tuple); this.stacking = slice.stacking; this.start = slice.start; this.end = slice.end; @@ -256,20 +326,19 @@ export class PersistedSlice extends Range implements MutableSlice /** ----------------------------------------------------- {@link Printable} */ public toStringName(): string { - const type = this.type(); - if (typeof type === 'number' && Math.abs(type) <= 64 && SliceTypeName[type]) { - return `slice [${SliceStackingName[this.stacking]}] <${SliceTypeName[type]}>`; - } - return `slice [${SliceStackingName[this.stacking]}] ${JSON.stringify(type)}`; + const typeFormatted = formatType(this.type()); + const stackingFormatted = SliceStackingName[this.stacking]; + return `Slice::${stackingFormatted} ${typeFormatted}`; } protected toStringHeaderName(): string { const data = this.data(); const dataFormatted = data ? prettyOneLine(data) : '∅'; const dataLengthBreakpoint = 32; - const header = `${this.toStringName()} ${super.toString('', true)}, ${ - SliceStackingName[this.stacking] - }, ${JSON.stringify(this.type())}${dataFormatted.length < dataLengthBreakpoint ? `, ${dataFormatted}` : ''}`; + const typeFormatted = formatType(this.type()); + const stackingFormatted = SliceStackingName[this.stacking]; + const dataFormattedShort = dataFormatted.length < dataLengthBreakpoint ? `, ${dataFormatted}` : ''; + const header = `${this.toStringName()} ${super.toString('', true)}, ${stackingFormatted}, ${typeFormatted}${dataFormattedShort}`; return header; } diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/slice/Slices.ts b/packages/json-joy/src/json-crdt-extensions/peritext/slice/Slices.ts index d7b0c9a154..c86fcaf845 100644 --- a/packages/json-joy/src/json-crdt-extensions/peritext/slice/Slices.ts +++ b/packages/json-joy/src/json-crdt-extensions/peritext/slice/Slices.ts @@ -1,18 +1,16 @@ import {AvlMap} from 'sonic-forest/lib/avl/AvlMap'; import {printTree} from 'tree-dump/lib/printTree'; -import {PersistedSlice} from './PersistedSlice'; +import {Slice} from './Slice'; import {Timespan, compare, tss} from '../../../json-crdt-patch/clock'; import {updateRga} from '../../../json-crdt/hash'; import {CONST, updateNum} from '../../../json-hash/hash'; import {SliceHeaderShift, SliceStacking, SliceTupleIndex} from './constants'; -import {MarkerSlice} from './MarkerSlice'; import {VecNode} from '../../../json-crdt/nodes'; -import {Chars} from '../constants'; import {Anchor} from '../rga/constants'; import {UndEndIterator, type UndEndNext} from '../../../util/iterator'; import * as schema from './schema'; import type {Range} from '../rga/Range'; -import type {Slice, SliceType} from './types'; +import type {SliceType} from './types'; import type {ITimespanStruct, ITimestampStruct} from '../../../json-crdt-patch/clock'; import type {Stateful} from '../types'; import type {Printable} from 'tree-dump/lib/types'; @@ -21,7 +19,7 @@ import type {AbstractRga} from '../../../json-crdt/nodes/rga'; import type {Peritext} from '../Peritext'; export class Slices implements Stateful, Printable { - private list = new AvlMap>(compare); + private list = new AvlMap>(compare); protected readonly rga: AbstractRga; @@ -34,20 +32,15 @@ export class Slices implements Stateful, Printable { this.rga = txt.str as unknown as AbstractRga; } - public ins< - S extends PersistedSlice, - K extends new ( - ...args: ConstructorParameters> - ) => S, - >( + public ins, K extends new (...args: ConstructorParameters>) => S>( range: Range, stacking: SliceStacking, type: SliceType, data?: unknown, - Klass: K = stacking === SliceStacking.Marker ? MarkerSlice : PersistedSlice, + Klass: K = Slice, ): S { const slicesModel = this.set.doc; - const set = this.set; + const arr = this.set; const api = slicesModel.api; const builder = api.builder; const stepId = builder.vec(); @@ -65,32 +58,28 @@ export class Slices implements Stateful, Printable { const tupleKeysUpdate: [key: number, value: ITimestampStruct][] = [ [SliceTupleIndex.Header, headerId], [SliceTupleIndex.X1, x1Id], + // TODO: Make `x2Id` undefined, when `start.id` and `end.id` are equal. [SliceTupleIndex.X2, x2Id], [SliceTupleIndex.Type, typeId], ]; if (data !== undefined) tupleKeysUpdate.push([SliceTupleIndex.Data, builder.json(data)]); builder.insVec(stepId, tupleKeysUpdate); - const chunkId = builder.insArr(set.id, set.id, [stepId]); + const chunkId = builder.insArr(arr.id, arr.id, [stepId]); // TODO: Consider using `s` schema here. api.apply(); const tuple = slicesModel.index.get(stepId) as VecNode; - const chunk = set.findById(chunkId)!; + const chunk = arr.findById(chunkId)!; // TODO: Need to check if split slice text was deleted - const slice = new Klass(slicesModel, this.txt, chunk, tuple, stacking, start, end); + const slice = new Klass(slicesModel, this.txt, arr, chunk, tuple, stacking, start, end); this.list.set(chunk.id, slice); return slice; } - public insMarker(range: Range, type: SliceType, data?: unknown | ITimestampStruct): MarkerSlice { - return this.ins(range, SliceStacking.Marker, type, data) as MarkerSlice; + public insMarker(range: Range, type: SliceType, data?: unknown | ITimestampStruct): Slice { + return this.ins(range, SliceStacking.Marker, type, data) as Slice; } - public insMarkerAfter( - after: ITimestampStruct, - type: SliceType, - data?: unknown, - separator: string = Chars.BlockSplitSentinel, - ): MarkerSlice { + public insMarkerAfter(after: ITimestampStruct, type: SliceType, data?: unknown): Slice { // TODO: test condition when cursors is at absolute or relative starts const txt = this.txt; const api = txt.model.api; @@ -101,38 +90,38 @@ export class Slices implements Stateful, Printable { */ builder.nop(1); // TODO: Handle case when marker is inserted at the abs start, prevent abs start/end inserts. - const textId = builder.insStr(txt.str.id, after, separator); + const textId = builder.insStr(txt.str.id, after, '\n'); api.apply(); const point = txt.point(textId, Anchor.Before); const range = txt.range(point, point.clone()); return this.insMarker(range, type, data); } - public insStack(range: Range, type: SliceType, data?: unknown | ITimestampStruct): PersistedSlice { + public insStack(range: Range, type: SliceType, data?: unknown | ITimestampStruct): Slice { return this.ins(range, SliceStacking.Many, type, data); } - public insOne(range: Range, type: SliceType, data?: unknown | ITimestampStruct): PersistedSlice { + public insOne(range: Range, type: SliceType, data?: unknown | ITimestampStruct): Slice { return this.ins(range, SliceStacking.One, type, data); } - public insErase(range: Range, type: SliceType, data?: unknown | ITimestampStruct): PersistedSlice { + public insErase(range: Range, type: SliceType, data?: unknown | ITimestampStruct): Slice { return this.ins(range, SliceStacking.Erase, type, data); } - protected unpack(chunk: ArrChunk): PersistedSlice { + protected unpack(arr: ArrNode, chunk: ArrChunk): Slice { const txt = this.txt; const model = this.set.doc; 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(model, txt, chunk, tuple); - if (slice.isSplit()) slice = new MarkerSlice(model, txt, chunk, tuple, slice.stacking, slice.start, slice.end); + let slice = Slice.deserialize(model, txt, arr, chunk, tuple); + if (slice.isMarker()) slice = new Slice(model, txt, arr, chunk, tuple, slice.stacking, slice.start, slice.end); return slice; } - public get(id: ITimestampStruct): PersistedSlice | undefined { + public get(id: ITimestampStruct): Slice | undefined { return this.list.get(id); } @@ -152,7 +141,7 @@ export class Slices implements Stateful, Printable { const api = doc.api; const spans: ITimespanStruct[] = []; for (const slice of slices) { - if (slice instanceof PersistedSlice) { + if (slice instanceof Slice) { const id = slice.id; if (!set.findById(id)) continue; spans.push(new Timespan(id.sid, id.time, 1)); @@ -171,16 +160,16 @@ export class Slices implements Stateful, Printable { /** * @todo Rename to `each0`. */ - public iterator0(): UndEndNext> { + public iterator0(): UndEndNext> { const iterator = this.list.iterator0(); return () => iterator()?.v; } - public each(): UndEndIterator> { - return new UndEndIterator>(this.iterator0()); + public each(): UndEndIterator> { + return new UndEndIterator>(this.iterator0()); } - public forEach(callback: (item: PersistedSlice) => void): void { + public forEach(callback: (item: Slice) => void): void { this.list.forEach((node) => callback(node.v)); } @@ -199,7 +188,7 @@ export class Slices implements Stateful, Printable { if (chunk.del) { if (item) this.list.del(chunk.id); } else { - if (!item) this.list.set(chunk.id, this.unpack(chunk)); + if (!item) this.list.set(chunk.id, this.unpack(this.set, chunk)); } } } diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/slice/__tests__/PersistedSlice.spec.ts b/packages/json-joy/src/json-crdt-extensions/peritext/slice/__tests__/PersistedSlice.spec.ts deleted file mode 100644 index ec42cfc056..0000000000 --- a/packages/json-joy/src/json-crdt-extensions/peritext/slice/__tests__/PersistedSlice.spec.ts +++ /dev/null @@ -1,474 +0,0 @@ -import {ObjApi, VecApi} from '../../../../json-crdt/model'; -import {SliceStacking} from '../constants'; -import {setup} from './setup'; - -const setupSlice = () => { - const deps = setup(); - const range = deps.peritext.rangeAt(2, 3); - const slice = deps.peritext.savedSlices.insMarker(range, 0); - return {...deps, range, slice}; -}; - -test('can read slice data', () => { - const {range, slice} = setupSlice(); - expect(slice.isSplit()).toBe(true); - expect(slice.stacking).toBe(SliceStacking.Marker); - expect(slice.type()).toBe(0); - expect(slice.data()).toBe(undefined); - expect(slice.start).not.toBe(range.start); - expect(slice.start.cmp(range.start)).toBe(0); - expect(slice.end).not.toBe(range.end); - expect(slice.end.cmp(range.end)).toBe(0); -}); - -describe('.update()', () => { - const testUpdate = (name: string, update: (deps: ReturnType) => void) => { - test('can update: ' + name, () => { - const deps = setupSlice(); - const {slice} = deps; - const hash1 = slice.refresh(); - const hash2 = slice.refresh(); - expect(hash1).toBe(hash2); - update(deps); - const hash3 = slice.refresh(); - expect(hash3).not.toBe(hash2); - }); - }; - - testUpdate('stacking', ({slice}) => { - slice.update({stacking: SliceStacking.Erase}); - expect(slice.stacking).toBe(SliceStacking.Erase); - }); - - testUpdate('type', ({slice}) => { - slice.update({type: 1}); - expect(slice.type()).toBe(1); - }); - - testUpdate('data', ({slice}) => { - slice.update({data: 123}); - expect(slice.data()).toBe(123); - }); - - testUpdate('range', ({peritext, slice}) => { - const range2 = peritext.rangeAt(0, 1); - slice.update({range: range2}); - expect(slice.cmp(range2)).toBe(0); - }); -}); - -describe('.del() and .isDel()', () => { - test('can delete a slice', () => { - const {peritext, slice} = setupSlice(); - expect(peritext.model.view().slices.length).toBe(1); - expect(slice.isDel()).toBe(false); - const slice2 = peritext.savedSlices.get(slice.id)!; - expect(peritext.model.view().slices.length).toBe(1); - expect(slice2.isDel()).toBe(false); - expect(slice2).toBe(slice); - 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.savedSlices.get(slice.id); - expect(slice3).toBe(undefined); - }); -}); - -describe('type retrieval an manipulation', () => { - describe('.type()', () => { - test('basic type', () => { - const kit = setup(); - const range = kit.peritext.rangeAt(3, 8); - const slice = kit.peritext.savedSlices.insOne(range, 'test', {}); - expect(slice.type()).toBe('test'); - }); - - test('nested', () => { - const kit = setup(); - const range = kit.peritext.rangeAt(9); - const slice = kit.peritext.savedSlices.insMarker(range, ['ul', 'li', 'p']); - expect(slice.type()).toEqual(['ul', 'li', 'p']); - }); - - test('nested with discriminants', () => { - const kit = setup(); - const range = kit.peritext.rangeAt(9); - const slice = kit.peritext.savedSlices.insMarker(range, [['ul', 1], ['li', 0], 'p']); - expect(slice.type()).toEqual([['ul', 1], ['li', 0], 'p']); - }); - - test('nested with data', () => { - const kit = setup(); - const range = kit.peritext.rangeAt(9); - const slice = kit.peritext.savedSlices.insMarker(range, [ - ['ul', 1, {type: 'todo'}], - ['li', 0], - ['p', 0, {indent: 2}], - ]); - expect(slice.type()).toEqual([ - ['ul', 1, {type: 'todo'}], - ['li', 0], - ['p', 0, {indent: 2}], - ]); - }); - }); - - // describe('.tag()', () => { - // test('basic type', () => { - // const kit = setup(); - // const range = kit.peritext.rangeAt(3, 8); - // const slice = kit.peritext.savedSlices.insOne(range, 'test', {}); - // expect(slice.tag()).toBe('test'); - // expect(slice.tag(1)).toBe('test'); - // expect(slice.tag(2)).toBe('test'); - // }); - - // test('nested', () => { - // const kit = setup(); - // const range = kit.peritext.rangeAt(9); - // const slice = kit.peritext.savedSlices.insMarker(range, ['ul', 'li', 'p']); - // expect(slice.tag()).toEqual('p'); - // expect(slice.tag(0)).toEqual('ul'); - // expect(slice.tag(1)).toEqual('li'); - // expect(slice.tag(2)).toEqual('p'); - // expect(slice.tag(3)).toEqual('p'); - // expect(slice.tag(4)).toEqual('p'); - // }); - - // test('nested with discriminants', () => { - // const kit = setup(); - // const range = kit.peritext.rangeAt(9); - // const slice = kit.peritext.savedSlices.insMarker(range, [['ul', 1], ['li', 0], 'p']); - // expect(slice.tag()).toEqual('p'); - // expect(slice.tag(0)).toEqual('ul'); - // expect(slice.tag(1)).toEqual('li'); - // expect(slice.tag(2)).toEqual('p'); - // expect(slice.tag(3)).toEqual('p'); - // expect(slice.tag(4)).toEqual('p'); - // }); - - // test('nested with data', () => { - // const kit = setup(); - // const range = kit.peritext.rangeAt(9); - // const slice = kit.peritext.savedSlices.insMarker(range, [['ul', 1, {type: 'todo'}], ['li', 0], ['p', 0, {indent: 2}]]); - // expect(slice.tag()).toEqual('p'); - // expect(slice.tag(0)).toEqual('ul'); - // expect(slice.tag(1)).toEqual('li'); - // expect(slice.tag(2)).toEqual('p'); - // expect(slice.tag(3)).toEqual('p'); - // expect(slice.tag(4)).toEqual('p'); - // }); - // }); - - // describe('.tagDisc()', () => { - // test('basic type', () => { - // const kit = setup(); - // const range = kit.peritext.rangeAt(3, 8); - // const slice = kit.peritext.savedSlices.insOne(range, 'test', {}); - // expect(slice.tagDisc()).toBe(0); - // expect(slice.tagDisc(1)).toBe(0); - // expect(slice.tagDisc(2)).toBe(0); - // }); - - // test('nested', () => { - // const kit = setup(); - // const range = kit.peritext.rangeAt(9); - // const slice = kit.peritext.savedSlices.insMarker(range, ['ul', 'li', 'p']); - // expect(slice.tagDisc()).toEqual(0); - // expect(slice.tagDisc(0)).toEqual(0); - // expect(slice.tagDisc(1)).toEqual(0); - // expect(slice.tagDisc(2)).toEqual(0); - // expect(slice.tagDisc(3)).toEqual(0); - // expect(slice.tagDisc(4)).toEqual(0); - // }); - - // test('nested with discriminants', () => { - // const kit = setup(); - // const range = kit.peritext.rangeAt(9); - // const slice = kit.peritext.savedSlices.insMarker(range, [['ul', 1], ['li', 0], 'p']); - // expect(slice.tagDisc()).toEqual(0); - // expect(slice.tagDisc(0)).toEqual(1); - // expect(slice.tagDisc(1)).toEqual(0); - // expect(slice.tagDisc(2)).toEqual(0); - // expect(slice.tagDisc(3)).toEqual(0); - // expect(slice.tagDisc(4)).toEqual(0); - // }); - - // test('nested with data', () => { - // const kit = setup(); - // const range = kit.peritext.rangeAt(9); - // const slice = kit.peritext.savedSlices.insMarker(range, [['ul', 1, {type: 'todo'}], ['li', 0], ['p', 2, {indent: 2}]]); - // expect(slice.tagDisc()).toEqual(2); - // expect(slice.tagDisc(0)).toEqual(1); - // expect(slice.tagDisc(1)).toEqual(0); - // expect(slice.tagDisc(2)).toEqual(2); - // expect(slice.tagDisc(3)).toEqual(2); - // expect(slice.tagDisc(4)).toEqual(2); - // }); - // }); - - // describe('.tagData()', () => { - // test('basic type', () => { - // const kit = setup(); - // const range = kit.peritext.rangeAt(3, 8); - // const slice = kit.peritext.savedSlices.insOne(range, 'test', {}); - // expect(slice.tagData()).toBe(void 0); - // expect(slice.tagData(1)).toBe(void 0); - // expect(slice.tagData(2)).toBe(void 0); - // }); - - // test('nested', () => { - // const kit = setup(); - // const range = kit.peritext.rangeAt(9); - // const slice = kit.peritext.savedSlices.insMarker(range, ['ul', 'li', 'p']); - // expect(slice.tagData()).toEqual(void 0); - // expect(slice.tagData(0)).toEqual(void 0); - // expect(slice.tagData(1)).toEqual(void 0); - // expect(slice.tagData(2)).toEqual(void 0); - // expect(slice.tagData(3)).toEqual(void 0); - // expect(slice.tagData(4)).toEqual(void 0); - // }); - - // test('nested with discriminants', () => { - // const kit = setup(); - // const range = kit.peritext.rangeAt(9); - // const slice = kit.peritext.savedSlices.insMarker(range, [['ul', 1], ['li', 0], 'p']); - // expect(slice.tagData()).toEqual(void 0); - // expect(slice.tagData(0)).toEqual(void 0); - // expect(slice.tagData(1)).toEqual(void 0); - // expect(slice.tagData(2)).toEqual(void 0); - // expect(slice.tagData(3)).toEqual(void 0); - // expect(slice.tagData(4)).toEqual(void 0); - // }); - - // test('nested with data', () => { - // const kit = setup(); - // const range = kit.peritext.rangeAt(9); - // const slice = kit.peritext.savedSlices.insMarker(range, [['ul', 1, {type: 'todo'}], ['li', 0], ['p', 2, {indent: 2}]]); - // expect(slice.tagData()).toEqual({indent: 2}); - // expect(slice.tagData(0)).toEqual({type: 'todo'}); - // expect(slice.tagData(1)).toEqual(void 0); - // expect(slice.tagData(2)).toEqual({indent: 2}); - // expect(slice.tagData(3)).toEqual({indent: 2}); - // expect(slice.tagData(4)).toEqual({indent: 2}); - // }); - // }); - - // describe('.typeStepApi()', () => { - // test('basic type', () => { - // const kit = setup(); - // const range = kit.peritext.rangeAt(3, 8); - // const slice = kit.peritext.savedSlices.insOne(range, 'test', {}); - // expect(slice.typeStepApi() instanceof ConApi).toBe(true); - // expect(slice.typeStepApi(0) instanceof ConApi).toBe(true); - // expect(slice.typeStepApi(1) instanceof ConApi).toBe(true); - // expect(slice.typeStepApi(2) instanceof ConApi).toBe(true); - // expect(slice.typeStepApi()?.view()).toBe('test'); - // expect(slice.typeStepApi(0)?.view()).toBe('test'); - // expect(slice.typeStepApi(1)?.view()).toBe('test'); - // expect(slice.typeStepApi(2)?.view()).toBe('test'); - // }); - - // test('nested', () => { - // const kit = setup(); - // const range = kit.peritext.rangeAt(9); - // const slice = kit.peritext.savedSlices.insMarker(range, ['ul', 'li', 'p']); - // expect(slice.typeStepApi() instanceof ConApi).toBe(true); - // expect(slice.typeStepApi(0) instanceof ConApi).toBe(true); - // expect(slice.typeStepApi(1) instanceof ConApi).toBe(true); - // expect(slice.typeStepApi(2) instanceof ConApi).toBe(true); - // expect(slice.typeStepApi(3) instanceof ConApi).toBe(true); - // expect(slice.typeStepApi(4) instanceof ConApi).toBe(true); - // expect(slice.typeStepApi()!.view()).toBe('p'); - // expect(slice.typeStepApi(0)!.view()).toBe('ul'); - // expect(slice.typeStepApi(1)!.view()).toBe('li'); - // expect(slice.typeStepApi(2)!.view()).toBe('p'); - // expect(slice.typeStepApi(3)!.view()).toBe('p'); - // expect(slice.typeStepApi(4)!.view()).toBe('p'); - // }); - - // test('nested with discriminants', () => { - // const kit = setup(); - // const range = kit.peritext.rangeAt(9); - // const slice = kit.peritext.savedSlices.insMarker(range, [['ul', 1], ['li', 0], 'p']); - // expect(slice.typeStepApi() instanceof ConApi).toBe(true); - // expect(slice.typeStepApi(0) instanceof VecApi).toBe(true); - // expect(slice.typeStepApi(1) instanceof VecApi).toBe(true); - // expect(slice.typeStepApi(2) instanceof ConApi).toBe(true); - // expect(slice.typeStepApi(3) instanceof ConApi).toBe(true); - // expect(slice.typeStepApi(4) instanceof ConApi).toBe(true); - // expect(slice.typeStepApi()!.view()).toEqual('p'); - // expect(slice.typeStepApi(0)!.view()).toEqual(['ul', 1]); - // expect(slice.typeStepApi(1)!.view()).toEqual(['li', 0]); - // expect(slice.typeStepApi(2)!.view()).toEqual('p'); - // expect(slice.typeStepApi(3)!.view()).toEqual('p'); - // expect(slice.typeStepApi(4)!.view()).toEqual('p'); - // }); - - // test('nested with data', () => { - // const kit = setup(); - // const range = kit.peritext.rangeAt(9); - // const slice = kit.peritext.savedSlices.insMarker(range, [['ul', 1, {type: 'todo'}], ['li', 0], ['p', 2, {indent: 2}]]); - // expect(slice.typeStepApi() instanceof VecApi).toBe(true); - // expect(slice.typeStepApi(0) instanceof VecApi).toBe(true); - // expect(slice.typeStepApi(1) instanceof VecApi).toBe(true); - // expect(slice.typeStepApi(2) instanceof VecApi).toBe(true); - // expect(slice.typeStepApi(3) instanceof VecApi).toBe(true); - // expect(slice.typeStepApi(4) instanceof VecApi).toBe(true); - // expect(slice.typeStepApi()!.view()).toEqual(['p', 2, {indent: 2}]); - // expect(slice.typeStepApi(0)!.view()).toEqual(['ul', 1, {type: 'todo'}]); - // expect(slice.typeStepApi(1)!.view()).toEqual(['li', 0]); - // expect(slice.typeStepApi(2)!.view()).toEqual(['p', 2, {indent: 2}]); - // expect(slice.typeStepApi(3)!.view()).toEqual(['p', 2, {indent: 2}]); - // expect(slice.typeStepApi(4)!.view()).toEqual(['p', 2, {indent: 2}]); - // }); - // }); - - // describe('.tagDataNode()', () => { - // test('basic type', () => { - // const kit = setup(); - // const range = kit.peritext.rangeAt(3, 8); - // const slice = kit.peritext.savedSlices.insOne(range, 'test', {}); - // expect(slice.tagDataNode()).toBe(void 0); - // expect(slice.tagDataNode(1)).toBe(void 0); - // expect(slice.tagDataNode(2)).toBe(void 0); - // }); - - // test('nested', () => { - // const kit = setup(); - // const range = kit.peritext.rangeAt(9); - // const slice = kit.peritext.savedSlices.insMarker(range, ['ul', 'li', 'p']); - // expect(slice.tagDataNode()).toBe(void 0); - // expect(slice.tagDataNode(0)).toBe(void 0); - // expect(slice.tagDataNode(1)).toBe(void 0); - // expect(slice.tagDataNode(2)).toBe(void 0); - // expect(slice.tagDataNode(3)).toBe(void 0); - // expect(slice.tagDataNode(4)).toBe(void 0); - // }); - - // test('nested with discriminants', () => { - // const kit = setup(); - // const range = kit.peritext.rangeAt(9); - // const slice = kit.peritext.savedSlices.insMarker(range, [['ul', 1], ['li', 0], 'p']); - // expect(slice.tagDataNode()).toBe(void 0); - // expect(slice.tagDataNode(0)).toBe(void 0); - // expect(slice.tagDataNode(1)).toBe(void 0); - // expect(slice.tagDataNode(2)).toBe(void 0); - // expect(slice.tagDataNode(3)).toBe(void 0); - // expect(slice.tagDataNode(4)).toBe(void 0); - // }); - - // test.only('nested with data', () => { - // const kit = setup(); - // const range = kit.peritext.rangeAt(9); - // const slice = kit.peritext.savedSlices.insMarker(range, [['ul', 1, {type: 'todo'}], ['li', 0], ['p', 2, {indent: 2}]]); - // console.log(slice.tagDataNode()); - // expect(slice.tagDataNode() instanceof ObjNode).toBe(true); - // // expect(slice.tagDataNode(0) instanceof ObjNode).toBe(true); - // // expect(slice.tagDataNode(1)).toBe(void 0); - // // expect(slice.tagDataNode(2) instanceof ObjNode).toBe(true); - // // expect(slice.tagDataNode(3) instanceof ObjNode).toBe(true); - // // expect(slice.tagDataNode()!.view()).toEqual({indent: 2}); - // // expect(slice.tagDataNode(0)!.view()).toEqual({type: 'todo'}); - // // expect(slice.tagDataNode(2)!.view()).toEqual({indent: 2}); - // // expect(slice.tagDataNode(3)!.view()).toEqual({indent: 2}); - // }); - // }); - - describe('.nestedType()', () => { - describe('.tag()', () => { - test('can read name, discriminant, and data', () => { - const kit = setup(); - const range = kit.peritext.rangeAt(9); - const slice = kit.peritext.savedSlices.insMarker(range, [ - ['note'] as any, - ['blockquote', 1, {foo: 'bar'}], - ['p', 2, {indent: 2}], - ]); - const tag0 = slice.nestedType().tag(0); - const tag1 = slice.nestedType().tag(1); - const tag2 = slice.nestedType().tag(2); - const tag3 = slice.nestedType().tag(3); - expect(tag0.name()).toBe('note'); - expect(tag0.discriminant()).toBe(0); - expect(tag0.data().view()).toEqual({}); - expect(tag0.name()).toBe('note'); - expect(tag0.discriminant()).toBe(0); - expect(tag1.name()).toBe('blockquote'); - expect(tag1.discriminant()).toBe(1); - expect(tag1.data().view()).toEqual({foo: 'bar'}); - expect(tag2.name()).toBe('p'); - expect(tag2.discriminant()).toBe(2); - expect(tag2.data().view()).toEqual({indent: 2}); - expect(tag3.name()).toBe('p'); - expect(tag3.discriminant()).toBe(2); - expect(tag3.data().view()).toEqual({indent: 2}); - }); - - describe('.asVec()', () => { - test('can convert basic type to a "vec" step', () => { - const kit = setup(); - const range = kit.peritext.rangeAt(9); - const slice = kit.peritext.savedSlices.insMarker(range, 'p'); - const node = slice.nestedType().tag(0).asVec(); - expect(node instanceof VecApi).toBe(true); - expect(node.view()).toEqual(['p']); - }); - - test('can convert basic type to a "vec" step - 2', () => { - const kit = setup(); - const range = kit.peritext.rangeAt(9); - const slice = kit.peritext.savedSlices.insMarker(range, ['p']); - const node = slice.nestedType().tag(0).asVec(); - expect(node instanceof VecApi).toBe(true); - expect(node.view()).toEqual(['p']); - }); - - test('can convert basic type to a "vec" step - 3', () => { - const kit = setup(); - const range = kit.peritext.rangeAt(9); - const slice = kit.peritext.savedSlices.insMarker(range, [['p', 0]]); - const node = slice.nestedType().tag(0).asVec(); - expect(node instanceof VecApi).toBe(true); - expect(node.view()).toEqual(['p', 0]); - }); - }); - - describe('.data()', () => { - test('creates empty {} object, when not provided', () => { - const kit = setup(); - const range = kit.peritext.rangeAt(9); - const slice = kit.peritext.savedSlices.insMarker(range, 'p'); - const node = slice.nestedType().tag(0).data(); - expect(node instanceof ObjApi).toBe(true); - expect(node.view()).toEqual({}); - }); - - test('converts node to "obj", if not already "obj', () => { - const kit = setup(); - const range = kit.peritext.rangeAt(9); - const slice = kit.peritext.savedSlices.insMarker(range, [['p', 0, [] as any]]); - const node = slice.nestedType().tag(0).data(); - expect(node instanceof ObjApi).toBe(true); - expect(node.view()).toEqual({}); - }); - - test('returns existing data node', () => { - const kit = setup(); - const range = kit.peritext.rangeAt(9); - const slice = kit.peritext.savedSlices.insMarker(range, [ - ['blockquote', 0, {foo: 'bar'}], - ['p', 1, {indent: 2}], - ]); - const obj0 = slice.nestedType().tag(0).data(); - const obj1 = slice.nestedType().tag(1).data(); - const obj2 = slice.nestedType().tag(2).data(); - expect(obj0.view()).toEqual({foo: 'bar'}); - expect(obj1.view()).toEqual({indent: 2}); - expect(obj2.view()).toEqual({indent: 2}); - }); - }); - }); - }); -}); diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/slice/__tests__/Slice.spec.ts b/packages/json-joy/src/json-crdt-extensions/peritext/slice/__tests__/Slice.spec.ts new file mode 100644 index 0000000000..820f1d014f --- /dev/null +++ b/packages/json-joy/src/json-crdt-extensions/peritext/slice/__tests__/Slice.spec.ts @@ -0,0 +1,212 @@ +import {ObjApi, VecApi} from '../../../../json-crdt/model'; +import {SliceStacking} from '../constants'; +import {setup} from './setup'; + +const setupSlice = () => { + const deps = setup(); + const range = deps.peritext.rangeAt(2, 3); + const slice = deps.peritext.savedSlices.insMarker(range, 0); + return {...deps, range, slice}; +}; + +test('can read slice data', () => { + const {range, slice} = setupSlice(); + expect(slice.isMarker()).toBe(true); + expect(slice.stacking).toBe(SliceStacking.Marker); + expect(slice.type()).toBe(0); + expect(slice.data()).toBe(undefined); + expect(slice.start).not.toBe(range.start); + expect(slice.start.cmp(range.start)).toBe(0); + expect(slice.end).not.toBe(range.end); + expect(slice.end.cmp(range.end)).toBe(0); +}); + +describe('.update()', () => { + const testUpdate = (name: string, update: (deps: ReturnType) => void) => { + test('can update: ' + name, () => { + const deps = setupSlice(); + const {slice} = deps; + const hash1 = slice.refresh(); + const hash2 = slice.refresh(); + expect(hash1).toBe(hash2); + update(deps); + const hash3 = slice.refresh(); + expect(hash3).not.toBe(hash2); + }); + }; + + testUpdate('stacking', ({slice}) => { + slice.update({stacking: SliceStacking.Erase}); + expect(slice.stacking).toBe(SliceStacking.Erase); + }); + + testUpdate('type', ({slice}) => { + slice.update({type: 1}); + expect(slice.type()).toBe(1); + }); + + testUpdate('data', ({slice}) => { + slice.update({data: 123}); + expect(slice.data()).toBe(123); + }); + + testUpdate('range', ({peritext, slice}) => { + const range2 = peritext.rangeAt(0, 1); + slice.update({range: range2}); + expect(slice.cmp(range2)).toBe(0); + }); +}); + +describe('.del() and .isDel()', () => { + test('can delete a slice', () => { + const {peritext, slice} = setupSlice(); + expect(peritext.model.view().slices.length).toBe(1); + expect(slice.isDel()).toBe(false); + const slice2 = peritext.savedSlices.get(slice.id)!; + expect(peritext.model.view().slices.length).toBe(1); + expect(slice2.isDel()).toBe(false); + expect(slice2).toBe(slice); + 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.savedSlices.get(slice.id); + expect(slice3).toBe(undefined); + }); +}); + +describe('type retrieval an manipulation', () => { + describe('.type()', () => { + test('basic type', () => { + const kit = setup(); + const range = kit.peritext.rangeAt(3, 8); + const slice = kit.peritext.savedSlices.insOne(range, 'test', {}); + expect(slice.type()).toBe('test'); + }); + + test('nested', () => { + const kit = setup(); + const range = kit.peritext.rangeAt(9); + const slice = kit.peritext.savedSlices.insMarker(range, ['ul', 'li', 'p']); + expect(slice.type()).toEqual(['ul', 'li', 'p']); + }); + + test('nested with discriminants', () => { + const kit = setup(); + const range = kit.peritext.rangeAt(9); + const slice = kit.peritext.savedSlices.insMarker(range, [['ul', 1], ['li', 0], 'p']); + expect(slice.type()).toEqual([['ul', 1], ['li', 0], 'p']); + }); + + test('nested with data', () => { + const kit = setup(); + const range = kit.peritext.rangeAt(9); + const slice = kit.peritext.savedSlices.insMarker(range, [ + ['ul', 1, {type: 'todo'}], + ['li', 0], + ['p', 0, {indent: 2}], + ]); + expect(slice.type()).toEqual([ + ['ul', 1, {type: 'todo'}], + ['li', 0], + ['p', 0, {indent: 2}], + ]); + }); + }); + + describe('.nestedType()', () => { + describe('.tag()', () => { + test('can read name, discriminant, and data', () => { + const kit = setup(); + const range = kit.peritext.rangeAt(9); + const slice = kit.peritext.savedSlices.insMarker(range, [ + ['note'] as any, + ['blockquote', 1, {foo: 'bar'}], + ['p', 2, {indent: 2}], + ]); + const tag0 = slice.nestedType().tag(0); + const tag1 = slice.nestedType().tag(1); + const tag2 = slice.nestedType().tag(2); + const tag3 = slice.nestedType().tag(3); + expect(tag0.name()).toBe('note'); + expect(tag0.discriminant()).toBe(0); + expect(tag0.data().view()).toEqual({}); + expect(tag0.name()).toBe('note'); + expect(tag0.discriminant()).toBe(0); + expect(tag1.name()).toBe('blockquote'); + expect(tag1.discriminant()).toBe(1); + expect(tag1.data().view()).toEqual({foo: 'bar'}); + expect(tag2.name()).toBe('p'); + expect(tag2.discriminant()).toBe(2); + expect(tag2.data().view()).toEqual({indent: 2}); + expect(tag3.name()).toBe('p'); + expect(tag3.discriminant()).toBe(2); + expect(tag3.data().view()).toEqual({indent: 2}); + }); + + describe('.asVec()', () => { + test('can convert basic type to a "vec" step', () => { + const kit = setup(); + const range = kit.peritext.rangeAt(9); + const slice = kit.peritext.savedSlices.insMarker(range, 'p'); + const node = slice.nestedType().tag(0).asVec(); + expect(node instanceof VecApi).toBe(true); + expect(node.view()).toEqual(['p']); + }); + + test('can convert basic type to a "vec" step - 2', () => { + const kit = setup(); + const range = kit.peritext.rangeAt(9); + const slice = kit.peritext.savedSlices.insMarker(range, ['p']); + const node = slice.nestedType().tag(0).asVec(); + expect(node instanceof VecApi).toBe(true); + expect(node.view()).toEqual(['p']); + }); + + test('can convert basic type to a "vec" step - 3', () => { + const kit = setup(); + const range = kit.peritext.rangeAt(9); + const slice = kit.peritext.savedSlices.insMarker(range, [['p', 0]]); + const node = slice.nestedType().tag(0).asVec(); + expect(node instanceof VecApi).toBe(true); + expect(node.view()).toEqual(['p', 0]); + }); + }); + + describe('.data()', () => { + test('creates empty {} object, when not provided', () => { + const kit = setup(); + const range = kit.peritext.rangeAt(9); + const slice = kit.peritext.savedSlices.insMarker(range, 'p'); + const node = slice.nestedType().tag(0).data(); + expect(node instanceof ObjApi).toBe(true); + expect(node.view()).toEqual({}); + }); + + test('converts node to "obj", if not already "obj', () => { + const kit = setup(); + const range = kit.peritext.rangeAt(9); + const slice = kit.peritext.savedSlices.insMarker(range, [['p', 0, [] as any]]); + const node = slice.nestedType().tag(0).data(); + expect(node instanceof ObjApi).toBe(true); + expect(node.view()).toEqual({}); + }); + + test('returns existing data node', () => { + const kit = setup(); + const range = kit.peritext.rangeAt(9); + const slice = kit.peritext.savedSlices.insMarker(range, [ + ['blockquote', 0, {foo: 'bar'}], + ['p', 1, {indent: 2}], + ]); + const obj0 = slice.nestedType().tag(0).data(); + const obj1 = slice.nestedType().tag(1).data(); + const obj2 = slice.nestedType().tag(2).data(); + expect(obj0.view()).toEqual({foo: 'bar'}); + expect(obj1.view()).toEqual({indent: 2}); + expect(obj2.view()).toEqual({indent: 2}); + }); + }); + }); + }); +}); diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/slice/__tests__/Slices.spec.ts b/packages/json-joy/src/json-crdt-extensions/peritext/slice/__tests__/Slices.spec.ts index da1366707f..fe376a706f 100644 --- a/packages/json-joy/src/json-crdt-extensions/peritext/slice/__tests__/Slices.spec.ts +++ b/packages/json-joy/src/json-crdt-extensions/peritext/slice/__tests__/Slices.spec.ts @@ -2,7 +2,7 @@ import {Model} from '../../../../json-crdt/model'; import {Peritext} from '../../Peritext'; import type {Range} from '../../rga/Range'; import {Anchor} from '../../rga/constants'; -import type {PersistedSlice} from '../PersistedSlice'; +import type {Slice} from '../Slice'; import {SliceStacking} from '../constants'; import {setup} from './setup'; @@ -154,7 +154,7 @@ describe('.delSlices()', () => { }); describe('.refresh()', () => { - const testSliceUpdate = (name: string, update: (controls: {range: Range; slice: PersistedSlice}) => void) => { + const testSliceUpdate = (name: string, update: (controls: {range: Range; slice: Slice}) => void) => { test('changes hash on: ' + name, () => { const {peritext, encodeAndDecode} = setup(); const range = peritext.rangeAt(6, 5); diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/slice/constants.ts b/packages/json-joy/src/json-crdt-extensions/peritext/slice/constants.ts index a620417b65..8239e98206 100644 --- a/packages/json-joy/src/json-crdt-extensions/peritext/slice/constants.ts +++ b/packages/json-joy/src/json-crdt-extensions/peritext/slice/constants.ts @@ -9,73 +9,92 @@ export enum CursorAnchor { End = 1, } +// biome-ignore format: keep table layout export const enum SliceTypeCon { - // ---------------------------------------------------- block slices (0 to 64) - p = 0, //

                                - blockquote = 1, //

                                - codeblock = 2, //
                                
                                -  pre = 3, // 
                                -  ul = 4, // 
                                  - ol = 5, //
                                    - tl = 6, // - [ ] Task list - li = 7, //
                                  1. - h1 = 8, //

                                    - h2 = 9, //

                                    - h3 = 10, //

                                    - h4 = 11, //

                                    - h5 = 12, //

                                    - h6 = 13, //
                                    - title = 14, // - subtitle = 15, // <subtitle> - br = 16, // <br> - nl = 17, // \n - hr = 18, // <hr> - page = 19, // Page break - aside = 20, // <aside> - embed = 21, // <embed>, <iframe>, <object>, <video>, <audio>, etc. - column = 22, // <div style="column-count: ..."> (represents 2 and 3 column layouts) - contents = 23, // Table of contents - table = 24, // <table> - row = 25, // Table row - cell = 26, // Table cell + // ------------------------ block slices (positive integers, starting from 0) + p = 0, // <p> + blockquote = 1 + p, // <blockquote> (used by Quill) + codeblock = 1 + blockquote, // <code> + 'code-block' = 1 + codeblock, // <code> (same as <codeblock>, used by Quill) + code_block = 2 + codeblock, // <code> (same as <codeblock>, used by Prosemirror) + pre = 1 + code_block, // <pre> + ul = 1 + pre, // <ul> + ol = 1 + ul, // <ol> + tl = 1 + ol, // - [ ] Task list + li = 1 + tl, // <li> + list = 1 + li, // A generic list (used by Quill) + h1 = 1 + list, // <h1> + h2 = 1 + h1, // <h2> + h3 = 1 + h2, // <h3> + h4 = 1 + h3, // <h4> + h5 = 1 + h4, // <h5> + h6 = 1 + h5, // <h6> + heading = 1 + h6, // <heading level="3"> (same as <h3>) + header = 1 + heading, // <header level="3"> (same as <h3>, used by Quill) + title = 1 + header, // <title> (whole document title) + subtitle = 1 + title, // <subtitle> (whole document subtitle) + br = 1 + subtitle, // <br> + hard_break = 1 + br, // Same as <br> (used by Prosemirror) + nl = 1 + hard_break, // \n + hr = 1 + nl, // <hr> + horizontal_rule = 1 + hr, // Same as <hr> (used by Prosemirror) + page = 1 + horizontal_rule, // Page break + aside = 1 + page, // <aside> + imgblock = 1 + aside, // <img> (image) + embed = 1 + imgblock, // <embed>, <iframe>, <object>, <video>, <audio>, etc. + column = 1 + embed, // <div style="column-count: ..."> + contents = 1 + column, // Table of contents + table = 1 + contents, // <table> + tr = 1 + table, // <tr> (table row) + td = 1 + tr, // <td> (table cell) + cl = 1 + td, // Collapsible list + collapse = 1 + cl, // Collapsible block + note = 1 + collapse, // Note block + mathblock = 1 + note, // <math> block + div = 1 + mathblock, // <div> - // TODO: rename to `cl` (collapsible list)? - collapselist = 27, // Collapsible list - > List item - - collapse = 28, // Collapsible block - note = 29, // Note block - mathblock = 30, // <math> block - div = 31, - - // ------------------------------------------------ inline slices (-64 to -1) - Cursor = -1, - RemoteCursor = -2, - b = -3, // <b> - i = -4, // <i> - u = -5, // <u> - s = -6, // <s> - code = -7, // <code> - mark = -8, // <mark> - a = -9, // <a> - comment = -10, // User comment attached to a slice - del = -11, // <del> - ins = -12, // <ins> - sup = -13, // <sup> - sub = -14, // <sub> - math = -15, // <math> inline - font = -16, // <span style="font-family: ..."> - col = -17, // <span style="color: ..."> - bg = -18, // <span style="background: ..."> - kbd = -19, // <kbd> - spoiler = -20, // <span style="color: transparent; background: black"> - q = -21, // <q> (inline quote) - cite = -22, // <cite> (inline citation) - footnote = -23, // <sup> or <a> with href="#footnote-..." and title="Footnote ..." - ref = -24, // <a> with href="#ref-..." and title="Reference ..." (Reference to some element in the document) - iaside = -25, // Inline <aside> - iembed = -26, // inline embed (any media, dropdown, Google Docs-like chips: date, person, file, etc.) - bookmark = -27, // UI for creating a link to this slice - overline = -28, // <span style="text-decoration: overline"> + // ---------------------- inline slices (negative integers, starting from -1) + Cursor = -1, // Current user's cursors. + RemoteCursor = -1 + Cursor, // Remote collaborator cursors. + b = -1 + RemoteCursor, // <b> + bold = -1 + b, // <bold> (same as <b>, used in Slate and Quill) + strong = -1 + bold, // <strong> (similar to <b>, used in Prosemirror) + i = -1 + strong, // <i> + italic = -1 + i, // <em> (same as <i>, used in Slate and Quill) + em = -1 + italic, // <em> (similar to <i>, used in Prosemirror) + u = -1 + em, // <u> + underline = -1 + u, // <underline> (same as <u>, used in Slate and Quill) + overline = -1 + underline, // <span style="text-decoration: overline"> + s = -1 + overline, // <s> + strike = -1 + s, // <strike> (same as <s>, used by Quill) + strikethrough = -1 + strike, // <strikethrough> (same as <s>) + code = -1 + strikethrough, // <code> + mark = -1 + code, // <mark> + a = -1 + mark, // <a> + link = -1 + a, // <link> (same as <a>, used in Prosemirror and Quill) + img = -1 + link, // inline <img> + image = -1 + img, // <image> (same as <img>, used in Quill) + comment = -1 + image, // User comment attached to a slice + del = -1 + comment, // <del> + ins = -1 + del, // <ins> + sup = -1 + ins, // <sup> + sub = -1 + sup, // <sub> + script = -1 + sub, // { script: 'sub' | 'sup' } (used in Quill) + math = -1 + script, // <math> inline + font = -1 + math, // <span style="font-family: ..."> (used in Quill) + col = -1 + font, // <span style="color: ..."> + color = -1 + col, // Same as col, used by Quill + bg = -1 + color, // <span style="background: ..."> + background = -1 + bg, // Same as bg, used by Quill + kbd = -1 + background, // <kbd> + spoiler = -1 + kbd, // <span style="color: transparent; background: black"> + q = -1 + spoiler, // <q> (inline quote) + cite = -1 + q, // <cite> (inline citation) + footnote = -1 + cite, // <sup> or <a> with href="#footnote-..." and title="Footnote ..." + ref = -1 + footnote, // <a> with href="#ref-..." and title="Reference ..." (Reference to some element in the document) + iaside = -1 + ref, // Inline <aside> + iembed = -1 + iaside, // inline embed (any media, dropdown, Google Docs-like chips: date, person, file, etc.) + bookmark = -1 + iembed, // UI for creating a link to this slice } /** @@ -86,31 +105,39 @@ export enum SliceTypeName { p = SliceTypeCon.p, blockquote = SliceTypeCon.blockquote, codeblock = SliceTypeCon.codeblock, + 'code-block' = SliceTypeCon['code-block'], + code_block = SliceTypeCon.code_block, pre = SliceTypeCon.pre, ul = SliceTypeCon.ul, ol = SliceTypeCon.ol, tl = SliceTypeCon.tl, li = SliceTypeCon.li, + list = SliceTypeCon.list, h1 = SliceTypeCon.h1, h2 = SliceTypeCon.h2, h3 = SliceTypeCon.h3, h4 = SliceTypeCon.h4, h5 = SliceTypeCon.h5, h6 = SliceTypeCon.h6, + heading = SliceTypeCon.heading, + header = SliceTypeCon.header, title = SliceTypeCon.title, subtitle = SliceTypeCon.subtitle, br = SliceTypeCon.br, + hard_break = SliceTypeCon.hard_break, nl = SliceTypeCon.nl, hr = SliceTypeCon.hr, + horizontal_rule = SliceTypeCon.horizontal_rule, page = SliceTypeCon.page, aside = SliceTypeCon.aside, + imgblock = SliceTypeCon.imgblock, embed = SliceTypeCon.embed, column = SliceTypeCon.column, contents = SliceTypeCon.contents, table = SliceTypeCon.table, - row = SliceTypeCon.row, - cell = SliceTypeCon.cell, - collapselist = SliceTypeCon.collapselist, + tr = SliceTypeCon.tr, + td = SliceTypeCon.td, + cl = SliceTypeCon.cl, collapse = SliceTypeCon.collapse, note = SliceTypeCon.note, mathblock = SliceTypeCon.mathblock, @@ -119,29 +146,44 @@ export enum SliceTypeName { Cursor = SliceTypeCon.Cursor, RemoteCursor = SliceTypeCon.RemoteCursor, b = SliceTypeCon.b, + bold = SliceTypeCon.bold, + strong = SliceTypeCon.strong, i = SliceTypeCon.i, + italic = SliceTypeCon.italic, + em = SliceTypeCon.em, u = SliceTypeCon.u, + underline = SliceTypeCon.underline, + overline = SliceTypeCon.overline, s = SliceTypeCon.s, + strike = SliceTypeCon.strike, + strikethrough = SliceTypeCon.strikethrough, code = SliceTypeCon.code, mark = SliceTypeCon.mark, a = SliceTypeCon.a, + link = SliceTypeCon.link, + img = SliceTypeCon.img, + image = SliceTypeCon.image, comment = SliceTypeCon.comment, del = SliceTypeCon.del, ins = SliceTypeCon.ins, sup = SliceTypeCon.sup, sub = SliceTypeCon.sub, + script = SliceTypeCon.script, math = SliceTypeCon.math, font = SliceTypeCon.font, col = SliceTypeCon.col, + color = SliceTypeCon.color, bg = SliceTypeCon.bg, + background = SliceTypeCon.background, kbd = SliceTypeCon.kbd, spoiler = SliceTypeCon.spoiler, + q = SliceTypeCon.q, + cite = SliceTypeCon.cite, footnote = SliceTypeCon.footnote, ref = SliceTypeCon.ref, iaside = SliceTypeCon.iaside, iembed = SliceTypeCon.iembed, bookmark = SliceTypeCon.bookmark, - overline = SliceTypeCon.overline, } /** Slice header octet (8 bits) masking specification. */ diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/slice/schema.ts b/packages/json-joy/src/json-crdt-extensions/peritext/slice/schema.ts index 4024591e83..80745d450e 100644 --- a/packages/json-joy/src/json-crdt-extensions/peritext/slice/schema.ts +++ b/packages/json-joy/src/json-crdt-extensions/peritext/slice/schema.ts @@ -1,4 +1,6 @@ -import {type NodeBuilder, s} from '../../../json-crdt-patch'; +import {compare, type NodeBuilder, s} from '../../../json-crdt-patch'; +import {SliceHeaderShift, type SliceStacking} from './constants'; +import type {Range} from '../rga/Range'; import type {SliceType, SliceTypeStep} from './types'; export const type = (sliceType: SliceType) => @@ -14,3 +16,19 @@ export const step = (sliceStep: SliceTypeStep) => { } return s.con(sliceStep); }; + +export const slice = (range: Range<any>, stacking: SliceStacking, sliceType: SliceType, data?: unknown) => { + const {start, end} = range; + const header = + (stacking << SliceHeaderShift.Stacking) + + ((start.anchor & 0b1) << SliceHeaderShift.X1Anchor) + + ((end.anchor & 0b1) << SliceHeaderShift.X2Anchor); + const elements = [ + s.con(header), + s.con(start.id), + s.con(!compare(start.id, end.id) ? 0 : end.id), + type(sliceType), + ] as const; + if (data !== void 0) (elements as any).push(s.json(data)); + return s.vec(...elements); +}; diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/slice/types.ts b/packages/json-joy/src/json-crdt-extensions/peritext/slice/types.ts index 8081f2403a..f84a861038 100644 --- a/packages/json-joy/src/json-crdt-extensions/peritext/slice/types.ts +++ b/packages/json-joy/src/json-crdt-extensions/peritext/slice/types.ts @@ -1,5 +1,4 @@ import type {Range} from '../rga/Range'; -import type {Stateful} from '../types'; import type {ITimestampStruct} from '../../../json-crdt-patch/clock'; import type {SliceStacking, SliceTypeCon} from './constants'; import type {NodeBuilder, nodes} from '../../../json-crdt-patch'; @@ -57,7 +56,8 @@ import type {JsonNodeView} from '../../../json-crdt/nodes'; */ export type SliceType = TypeTag | SliceTypeSteps; export type SliceTypeSteps = SliceTypeStep[]; -export type SliceTypeStep = TypeTag | [tag: TypeTag, discriminant: number, data?: Record<string, unknown>]; +export type SliceTypeStep = TypeTag | SliceTypeCompositeStep; +export type SliceTypeCompositeStep = [tag: TypeTag, discriminant: number, data?: Record<string, unknown>]; /** * Tag is number or a string, the last type element if type is a list. Tag @@ -163,63 +163,6 @@ export type SliceNode = SchemaToJsonNode<SliceSchema>; */ export type SliceView = JsonNodeView<SliceNode>; -/** - * Slices represent Peritext's rich-text formatting/annotations. The "slice" - * concept captures both: (1) range annotations; as well as, (2) *markers*, - * which are a single-point annotations. The markers are used as block splits, - * e.g. paragraph, heading, blockquote, etc. In markers, the start and end - * positions of the range are normally the same, but could also wrap around - * a single RGA chunk. - */ -export interface Slice<T = string> extends Range<T>, Stateful { - /** - * ID of the slice. ID is used for layer sorting. - */ - id: ITimestampStruct; - - /** - * The low-level stacking behavior of the slice. Specifies whether the - * slice is a split, i.e. a "marker" for a block split, in which case it - * represents a single place in the text where text is split into blocks. - * Otherwise, specifies the low-level behavior or the rich-text formatting - * of the slice. - */ - stacking: SliceStacking; - - /** - * The high-level behavior identifier of the slice. Specifies the - * user-defined type of the slice, e.g. paragraph, heading, blockquote, etc. - * - * Usually the type is a number or string primitive, in which case it is - * referred to as *tag*. - * - * The type is a list only for nested blocks, e.g. `['ul', 'li']`, in which - * case the type is a list of tags. The last tag in the list is the - * "leaf" tag, which is the type of the leaf block element. - */ - type(): SliceType; - - /** - * High-level user-defined metadata of the slice, which accompanies the slice - * type. - */ - data(): unknown | undefined; -} - -export interface MutableSlice<T = string> extends Slice<T> { - update(params: SliceUpdateParams<T>): void; - - /** - * Delete this slice from its backing store. - */ - del(): void; - - /** - * Whether the slice is deleted. - */ - isDel(): boolean; -} - /** * Parameters for updating a slice. */ diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/transfer/__tests__/jsx.spec.tsx b/packages/json-joy/src/json-crdt-extensions/peritext/transfer/__tests__/jsx.spec.tsx index ea664ff339..659a0f7a86 100644 --- a/packages/json-joy/src/json-crdt-extensions/peritext/transfer/__tests__/jsx.spec.tsx +++ b/packages/json-joy/src/json-crdt-extensions/peritext/transfer/__tests__/jsx.spec.tsx @@ -1,6 +1,6 @@ /** @jsx h */ /** @jsxFrag h */ -// biome-ignore lint: this import is necessary for JSX +// biome-ignore lint: Import needed for JSX import {h} from '../jsx'; import {SliceTypeCon} from '../../slice/constants'; import {setup} from './setup'; diff --git a/packages/json-joy/src/json-crdt-extensions/peritext/transfer/import-html.ts b/packages/json-joy/src/json-crdt-extensions/peritext/transfer/import-html.ts index b0f37eff3a..92c37e181a 100644 --- a/packages/json-joy/src/json-crdt-extensions/peritext/transfer/import-html.ts +++ b/packages/json-joy/src/json-crdt-extensions/peritext/transfer/import-html.ts @@ -169,7 +169,7 @@ const getExportData = (html: string): [jsonml: undefined | JsonMlNode, exportDat let node: JsonMlNode | undefined; while ((node = iterator())) { if (node && typeof node === 'object') { - const [_tag, attr] = node; + const [, attr] = node; if (attr?.[attrName]) { const jsonBase64 = attr[attrName]; const buffer = fromBase64(jsonBase64); diff --git a/packages/json-joy/src/json-crdt-extensions/quill-delta/QuillDeltaApi.ts b/packages/json-joy/src/json-crdt-extensions/quill-delta/QuillDeltaApi.ts index 26e615f479..b0965bce49 100644 --- a/packages/json-joy/src/json-crdt-extensions/quill-delta/QuillDeltaApi.ts +++ b/packages/json-joy/src/json-crdt-extensions/quill-delta/QuillDeltaApi.ts @@ -1,7 +1,7 @@ import {QuillConst} from './constants'; import {NodeApi} from '../../json-crdt/model/api/nodes'; import {SliceStacking} from '../peritext/slice/constants'; -import {PersistedSlice} from '../peritext/slice/PersistedSlice'; +import {Slice} from '../peritext/slice/Slice'; import {diffAttributes, getAttributes, removeErasures} from './util'; import type {PathStep} from '@jsonjoy.com/json-pointer'; import type {QuillDeltaNode} from './QuillDeltaNode'; @@ -43,7 +43,7 @@ const rewriteAttributes = (txt: Peritext, attributes: QuillDeltaAttributes | und if (length) { const savedSlices = txt.savedSlices; slices.forEach((slice) => { - if (slice instanceof PersistedSlice) { + if (slice instanceof Slice) { const isContained = range.contains(slice); if (!isContained) { relevantOverlappingButNotContained.add(slice.type() as PathStep); diff --git a/packages/json-joy/src/json-crdt-extensions/quill-delta/util.ts b/packages/json-joy/src/json-crdt-extensions/quill-delta/util.ts index 528f8f8728..4febdb7822 100644 --- a/packages/json-joy/src/json-crdt-extensions/quill-delta/util.ts +++ b/packages/json-joy/src/json-crdt-extensions/quill-delta/util.ts @@ -1,5 +1,5 @@ import {isEmpty} from '@jsonjoy.com/util/lib/isEmpty'; -import {PersistedSlice} from '../peritext/slice/PersistedSlice'; +import {Slice} from '../peritext/slice/Slice'; import {SliceStacking} from '../peritext/slice/constants'; import type {OverlayPoint} from '../peritext/overlay/OverlayPoint'; import type {QuillDeltaAttributes} from './types'; @@ -12,7 +12,7 @@ export const getAttributes = (overlayPoint: OverlayPoint): QuillDeltaAttributes const attributes: QuillDeltaAttributes = {}; for (let i = 0; i < layerLength; i++) { const slice = layers[i]; - if (!(slice instanceof PersistedSlice)) continue; + if (!(slice instanceof Slice)) continue; switch (slice.stacking) { case SliceStacking.One: { const tag = slice.type() as PathStep; diff --git a/packages/json-joy/src/json-crdt-peritext-ui/__demos__/main.tsx b/packages/json-joy/src/json-crdt-peritext-ui/__demos__/main.tsx index 80ae2400af..a0b5c2aa7e 100644 --- a/packages/json-joy/src/json-crdt-peritext-ui/__demos__/main.tsx +++ b/packages/json-joy/src/json-crdt-peritext-ui/__demos__/main.tsx @@ -1,5 +1,5 @@ -import * as React from 'react'; import {createRoot} from 'react-dom/client'; +import * as React from 'react'; import {App} from './components/App'; const div = document.createElement('div'); diff --git a/packages/json-joy/src/json-crdt-peritext-ui/plugins/toolbar/formatting/FormattingsManagePane/state.ts b/packages/json-joy/src/json-crdt-peritext-ui/plugins/toolbar/formatting/FormattingsManagePane/state.ts index 5b471d1581..9150102ea0 100644 --- a/packages/json-joy/src/json-crdt-peritext-ui/plugins/toolbar/formatting/FormattingsManagePane/state.ts +++ b/packages/json-joy/src/json-crdt-peritext-ui/plugins/toolbar/formatting/FormattingsManagePane/state.ts @@ -1,6 +1,6 @@ import {BehaviorSubject} from 'rxjs'; import {SavedFormatting} from '../../state/formattings'; -import {PersistedSlice} from '../../../../../json-crdt-extensions/peritext/slice/PersistedSlice'; +import {Slice} from '../../../../../json-crdt-extensions/peritext/slice/Slice'; import {subject} from '../../../../util/rx'; import {toSchema} from '../../../../../json-crdt/schema/toSchema'; import {JsonCrdtDiff} from '../../../../../json-crdt-diff/JsonCrdtDiff'; @@ -33,7 +33,7 @@ export class FormattingManageState { if (!behavior) continue; const isConfigurable = !!behavior.schema; if (!isConfigurable) continue; - if (!(slice instanceof PersistedSlice)) continue; + if (!(slice instanceof Slice)) continue; res.push(new SavedFormatting(behavior, slice, state)); } return res; diff --git a/packages/json-joy/src/json-crdt-peritext-ui/plugins/toolbar/formatting/FormattingsNewPane.tsx b/packages/json-joy/src/json-crdt-peritext-ui/plugins/toolbar/formatting/FormattingsNewPane.tsx index 160ce4acc8..2da12ac278 100644 --- a/packages/json-joy/src/json-crdt-peritext-ui/plugins/toolbar/formatting/FormattingsNewPane.tsx +++ b/packages/json-joy/src/json-crdt-peritext-ui/plugins/toolbar/formatting/FormattingsNewPane.tsx @@ -52,7 +52,7 @@ export const FormattingsNewPane: React.FC<FormattingsNewPaneProps> = ({formattin <Button small lite={!valid} - // positive={validation === 'good'} + primary={validation === 'good'} block disabled={!valid} submit diff --git a/packages/json-joy/src/json-crdt-peritext-ui/plugins/toolbar/state/ToolbarState.tsx b/packages/json-joy/src/json-crdt-peritext-ui/plugins/toolbar/state/ToolbarState.tsx index 832dc94aef..83309d063d 100644 --- a/packages/json-joy/src/json-crdt-peritext-ui/plugins/toolbar/state/ToolbarState.tsx +++ b/packages/json-joy/src/json-crdt-peritext-ui/plugins/toolbar/state/ToolbarState.tsx @@ -1543,7 +1543,7 @@ export class ToolbarState implements UiLifeCycles { name: 'Table', icon: () => <Iconista width={16} height={16} set="tabler" icon="table" />, onSelect: () => { - et.marker('upd', [SliceTypeCon.table, SliceTypeCon.row, SliceTypeCon.p]); + et.marker('upd', [SliceTypeCon.table, SliceTypeCon.tr, SliceTypeCon.p]); }, }, { diff --git a/packages/json-joy/src/json-crdt-peritext-ui/plugins/toolbar/state/formattings.ts b/packages/json-joy/src/json-crdt-peritext-ui/plugins/toolbar/state/formattings.ts index b6bc69b9e2..5a3fb8022e 100644 --- a/packages/json-joy/src/json-crdt-peritext-ui/plugins/toolbar/state/formattings.ts +++ b/packages/json-joy/src/json-crdt-peritext-ui/plugins/toolbar/state/formattings.ts @@ -1,12 +1,11 @@ import {s} from '../../../../json-crdt-patch'; import {Model, ObjApi} from '../../../../json-crdt/model'; -import type {Slice} from '../../../../json-crdt-extensions'; import type {Range} from '../../../../json-crdt-extensions/peritext/rga/Range'; import type {ToolbarSliceBehavior, ValidationResult} from '../types'; import type {SliceBehavior} from '../../../../json-crdt-extensions/peritext/registry/SliceBehavior'; import type {ObjNode} from '../../../../json-crdt/nodes'; import type {ToolbarState} from '.'; -import type {PersistedSlice} from '../../../../json-crdt-extensions/peritext/slice/PersistedSlice'; +import type {Slice} from '../../../../json-crdt-extensions/peritext/slice/Slice'; export interface FormattingBase<B extends SliceBehavior<any, any, any, any>, R extends Range<string>> { behavior: B; @@ -45,7 +44,7 @@ export abstract class EditableFormatting<R extends Range<string> = Range<string> * state (location, data) of the formatting and a {@link ToolbarSliceBehavior} * which defines the formatting behavior. */ -export class SavedFormatting<Node extends ObjNode = ObjNode> extends EditableFormatting<PersistedSlice<string>, Node> { +export class SavedFormatting<Node extends ObjNode = ObjNode> extends EditableFormatting<Slice<string>, Node> { /** * @returns Unique key for this formatting. This is the hash of the slice. * This is used to identify the formatting in the UI. diff --git a/packages/json-joy/src/json-crdt-peritext-ui/web/react/hooks.ts b/packages/json-joy/src/json-crdt-peritext-ui/web/react/hooks.ts index 574f63c9cf..b5969c693b 100644 --- a/packages/json-joy/src/json-crdt-peritext-ui/web/react/hooks.ts +++ b/packages/json-joy/src/json-crdt-peritext-ui/web/react/hooks.ts @@ -21,18 +21,21 @@ export const useSyncStoreOpt = <T>(store: SyncStore<T | undefined> = emptySyncSt export const useTimeout = (ms: number, deps: React.DependencyList = [ms]) => { const [ready, setReady] = React.useState(false); - React.useEffect(() => { - if (ready) setReady(false); - - const timer = setTimeout(() => { - setReady(true); - }, ms); - - return () => { - clearTimeout(timer); - }; - // biome-ignore lint: useExhaustiveDependencies: want to control deps manually - }, deps); + React.useEffect( + () => { + if (ready) setReady(false); + + const timer = setTimeout(() => { + setReady(true); + }, ms); + + return () => { + clearTimeout(timer); + }; + }, + // biome-ignore lint: manual deps + deps, + ); return ready; };