diff --git a/package.json b/package.json index af92508348..f16ef7f967 100644 --- a/package.json +++ b/package.json @@ -136,7 +136,8 @@ "hyperdyperid": "^1.2.0", "sonic-forest": "^1.0.3", "thingies": "^2.1.1", - "tree-dump": "^1.0.2" + "tree-dump": "^1.0.2", + "very-small-parser": "^1.8.0" }, "devDependencies": { "@biomejs/biome": "^1.9.4", diff --git a/src/json-crdt-extensions/peritext/__tests__/setup.ts b/src/json-crdt-extensions/peritext/__tests__/setup.ts index 1bbd9f67b7..f13ae3d407 100644 --- a/src/json-crdt-extensions/peritext/__tests__/setup.ts +++ b/src/json-crdt-extensions/peritext/__tests__/setup.ts @@ -146,6 +146,28 @@ export const setupNumbersWithMultipleChunksAndDeletesKit = (): Kit => { }); }; +export const runNumbersKitTestSuite = (runTestSuite: (getKit: () => Kit) => void) => { + describe('numbers "0123456789", no edits', () => { + runTestSuite(setupNumbersKit); + }); + + describe('numbers "0123456789", with default schema and tombstones', () => { + runTestSuite(setupNumbersWithTombstonesKit); + }); + + describe('numbers "0123456789", two RGA chunks', () => { + runTestSuite(setupNumbersWithTwoChunksKit); + }); + + describe('numbers "0123456789", with RGA split', () => { + runTestSuite(setupNumbersWithRgaSplitKit); + }); + + describe('numbers "0123456789", with multiple deletes', () => { + runTestSuite(setupNumbersWithMultipleChunksAndDeletesKit); + }); +}; + /** * Creates a Peritext instance with text "abcdefghijklmnopqrstuvwxyz", no edits. */ @@ -262,19 +284,19 @@ export const runAlphabetKitTestSuite = (runTestSuite: (getKit: () => Kit) => voi describe('basic alphabet', () => { runTestSuite(setupAlphabetKit); }); - // describe('alphabet with two chunks', () => { - // runTestSuite(setupAlphabetWithTwoChunksKit); - // }); - // describe('alphabet with chunk split', () => { - // runTestSuite(setupAlphabetChunkSplitKit); - // }); - // describe('alphabet with deletes', () => { - // runTestSuite(setupAlphabetWithDeletesKit); - // }); - // describe('alphabet written in reverse', () => { - // runTestSuite(setupAlphabetWrittenInReverse); - // }); - // describe('alphabet written in reverse with deletes', () => { - // runTestSuite(setupAlphabetWrittenInReverseWithDeletes); - // }); + describe('alphabet with two chunks', () => { + runTestSuite(setupAlphabetWithTwoChunksKit); + }); + describe('alphabet with chunk split', () => { + runTestSuite(setupAlphabetChunkSplitKit); + }); + describe('alphabet with deletes', () => { + runTestSuite(setupAlphabetWithDeletesKit); + }); + describe('alphabet written in reverse', () => { + runTestSuite(setupAlphabetWrittenInReverse); + }); + describe('alphabet written in reverse with deletes', () => { + runTestSuite(setupAlphabetWrittenInReverseWithDeletes); + }); }; diff --git a/src/json-crdt-extensions/peritext/block/Block.ts b/src/json-crdt-extensions/peritext/block/Block.ts index b3c2703e22..08c5ecf7dd 100644 --- a/src/json-crdt-extensions/peritext/block/Block.ts +++ b/src/json-crdt-extensions/peritext/block/Block.ts @@ -52,33 +52,6 @@ export class Block extends Range implements IBlock, Printable, S return length ? path[length - 1] : ''; } - // public htmlTag(): string { - // const tag = this.tag(); - // switch (typeof tag) { - // case 'string': return tag.toLowerCase(); - // case 'number': return SliceTypeName[tag] || 'div'; - // default: return 'div'; - // } - // } - - // protected jsonMlNode(): JsonMlElement { - // const props: Record = {}; - // const node: JsonMlElement = ['div', props]; - // const tag = this.tag(); - // switch (typeof tag) { - // case 'string': - // node[0] = tag; - // break; - // case 'number': - // const tag0 = SliceTypeName[tag]; - // if (tag0) node[0] = tag0; else props['data-tag'] = tag + ''; - // break; - // } - // const attr = this.attr(); - // if (attr !== undefined) props['data-attr'] = JSON.stringify(attr); - // return node; - // } - public attr(): Attr | undefined { return this.marker?.data() as Attr | undefined; } @@ -112,59 +85,70 @@ export class Block extends Range implements IBlock, Printable, S return new UndefEndIter(this.points0(withMarker)); } - public tuples0(): UndefIterator> { + protected tuples0(): UndefIterator> { const overlay = this.txt.overlay; - const iterator = overlay.tuples0(this.marker); + const marker = this.marker; + const iterator = overlay.tuples0(marker); let closed = false; return () => { if (closed) return; - const pair = iterator(); - if (!pair) return; + 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; return pair; }; } - public tuples(): IterableIterator> { - return new UndefEndIter(this.tuples0()); - } - + /** + * @todo Consider moving inline-related methods to {@link LeafBlock}. + */ public texts0(): UndefIterator { const txt = this.txt; const iterator = this.tuples0(); - const blockStart = this.start; - const blockEnd = this.end; + const start = this.start; + const end = this.end; + const startIsMarker = txt.overlay.isMarker(start.id); + const endIsMarker = txt.overlay.isMarker(end.id); let isFirst = true; let next = iterator(); + let closed = false; return () => { + if (closed) return; const pair = next; next = iterator(); if (!pair) return; - const [p1, p2] = pair; - let start: Point = p1; - let end: Point = p2; + const [overlayPoint1, overlayPoint2] = pair; + let point1: Point = overlayPoint1; + let point2: Point = overlayPoint2; if (isFirst) { isFirst = false; - if (blockStart.cmpSpatial(p1) > 0) start = blockStart; + if (start.cmpSpatial(overlayPoint1) > 0) point1 = start; + if (startIsMarker) { + point1 = point1.clone(); + point1.step(1); + } } - const isLast = !next; - if (isLast) if (blockEnd.cmpSpatial(p2) < 0) end = blockEnd; - return new Inline(txt, p1, p2, start, end); + if (!endIsMarker && end.cmpSpatial(overlayPoint2) < 0) { + closed = true; + point2 = end; + } + return new Inline(txt, overlayPoint1, overlayPoint2, point1, point2); }; } + /** + * @todo Consider moving inline-related methods to {@link LeafBlock}. + */ public texts(): IterableIterator { return new UndefEndIter(this.texts0()); } public text(): string { let str = ''; - const iterator = this.texts0(); - let inline = iterator(); - while (inline) { - str += inline.text(); - inline = iterator(); - } + const children = this.children; + const length = children.length; + for (let i = 0; i < length; i++) str += children[i].text(); return str; } @@ -204,6 +188,7 @@ export class Block extends Range implements IBlock, Printable, S public toStringName(): string { return 'Block'; } + protected toStringHeader(): string { const hash = `#${this.hash.toString(36).slice(-4)}`; const tag = this.path.map((step) => formatType(step)).join('.'); diff --git a/src/json-crdt-extensions/peritext/block/Fragment.ts b/src/json-crdt-extensions/peritext/block/Fragment.ts index be021219b3..e8e231e53f 100644 --- a/src/json-crdt-extensions/peritext/block/Fragment.ts +++ b/src/json-crdt-extensions/peritext/block/Fragment.ts @@ -71,24 +71,16 @@ export class Fragment extends Range implements Printable, Stateful { } protected build(): void { - const {end, root} = this; + const {root} = this; root.children = []; let parent = this.root; const txt = this.txt; const overlay = txt.overlay; - /** - * @todo This line always inserts a markerless block at the beginning of - * the fragment. But what happens if one actually exists? - */ - this.insertBlock(parent, [CommonSliceType.p], void 0, void 0); const iterator = overlay.markerPairs0(this.start, this.end); - const checkEnd = !end.isAbsEnd(); let pair: ReturnType; while ((pair = iterator())) { const [p1, p2] = pair; - if (!p1) break; - if (checkEnd && p1.cmpSpatial(end) > 0) break; - const type = p1.type(); + const type = p1 ? p1.type() : CommonSliceType.p; const path = type instanceof Array ? type : [type]; const block = this.insertBlock(parent, path, p1, p2); if (block.parent) parent = block.parent; diff --git a/src/json-crdt-extensions/peritext/block/Inline.ts b/src/json-crdt-extensions/peritext/block/Inline.ts index 7d84ff445a..94b570bd86 100644 --- a/src/json-crdt-extensions/peritext/block/Inline.ts +++ b/src/json-crdt-extensions/peritext/block/Inline.ts @@ -238,11 +238,6 @@ export class Inline extends Range implements Printable { return texts; } - public text(): string { - const str = super.text(); - return this.p1 instanceof MarkerOverlayPoint ? str.slice(1) : str; - } - // ------------------------------------------------------------------- export public toJson(): PeritextMlNode { @@ -273,12 +268,7 @@ export class Inline extends Range implements Printable { } public toString(tab: string = ''): string { - const str = this.text(); - const truncate = str.length > 32; - const text = JSON.stringify(truncate ? str.slice(0, 32) : str) + (truncate ? ' …' : ''); - const startFormatted = this.p1.toString(tab, true); - const range = this.p1.cmp(this.end) === 0 ? startFormatted : `${startFormatted} ↔ ${this.end.toString(tab, true)}`; - const header = `Inline ${range} ${text}`; + const header = `${super.toString(tab)}`; const attr = this.attr(); const attrKeys = Object.keys(attr); const texts = this.texts(); diff --git a/src/json-crdt-extensions/peritext/block/LeafBlock.ts b/src/json-crdt-extensions/peritext/block/LeafBlock.ts index 2948263809..1b2ce327d8 100644 --- a/src/json-crdt-extensions/peritext/block/LeafBlock.ts +++ b/src/json-crdt-extensions/peritext/block/LeafBlock.ts @@ -1,7 +1,7 @@ import {printTree} from 'tree-dump/lib/printTree'; import {Block} from './Block'; import type {Path} from '@jsonjoy.com/json-pointer'; -import type {PeritextMlAttributes, PeritextMlElement, PeritextMlNode} from './types'; +import type {PeritextMlAttributes, PeritextMlElement} from './types'; export interface IBlock { readonly path: Path; @@ -10,6 +10,12 @@ export interface IBlock { } export class LeafBlock extends Block { + public text(): string { + let str = ''; + for (let iterator = this.texts0(), inline = iterator(); inline; inline = iterator()) str += inline.text(); + return str; + } + // ------------------------------------------------------------------- export public toJson(): PeritextMlElement { diff --git a/src/json-crdt-extensions/peritext/block/__tests__/Block.iteration.spec.ts b/src/json-crdt-extensions/peritext/block/__tests__/Block.iteration.spec.ts index f1f7a9c01f..ae36965f26 100644 --- a/src/json-crdt-extensions/peritext/block/__tests__/Block.iteration.spec.ts +++ b/src/json-crdt-extensions/peritext/block/__tests__/Block.iteration.spec.ts @@ -93,32 +93,6 @@ describe('points', () => { }); }); -describe('tuples', () => { - test('in markup-less document, returns a single pair', () => { - const {peritext} = setupHelloWorldKit(); - peritext.refresh(); - const blocks = peritext.blocks; - const block = blocks.root.children[0]!; - const pairs = [...block.tuples()]; - expect(pairs.length).toBe(1); - expect(pairs[0]).toEqual([peritext.overlay.START, peritext.overlay.END]); - }); - - test('can iterate through all text chunks in two-block documents', () => { - const {peritext} = setupTwoBlockDocument(); - expect(peritext.blocks.root.children.length).toBe(2); - const block1 = peritext.blocks.root.children[0]!; - const block2 = peritext.blocks.root.children[1]!; - const tuples1 = [...block1.tuples()]; - const tuples2 = [...block2.tuples()]; - expect(tuples1.length).toBe(3); - const text1 = tuples1.map(([p1, p2]) => new Inline(peritext, p1, p2, p1, p2).text()).join(''); - const text2 = tuples2.map(([p1, p2]) => new Inline(peritext, p1, p2, p1, p2).text()).join(''); - expect(text1).toBe('hello '); - expect(text2).toBe('world'); - }); -}); - describe('texts', () => { test('in markup-less document', () => { const {peritext} = setupHelloWorldKit(); diff --git a/src/json-crdt-extensions/peritext/block/__tests__/Fragment-export-html.spec.ts b/src/json-crdt-extensions/peritext/block/__tests__/Fragment-export-html.spec.ts new file mode 100644 index 0000000000..28e7a56618 --- /dev/null +++ b/src/json-crdt-extensions/peritext/block/__tests__/Fragment-export-html.spec.ts @@ -0,0 +1,142 @@ +import {type Kit, runAlphabetKitTestSuite} from '../../__tests__/setup'; +import {toHtml, toJsonMl} from '../../lazy/export-html'; +import {CommonSliceType} from '../../slice'; + +const runTests = (setup: () => Kit) => { + describe('JSON-ML', () => { + test('can export two paragraphs', () => { + const {editor, peritext} = setup(); + editor.cursor.setAt(10); + editor.saved.insMarker(CommonSliceType.p); + peritext.refresh(); + const fragment = peritext.fragment(peritext.rangeAt(4, 10)); + fragment.refresh(); + const html = toJsonMl(fragment.toJson()); + expect(html).toEqual(['', null, ['p', null, 'efghij'], ['p', null, 'klm']]); + }); + + test('can export two paragraphs with inline formatting', () => { + const {editor, peritext} = setup(); + editor.cursor.setAt(10); + editor.saved.insMarker(CommonSliceType.p); + editor.cursor.setAt(6, 2); + editor.saved.insOverwrite(CommonSliceType.b); + editor.cursor.setAt(7, 2); + editor.saved.insOverwrite(CommonSliceType.i); + peritext.refresh(); + const fragment = peritext.fragment(peritext.rangeAt(4, 10)); + fragment.refresh(); + const html = toJsonMl(fragment.toJson()); + expect(html).toEqual([ + '', + null, + ['p', null, 'ef', ['b', null, 'g'], ['i', null, ['b', null, 'h']], ['i', null, 'i'], 'j'], + ['p', null, 'klm'], + ]); + }); + }); + + describe('HTML', () => { + test('can export two paragraphs', () => { + const {editor, peritext} = setup(); + editor.cursor.setAt(10); + editor.saved.insMarker(CommonSliceType.p); + peritext.refresh(); + const fragment = peritext.fragment(peritext.rangeAt(4, 10)); + fragment.refresh(); + const html = toHtml(fragment.toJson()); + expect(html).toBe('

efghij

klm

'); + }); + + test('can export two paragraphs (formatted)', () => { + const {editor, peritext} = setup(); + editor.cursor.setAt(10); + editor.saved.insMarker(CommonSliceType.p); + peritext.refresh(); + const fragment = peritext.fragment(peritext.rangeAt(4, 10)); + fragment.refresh(); + const html = toHtml(fragment.toJson(), ' '); + expect(html).toBe('

efghij

\n

klm

'); + }); + + test('can export two paragraphs (formatted and wrapped in
)', () => { + const {editor, peritext} = setup(); + editor.cursor.setAt(10); + editor.saved.insMarker(CommonSliceType.p); + peritext.refresh(); + const fragment = peritext.fragment(peritext.rangeAt(4, 10)); + fragment.refresh(); + const json = fragment.toJson(); + json[0] = 'div'; + const html = toHtml(json, ' '); + expect(html).toBe('
\n

efghij

\n

klm

\n
'); + }); + + test('can export two paragraphs with inline formatting', () => { + const {editor, peritext} = setup(); + editor.cursor.setAt(10); + editor.saved.insMarker(CommonSliceType.p); + editor.cursor.setAt(6, 2); + editor.saved.insOverwrite(CommonSliceType.b); + editor.cursor.setAt(7, 2); + editor.saved.insOverwrite(CommonSliceType.i); + peritext.refresh(); + const fragment = peritext.fragment(peritext.rangeAt(4, 10)); + fragment.refresh(); + const json = fragment.toJson(); + const html = toHtml(json, ''); + expect(html).toEqual('

efghij

klm

'); + }); + + test('can export two paragraphs with inline formatting (formatted)', () => { + const {editor, peritext} = setup(); + editor.cursor.setAt(10); + editor.saved.insMarker(CommonSliceType.p); + editor.cursor.setAt(6, 2); + editor.saved.insOverwrite(CommonSliceType.b); + editor.cursor.setAt(7, 2); + editor.saved.insOverwrite(CommonSliceType.i); + peritext.refresh(); + const fragment = peritext.fragment(peritext.rangeAt(4, 10)); + fragment.refresh(); + const json = fragment.toJson(); + const html = toHtml(json, ' '); + expect(html).toEqual( + '

\n ef\n g\n \n h\n \n i\n j\n

\n

klm

', + ); + }); + + test('can export two paragraphs with inline formatting (formatted, wrapped in
)', () => { + const {editor, peritext} = setup(); + editor.cursor.setAt(10); + editor.saved.insMarker(CommonSliceType.p); + editor.cursor.setAt(6, 2); + editor.saved.insOverwrite(CommonSliceType.b); + editor.cursor.setAt(7, 2); + editor.saved.insOverwrite(CommonSliceType.i); + peritext.refresh(); + const fragment = peritext.fragment(peritext.rangeAt(4, 10)); + fragment.refresh(); + const json = fragment.toJson(); + json[0] = 'div'; + const html = toHtml(json, ' '); + expect('\n' + html).toEqual(` +
+

+ ef + g + + h + + i + j +

+

klm

+
`); + }); + }); +}; + +describe('Fragment.toJson()', () => { + runAlphabetKitTestSuite(runTests); +}); diff --git a/src/json-crdt-extensions/peritext/block/__tests__/Fragment-export-markdown.spec.ts b/src/json-crdt-extensions/peritext/block/__tests__/Fragment-export-markdown.spec.ts new file mode 100644 index 0000000000..411f03bb1e --- /dev/null +++ b/src/json-crdt-extensions/peritext/block/__tests__/Fragment-export-markdown.spec.ts @@ -0,0 +1,181 @@ +import {type Kit, runAlphabetKitTestSuite} from '../../__tests__/setup'; +import {toMdast, toMarkdown} from '../../lazy/export-markdown'; +import {CommonSliceType} from '../../slice'; + +const runTests = (setup: () => Kit) => { + describe('MDAST', () => { + test('can export two paragraphs', () => { + const {editor, peritext} = setup(); + editor.cursor.setAt(10); + editor.saved.insMarker(CommonSliceType.p); + peritext.refresh(); + const fragment = peritext.fragment(peritext.rangeAt(4, 10)); + fragment.refresh(); + const mdast = toMdast(fragment.toJson()); + expect(mdast).toMatchObject({ + type: 'root', + children: [ + { + type: 'paragraph', + children: [{type: 'text', value: 'efghij'}], + }, + { + type: 'paragraph', + children: [{type: 'text', value: 'klm'}], + }, + ], + }); + }); + + test('can export two paragraphs (formatted and wrapped in
)', () => { + const {editor, peritext} = setup(); + editor.cursor.setAt(10); + editor.saved.insMarker(CommonSliceType.p); + peritext.refresh(); + const fragment = peritext.fragment(peritext.rangeAt(4, 10)); + fragment.refresh(); + const json = fragment.toJson(); + json[0] = 'div'; + const mdast = toMdast(fragment.toJson()); + expect(mdast).toMatchObject({ + type: 'root', + children: [ + { + type: 'paragraph', + children: [{type: 'text', value: 'efghij'}], + }, + { + type: 'paragraph', + children: [{type: 'text', value: 'klm'}], + }, + ], + }); + }); + + test('can export two paragraphs with inline formatting', () => { + const {editor, peritext} = setup(); + editor.cursor.setAt(10); + editor.saved.insMarker(CommonSliceType.p); + editor.cursor.setAt(6, 2); + editor.saved.insOverwrite(CommonSliceType.b); + editor.cursor.setAt(7, 2); + editor.saved.insOverwrite(CommonSliceType.i); + peritext.refresh(); + const fragment = peritext.fragment(peritext.rangeAt(4, 10)); + fragment.refresh(); + const mdast = toMdast(fragment.toJson()); + expect(mdast).toMatchObject({ + type: 'root', + children: [ + { + type: 'paragraph', + children: [ + {type: 'text', value: 'ef'}, + {type: 'strong', children: [{type: 'text', value: 'g'}]}, + {type: 'emphasis', children: [{type: 'strong', children: [{type: 'text', value: 'h'}]}]}, + {type: 'emphasis', children: [{type: 'text', value: 'i'}]}, + {type: 'text', value: 'j'}, + ], + }, + { + type: 'paragraph', + children: [{type: 'text', value: 'klm'}], + }, + ], + }); + }); + + test('can export blockquote', () => { + const {editor, peritext} = setup(); + editor.cursor.setAt(10); + editor.saved.insMarker(CommonSliceType.blockquote); + editor.cursor.setAt(13, 2); + editor.saved.insOverwrite(CommonSliceType.b); + peritext.refresh(); + const fragment = peritext.fragment(peritext.rangeAt(4, 10)); + fragment.refresh(); + const mdast = toMdast(fragment.toJson()); + expect(mdast).toMatchObject({ + type: 'root', + children: [ + { + type: 'paragraph', + children: [{type: 'text', value: 'efghij'}], + }, + { + type: 'blockquote', + children: [ + { + type: 'paragraph', + children: [ + {type: 'text', value: 'kl'}, + {type: 'strong', children: [{type: 'text', value: 'm'}]}, + ], + }, + ], + }, + ], + }); + }); + }); + + describe('Markdown', () => { + test('can export two paragraphs', () => { + const {editor, peritext} = setup(); + editor.cursor.setAt(10); + editor.saved.insMarker(CommonSliceType.p); + peritext.refresh(); + const fragment = peritext.fragment(peritext.rangeAt(4, 10)); + fragment.refresh(); + const html = toMarkdown(fragment.toJson()); + expect(html).toBe('efghij\n\nklm'); + }); + + test('can export two paragraphs (formatted and wrapped in
)', () => { + const {editor, peritext} = setup(); + editor.cursor.setAt(10); + editor.saved.insMarker(CommonSliceType.p); + peritext.refresh(); + const fragment = peritext.fragment(peritext.rangeAt(4, 10)); + fragment.refresh(); + const json = fragment.toJson(); + json[0] = 'div'; + const html = toMarkdown(json); + expect(html).toBe('efghij\n\nklm'); + }); + + test('can export two paragraphs with inline formatting', () => { + const {editor, peritext} = setup(); + editor.cursor.setAt(10); + editor.saved.insMarker(CommonSliceType.p); + editor.cursor.setAt(6, 2); + editor.saved.insOverwrite(CommonSliceType.b); + editor.cursor.setAt(7, 2); + editor.saved.insOverwrite(CommonSliceType.i); + peritext.refresh(); + const fragment = peritext.fragment(peritext.rangeAt(4, 10)); + fragment.refresh(); + const json = fragment.toJson(); + const html = toMarkdown(json); + expect(html).toEqual('ef__g___**h**__i_j\n\nklm'); + }); + + test('can export blockquote', () => { + const {editor, peritext} = setup(); + editor.cursor.setAt(10); + editor.saved.insMarker(CommonSliceType.blockquote); + editor.cursor.setAt(13, 2); + editor.saved.insOverwrite(CommonSliceType.b); + peritext.refresh(); + const fragment = peritext.fragment(peritext.rangeAt(4, 10)); + fragment.refresh(); + const json = fragment.toJson(); + const html = toMarkdown(json); + expect(html).toEqual('efghij\n\n> kl__m__'); + }); + }); +}; + +describe('Fragment.toJson()', () => { + runAlphabetKitTestSuite(runTests); +}); diff --git a/src/json-crdt-extensions/peritext/block/__tests__/Fragment-export.spec.ts b/src/json-crdt-extensions/peritext/block/__tests__/Fragment-export.spec.ts index a09eb76425..09e4e5513a 100644 --- a/src/json-crdt-extensions/peritext/block/__tests__/Fragment-export.spec.ts +++ b/src/json-crdt-extensions/peritext/block/__tests__/Fragment-export.spec.ts @@ -1,139 +1,44 @@ import {type Kit, runAlphabetKitTestSuite} from '../../__tests__/setup'; -import {toHtml, toJsonMl} from '../../export/export'; import {CommonSliceType} from '../../slice'; const runTests = (setup: () => Kit) => { - describe('JSON-ML', () => { - test('can export two paragraphs', () => { - const {editor, peritext} = setup(); - editor.cursor.setAt(10); - editor.saved.insMarker(CommonSliceType.p); - peritext.refresh(); - const fragment = peritext.fragment(peritext.rangeAt(4, 10)); - fragment.refresh(); - const html = toJsonMl(fragment.toJson()); - expect(html).toEqual(['', null, ['p', null, 'efghij'], ['p', null, 'klm']]); - }); - - test('can export two paragraphs with inline formatting', () => { - const {editor, peritext} = setup(); - editor.cursor.setAt(10); - editor.saved.insMarker(CommonSliceType.p); - editor.cursor.setAt(6, 2); - editor.saved.insOverwrite(CommonSliceType.b); - editor.cursor.setAt(7, 2); - editor.saved.insOverwrite(CommonSliceType.i); - peritext.refresh(); - const fragment = peritext.fragment(peritext.rangeAt(4, 10)); - fragment.refresh(); - const html = toJsonMl(fragment.toJson()); - expect(html).toEqual([ - '', - null, - ['p', null, 'ef', ['b', null, 'g'], ['i', null, ['b', null, 'h']], ['i', null, 'i'], 'j'], - ['p', null, 'klm'], - ]); - }); + test('can export two paragraphs', () => { + const {editor, peritext} = setup(); + editor.cursor.setAt(10); + editor.saved.insMarker(CommonSliceType.p); + peritext.refresh(); + const fragment = peritext.fragment(peritext.rangeAt(4, 10)); + fragment.refresh(); + const node = fragment.toJson(); + expect(node).toEqual(['', null, [0, null, 'efghij'], [0, null, 'klm']]); }); - describe('HTML', () => { - test('can export two paragraphs', () => { - const {editor, peritext} = setup(); - editor.cursor.setAt(10); - editor.saved.insMarker(CommonSliceType.p); - peritext.refresh(); - const fragment = peritext.fragment(peritext.rangeAt(4, 10)); - fragment.refresh(); - const html = toHtml(fragment.toJson()); - expect(html).toBe('

efghij

klm

'); - }); - - test('can export two paragraphs (formatted)', () => { - const {editor, peritext} = setup(); - editor.cursor.setAt(10); - editor.saved.insMarker(CommonSliceType.p); - peritext.refresh(); - const fragment = peritext.fragment(peritext.rangeAt(4, 10)); - fragment.refresh(); - const html = toHtml(fragment.toJson(), ' '); - expect(html).toBe('

efghij

\n

klm

'); - }); - - test('can export two paragraphs (formatted and wrapped in
)', () => { - const {editor, peritext} = setup(); - editor.cursor.setAt(10); - editor.saved.insMarker(CommonSliceType.p); - peritext.refresh(); - const fragment = peritext.fragment(peritext.rangeAt(4, 10)); - fragment.refresh(); - const json = fragment.toJson(); - json[0] = 'div'; - const html = toHtml(json, ' '); - expect(html).toBe('
\n

efghij

\n

klm

\n
'); - }); - - test('can export two paragraphs with inline formatting', () => { - const {editor, peritext} = setup(); - editor.cursor.setAt(10); - editor.saved.insMarker(CommonSliceType.p); - editor.cursor.setAt(6, 2); - editor.saved.insOverwrite(CommonSliceType.b); - editor.cursor.setAt(7, 2); - editor.saved.insOverwrite(CommonSliceType.i); - peritext.refresh(); - const fragment = peritext.fragment(peritext.rangeAt(4, 10)); - fragment.refresh(); - const json = fragment.toJson(); - const html = toHtml(json, ''); - expect(html).toEqual('

efghij

klm

'); - }); - - test('can export two paragraphs with inline formatting (formatted)', () => { - const {editor, peritext} = setup(); - editor.cursor.setAt(10); - editor.saved.insMarker(CommonSliceType.p); - editor.cursor.setAt(6, 2); - editor.saved.insOverwrite(CommonSliceType.b); - editor.cursor.setAt(7, 2); - editor.saved.insOverwrite(CommonSliceType.i); - peritext.refresh(); - const fragment = peritext.fragment(peritext.rangeAt(4, 10)); - fragment.refresh(); - const json = fragment.toJson(); - const html = toHtml(json, ' '); - expect(html).toEqual( - '

\n ef\n g\n \n h\n \n i\n j\n

\n

klm

', - ); - }); - - test('can export two paragraphs with inline formatting (formatted, wrapped in
)', () => { - const {editor, peritext} = setup(); - editor.cursor.setAt(10); - editor.saved.insMarker(CommonSliceType.p); - editor.cursor.setAt(6, 2); - editor.saved.insOverwrite(CommonSliceType.b); - editor.cursor.setAt(7, 2); - editor.saved.insOverwrite(CommonSliceType.i); - peritext.refresh(); - const fragment = peritext.fragment(peritext.rangeAt(4, 10)); - fragment.refresh(); - const json = fragment.toJson(); - json[0] = 'div'; - const html = toHtml(json, ' '); - expect('\n' + html).toEqual(` -
-

- ef - g - - h - - i - j -

-

klm

-
`); - }); + test('can export two paragraphs with inline formatting', () => { + const {editor, peritext} = setup(); + editor.cursor.setAt(10); + editor.saved.insMarker(CommonSliceType.p); + editor.cursor.setAt(6, 2); + editor.saved.insOverwrite(CommonSliceType.b); + editor.cursor.setAt(7, 2); + editor.saved.insOverwrite(CommonSliceType.i); + peritext.refresh(); + const fragment = peritext.fragment(peritext.rangeAt(4, 10)); + fragment.refresh(); + const node = fragment.toJson(); + expect(node).toEqual([ + '', + null, + [ + 0, + null, + 'ef', + [-3, {inline: true}, 'g'], + [-4, {inline: true}, [-3, {inline: true}, 'h']], + [-4, {inline: true}, 'i'], + 'j', + ], + [0, null, 'klm'], + ]); }); }; diff --git a/src/json-crdt-extensions/peritext/block/__tests__/Fragment.spec.ts b/src/json-crdt-extensions/peritext/block/__tests__/Fragment.spec.ts index 9ad44f4b7e..34c5559b83 100644 --- a/src/json-crdt-extensions/peritext/block/__tests__/Fragment.spec.ts +++ b/src/json-crdt-extensions/peritext/block/__tests__/Fragment.spec.ts @@ -2,6 +2,9 @@ import {setupHelloWorldKit} from '../../__tests__/setup'; import {MarkerOverlayPoint} from '../../overlay/MarkerOverlayPoint'; import {Block} from '../Block'; import {LeafBlock} from '../LeafBlock'; +import {CommonSliceType} from '../../slice'; +import {type Kit, runAlphabetKitTestSuite} from '../../__tests__/setup'; +import {Inline} from '../Inline'; test('can construct block representation of a document without markers', () => { const {peritext} = setupHelloWorldKit(); @@ -43,3 +46,148 @@ test('first inline element does not contain marker text', () => { expect([...block1.texts()][0].text()).toBe('hello '); expect([...block2.texts()][0].text()).toBe('world'); }); + +const runTests = (setup: () => Kit) => { + describe('"abcdefghijklmnopqrstuvwxyz"', () => { + test('paragraph + blockquote with inline formatting', () => { + const {editor, peritext} = setup(); + editor.cursor.setAt(10); + editor.saved.insMarker(CommonSliceType.blockquote); + editor.cursor.setAt(13, 2); + editor.saved.insOverwrite(CommonSliceType.b); + peritext.refresh(); + const range = peritext.rangeAt(4, 10); + const fragment = peritext.fragment(range); + fragment.refresh(); + expect(fragment.root instanceof Block).toBe(true); + expect(fragment.root.children.length).toBe(2); + const [paragraph, blockquote] = fragment.root.children; + expect(paragraph instanceof LeafBlock).toBe(true); + expect(blockquote instanceof LeafBlock).toBe(true); + expect(paragraph.path).toEqual([CommonSliceType.p]); + expect(paragraph.text()).toBe('efghij'); + expect(blockquote.path).toEqual([CommonSliceType.blockquote]); + expect(blockquote.text()).toBe('klm'); + const [inline1, inline2] = [...blockquote.texts()]; + expect(inline1 instanceof Inline).toBe(true); + expect(inline2 instanceof Inline).toBe(true); + expect(inline1.text()).toBe('kl'); + expect(inline2.text()).toBe('m'); + expect(!!inline1.attr()[CommonSliceType.b]).toBe(false); + expect(!!inline2.attr()[CommonSliceType.b]).toBe(true); + }); + + test('explicit paragraph + blockquote with inline formatting', () => { + const {editor, peritext} = setup(); + editor.cursor.setAt(10); + editor.saved.insMarker(CommonSliceType.blockquote); + editor.cursor.setAt(13, 2); + editor.saved.insOverwrite(CommonSliceType.b); + editor.cursor.setAt(5); + editor.saved.insMarker(CommonSliceType.p); + peritext.refresh(); + const range = peritext.rangeAt(4, 11); + const fragment = peritext.fragment(range); + fragment.refresh(); + expect(fragment.root instanceof Block).toBe(true); + expect(fragment.root.children.length).toBe(3); + const [paragraph1, paragraph2, blockquote] = fragment.root.children; + expect(paragraph1 instanceof LeafBlock).toBe(true); + expect(paragraph2 instanceof LeafBlock).toBe(true); + expect(blockquote instanceof LeafBlock).toBe(true); + expect(paragraph1.path).toEqual([CommonSliceType.p]); + expect(paragraph1.text()).toBe('e'); + expect(paragraph2.path).toEqual([CommonSliceType.p]); + expect(paragraph2.text()).toBe('fghij'); + expect(blockquote.path).toEqual([CommonSliceType.blockquote]); + expect(blockquote.text()).toBe('klm'); + const [inline1, inline2] = [...blockquote.texts()]; + expect(inline1 instanceof Inline).toBe(true); + expect(inline2 instanceof Inline).toBe(true); + expect(inline1.text()).toBe('kl'); + expect(inline2.text()).toBe('m'); + expect(!!inline1.attr()[CommonSliceType.b]).toBe(false); + expect(!!inline2.attr()[CommonSliceType.b]).toBe(true); + }); + + test('explicit paragraph + blockquote with inline formatting (start middle of second paragraph)', () => { + const {editor, peritext} = setup(); + editor.cursor.setAt(10); + editor.saved.insMarker(CommonSliceType.blockquote); + editor.cursor.setAt(13, 2); + editor.saved.insOverwrite(CommonSliceType.b); + editor.cursor.setAt(5); + editor.saved.insMarker(CommonSliceType.p); + peritext.refresh(); + const range = peritext.rangeAt(7, 8); + const fragment = peritext.fragment(range); + fragment.refresh(); + expect(fragment.root instanceof Block).toBe(true); + expect(fragment.root.children.length).toBe(2); + const [paragraph1, blockquote] = fragment.root.children; + expect(paragraph1 instanceof LeafBlock).toBe(true); + expect(blockquote instanceof LeafBlock).toBe(true); + expect(paragraph1.path).toEqual([CommonSliceType.p]); + expect(paragraph1.text()).toBe('ghij'); + expect(blockquote.path).toEqual([CommonSliceType.blockquote]); + expect(blockquote.text()).toBe('klm'); + const [inline1, inline2] = [...blockquote.texts()]; + expect(inline1 instanceof Inline).toBe(true); + expect(inline2 instanceof Inline).toBe(true); + expect(inline1.text()).toBe('kl'); + expect(inline2.text()).toBe('m'); + expect(!!inline1.attr()[CommonSliceType.b]).toBe(false); + expect(!!inline2.attr()[CommonSliceType.b]).toBe(true); + }); + + test('explicit paragraph + blockquote with inline formatting (start and end in the middle of second paragraph)', () => { + const {editor, peritext} = setup(); + editor.cursor.setAt(10); + editor.saved.insMarker(CommonSliceType.blockquote); + editor.cursor.setAt(13, 2); + editor.saved.insOverwrite(CommonSliceType.b); + editor.cursor.setAt(5); + editor.saved.insMarker(CommonSliceType.p); + peritext.refresh(); + const range = peritext.rangeAt(7, 2); + const fragment = peritext.fragment(range); + fragment.refresh(); + expect(fragment.root instanceof Block).toBe(true); + expect(fragment.root.children.length).toBe(1); + const [paragraph1] = fragment.root.children; + expect(paragraph1 instanceof LeafBlock).toBe(true); + expect(paragraph1.path).toEqual([CommonSliceType.p]); + expect(paragraph1.text()).toBe('gh'); + expect([...paragraph1.texts()].length).toBe(1); + const [inline1] = [...paragraph1.texts()]; + expect(inline1.text()).toBe('gh'); + }); + + test('explicit paragraph + blockquote with inline formatting (end before blockquote)', () => { + const {editor, peritext} = setup(); + editor.cursor.setAt(10); + editor.saved.insMarker(CommonSliceType.blockquote); + editor.cursor.setAt(13, 2); + editor.saved.insOverwrite(CommonSliceType.b); + editor.cursor.setAt(5); + editor.saved.insMarker(CommonSliceType.p); + peritext.refresh(); + const range = peritext.rangeAt(2, 6); + const fragment = peritext.fragment(range); + fragment.refresh(); + expect(fragment.root instanceof Block).toBe(true); + expect(fragment.root.children.length).toBe(2); + const [paragraph1, paragraph2] = fragment.root.children; + expect(paragraph1 instanceof LeafBlock).toBe(true); + expect(paragraph2 instanceof LeafBlock).toBe(true); + expect(paragraph1.path).toEqual([CommonSliceType.p]); + expect(paragraph1.text()).toBe('cde'); + expect(paragraph2.path).toEqual([CommonSliceType.p]); + expect(paragraph2.text()).toBe('fg'); + }); + }); +}; + +describe('Fragment.toJson()', () => { + runAlphabetKitTestSuite(runTests); +}); diff --git a/src/json-crdt-extensions/peritext/lazy/README.md b/src/json-crdt-extensions/peritext/lazy/README.md new file mode 100644 index 0000000000..342d64635f --- /dev/null +++ b/src/json-crdt-extensions/peritext/lazy/README.md @@ -0,0 +1,2 @@ +Useful features which can be lazy-loaded (dynamically imported) separately from +the main codebase. diff --git a/src/json-crdt-extensions/peritext/export/export.ts b/src/json-crdt-extensions/peritext/lazy/export-html.ts similarity index 55% rename from src/json-crdt-extensions/peritext/export/export.ts rename to src/json-crdt-extensions/peritext/lazy/export-html.ts index 644161ef32..c15fb776fd 100644 --- a/src/json-crdt-extensions/peritext/export/export.ts +++ b/src/json-crdt-extensions/peritext/lazy/export-html.ts @@ -1,6 +1,8 @@ import {SliceTypeName} from '../slice'; -import {toHtml as _toHtml} from '../../../json-ml/toHtml'; -import type {JsonMlNode} from '../../../json-ml'; +import {toText as _toHtml} from 'very-small-parser/lib/html/toText'; +import {toHast as _toHast} from 'very-small-parser/lib/html/json-ml/toHast'; +import type {JsonMlNode} from 'very-small-parser/lib/html/json-ml/types'; +import type {THtmlToken} from 'very-small-parser/lib/html/types'; import type {PeritextMlNode} from '../block/types'; export const toJsonMl = (json: PeritextMlNode): JsonMlNode => { @@ -15,7 +17,12 @@ export const toJsonMl = (json: PeritextMlNode): JsonMlNode => { return htmlNode; }; -export const toHtml = (json: PeritextMlNode, tab?: string): string => { +export const toHast = (json: PeritextMlNode): THtmlToken => { const jsonml = toJsonMl(json); - return _toHtml(jsonml, tab); + // console.log(jsonml); + const hast = _toHast(jsonml); + return hast; }; + +export const toHtml = (json: PeritextMlNode, tab?: string, indent?: string): string => + _toHtml(toHast(json), tab, indent); diff --git a/src/json-crdt-extensions/peritext/lazy/export-markdown.ts b/src/json-crdt-extensions/peritext/lazy/export-markdown.ts new file mode 100644 index 0000000000..e9b5aa78f3 --- /dev/null +++ b/src/json-crdt-extensions/peritext/lazy/export-markdown.ts @@ -0,0 +1,15 @@ +import {toHast} from './export-html'; +import {toMdast as _toMdast} from 'very-small-parser/lib/html/toMdast'; +import {toText as _toMarkdown} from 'very-small-parser/lib/markdown/block/toText'; +import type {IRoot} from 'very-small-parser/lib/markdown/block/types'; +import type {PeritextMlNode} from '../block/types'; + +export const toMdast = (json: PeritextMlNode): IRoot => { + const hast = toHast(json); + // console.log(hast); + const mdast = _toMdast(hast) as IRoot; + // console.log(mdast); + return mdast; +}; + +export const toMarkdown = (json: PeritextMlNode): string => _toMarkdown(toMdast(json)); diff --git a/src/json-crdt-extensions/peritext/overlay/Overlay.ts b/src/json-crdt-extensions/peritext/overlay/Overlay.ts index e805003ba3..f3cbc79f54 100644 --- a/src/json-crdt-extensions/peritext/overlay/Overlay.ts +++ b/src/json-crdt-extensions/peritext/overlay/Overlay.ts @@ -232,8 +232,8 @@ export class Overlay implements Printable, Stateful { }; } - public markers(): UndefEndIter> { - return new UndefEndIter(this.markers0(undefined)); + public markers(after?: undefined | MarkerOverlayPoint): UndefEndIter> { + return new UndefEndIter(this.markers0(after)); } /** @@ -245,24 +245,54 @@ export class Overlay implements Printable, Stateful { * @returns All marker points in the overlay, starting from the given marker * point. */ - public markers1(point: Point): UndefIterator> { + public markersFrom0(point: Point): UndefIterator> { if (point.isAbsStart()) return this.markers0(undefined); let after = this.getOrNextLowerMarker(point); if (after && after.cmp(point) === 0) after = prev2(after); return this.markers0(after); } + /** + * Returns a pair of overlay marker points for each pair of adjacent marker + * points in the overlay, starting from a given point (which may not be a + * marker). The very first point in the first pair might be `undefined`, if + * the given point is not a marker. Similarly, the very last point in the last + * pair might be `undefined`, if the iteration end point is not a marker. + * + * @param start Start point of the iteration, inclusive. + * @param end End point of the iteration. If not provided, the iteration + * continues until the end of the overlay. + * @returns Iterator that returns pairs of overlay points. + */ public markerPairs0(start: Point, end?: Point): UndefIterator> { - const i = this.markers1(start); - let one: MarkerOverlayPoint | undefined = i(); - let two: MarkerOverlayPoint | undefined = i(); + const i = this.markersFrom0(start); + let closed = false; + let p1: MarkerOverlayPoint | undefined; + let p2: MarkerOverlayPoint | undefined = i(); + if (p2) { + if (p2.isAbsStart() || !p2.cmp(start)) { + p1 = p2; + p2 = i(); + } + if (end && p2) { + const cmp = end.cmpSpatial(p2); + if (cmp <= 0) return () => (closed ? void 0 : ((closed = true), [p1, cmp ? void 0 : p2])); + } + } return () => { - if (!one) return; - if (end && end.cmpSpatial(one) <= 0) return (one = void 0); - const ret: MarkerOverlayPair = [one, two]; - one = two; - two = i(); - return ret; + if (closed) return; + if (!p2 || p2.isAbsEnd()) return (closed = true), [p1, p2]; + else if (p2 && end) { + const cmp = end.cmpSpatial(p2); + if (cmp <= 0) { + closed = true; + return [p1, cmp ? void 0 : p2]; + } + } + const result: MarkerOverlayPair = [p1, p2]; + p1 = p2; + p2 = i(); + return result; }; } diff --git a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.markers.spec.ts b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.markers.spec.ts index f02b08baa3..13abce5ef4 100644 --- a/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.markers.spec.ts +++ b/src/json-crdt-extensions/peritext/overlay/__tests__/Overlay.markers.spec.ts @@ -1,11 +1,6 @@ -import { - type Kit, - setupNumbersKit, - setupNumbersWithMultipleChunksAndDeletesKit, - setupNumbersWithRgaSplitKit, - setupNumbersWithTombstonesKit, - setupNumbersWithTwoChunksKit, -} from '../../__tests__/setup'; +import {UndefEndIter} from '../../../../util/iterator'; +import {type Kit, runNumbersKitTestSuite} from '../../__tests__/setup'; +import type {Point} from '../../rga/Point'; import {MarkerOverlayPoint} from '../MarkerOverlayPoint'; const runMarkersTests = (setup: () => Kit) => { @@ -107,24 +102,585 @@ const runMarkersTests = (setup: () => Kit) => { expect([...peritext.overlay.markers()].length).toBe(0); }); }); -}; -describe('numbers "0123456789", no edits', () => { - runMarkersTests(setupNumbersKit); -}); + describe('.markersFrom0()', () => { + test('returns empty set by default', () => { + const {peritext} = setup(); + peritext.overlay.refresh(); + const point = peritext.pointAt(3); + const list = [...new UndefEndIter(peritext.overlay.markersFrom0(point))]; + expect(list.length).toBe(0); + }); + + test('returns a single marker (when point equals marker position)', () => { + const {peritext, editor} = setup(); + editor.cursor.setAt(3); + editor.saved.insMarker(''); + peritext.overlay.refresh(); + const point = peritext.pointAt(3); + const list = [...new UndefEndIter(peritext.overlay.markersFrom0(point))]; + expect(list.length).toBe(1); + expect(list[0] instanceof MarkerOverlayPoint).toBe(true); + }); + + test('returns a single marker (when point is before marker position)', () => { + const {peritext, editor} = setup(); + editor.cursor.setAt(3); + editor.saved.insMarker(''); + peritext.overlay.refresh(); + const point = peritext.pointAt(1); + const list = [...new UndefEndIter(peritext.overlay.markersFrom0(point))]; + expect(list.length).toBe(1); + expect(list[0] instanceof MarkerOverlayPoint).toBe(true); + }); + + test('can iterate through multiple markers', () => { + const {peritext, editor} = setup(); + editor.cursor.setAt(5); + const [m2] = editor.saved.insMarker(''); + peritext.overlay.refresh(); + editor.cursor.setAt(8); + const [m3] = editor.local.insMarker(''); + peritext.overlay.refresh(); + editor.cursor.setAt(2); + const [m1] = editor.local.insMarker(''); + peritext.overlay.refresh(); + const point = peritext.pointAt(1); + const list = [...new UndefEndIter(peritext.overlay.markersFrom0(point))]; + 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); + }); + + test('can iterate through multiple markers (ABS start as starting point)', () => { + const {peritext, editor} = setup(); + editor.cursor.setAt(5); + const [m2] = editor.saved.insMarker(''); + peritext.overlay.refresh(); + editor.cursor.setAt(8); + const [m3] = editor.local.insMarker(''); + peritext.overlay.refresh(); + editor.cursor.setAt(2); + const [m1] = editor.local.insMarker(''); + peritext.overlay.refresh(); + const point = peritext.pointAbsStart(); + const list = [...new UndefEndIter(peritext.overlay.markersFrom0(point))]; + 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); + }); + + test('can add marker at the REL start of text', () => { + const {peritext, editor} = setup(); + editor.cursor.setAt(0); + const [marker] = editor.extra.insMarker(0); + peritext.overlay.refresh(); + const point = peritext.pointAbsStart(); + const list = [...new UndefEndIter(peritext.overlay.markersFrom0(point))]; + expect(list.length).toBe(1); + expect(list[0].marker).toBe(marker); + editor.extra.del(marker); + peritext.overlay.refresh(); + expect([...peritext.overlay.markers()].length).toBe(0); + }); + + test('can add marker at the ABS start of text', () => { + const {peritext, editor} = setup(); + editor.cursor.set(peritext.pointAbsStart()); + const [marker] = editor.extra.insMarker(0); + peritext.overlay.refresh(); + const point = peritext.pointAbsStart(); + const list = [...new UndefEndIter(peritext.overlay.markersFrom0(point))]; + expect(list.length).toBe(1); + expect(list[0].marker).toBe(marker); + editor.extra.del(marker); + peritext.overlay.refresh(); + expect([...peritext.overlay.markers()].length).toBe(0); + }); + + test('can add marker at the REL end of text', () => { + const {peritext, editor} = setup(); + editor.cursor.set(peritext.pointEnd()!); + const [marker] = editor.extra.insMarker('0'); + peritext.overlay.refresh(); + const point = peritext.pointAt(1); + const list = [...new UndefEndIter(peritext.overlay.markersFrom0(point))]; + expect(list.length).toBe(1); + expect(list[0].marker).toBe(marker); + editor.extra.del(marker); + peritext.overlay.refresh(); + expect([...peritext.overlay.markers()].length).toBe(0); + }); + + test('can add marker at the ABS end of text', () => { + const {peritext, editor} = setup(); + editor.cursor.set(peritext.pointAbsEnd()); + const [marker] = editor.extra.insMarker('0'); + peritext.overlay.refresh(); + const point = peritext.pointAt(1); + const list = [...new UndefEndIter(peritext.overlay.markersFrom0(point))]; + expect(list.length).toBe(1); + expect(list[0].marker).toBe(marker); + editor.extra.del(marker); + peritext.overlay.refresh(); + expect([...peritext.overlay.markers()].length).toBe(0); + }); + }); + + describe('.markerPairs0()', () => { + describe('no markers', () => { + test('starting point in string', () => { + const {peritext} = setup(); + peritext.overlay.refresh(); + const point = peritext.pointAt(3); + const iterator = peritext.overlay.markerPairs0(point); + const list = [...new UndefEndIter(iterator)]; + expect(list.length).toBe(1); + expect(list[0]).toEqual([undefined, undefined]); + }); + + test('starting point ABS start', () => { + const {peritext} = setup(); + peritext.overlay.refresh(); + const point = peritext.pointAbsStart(); + const iterator = peritext.overlay.markerPairs0(point); + const list = [...new UndefEndIter(iterator)]; + expect(list.length).toBe(1); + expect(list[0]).toEqual([undefined, undefined]); + }); + + test('starting point and ending point in string', () => { + const {peritext} = setup(); + peritext.overlay.refresh(); + const start = peritext.pointAt(2); + const end = peritext.pointAt(4); + const iterator = peritext.overlay.markerPairs0(start, end); + const list = [...new UndefEndIter(iterator)]; + expect(list.length).toBe(1); + expect(list[0]).toEqual([undefined, undefined]); + }); + + test('starting point and ending point in string, and the same', () => { + const {peritext} = setup(); + peritext.overlay.refresh(); + const start = peritext.pointAt(2); + const end = peritext.pointAt(2); + const iterator = peritext.overlay.markerPairs0(start, end); + const list = [...new UndefEndIter(iterator)]; + expect(list.length).toBe(1); + expect(list[0]).toEqual([undefined, undefined]); + }); + + test('ending point is ABS end', () => { + const {peritext} = setup(); + peritext.overlay.refresh(); + const start = peritext.pointAt(2); + const end = peritext.pointAbsEnd(); + const iterator = peritext.overlay.markerPairs0(start, end); + const list = [...new UndefEndIter(iterator)]; + expect(list.length).toBe(1); + expect(list[0]).toEqual([undefined, undefined]); + }); + + test('both endpoints are ABS', () => { + const {peritext} = setup(); + peritext.overlay.refresh(); + const start = peritext.pointAbsStart(); + const end = peritext.pointAbsEnd(); + const iterator = peritext.overlay.markerPairs0(start, end); + const list = [...new UndefEndIter(iterator)]; + expect(list.length).toBe(1); + expect(list[0]).toEqual([undefined, undefined]); + }); + + test('both endpoints are ABS start', () => { + const {peritext} = setup(); + peritext.overlay.refresh(); + const start = peritext.pointAbsStart(); + const end = peritext.pointAbsStart(); + const iterator = peritext.overlay.markerPairs0(start, end); + const list = [...new UndefEndIter(iterator)]; + expect(list.length).toBe(1); + expect(list[0]).toEqual([undefined, undefined]); + }); + + test('both endpoints are ABS end', () => { + const {peritext} = setup(); + peritext.overlay.refresh(); + const start = peritext.pointAbsEnd(); + const end = peritext.pointAbsEnd(); + const iterator = peritext.overlay.markerPairs0(start, end); + const list = [...new UndefEndIter(iterator)]; + expect(list.length).toBe(1); + expect(list[0]).toEqual([undefined, undefined]); + }); + }); + + describe('one marker', () => { + const create = () => { + const kit = setup(); + kit.editor.cursor.setAt(5); + kit.editor.saved.insMarker(''); + kit.peritext.overlay.refresh(); + return kit; + }; + + test('start before marker', () => { + const {peritext} = create(); + const point = peritext.pointAt(1); + const list = [...new UndefEndIter(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[1][1]).toBe(undefined); + expect(list[0][1]).toBe(list[1][0]); + }); + + test('start point equals marker', () => { + const {peritext} = create(); + const point = peritext.pointAt(5); + const list = [...new UndefEndIter(peritext.overlay.markerPairs0(point))]; + expect(list.length).toBe(1); + expect(list[0][0]).toBeInstanceOf(MarkerOverlayPoint); + expect(list[0][1]).toBe(undefined); + }); + + test('start point after marker', () => { + const {peritext} = create(); + const point = peritext.pointAt(6); + const list = [...new UndefEndIter(peritext.overlay.markerPairs0(point))]; + expect(list.length).toBe(1); + expect(list[0][0]).toBe(undefined); + expect(list[0][1]).toBe(undefined); + }); + + test('start and end points before marker', () => { + const {peritext} = create(); + const start = peritext.pointAt(1); + const end = peritext.pointAt(3); + const iterator = peritext.overlay.markerPairs0(start, end); + const list = [...new UndefEndIter(iterator)]; + expect(list.length).toBe(1); + expect(list[0][0]).toBe(undefined); + expect(list[0][1]).toBe(undefined); + }); + + test('start point before marker, end point after marker', () => { + const {peritext} = create(); + const start = peritext.pointAt(2); + const end = peritext.pointAt(7); + const iterator = peritext.overlay.markerPairs0(start, end); + const list = [...new UndefEndIter(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[1][1]).toBe(undefined); + }); + + test('start at REL start, end point at REL end', () => { + const {peritext} = create(); + const start = peritext.pointStart()!; + const end = peritext.pointEnd()!; + const iterator = peritext.overlay.markerPairs0(start, end); + const list = [...new UndefEndIter(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[1][1]).toBe(undefined); + }); + + test('start at ABS start, end point at ABS end', () => { + const {peritext} = create(); + const start = peritext.pointAbsStart(); + const end = peritext.pointAbsEnd(); + const iterator = peritext.overlay.markerPairs0(start, end); + const list = [...new UndefEndIter(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[1][1]).toBe(undefined); + }); + + test('start and end points after marker', () => { + const {peritext} = create(); + const start = peritext.pointAt(6); + const end = peritext.pointAt(7); + const iterator = peritext.overlay.markerPairs0(start, end); + const list = [...new UndefEndIter(iterator)]; + expect(list.length).toBe(1); + expect(list[0][0]).toBe(undefined); + expect(list[0][1]).toBe(undefined); + }); + + test('start and end points after marker (collapsed at same point)', () => { + const {peritext} = create(); + const start = peritext.pointAt(7); + const end = peritext.pointAt(7); + const iterator = peritext.overlay.markerPairs0(start, end); + const list = [...new UndefEndIter(iterator)]; + expect(list.length).toBe(1); + expect(list[0][0]).toBe(undefined); + expect(list[0][1]).toBe(undefined); + }); + + test('start and end points before marker (collapsed at same point)', () => { + const {peritext} = create(); + const start = peritext.pointAt(2); + const end = peritext.pointAt(2); + const iterator = peritext.overlay.markerPairs0(start, end); + const list = [...new UndefEndIter(iterator)]; + expect(list.length).toBe(1); + expect(list[0][0]).toBe(undefined); + expect(list[0][1]).toBe(undefined); + }); + + test('start and end points exactly at marker (collapsed at same point)', () => { + const {peritext} = create(); + const start = peritext.pointAt(5); + const end = peritext.pointAt(5); + const iterator = peritext.overlay.markerPairs0(start, end); + const list = [...new UndefEndIter(iterator)]; + expect(list.length).toBe(1); + expect(list[0][0]).toBeInstanceOf(MarkerOverlayPoint); + expect(list[0][1]).toBe(undefined); + }); + + test('start and end points exactly at ABS start', () => { + const {peritext} = create(); + const start = peritext.pointAbsStart(); + const end = peritext.pointAbsStart(); + const iterator = peritext.overlay.markerPairs0(start, end); + const list = [...new UndefEndIter(iterator)]; + expect(list.length).toBe(1); + expect(list[0][0]).toBe(undefined); + expect(list[0][1]).toBe(undefined); + }); + + test('start and end points exactly at REL start', () => { + const {peritext} = create(); + const start = peritext.pointStart()!; + const end = peritext.pointStart()!; + const iterator = peritext.overlay.markerPairs0(start, end); + const list = [...new UndefEndIter(iterator)]; + expect(list.length).toBe(1); + expect(list[0][0]).toBe(undefined); + expect(list[0][1]).toBe(undefined); + }); + + test('start and end points exactly at ABS end', () => { + const {peritext} = create(); + const start = peritext.pointAbsEnd(); + const end = peritext.pointAbsEnd(); + const iterator = peritext.overlay.markerPairs0(start, end); + const list = [...new UndefEndIter(iterator)]; + expect(list.length).toBe(1); + expect(list[0][0]).toBe(undefined); + expect(list[0][1]).toBe(undefined); + }); + + test('start and end points exactly at REL end', () => { + const {peritext} = create(); + const start = peritext.pointEnd()!; + const end = peritext.pointEnd()!; + const iterator = peritext.overlay.markerPairs0(start, end); + const list = [...new UndefEndIter(iterator)]; + expect(list.length).toBe(1); + expect(list[0][0]).toBe(undefined); + expect(list[0][1]).toBe(undefined); + }); + }); -describe('numbers "0123456789", with default schema and tombstones', () => { - runMarkersTests(setupNumbersWithTombstonesKit); -}); + describe('multiple markers', () => { + const create = (start: (kit: Kit) => Point, end?: (kit: Kit) => Point) => { + const kit = setup(); + kit.editor.cursor.setAt(5); + const [m2] = kit.editor.saved.insMarker(''); + kit.peritext.overlay.refresh(); + kit.editor.cursor.setAt(7); + const [m3] = kit.editor.local.insMarker(''); + kit.peritext.overlay.refresh(); + kit.editor.cursor.setAt(3); + const [m1] = kit.editor.local.insMarker(''); + kit.peritext.overlay.refresh(); + const iterator = kit.peritext.overlay.markerPairs0(start(kit), end?.(kit)); + const list = [...new UndefEndIter(iterator)]; + const markers = [...kit.peritext.overlay.markers()]; + return {...kit, m1, m2, m3, list, markers}; + }; -describe('numbers "0123456789", two RGA chunks', () => { - runMarkersTests(setupNumbersWithTwoChunksKit); -}); + const assertAllPoints = (kit: ReturnType) => { + const {list, markers} = kit; + expect(list.length).toBe(4); + expect(list[0][0]).toBe(undefined); + expect(list[0][1]).toBe(markers[0]); + expect(list[1][0]).toBe(markers[0]); + expect(list[1][1]).toBe(markers[1]); + expect(list[2][0]).toBe(markers[1]); + expect(list[2][1]).toBe(markers[2]); + expect(list[3][0]).toBe(markers[2]); + expect(list[3][1]).toBe(undefined); + }; -describe('numbers "0123456789", with RGA split', () => { - runMarkersTests(setupNumbersWithRgaSplitKit); -}); + test('start before all markers', () => { + const kit = create((kit) => kit.peritext.pointAt(1)); + assertAllPoints(kit); + }); + + test('start at ABS start', () => { + const kit = create((kit) => kit.peritext.pointAbsStart()); + assertAllPoints(kit); + }); + + test('start at REL start', () => { + const kit = create((kit) => kit.peritext.pointStart()!); + assertAllPoints(kit); + }); + + test('start half-point before first marker', () => { + const kit = create((kit) => { + const point = kit.peritext.pointAt(3); + point.halfstep(-1); + return point; + }); + assertAllPoints(kit); + }); + + test('end after all markers', () => { + const kit = create( + (kit) => kit.peritext.pointAt(1), + (kit) => kit.peritext.pointAt(10), + ); + assertAllPoints(kit); + }); + + test('end at ABS end', () => { + const kit = create( + (kit) => kit.peritext.pointAt(1), + (kit) => kit.peritext.pointAbsEnd(), + ); + assertAllPoints(kit); + }); + + test('end at REL end', () => { + const kit = create( + (kit) => kit.peritext.pointAt(1), + (kit) => kit.peritext.pointEnd()!, + ); + assertAllPoints(kit); + }); + + test('end half-point after last marker', () => { + const kit = create( + (kit) => kit.peritext.pointAbsStart(), + (kit) => { + const point = kit.peritext.pointAt(8); + point.halfstep(1); + return point; + }, + ); + assertAllPoints(kit); + }); + + test('start and end at ABS endpoints', () => { + const kit = create( + (kit) => kit.peritext.pointAbsStart(), + (kit) => kit.peritext.pointAbsEnd(), + ); + assertAllPoints(kit); + }); + + test('start and end at REL endpoints', () => { + const kit = create( + (kit) => kit.peritext.pointStart()!, + (kit) => kit.peritext.pointEnd()!, + ); + assertAllPoints(kit); + }); + + test('start point past the first marker', () => { + const {list, markers} = create((kit) => kit.peritext.pointAt(4)); + expect(list.length).toBe(3); + expect(list[0][0]).toBe(undefined); + expect(list[0][1]).toBe(markers[1]); + expect(list[1][0]).toBe(markers[1]); + expect(list[1][1]).toBe(markers[2]); + expect(list[2][0]).toBe(markers[2]); + expect(list[2][1]).toBe(undefined); + }); + + test('start point past the first marker, end point ahead of last marker', () => { + const {list, markers} = create( + (kit) => kit.peritext.pointAt(4), + (kit) => { + const point = kit.peritext.pointAt(7); + return point; + }, + ); + expect(list.length).toBe(2); + expect(list[0][0]).toBe(undefined); + expect(list[0][1]).toBe(markers[1]); + expect(list[1][0]).toBe(markers[1]); + expect(list[1][1]).toBe(undefined); + }); + + test('start point past the first marker, end point right on second marker', () => { + const {list, markers} = create( + (kit) => kit.peritext.pointAt(4), + (kit) => { + const end = kit.peritext.pointAt(6); + return end; + }, + ); + expect(list.length).toBe(1); + expect(list[0][0]).toBe(undefined); + expect(list[0][1]).toBe(markers[1]); + }); + + test('start point right on first maker, end point past the first marker', () => { + const {list, markers} = create( + (kit) => kit.peritext.pointAt(3), + (kit) => kit.peritext.pointAt(4), + ); + expect(list.length).toBe(1); + expect(list[0][0]).toBe(markers[0]); + expect(list[0][1]).toBe(undefined); + }); + + test('start point right on first maker, end point past the second marker', () => { + const {list, markers} = create( + (kit) => kit.peritext.pointAt(3), + (kit) => kit.peritext.pointAt(7), + ); + expect(list.length).toBe(2); + expect(list[0][0]).toBe(markers[0]); + expect(list[0][1]).toBe(markers[1]); + expect(list[1][0]).toBe(markers[1]); + expect(list[1][1]).toBe(undefined); + }); + + test('start point right on first maker, at REL end', () => { + const {list, markers} = create( + (kit) => kit.peritext.pointAt(3), + (kit) => kit.peritext.pointEnd()!, + ); + expect(list.length).toBe(3); + expect(list[0][0]).toBe(markers[0]); + expect(list[0][1]).toBe(markers[1]); + expect(list[1][0]).toBe(markers[1]); + expect(list[1][1]).toBe(markers[2]); + expect(list[2][0]).toBe(markers[2]); + expect(list[2][1]).toBe(undefined); + }); + }); + }); +}; -describe('numbers "0123456789", with multiple deletes', () => { - runMarkersTests(setupNumbersWithMultipleChunksAndDeletesKit); -}); +runNumbersKitTestSuite(runMarkersTests); diff --git a/src/json-crdt-extensions/peritext/overlay/types.ts b/src/json-crdt-extensions/peritext/overlay/types.ts index 0ed3b06782..8e8b42c0ab 100644 --- a/src/json-crdt-extensions/peritext/overlay/types.ts +++ b/src/json-crdt-extensions/peritext/overlay/types.ts @@ -17,4 +17,12 @@ export type OverlayPair = [p1: OverlayPoint | undefined, p2: OverlayPoint< */ 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/yarn.lock b/yarn.lock index 92defda14a..a815400dd0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2181,7 +2181,6 @@ dunder-proto@^1.0.0: "editing-traces@https://github.com/streamich/editing-traces#6494020428530a6e382378b98d1d7e31334e2d7b": version "0.0.0" - uid "6494020428530a6e382378b98d1d7e31334e2d7b" resolved "https://github.com/streamich/editing-traces#6494020428530a6e382378b98d1d7e31334e2d7b" ee-first@1.1.1: @@ -3522,7 +3521,6 @@ jsesc@^3.0.2: "json-crdt-traces@https://github.com/streamich/json-crdt-traces#ec825401dc05cbb74b9e0b3c4d6527399f54d54d": version "0.0.1" - uid ec825401dc05cbb74b9e0b3c4d6527399f54d54d resolved "https://github.com/streamich/json-crdt-traces#ec825401dc05cbb74b9e0b3c4d6527399f54d54d" json-logic-js@^2.0.2: @@ -5512,6 +5510,11 @@ vary@~1.1.2: resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== +very-small-parser@^1.8.0: + version "1.8.0" + resolved "https://registry.yarnpkg.com/very-small-parser/-/very-small-parser-1.8.0.tgz#322ec046d396fc67376fd61c30fa8635e562636f" + integrity sha512-UIN0iEqOiTheG+VpfKNZq+E3tCp7sBir/JROC9w6vS5rAW8ha+hX4OlIkwLymhcfkN44sHktXfkUqNCCljQYEw== + walker@^1.0.8: version "1.0.8" resolved "https://registry.yarnpkg.com/walker/-/walker-1.0.8.tgz#bd498db477afe573dc04185f011d3ab8a8d7653f"