From 519115a2263777b69dbc88e603fdf2d3a6975e5e Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Mon, 25 Nov 2024 14:23:32 +0100 Subject: [PATCH 01/15] =?UTF-8?q?feat(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=8E=B8=20start=20fragment=20serialization=20implementatio?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../peritext/block/Block.ts | 6 +++- .../peritext/block/Fragment.ts | 2 +- .../peritext/block/Inline.ts | 3 +- .../peritext/block/LeafBlock.ts | 13 +++++++ ...fresh.spec.ts => Fragment-refresh.spec.ts} | 0 .../block/__tests__/Fragment-toJsonMl.spec.ts | 36 +++++++++++++++++++ 6 files changed, 57 insertions(+), 3 deletions(-) rename src/json-crdt-extensions/peritext/block/__tests__/{Fragment.refresh.spec.ts => Fragment-refresh.spec.ts} (100%) create mode 100644 src/json-crdt-extensions/peritext/block/__tests__/Fragment-toJsonMl.spec.ts diff --git a/src/json-crdt-extensions/peritext/block/Block.ts b/src/json-crdt-extensions/peritext/block/Block.ts index 5f5483e782..ce55fa4c1a 100644 --- a/src/json-crdt-extensions/peritext/block/Block.ts +++ b/src/json-crdt-extensions/peritext/block/Block.ts @@ -144,7 +144,11 @@ export class Block extends Range implements IBlock, Printable, S // ------------------------------------------------------------------- export toJsonMl(): JsonMlNode { - throw new Error('not implemented'); + let node: JsonMlNode = ['div', {}]; + const children = this.children; + const length = children.length; + for (let i = 0; i < length; i++) node.push(children[i].toJsonMl()); + return node; } // ----------------------------------------------------------------- Stateful diff --git a/src/json-crdt-extensions/peritext/block/Fragment.ts b/src/json-crdt-extensions/peritext/block/Fragment.ts index 69c913ba35..8f86984c24 100644 --- a/src/json-crdt-extensions/peritext/block/Fragment.ts +++ b/src/json-crdt-extensions/peritext/block/Fragment.ts @@ -33,7 +33,7 @@ export class Fragment extends Range implements Printable, Stateful { // ------------------------------------------------------------------- export toJsonMl(): JsonMlNode { - throw new Error('not implemented'); + return this.root.toJsonMl(); } // ---------------------------------------------------------------- Printable diff --git a/src/json-crdt-extensions/peritext/block/Inline.ts b/src/json-crdt-extensions/peritext/block/Inline.ts index 726c2c3ea7..27a66dbc9f 100644 --- a/src/json-crdt-extensions/peritext/block/Inline.ts +++ b/src/json-crdt-extensions/peritext/block/Inline.ts @@ -246,7 +246,8 @@ export class Inline extends Range implements Printable { // ------------------------------------------------------------------- export toJsonMl(): JsonMlNode { - throw new Error('not implemented'); + let node: JsonMlNode = this.text(); + return node; } // ---------------------------------------------------------------- Printable diff --git a/src/json-crdt-extensions/peritext/block/LeafBlock.ts b/src/json-crdt-extensions/peritext/block/LeafBlock.ts index 07e5ed02ae..95b70cfcb0 100644 --- a/src/json-crdt-extensions/peritext/block/LeafBlock.ts +++ b/src/json-crdt-extensions/peritext/block/LeafBlock.ts @@ -1,6 +1,7 @@ import {printTree} from 'tree-dump/lib/printTree'; import {Block} from './Block'; import type {Path} from '@jsonjoy.com/json-pointer'; +import type {JsonMlNode} from '../../../json-ml'; export interface IBlock { readonly path: Path; @@ -9,6 +10,18 @@ export interface IBlock { } export class LeafBlock extends Block { + + // ------------------------------------------------------------------- export + + toJsonMl(): JsonMlNode { + let node: JsonMlNode = ['div', {}]; + for (const inline of this.texts()) { + const span = inline.toJsonMl(); + if (span) node.push(span); + } + return node; + } + // ---------------------------------------------------------------- Printable public toStringName(): string { return 'LeafBlock'; diff --git a/src/json-crdt-extensions/peritext/block/__tests__/Fragment.refresh.spec.ts b/src/json-crdt-extensions/peritext/block/__tests__/Fragment-refresh.spec.ts similarity index 100% rename from src/json-crdt-extensions/peritext/block/__tests__/Fragment.refresh.spec.ts rename to src/json-crdt-extensions/peritext/block/__tests__/Fragment-refresh.spec.ts diff --git a/src/json-crdt-extensions/peritext/block/__tests__/Fragment-toJsonMl.spec.ts b/src/json-crdt-extensions/peritext/block/__tests__/Fragment-toJsonMl.spec.ts new file mode 100644 index 0000000000..98ac6d7272 --- /dev/null +++ b/src/json-crdt-extensions/peritext/block/__tests__/Fragment-toJsonMl.spec.ts @@ -0,0 +1,36 @@ +import { + type Kit, + setupAlphabetKit, +} from '../../__tests__/setup'; + +const runTests = (setup: () => Kit) => { + test('...', () => { + const {editor, peritext} = setup(); + editor.cursor.setAt(10); + editor.saved.insMarker(['p'], 'p1'); + peritext.refresh(); + const fragment = peritext.fragment(peritext.rangeAt(4, 10)); + fragment.refresh(); + + console.log(fragment + '') + console.log(fragment.toJsonMl()); + }); +}; + +describe('Fragment.toJsonMl()', () => { + describe('basic alphabet', () => { + runTests(setupAlphabetKit); + }); + + // describe('alphabet with two chunks', () => { + // runTests(setupAlphabetWithTwoChunksKit); + // }); + + // describe('alphabet with chunk split', () => { + // runTests(setupAlphabetChunkSplitKit); + // }); + + // describe('alphabet with deletes', () => { + // runTests(setupAlphabetWithDeletesKit); + // }); +}); From f9bc89ebccf5803358c2b74e1d5a476a94cabea9 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Mon, 25 Nov 2024 15:20:50 +0100 Subject: [PATCH 02/15] =?UTF-8?q?feat(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=8E=B8=20start=20serialization=20implementation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../peritext/editor/Editor.ts | 38 ++++++++++++++++++- .../editor/__tests__/Editor-export.spec.ts | 35 +++++++++++++++++ .../peritext/editor/types.ts | 18 +++++++++ .../peritext/slice/types.ts | 1 + 4 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 src/json-crdt-extensions/peritext/editor/__tests__/Editor-export.spec.ts diff --git a/src/json-crdt-extensions/peritext/editor/Editor.ts b/src/json-crdt-extensions/peritext/editor/Editor.ts index a63911b76d..648f17f47f 100644 --- a/src/json-crdt-extensions/peritext/editor/Editor.ts +++ b/src/json-crdt-extensions/peritext/editor/Editor.ts @@ -17,7 +17,7 @@ import type {ChunkSlice} from '../util/ChunkSlice'; import type {Peritext} from '../Peritext'; import type {Point} from '../rga/Point'; import type {Range} from '../rga/Range'; -import type {CharIterator, CharPredicate, Position, TextRangeUnit} from './types'; +import type {CharIterator, CharPredicate, Position, TextRangeUnit, ViewRange, ViewSlice} from './types'; import type {Printable} from 'tree-dump'; /** @@ -666,6 +666,42 @@ export class Editor implements Printable { } } + // ---------------------------------------------------------- export / import + + public export(range: Range): ViewRange { + const r = range.range(); + r.start.refBefore(); + r.end.refAfter(); + const text = r.text(); + const viewSlices: ViewRange[1] = []; + const view: ViewRange = [text, viewSlices]; + const overlay = this.txt.overlay; + const slices = overlay.findOverlapping(r); + const offset = r.start.viewPos(); + for (const slice of slices) { + const behavior = slice.behavior; + switch (behavior) { + case SliceBehavior.One: + case SliceBehavior.Many: + case SliceBehavior.Erase: + case SliceBehavior.Marker: { + const viewSlice: ViewSlice = [ + slice.start.viewPos() - offset, + slice.start.anchor, + slice.end.viewPos() - offset, + slice.end.anchor, + slice.behavior, + slice.type, + ]; + const data = slice.data(); + if (data !== void 0) viewSlice.push(data); + viewSlices.push(viewSlice); + } + } + } + return view; + } + // ------------------------------------------------------------------ various public point(at: Position): Point { diff --git a/src/json-crdt-extensions/peritext/editor/__tests__/Editor-export.spec.ts b/src/json-crdt-extensions/peritext/editor/__tests__/Editor-export.spec.ts new file mode 100644 index 0000000000..83c21b4dc9 --- /dev/null +++ b/src/json-crdt-extensions/peritext/editor/__tests__/Editor-export.spec.ts @@ -0,0 +1,35 @@ +import {type Kit, runAlphabetKitTestSuite} from '../../__tests__/setup'; +import {Anchor} from '../../rga/constants'; +import {SliceBehavior} from '../../slice/constants'; + +const testSuite = (setup: () => Kit) => { + describe('.export()', () => { + test('can export whole un-annotated document', () => { + const {editor} = setup(); + editor.selectAll(); + const json = editor.export(editor.cursor); + expect(json).toEqual(['abcdefghijklmnopqrstuvwxyz', []]); + }); + + test('can export range, which contains bold text', () => { + const {editor, peritext} = setup(); + editor.cursor.setAt(3, 3); + editor.saved.insOverwrite('bold'); + const range = peritext.rangeAt(2, 5); + peritext.refresh(); + const json = editor.export(range); + expect(json).toEqual(['cdefg', [ + [ + 1, + Anchor.Before, + 4, + Anchor.After, + SliceBehavior.One, + 'bold', + ], + ]]); + }); + }); +}; + +runAlphabetKitTestSuite(testSuite); diff --git a/src/json-crdt-extensions/peritext/editor/types.ts b/src/json-crdt-extensions/peritext/editor/types.ts index 178634c2fb..dd154bc080 100644 --- a/src/json-crdt-extensions/peritext/editor/types.ts +++ b/src/json-crdt-extensions/peritext/editor/types.ts @@ -1,5 +1,8 @@ import type {UndefIterator} from '../../../util/iterator'; +import type {Anchor} from '../rga/constants'; import type {Point} from '../rga/Point'; +import type {SliceType} from '../slice'; +import type {SliceBehavior} from '../slice/constants'; import type {ChunkSlice} from '../util/ChunkSlice'; export type CharIterator = UndefIterator>; @@ -7,3 +10,18 @@ export type CharPredicate = (char: T) => boolean; export type Position = number | [at: number, anchor: 0 | 1] | Point; export type TextRangeUnit = 'point' | 'char' | 'word' | 'line' | 'block' | 'all'; + +export type ViewRange = [ + text: string, + slices: ViewSlice[], +]; + +export type ViewSlice = [ + x1: number, + a1: Anchor, + x2: number, + a2: Anchor, + behavior: SliceBehavior, + type: SliceType, + data?: unknown, +]; diff --git a/src/json-crdt-extensions/peritext/slice/types.ts b/src/json-crdt-extensions/peritext/slice/types.ts index b0372ed45f..ec4fcdb3ca 100644 --- a/src/json-crdt-extensions/peritext/slice/types.ts +++ b/src/json-crdt-extensions/peritext/slice/types.ts @@ -6,6 +6,7 @@ import type {SliceBehavior} from './constants'; import type {nodes} from '../../../json-crdt-patch'; import type {SchemaToJsonNode} from '../../../json-crdt/schema/types'; import type {JsonNodeView} from '../../../json-crdt/nodes'; +import type {Anchor} from '../rga/constants'; /** * Represents a developer-defined type of a slice, allows developers to assign From 0b8c7b764d48860c57c748dd23f2edd194f88c49 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Mon, 25 Nov 2024 23:33:29 +0100 Subject: [PATCH 03/15] =?UTF-8?q?feat(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=8E=B8=20simplify=20export=20view=20range=20interface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../peritext/editor/Editor.ts | 23 +++++++++++-------- .../editor/__tests__/Editor-export.spec.ts | 14 ++++------- .../peritext/editor/types.ts | 7 ++---- 3 files changed, 20 insertions(+), 24 deletions(-) diff --git a/src/json-crdt-extensions/peritext/editor/Editor.ts b/src/json-crdt-extensions/peritext/editor/Editor.ts index 648f17f47f..58aebecb9a 100644 --- a/src/json-crdt-extensions/peritext/editor/Editor.ts +++ b/src/json-crdt-extensions/peritext/editor/Editor.ts @@ -1,7 +1,7 @@ import {printTree} from 'tree-dump/lib/printTree'; import {Cursor} from './Cursor'; import {stringify} from '../../../json-text/stringify'; -import {CursorAnchor, SliceBehavior} from '../slice/constants'; +import {CursorAnchor, SliceBehavior, SliceHeaderShift} from '../slice/constants'; import {EditorSlices} from './EditorSlices'; import {next, prev} from 'sonic-forest/lib/util'; import {isLetter, isPunctuation, isWhitespace} from './util'; @@ -673,11 +673,11 @@ export class Editor implements Printable { r.start.refBefore(); r.end.refAfter(); const text = r.text(); - const viewSlices: ViewRange[1] = []; - const view: ViewRange = [text, viewSlices]; + const offset = r.start.viewPos(); + const viewSlices: ViewSlice[] = []; + const view: ViewRange = [text, offset, viewSlices]; const overlay = this.txt.overlay; const slices = overlay.findOverlapping(r); - const offset = r.start.viewPos(); for (const slice of slices) { const behavior = slice.behavior; switch (behavior) { @@ -685,13 +685,16 @@ export class Editor implements Printable { case SliceBehavior.Many: case SliceBehavior.Erase: case SliceBehavior.Marker: { + const {behavior, type, start, end} = slice; + const header: number = + (behavior << SliceHeaderShift.Behavior) + + (start.anchor << SliceHeaderShift.X1Anchor) + + (end.anchor << SliceHeaderShift.X2Anchor); const viewSlice: ViewSlice = [ - slice.start.viewPos() - offset, - slice.start.anchor, - slice.end.viewPos() - offset, - slice.end.anchor, - slice.behavior, - slice.type, + header, + start.viewPos(), + end.viewPos(), + type, ]; const data = slice.data(); if (data !== void 0) viewSlice.push(data); diff --git a/src/json-crdt-extensions/peritext/editor/__tests__/Editor-export.spec.ts b/src/json-crdt-extensions/peritext/editor/__tests__/Editor-export.spec.ts index 83c21b4dc9..68e786ba8a 100644 --- a/src/json-crdt-extensions/peritext/editor/__tests__/Editor-export.spec.ts +++ b/src/json-crdt-extensions/peritext/editor/__tests__/Editor-export.spec.ts @@ -1,6 +1,4 @@ import {type Kit, runAlphabetKitTestSuite} from '../../__tests__/setup'; -import {Anchor} from '../../rga/constants'; -import {SliceBehavior} from '../../slice/constants'; const testSuite = (setup: () => Kit) => { describe('.export()', () => { @@ -8,7 +6,7 @@ const testSuite = (setup: () => Kit) => { const {editor} = setup(); editor.selectAll(); const json = editor.export(editor.cursor); - expect(json).toEqual(['abcdefghijklmnopqrstuvwxyz', []]); + expect(json).toEqual(['abcdefghijklmnopqrstuvwxyz', 0, []]); }); test('can export range, which contains bold text', () => { @@ -18,13 +16,11 @@ const testSuite = (setup: () => Kit) => { const range = peritext.rangeAt(2, 5); peritext.refresh(); const json = editor.export(range); - expect(json).toEqual(['cdefg', [ + expect(json).toEqual(['cdefg', 2, [ [ - 1, - Anchor.Before, - 4, - Anchor.After, - SliceBehavior.One, + expect.any(Number), + 3, + 6, 'bold', ], ]]); diff --git a/src/json-crdt-extensions/peritext/editor/types.ts b/src/json-crdt-extensions/peritext/editor/types.ts index dd154bc080..497d8510a3 100644 --- a/src/json-crdt-extensions/peritext/editor/types.ts +++ b/src/json-crdt-extensions/peritext/editor/types.ts @@ -1,8 +1,6 @@ import type {UndefIterator} from '../../../util/iterator'; -import type {Anchor} from '../rga/constants'; import type {Point} from '../rga/Point'; import type {SliceType} from '../slice'; -import type {SliceBehavior} from '../slice/constants'; import type {ChunkSlice} from '../util/ChunkSlice'; export type CharIterator = UndefIterator>; @@ -13,15 +11,14 @@ export type TextRangeUnit = 'point' | 'char' | 'word' | 'line' | 'block' | 'all' export type ViewRange = [ text: string, + textPosition: number, slices: ViewSlice[], ]; export type ViewSlice = [ + header: number, x1: number, - a1: Anchor, x2: number, - a2: Anchor, - behavior: SliceBehavior, type: SliceType, data?: unknown, ]; From 43ad61de7a30f09c265f716d11573898ef139472 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Tue, 26 Nov 2024 09:38:29 +0100 Subject: [PATCH 04/15] =?UTF-8?q?feat(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=8E=B8=20add=20ability=20to=20import=20serialized=20view?= =?UTF-8?q?=20range?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../peritext/editor/Editor.ts | 32 +++++++++- .../editor/__tests__/Editor-export.spec.ts | 64 ++++++++++++++++++- 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/src/json-crdt-extensions/peritext/editor/Editor.ts b/src/json-crdt-extensions/peritext/editor/Editor.ts index 58aebecb9a..b4c61db275 100644 --- a/src/json-crdt-extensions/peritext/editor/Editor.ts +++ b/src/json-crdt-extensions/peritext/editor/Editor.ts @@ -1,7 +1,7 @@ import {printTree} from 'tree-dump/lib/printTree'; import {Cursor} from './Cursor'; import {stringify} from '../../../json-text/stringify'; -import {CursorAnchor, SliceBehavior, SliceHeaderShift} from '../slice/constants'; +import {CursorAnchor, SliceBehavior, SliceHeaderMask, SliceHeaderShift} from '../slice/constants'; import {EditorSlices} from './EditorSlices'; import {next, prev} from 'sonic-forest/lib/util'; import {isLetter, isPunctuation, isWhitespace} from './util'; @@ -156,6 +156,18 @@ export class Editor implements Printable { // ------------------------------------------------------------- text editing + /** + * Ensures there is exactly one cursor. If the cursor is a range, contents + * inside the range is deleted and cursor is collapsed to a single point. + * + * @returns A single cursor collapsed to a single point. + */ + public caret(): Cursor { + const cursor = this.cursor; + if (!cursor.isCollapsed()) this.delRange(cursor); + return cursor; + } + /** * Insert inline text at current cursor position. If cursor selects a range, * the range is removed and the text is inserted at the start of the range. @@ -705,6 +717,24 @@ export class Editor implements Printable { return view; } + public import(pos: number, view: ViewRange): void { + const [text, offset, slices] = view; + const txt = this.txt; + txt.insAt(pos, text); + const length = slices.length; + for (let i = 0; i < length; i++) { + const slice = slices[i]; + const [header, x1, x2, type, data] = slice; + const anchor1: Anchor = (header & SliceHeaderMask.X1Anchor) >>> SliceHeaderShift.X1Anchor; + const anchor2: Anchor = (header & SliceHeaderMask.X2Anchor) >>> SliceHeaderShift.X2Anchor; + const behavior: SliceBehavior = (header & SliceHeaderMask.Behavior) >>> SliceHeaderShift.Behavior; + const range = txt.rangeAt(Math.max(0, x1 - offset + pos), x2 - x1); + if (anchor1 === Anchor.Before) range.start.refBefore(); else range.start.refAfter(); + if (anchor2 === Anchor.Before) range.end.refBefore(); else range.end.refAfter(); + txt.savedSlices.ins(range, behavior, type, data); + } + } + // ------------------------------------------------------------------ various public point(at: Position): Point { diff --git a/src/json-crdt-extensions/peritext/editor/__tests__/Editor-export.spec.ts b/src/json-crdt-extensions/peritext/editor/__tests__/Editor-export.spec.ts index 68e786ba8a..7e9b2b90e9 100644 --- a/src/json-crdt-extensions/peritext/editor/__tests__/Editor-export.spec.ts +++ b/src/json-crdt-extensions/peritext/editor/__tests__/Editor-export.spec.ts @@ -1,4 +1,5 @@ import {type Kit, runAlphabetKitTestSuite} from '../../__tests__/setup'; +import {CommonSliceType} from '../../slice'; const testSuite = (setup: () => Kit) => { describe('.export()', () => { @@ -9,7 +10,7 @@ const testSuite = (setup: () => Kit) => { expect(json).toEqual(['abcdefghijklmnopqrstuvwxyz', 0, []]); }); - test('can export range, which contains bold text', () => { + test('range which contains bold text', () => { const {editor, peritext} = setup(); editor.cursor.setAt(3, 3); editor.saved.insOverwrite('bold'); @@ -25,6 +26,67 @@ const testSuite = (setup: () => Kit) => { ], ]]); }); + + test('range which start in bold text', () => { + const {editor, peritext} = setup(); + editor.cursor.setAt(3, 10); + editor.saved.insOverwrite(CommonSliceType.b); + editor.cursor.setAt(5, 15); + peritext.refresh(); + const json = editor.export(editor.cursor); + expect(json).toEqual(['fghijklmnopqrst', 5, [ + [ + expect.any(Number), + 3, + 13, + CommonSliceType.b, + ], + ]]); + }); + + test('range which ends in bold text', () => { + const {editor, peritext} = setup(); + editor.cursor.setAt(3, 10); + editor.saved.insOverwrite(CommonSliceType.b); + const range = peritext.rangeAt(0, 5); + peritext.refresh(); + const json = editor.export(range); + expect(json).toEqual(['abcde', 0, [ + [ + expect.any(Number), + 3, + 13, + CommonSliceType.b, + ], + ]]); + }); + }); + + describe('.import()', () => { + test('can insert text into another document', () => { + const kit1 = setup(); + const kit2 = setup(); + kit1.editor.cursor.setAt(0, 3); + const json = kit1.editor.export(kit1.editor.cursor); + kit2.editor.import(3, json); + expect(kit2.peritext.strApi().view()).toBe('abcabcdefghijklmnopqrstuvwxyz'); + }); + + test('can copy a range with bold text annotation', () => { + const kit1 = setup(); + const kit2 = setup(); + kit1.editor.cursor.setAt(5, 5); + kit1.editor.saved.insOverwrite('bold'); + kit1.editor.cursor.setAt(3, 10); + kit1.peritext.refresh(); + const json = kit1.editor.export(kit1.editor.cursor); + kit2.editor.import(5, json); + kit2.peritext.refresh(); + expect(kit2.peritext.strApi().view()).toBe('abcde' + 'defghijklm' + 'fghijklmnopqrstuvwxyz'); + const [, i2] = kit2.peritext.blocks.root.children[0].texts(); + expect(i2.text()).toBe('fghij'); + expect(!!i2.attr().bold).toBe(true); + }); }); }; From fb45ba71f150766900572fd5fa05a5a6282843c3 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Tue, 26 Nov 2024 10:37:22 +0100 Subject: [PATCH 05/15] =?UTF-8?q?feat(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=8E=B8=20improve=20JSOM-ML=20generation=20for=20block=20n?= =?UTF-8?q?odes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../peritext/block/Block.ts | 30 ++++++++++++++++++- .../peritext/block/Fragment.ts | 6 ++++ .../peritext/block/LeafBlock.ts | 2 +- ...JsonMl.spec.ts => Fragment-export.spec.ts} | 3 +- .../peritext/slice/constants.ts | 22 ++++++++------ src/json-ml/types.ts | 3 +- 6 files changed, 53 insertions(+), 13 deletions(-) rename src/json-crdt-extensions/peritext/block/__tests__/{Fragment-toJsonMl.spec.ts => Fragment-export.spec.ts} (90%) diff --git a/src/json-crdt-extensions/peritext/block/Block.ts b/src/json-crdt-extensions/peritext/block/Block.ts index ce55fa4c1a..19c9775586 100644 --- a/src/json-crdt-extensions/peritext/block/Block.ts +++ b/src/json-crdt-extensions/peritext/block/Block.ts @@ -5,6 +5,7 @@ import {UndefEndIter, type UndefIterator} from '../../../util/iterator'; import {Inline} from './Inline'; import {formatType} from '../slice/util'; import {Range} from '../rga/Range'; +import {SliceTypeName} from '../slice/constants'; import type {Point} from '../rga/Point'; import type {OverlayPoint} from '../overlay/OverlayPoint'; import type {Path} from '@jsonjoy.com/json-pointer'; @@ -12,7 +13,7 @@ import type {Printable} from 'tree-dump'; import type {Peritext} from '../Peritext'; import type {Stateful} from '../types'; import type {OverlayTuple} from '../overlay/types'; -import type {JsonMlNode} from '../../../json-ml'; +import type {JsonMlElement, JsonMlNode} from '../../../json-ml'; export interface IBlock { readonly path: Path; @@ -52,6 +53,33 @@ 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'; + } + } + + public 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; } diff --git a/src/json-crdt-extensions/peritext/block/Fragment.ts b/src/json-crdt-extensions/peritext/block/Fragment.ts index 8f86984c24..de72d8eb73 100644 --- a/src/json-crdt-extensions/peritext/block/Fragment.ts +++ b/src/json-crdt-extensions/peritext/block/Fragment.ts @@ -11,6 +11,7 @@ import type {Printable} from 'tree-dump/lib/types'; import type {Peritext} from '../Peritext'; import type {Point} from '../rga/Point'; import type {JsonMlNode} from '../../../json-ml/types'; +import {toHtml} from '../../../json-ml'; /** * A *fragment* represents a structural slice of a rich-text document. A @@ -36,6 +37,11 @@ export class Fragment extends Range implements Printable, Stateful { return this.root.toJsonMl(); } + toHtml(): string { + const json = this.root.toJsonMl(); + return toHtml(json); + } + // ---------------------------------------------------------------- Printable public toString(tab: string = ''): string { diff --git a/src/json-crdt-extensions/peritext/block/LeafBlock.ts b/src/json-crdt-extensions/peritext/block/LeafBlock.ts index 95b70cfcb0..5ad23d95d5 100644 --- a/src/json-crdt-extensions/peritext/block/LeafBlock.ts +++ b/src/json-crdt-extensions/peritext/block/LeafBlock.ts @@ -14,7 +14,7 @@ export class LeafBlock extends Block { // ------------------------------------------------------------------- export toJsonMl(): JsonMlNode { - let node: JsonMlNode = ['div', {}]; + const node = this.jsonMlNode(); for (const inline of this.texts()) { const span = inline.toJsonMl(); if (span) node.push(span); diff --git a/src/json-crdt-extensions/peritext/block/__tests__/Fragment-toJsonMl.spec.ts b/src/json-crdt-extensions/peritext/block/__tests__/Fragment-export.spec.ts similarity index 90% rename from src/json-crdt-extensions/peritext/block/__tests__/Fragment-toJsonMl.spec.ts rename to src/json-crdt-extensions/peritext/block/__tests__/Fragment-export.spec.ts index 98ac6d7272..b9096b8165 100644 --- a/src/json-crdt-extensions/peritext/block/__tests__/Fragment-toJsonMl.spec.ts +++ b/src/json-crdt-extensions/peritext/block/__tests__/Fragment-export.spec.ts @@ -2,12 +2,13 @@ import { type Kit, setupAlphabetKit, } from '../../__tests__/setup'; +import {CommonSliceType} from '../../slice'; const runTests = (setup: () => Kit) => { test('...', () => { const {editor, peritext} = setup(); editor.cursor.setAt(10); - editor.saved.insMarker(['p'], 'p1'); + editor.saved.insMarker(CommonSliceType.p); peritext.refresh(); const fragment = peritext.fragment(peritext.rangeAt(4, 10)); fragment.refresh(); diff --git a/src/json-crdt-extensions/peritext/slice/constants.ts b/src/json-crdt-extensions/peritext/slice/constants.ts index b0eff56020..f779bda965 100644 --- a/src/json-crdt-extensions/peritext/slice/constants.ts +++ b/src/json-crdt-extensions/peritext/slice/constants.ts @@ -9,7 +9,7 @@ export enum CursorAnchor { End = 1, } -export enum SliceTypeCon { +export const enum SliceTypeCon { // ---------------------------------------------------- block slices (0 to 64) p = 0, //

blockquote = 1, //

@@ -17,7 +17,7 @@ export enum SliceTypeCon { pre = 3, //
   ul = 4, // 
    ol = 5, //
      - TaskList = 6, // - [ ] Task list + tasklist = 6, // - [ ] Task list h1 = 7, //

      h2 = 8, //

      h3 = 9, //

      @@ -37,9 +37,9 @@ export enum SliceTypeCon { table = 23, // row = 24, // Table row cell = 25, // Table cell - CollapseList = 26, // Collapsible list - > List item - Collapse = 27, // Collapsible block - Note = 28, // Note block + collapselist = 26, // Collapsible list - > List item + collapse = 27, // Collapsible block + note = 28, // Note block // ------------------------------------------------ inline slices (-64 to -1) Cursor = -1, @@ -69,6 +69,10 @@ export enum SliceTypeCon { bookmark = -25, // UI for creating a link to this slice } +/** + * All type name must be fully lowercase, as HTML custom element tag names must + * be lowercase. + */ export enum SliceTypeName { p = SliceTypeCon.p, blockquote = SliceTypeCon.blockquote, @@ -76,7 +80,7 @@ export enum SliceTypeName { pre = SliceTypeCon.pre, ul = SliceTypeCon.ul, ol = SliceTypeCon.ol, - TaskList = SliceTypeCon.TaskList, + tasklist = SliceTypeCon.tasklist, h1 = SliceTypeCon.h1, h2 = SliceTypeCon.h2, h3 = SliceTypeCon.h3, @@ -96,9 +100,9 @@ export enum SliceTypeName { table = SliceTypeCon.table, row = SliceTypeCon.row, cell = SliceTypeCon.cell, - CollapseList = SliceTypeCon.CollapseList, - Collapse = SliceTypeCon.Collapse, - Note = SliceTypeCon.Note, + collapselist = SliceTypeCon.collapselist, + collapse = SliceTypeCon.collapse, + note = SliceTypeCon.note, Cursor = SliceTypeCon.Cursor, RemoteCursor = SliceTypeCon.RemoteCursor, diff --git a/src/json-ml/types.ts b/src/json-ml/types.ts index 8c2564bd11..6e6b43af11 100644 --- a/src/json-ml/types.ts +++ b/src/json-ml/types.ts @@ -1 +1,2 @@ -export type JsonMlNode = string | [tag: string, attrs: null | Record, ...children: JsonMlNode[]]; +export type JsonMlNode = string | JsonMlElement; +export type JsonMlElement = [tag: string, attrs: null | Record, ...children: JsonMlNode[]]; From 79fa03647729d543f41ecb40dbcd0eb39f3bbbe8 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Wed, 27 Nov 2024 15:07:50 +0100 Subject: [PATCH 06/15] =?UTF-8?q?chore:=20=F0=9F=A4=96=20minor=20updates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-extensions/peritext/block/Fragment.ts | 2 +- .../peritext/block/__tests__/Fragment-export.spec.ts | 12 ++++++++---- src/json-ml/types.ts | 2 +- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/json-crdt-extensions/peritext/block/Fragment.ts b/src/json-crdt-extensions/peritext/block/Fragment.ts index de72d8eb73..5086e253ea 100644 --- a/src/json-crdt-extensions/peritext/block/Fragment.ts +++ b/src/json-crdt-extensions/peritext/block/Fragment.ts @@ -4,6 +4,7 @@ import {printTree} from 'tree-dump/lib/printTree'; import {LeafBlock} from './LeafBlock'; import {Range} from '../rga/Range'; import {CommonSliceType} from '../slice'; +import {toHtml} from '../../../json-ml'; import type {MarkerOverlayPoint} from '../overlay/MarkerOverlayPoint'; import type {Path} from '@jsonjoy.com/json-pointer'; import type {Stateful} from '../types'; @@ -11,7 +12,6 @@ import type {Printable} from 'tree-dump/lib/types'; import type {Peritext} from '../Peritext'; import type {Point} from '../rga/Point'; import type {JsonMlNode} from '../../../json-ml/types'; -import {toHtml} from '../../../json-ml'; /** * A *fragment* represents a structural slice of a rich-text document. A 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 b9096b8165..750329e85a 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 @@ -5,16 +5,20 @@ import { import {CommonSliceType} from '../../slice'; const runTests = (setup: () => Kit) => { - test('...', () => { + 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(); - - console.log(fragment + '') - console.log(fragment.toJsonMl()); + expect(fragment.toJsonMl()).toEqual([ + 'div', + {}, + ['p', {}, 'efghij'], + ['p', {}, 'klm'], + ]); + expect(fragment.toHtml()).toBe('

      efghij

      klm

      '); }); }; diff --git a/src/json-ml/types.ts b/src/json-ml/types.ts index 6e6b43af11..53fddfc838 100644 --- a/src/json-ml/types.ts +++ b/src/json-ml/types.ts @@ -1,2 +1,2 @@ export type JsonMlNode = string | JsonMlElement; -export type JsonMlElement = [tag: string, attrs: null | Record, ...children: JsonMlNode[]]; +export type JsonMlElement = [tag: string | number, attrs: null | Record, ...children: JsonMlNode[]]; From a8ec0b1df8e7937b59a393e74f4b6344637be44a Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Wed, 27 Nov 2024 15:32:30 +0100 Subject: [PATCH 07/15] =?UTF-8?q?feat(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=8E=B8=20start=20.toJson()=20implementation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../peritext/block/Block.ts | 58 +++++++++---------- .../peritext/block/Fragment.ts | 11 +--- .../peritext/block/Inline.ts | 13 ++++- .../peritext/block/LeafBlock.ts | 10 ++-- .../block/__tests__/Fragment-toJson.spec.ts | 48 +++++++++++++++ 5 files changed, 97 insertions(+), 43 deletions(-) create mode 100644 src/json-crdt-extensions/peritext/block/__tests__/Fragment-toJson.spec.ts diff --git a/src/json-crdt-extensions/peritext/block/Block.ts b/src/json-crdt-extensions/peritext/block/Block.ts index 19c9775586..9eded3b6f9 100644 --- a/src/json-crdt-extensions/peritext/block/Block.ts +++ b/src/json-crdt-extensions/peritext/block/Block.ts @@ -53,32 +53,32 @@ 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'; - } - } - - public 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 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; @@ -171,11 +171,11 @@ export class Block extends Range implements IBlock, Printable, S // ------------------------------------------------------------------- export - toJsonMl(): JsonMlNode { - let node: JsonMlNode = ['div', {}]; + public toJson(): JsonMlElement { + const node: JsonMlElement = [this.tag(), this.attr() ?? null]; const children = this.children; const length = children.length; - for (let i = 0; i < length; i++) node.push(children[i].toJsonMl()); + for (let i = 0; i < length; i++) node.push(children[i].toJson()); return node; } diff --git a/src/json-crdt-extensions/peritext/block/Fragment.ts b/src/json-crdt-extensions/peritext/block/Fragment.ts index 5086e253ea..939a335339 100644 --- a/src/json-crdt-extensions/peritext/block/Fragment.ts +++ b/src/json-crdt-extensions/peritext/block/Fragment.ts @@ -11,7 +11,7 @@ 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 {JsonMlNode} from '../../../json-ml/types'; +import type {JsonMlElement, JsonMlNode} from '../../../json-ml/types'; /** * A *fragment* represents a structural slice of a rich-text document. A @@ -33,13 +33,8 @@ export class Fragment extends Range implements Printable, Stateful { // ------------------------------------------------------------------- export - toJsonMl(): JsonMlNode { - return this.root.toJsonMl(); - } - - toHtml(): string { - const json = this.root.toJsonMl(); - return toHtml(json); + public toJson(): JsonMlElement { + return this.root.toJson(); } // ---------------------------------------------------------------- Printable diff --git a/src/json-crdt-extensions/peritext/block/Inline.ts b/src/json-crdt-extensions/peritext/block/Inline.ts index 27a66dbc9f..b95bf199d6 100644 --- a/src/json-crdt-extensions/peritext/block/Inline.ts +++ b/src/json-crdt-extensions/peritext/block/Inline.ts @@ -245,8 +245,19 @@ export class Inline extends Range implements Printable { // ------------------------------------------------------------------- export - toJsonMl(): JsonMlNode { + public toJson(): JsonMlNode { let node: JsonMlNode = this.text(); + const attrs = this.attr(); + for (const key in attrs) { + const keyNum = Number(key); + if (keyNum === SliceTypeName.Cursor || keyNum === SliceTypeName.RemoteCursor) continue; + const attr = attrs[key]; + if (!attr.length) node = [key, null, node]; + else { + const props = {}; + node = [key === keyNum + '' ? keyNum : key, props, node]; + } + } return node; } diff --git a/src/json-crdt-extensions/peritext/block/LeafBlock.ts b/src/json-crdt-extensions/peritext/block/LeafBlock.ts index 5ad23d95d5..ef31da54f0 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 {JsonMlNode} from '../../../json-ml'; +import type {JsonMlElement} from '../../../json-ml'; export interface IBlock { readonly path: Path; @@ -13,11 +13,11 @@ export class LeafBlock extends Block { // ------------------------------------------------------------------- export - toJsonMl(): JsonMlNode { - const node = this.jsonMlNode(); + public toJson(): JsonMlElement { + const node: JsonMlElement = [this.tag(), this.attr() ?? null]; for (const inline of this.texts()) { - const span = inline.toJsonMl(); - if (span) node.push(span); + const child = inline.toJson(); + if (child) node.push(child); } return node; } diff --git a/src/json-crdt-extensions/peritext/block/__tests__/Fragment-toJson.spec.ts b/src/json-crdt-extensions/peritext/block/__tests__/Fragment-toJson.spec.ts new file mode 100644 index 0000000000..068851f35e --- /dev/null +++ b/src/json-crdt-extensions/peritext/block/__tests__/Fragment-toJson.spec.ts @@ -0,0 +1,48 @@ +import {type Kit, runAlphabetKitTestSuite} from '../../__tests__/setup'; +import {CommonSliceType} from '../../slice'; + +const runTests = (setup: () => Kit) => { + 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 json = fragment.toJson(); + expect(json).toEqual([ + '', + null, + [0, null, 'efghij'], + [0, 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(); + console.log(fragment.toString()); + const json = fragment.toJson(); + console.log(JSON.stringify(json, null, 2)); + expect(json).toEqual([ + '', + null, + [0, null, + 'efghij' + ], + [0, null, 'klm'], + ]); + }); +}; + +describe('Fragment.toJson()', () => { + runAlphabetKitTestSuite(runTests); +}); From 7071ada137b7ab2349a0f49875073b8447f2808d Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Thu, 28 Nov 2024 00:47:54 +0100 Subject: [PATCH 08/15] =?UTF-8?q?feat(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=8E=B8=20do=20not=20create=20props=20objects,=20when=20no?= =?UTF-8?q?t=20necessary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-extensions/peritext/block/Inline.ts | 3 +-- .../peritext/block/__tests__/Fragment-toJson.spec.ts | 10 +++++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/json-crdt-extensions/peritext/block/Inline.ts b/src/json-crdt-extensions/peritext/block/Inline.ts index b95bf199d6..d11366eb9e 100644 --- a/src/json-crdt-extensions/peritext/block/Inline.ts +++ b/src/json-crdt-extensions/peritext/block/Inline.ts @@ -254,8 +254,7 @@ export class Inline extends Range implements Printable { const attr = attrs[key]; if (!attr.length) node = [key, null, node]; else { - const props = {}; - node = [key === keyNum + '' ? keyNum : key, props, node]; + node = [key === keyNum + '' ? keyNum : key, null, node]; } } return node; diff --git a/src/json-crdt-extensions/peritext/block/__tests__/Fragment-toJson.spec.ts b/src/json-crdt-extensions/peritext/block/__tests__/Fragment-toJson.spec.ts index 068851f35e..ce2c35aabe 100644 --- a/src/json-crdt-extensions/peritext/block/__tests__/Fragment-toJson.spec.ts +++ b/src/json-crdt-extensions/peritext/block/__tests__/Fragment-toJson.spec.ts @@ -29,14 +29,18 @@ const runTests = (setup: () => Kit) => { peritext.refresh(); const fragment = peritext.fragment(peritext.rangeAt(4, 10)); fragment.refresh(); - console.log(fragment.toString()); const json = fragment.toJson(); - console.log(JSON.stringify(json, null, 2)); expect(json).toEqual([ '', null, [0, null, - 'efghij' + 'ef', + [CommonSliceType.b, null, 'g'], + [CommonSliceType.i, null, + [CommonSliceType.b, null, 'h'], + ], + [CommonSliceType.i, null, 'i'], + 'j', ], [0, null, 'klm'], ]); From ef5327e5bad2b6bcad431a4ffe6b7237978204f4 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Sun, 1 Dec 2024 09:23:07 +0100 Subject: [PATCH 09/15] =?UTF-8?q?feat(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=8E=B8=20improve=20toJson=20exports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-extensions/peritext/block/Block.ts | 9 +++++---- .../peritext/block/Fragment.ts | 5 ++--- .../peritext/block/Inline.ts | 16 +++++++++++----- .../peritext/block/LeafBlock.ts | 8 +++++--- .../block/__tests__/Fragment-toJson.spec.ts | 8 ++++---- src/json-crdt-extensions/peritext/block/types.ts | 6 ++++++ src/json-ml/types.ts | 2 +- 7 files changed, 34 insertions(+), 20 deletions(-) create mode 100644 src/json-crdt-extensions/peritext/block/types.ts diff --git a/src/json-crdt-extensions/peritext/block/Block.ts b/src/json-crdt-extensions/peritext/block/Block.ts index 9eded3b6f9..b3c2703e22 100644 --- a/src/json-crdt-extensions/peritext/block/Block.ts +++ b/src/json-crdt-extensions/peritext/block/Block.ts @@ -5,7 +5,6 @@ import {UndefEndIter, type UndefIterator} from '../../../util/iterator'; import {Inline} from './Inline'; import {formatType} from '../slice/util'; import {Range} from '../rga/Range'; -import {SliceTypeName} from '../slice/constants'; import type {Point} from '../rga/Point'; import type {OverlayPoint} from '../overlay/OverlayPoint'; import type {Path} from '@jsonjoy.com/json-pointer'; @@ -13,7 +12,7 @@ import type {Printable} from 'tree-dump'; import type {Peritext} from '../Peritext'; import type {Stateful} from '../types'; import type {OverlayTuple} from '../overlay/types'; -import type {JsonMlElement, JsonMlNode} from '../../../json-ml'; +import type {PeritextMlAttributes, PeritextMlElement} from './types'; export interface IBlock { readonly path: Path; @@ -171,8 +170,10 @@ export class Block extends Range implements IBlock, Printable, S // ------------------------------------------------------------------- export - public toJson(): JsonMlElement { - const node: JsonMlElement = [this.tag(), this.attr() ?? null]; + public toJson(): PeritextMlElement { + const data = this.attr(); + const attr: PeritextMlAttributes | null = data !== void 0 ? {data} : null; + const node: PeritextMlElement = [this.tag(), attr]; const children = this.children; const length = children.length; for (let i = 0; i < length; i++) node.push(children[i].toJson()); diff --git a/src/json-crdt-extensions/peritext/block/Fragment.ts b/src/json-crdt-extensions/peritext/block/Fragment.ts index 939a335339..7c7fb875a1 100644 --- a/src/json-crdt-extensions/peritext/block/Fragment.ts +++ b/src/json-crdt-extensions/peritext/block/Fragment.ts @@ -4,14 +4,13 @@ import {printTree} from 'tree-dump/lib/printTree'; import {LeafBlock} from './LeafBlock'; import {Range} from '../rga/Range'; import {CommonSliceType} from '../slice'; -import {toHtml} from '../../../json-ml'; import type {MarkerOverlayPoint} from '../overlay/MarkerOverlayPoint'; import type {Path} from '@jsonjoy.com/json-pointer'; 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 {JsonMlElement, JsonMlNode} from '../../../json-ml/types'; +import type {PeritextMlElement} from './types'; /** * A *fragment* represents a structural slice of a rich-text document. A @@ -33,7 +32,7 @@ export class Fragment extends Range implements Printable, Stateful { // ------------------------------------------------------------------- export - public toJson(): JsonMlElement { + public toJson(): PeritextMlElement { return this.root.toJson(); } diff --git a/src/json-crdt-extensions/peritext/block/Inline.ts b/src/json-crdt-extensions/peritext/block/Inline.ts index d11366eb9e..7d84ff445a 100644 --- a/src/json-crdt-extensions/peritext/block/Inline.ts +++ b/src/json-crdt-extensions/peritext/block/Inline.ts @@ -13,7 +13,7 @@ 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 {JsonMlNode} from '../../../json-ml'; +import type {PeritextMlAttributes, PeritextMlNode} from './types'; /** The attribute started before this inline and ends after this inline. */ export class InlineAttrPassing { @@ -245,16 +245,22 @@ export class Inline extends Range implements Printable { // ------------------------------------------------------------------- export - public toJson(): JsonMlNode { - let node: JsonMlNode = this.text(); + public toJson(): PeritextMlNode { + let node: PeritextMlNode = this.text(); const attrs = this.attr(); for (const key in attrs) { const keyNum = Number(key); if (keyNum === SliceTypeName.Cursor || keyNum === SliceTypeName.RemoteCursor) continue; const attr = attrs[key]; - if (!attr.length) node = [key, null, node]; + if (!attr.length) node = [key, {inline: true}, node]; else { - node = [key === keyNum + '' ? keyNum : key, null, node]; + const length = attr.length; + for (let i = 0; i < length; i++) { + const slice = attr[i].slice; + const data = slice.data(); + const attributes: PeritextMlAttributes = data === void 0 ? {inline: true} : {inline: true, data}; + node = [key === keyNum + '' ? keyNum : key, attributes, node]; + } } } return node; diff --git a/src/json-crdt-extensions/peritext/block/LeafBlock.ts b/src/json-crdt-extensions/peritext/block/LeafBlock.ts index ef31da54f0..26fac2dcf7 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 {JsonMlElement} from '../../../json-ml'; +import type {PeritextMlAttributes, PeritextMlElement, PeritextMlNode} from './types'; export interface IBlock { readonly path: Path; @@ -13,8 +13,10 @@ export class LeafBlock extends Block { // ------------------------------------------------------------------- export - public toJson(): JsonMlElement { - const node: JsonMlElement = [this.tag(), this.attr() ?? null]; + public toJson(): PeritextMlElement { + const data = this.attr(); + const attr: PeritextMlAttributes | null = data !== void 0 ? {data} : null; + const node: PeritextMlElement = [this.tag(), attr]; for (const inline of this.texts()) { const child = inline.toJson(); if (child) node.push(child); diff --git a/src/json-crdt-extensions/peritext/block/__tests__/Fragment-toJson.spec.ts b/src/json-crdt-extensions/peritext/block/__tests__/Fragment-toJson.spec.ts index ce2c35aabe..2d6588905d 100644 --- a/src/json-crdt-extensions/peritext/block/__tests__/Fragment-toJson.spec.ts +++ b/src/json-crdt-extensions/peritext/block/__tests__/Fragment-toJson.spec.ts @@ -35,11 +35,11 @@ const runTests = (setup: () => Kit) => { null, [0, null, 'ef', - [CommonSliceType.b, null, 'g'], - [CommonSliceType.i, null, - [CommonSliceType.b, null, 'h'], + [CommonSliceType.b, {inline: true}, 'g'], + [CommonSliceType.i, {inline: true}, + [CommonSliceType.b, {inline: true}, 'h'], ], - [CommonSliceType.i, null, 'i'], + [CommonSliceType.i, {inline: true}, 'i'], 'j', ], [0, null, 'klm'], diff --git a/src/json-crdt-extensions/peritext/block/types.ts b/src/json-crdt-extensions/peritext/block/types.ts new file mode 100644 index 0000000000..c30d9e3368 --- /dev/null +++ b/src/json-crdt-extensions/peritext/block/types.ts @@ -0,0 +1,6 @@ +export type PeritextMlNode = string | PeritextMlElement; +export type PeritextMlElement = [tag: string | number, attrs: null | PeritextMlAttributes, ...children: PeritextMlNode[]]; +export interface PeritextMlAttributes { + inline?: boolean; + data?: unknown; +} diff --git a/src/json-ml/types.ts b/src/json-ml/types.ts index 53fddfc838..410035b83e 100644 --- a/src/json-ml/types.ts +++ b/src/json-ml/types.ts @@ -1,2 +1,2 @@ export type JsonMlNode = string | JsonMlElement; -export type JsonMlElement = [tag: string | number, attrs: null | Record, ...children: JsonMlNode[]]; +export type JsonMlElement = [tag: string | number, attrs: null | Record, ...children: JsonMlNode[]]; From a071f3c417db304c3a3f09fffdaa76057305dfe8 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Sun, 1 Dec 2024 09:43:50 +0100 Subject: [PATCH 10/15] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20improve=20json-ml?= =?UTF-8?q?=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-ml/toHtml.ts | 2 +- src/json-ml/types.ts | 30 +++++++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/json-ml/toHtml.ts b/src/json-ml/toHtml.ts index 750cf2e0f4..6c3aeba595 100644 --- a/src/json-ml/toHtml.ts +++ b/src/json-ml/toHtml.ts @@ -12,6 +12,6 @@ export const toHtml = (node: JsonMlNode): string => { let childrenStr = ''; for (let i = 0; i < childrenLength; i++) childrenStr += toHtml(children[i]); if (!tag) return childrenStr; - if (attrs) for (const key in attrs) attrStr += ' ' + key + '="' + escapeAttr(attrs[key]) + '"'; + if (attrs) for (const key in attrs) attrStr += ' ' + key + '="' + escapeAttr(attrs[key] + '') + '"'; return '<' + tag + attrStr + '>' + childrenStr + ''; }; diff --git a/src/json-ml/types.ts b/src/json-ml/types.ts index 410035b83e..4ffb98bfc0 100644 --- a/src/json-ml/types.ts +++ b/src/json-ml/types.ts @@ -1,2 +1,30 @@ +/** + * Represents a node in the JsonML tree. Can be a string or an element. + */ export type JsonMlNode = string | JsonMlElement; -export type JsonMlElement = [tag: string | number, attrs: null | Record, ...children: JsonMlNode[]]; + +/** + * Represents an element in the JsonML tree. Lke an HTML element. + */ +export type JsonMlElement = [ + /** + * Tag name of the element. An empty string `''` tag represents a *fragment* - + * a list of nodes. Similar to a `DocumentFragment` in the DOM, or + * `React.Fragment` `<>` in React. + * + * When converting to HTML, an empty string tag is not rendered and numeric + * tags are converted to strings. + */ + tag: '' | string | number, + + /** + * Attributes of the element. `null` if there are no attributes. Attribute + * object values are converted to strings when formatting to HTML. + */ + attrs: null | Record, + + /** + * Child nodes of the element. Can be a mix of strings and elements. + */ + ...children: JsonMlNode[], +]; From 8b473580501b8da73e3e356db687b59f5484e6ef Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Sun, 1 Dec 2024 09:58:16 +0100 Subject: [PATCH 11/15] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20add=20identation?= =?UTF-8?q?=20formatting=20ability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-ml/__tests__/toHtml.spec.ts | 20 ++++++++++++++++++++ src/json-ml/toHtml.ts | 11 +++++++---- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/json-ml/__tests__/toHtml.spec.ts b/src/json-ml/__tests__/toHtml.spec.ts index 8fa3ba2bb0..5e3489908e 100644 --- a/src/json-ml/__tests__/toHtml.spec.ts +++ b/src/json-ml/__tests__/toHtml.spec.ts @@ -10,6 +10,16 @@ test('bold text', () => { expect(toHtml(ml)).toBe('bold'); }); +test('when no children, renders self closing tag', () => { + const ml: JsonMlNode = ['hr', null]; + expect(toHtml(ml)).toBe('
      '); +}); + +test('can render self closing tag with attributes', () => { + const ml: JsonMlNode = ['hr', {foo: 'bar'}]; + expect(toHtml(ml)).toBe('
      '); +}); + test('fragment', () => { const ml: JsonMlNode = ['', null, ['b', null, 'bold'], ' text']; expect(toHtml(ml)).toBe('bold text'); @@ -42,3 +52,13 @@ test('can escape attribute values', () => { const ml: JsonMlNode = ['span', {class: 'testtext'); }); + +test('can format HTML with tabbing', () => { + const ml: JsonMlNode = ['div', null, + ['hr', {foo: 'bar'}], + ['span', null, 'text'], + ]; + const html = toHtml(ml, ' '); + // console.log(html); + expect(html).toBe('
      \n
      \n \n text\n \n
      '); +}); diff --git a/src/json-ml/toHtml.ts b/src/json-ml/toHtml.ts index 6c3aeba595..fa299eb77a 100644 --- a/src/json-ml/toHtml.ts +++ b/src/json-ml/toHtml.ts @@ -4,14 +4,17 @@ const escapeText = (str: string): string => str.replace(/[\u00A0-\u9999<>\&]/gim const escapeAttr = (str: string): string => str.replace(/&/g, '&').replace(/"/g, '"').replace(/ { - if (typeof node === 'string') return escapeText(node); +export const toHtml = (node: JsonMlNode, tab: string = '', ident: string = ''): string => { + if (typeof node === 'string') return ident + escapeText(node); const [tag, attrs, ...children] = node; const childrenLength = children.length; let attrStr = ''; let childrenStr = ''; - for (let i = 0; i < childrenLength; i++) childrenStr += toHtml(children[i]); + const childrenIdent = ident + tab; + for (let i = 0; i < childrenLength; i++) childrenStr += toHtml(children[i], tab, childrenIdent) + (tab ? '\n' : ''); if (!tag) return childrenStr; if (attrs) for (const key in attrs) attrStr += ' ' + key + '="' + escapeAttr(attrs[key] + '') + '"'; - return '<' + tag + attrStr + '>' + childrenStr + ''; + const htmlHead = '<' + tag + attrStr; + return ident + + (childrenStr ? (htmlHead + '>' + (tab ? '\n' : '') + childrenStr + ident + '') : htmlHead + ' />'); }; From b3e7e5c87f5dd90e4389563a4ec84847f9077489 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Sun, 1 Dec 2024 10:03:40 +0100 Subject: [PATCH 12/15] =?UTF-8?q?feat(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=8E=B8=20improve=20to=20JSON-ML=20export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../peritext/block/Fragment.ts | 4 +- .../block/__tests__/Fragment-export.spec.ts | 62 +++++++++++-------- .../peritext/export/toJsonMl.ts | 15 +++++ .../peritext/slice/index.ts | 2 +- 4 files changed, 56 insertions(+), 27 deletions(-) create mode 100644 src/json-crdt-extensions/peritext/export/toJsonMl.ts diff --git a/src/json-crdt-extensions/peritext/block/Fragment.ts b/src/json-crdt-extensions/peritext/block/Fragment.ts index 7c7fb875a1..be021219b3 100644 --- a/src/json-crdt-extensions/peritext/block/Fragment.ts +++ b/src/json-crdt-extensions/peritext/block/Fragment.ts @@ -33,7 +33,9 @@ export class Fragment extends Range implements Printable, Stateful { // ------------------------------------------------------------------- export public toJson(): PeritextMlElement { - return this.root.toJson(); + const node = this.root.toJson(); + node[0] = ''; + return node; } // ---------------------------------------------------------------- Printable 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 750329e85a..6a36dd9b07 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,7 +1,5 @@ -import { - type Kit, - setupAlphabetKit, -} from '../../__tests__/setup'; +import {type Kit, runAlphabetKitTestSuite} from '../../__tests__/setup'; +import {toJsonMl} from '../../export/toJsonMl'; import {CommonSliceType} from '../../slice'; const runTests = (setup: () => Kit) => { @@ -12,30 +10,44 @@ const runTests = (setup: () => Kit) => { peritext.refresh(); const fragment = peritext.fragment(peritext.rangeAt(4, 10)); fragment.refresh(); - expect(fragment.toJsonMl()).toEqual([ - 'div', - {}, - ['p', {}, 'efghij'], - ['p', {}, 'klm'], + const html = toJsonMl(fragment.toJson()); + expect(html).toEqual([ + '', + null, + ['p', null, 'efghij'], + ['p', null, 'klm'], ]); - expect(fragment.toHtml()).toBe('

      efghij

      klm

      '); }); -}; -describe('Fragment.toJsonMl()', () => { - describe('basic alphabet', () => { - runTests(setupAlphabetKit); + 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('alphabet with two chunks', () => { - // runTests(setupAlphabetWithTwoChunksKit); - // }); - - // describe('alphabet with chunk split', () => { - // runTests(setupAlphabetChunkSplitKit); - // }); - - // describe('alphabet with deletes', () => { - // runTests(setupAlphabetWithDeletesKit); - // }); +describe('Fragment.toJson()', () => { + runAlphabetKitTestSuite(runTests); }); diff --git a/src/json-crdt-extensions/peritext/export/toJsonMl.ts b/src/json-crdt-extensions/peritext/export/toJsonMl.ts new file mode 100644 index 0000000000..bfc5acd664 --- /dev/null +++ b/src/json-crdt-extensions/peritext/export/toJsonMl.ts @@ -0,0 +1,15 @@ +import {SliceTypeName} from "../slice"; +import type {JsonMlNode} from "../../../json-ml"; +import type {PeritextMlNode} from "../block/types"; + +export const toJsonMl = (json: PeritextMlNode): JsonMlNode => { + if (typeof json === 'string') return json; + const [tag, attr, ...children] = json; + const namedTag = tag === '' ? tag : SliceTypeName[tag as any]; + const htmlTag = namedTag ?? (attr?.inline ? 'span' : 'div'); + const htmlAttr = attr && (attr.data !== void 0) ? {'data-attr': JSON.stringify(attr.data)} : null; + const htmlNode: JsonMlNode = [htmlTag, htmlAttr]; + const length = children.length; + for (let i = 0; i < length; i++) htmlNode.push(toJsonMl(children[i])); + return htmlNode; +}; diff --git a/src/json-crdt-extensions/peritext/slice/index.ts b/src/json-crdt-extensions/peritext/slice/index.ts index 915c95da00..6a22d47d74 100644 --- a/src/json-crdt-extensions/peritext/slice/index.ts +++ b/src/json-crdt-extensions/peritext/slice/index.ts @@ -1,2 +1,2 @@ export type * from './types'; -export {CursorAnchor, SliceTypeName as CommonSliceType} from './constants'; +export {CursorAnchor, SliceTypeName, SliceTypeName as CommonSliceType} from './constants'; From f51f6cc7eb38c6e04b05c13c74c4034e4fd54f2c Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Sun, 1 Dec 2024 10:51:01 +0100 Subject: [PATCH 13/15] =?UTF-8?q?feat(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=8E=B8=20add=20ability=20to=20export=20to=20HTML?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../block/__tests__/Fragment-export.spec.ts | 182 ++++++++++++++---- .../export/{toJsonMl.ts => export.ts} | 6 + src/json-ml/toHtml.ts | 20 +- 3 files changed, 162 insertions(+), 46 deletions(-) rename src/json-crdt-extensions/peritext/export/{toJsonMl.ts => export.ts} (77%) 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 6a36dd9b07..a7edc7ad35 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,50 +1,150 @@ import {type Kit, runAlphabetKitTestSuite} from '../../__tests__/setup'; -import {toJsonMl} from '../../export/toJsonMl'; +import {toHtml, toJsonMl} from '../../export/export'; import {CommonSliceType} from '../../slice'; const runTests = (setup: () => Kit) => { - 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'], - ]); + 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 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

      +
      `); + }); }); }; diff --git a/src/json-crdt-extensions/peritext/export/toJsonMl.ts b/src/json-crdt-extensions/peritext/export/export.ts similarity index 77% rename from src/json-crdt-extensions/peritext/export/toJsonMl.ts rename to src/json-crdt-extensions/peritext/export/export.ts index bfc5acd664..67a10e81b7 100644 --- a/src/json-crdt-extensions/peritext/export/toJsonMl.ts +++ b/src/json-crdt-extensions/peritext/export/export.ts @@ -1,4 +1,5 @@ import {SliceTypeName} from "../slice"; +import {toHtml as _toHtml} from "../../../json-ml/toHtml"; import type {JsonMlNode} from "../../../json-ml"; import type {PeritextMlNode} from "../block/types"; @@ -13,3 +14,8 @@ export const toJsonMl = (json: PeritextMlNode): JsonMlNode => { for (let i = 0; i < length; i++) htmlNode.push(toJsonMl(children[i])); return htmlNode; }; + +export const toHtml = (json: PeritextMlNode, tab?: string): string => { + const jsonml = toJsonMl(json); + return _toHtml(jsonml, tab); +}; diff --git a/src/json-ml/toHtml.ts b/src/json-ml/toHtml.ts index fa299eb77a..019c2a64a3 100644 --- a/src/json-ml/toHtml.ts +++ b/src/json-ml/toHtml.ts @@ -8,13 +8,23 @@ export const toHtml = (node: JsonMlNode, tab: string = '', ident: string = ''): if (typeof node === 'string') return ident + escapeText(node); const [tag, attrs, ...children] = node; const childrenLength = children.length; - let attrStr = ''; + const isFragment = !tag; + const childrenIdent = ident + (isFragment ? '' : tab); + const doIdent = !!tab; let childrenStr = ''; - const childrenIdent = ident + tab; - for (let i = 0; i < childrenLength; i++) childrenStr += toHtml(children[i], tab, childrenIdent) + (tab ? '\n' : ''); - if (!tag) return childrenStr; + let textOnlyChildren = true; + for (let i = 0; i < childrenLength; i++) if (typeof children[i] !== 'string') { + textOnlyChildren = false; + break; + } + if (textOnlyChildren) for (let i = 0; i < childrenLength; i++) + childrenStr += escapeText(children[i] as string); + else for (let i = 0; i < childrenLength; i++) + childrenStr += (doIdent ? ((!isFragment || i) ? '\n' : '') : '') + toHtml(children[i], tab, childrenIdent); + if (isFragment) return childrenStr; + let attrStr = ''; if (attrs) for (const key in attrs) attrStr += ' ' + key + '="' + escapeAttr(attrs[key] + '') + '"'; const htmlHead = '<' + tag + attrStr; return ident + - (childrenStr ? (htmlHead + '>' + (tab ? '\n' : '') + childrenStr + ident + '') : htmlHead + ' />'); + (childrenStr ? (htmlHead + '>' + childrenStr + ((doIdent && !textOnlyChildren) ? '\n' + ident : '') + '') : htmlHead + ' />'); }; From 98429d235d50fa63e37e91a321eadfd3d914302c Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Sun, 1 Dec 2024 10:53:04 +0100 Subject: [PATCH 14/15] =?UTF-8?q?test:=20=F0=9F=92=8D=20improve=20JSON-ML?= =?UTF-8?q?=20HTML=20formatting=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-ml/__tests__/toHtml.spec.ts | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/json-ml/__tests__/toHtml.spec.ts b/src/json-ml/__tests__/toHtml.spec.ts index 5e3489908e..83f64a1305 100644 --- a/src/json-ml/__tests__/toHtml.spec.ts +++ b/src/json-ml/__tests__/toHtml.spec.ts @@ -60,5 +60,27 @@ test('can format HTML with tabbing', () => { ]; const html = toHtml(ml, ' '); // console.log(html); - expect(html).toBe('
      \n
      \n \n text\n \n
      '); + expect(html).toBe('
      \n
      \n text\n
      '); +}); + +test('can format HTML fragment with tabbing', () => { + const ml: JsonMlNode = ['', null, + ['hr', {foo: 'bar'}], + ['span', null, 'text'], + ]; + const html = toHtml(ml, ' '); + // console.log(html); + expect(html).toBe('
      \ntext'); +}); + +test('can format HTML fragment with tabbing - 2', () => { + const ml: JsonMlNode = ['div', null, + ['', null, + ['hr', {foo: 'bar'}], + ['span', null, 'text'], + ], + ]; + const html = toHtml(ml, ' '); + // console.log(html); + expect(html).toBe('
      \n
      \n text\n
      '); }); From 8e48422fe29c1471a64404dc2f81efa646db435b Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Sun, 1 Dec 2024 10:56:20 +0100 Subject: [PATCH 15/15] =?UTF-8?q?style:=20=F0=9F=92=84=20fix=20linter=20is?= =?UTF-8?q?sues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- biome.json | 3 +- .../peritext/block/LeafBlock.ts | 1 - .../block/__tests__/Fragment-export.spec.ts | 29 ++++++------------- .../block/__tests__/Fragment-toJson.spec.ts | 15 ++++------ .../peritext/block/types.ts | 6 +++- .../peritext/editor/Editor.ts | 13 ++++----- .../editor/__tests__/Editor-export.spec.ts | 27 ++--------------- .../peritext/editor/types.ts | 14 ++------- .../peritext/export/export.ts | 10 +++---- src/json-ml/__tests__/toHtml.spec.ts | 17 ++--------- src/json-ml/toHtml.ts | 25 +++++++++------- src/json-ml/types.ts | 4 +-- 12 files changed, 55 insertions(+), 109 deletions(-) diff --git a/biome.json b/biome.json index 9f6cdfa299..73a3428371 100644 --- a/biome.json +++ b/biome.json @@ -34,7 +34,8 @@ "useIsArray": "off", "noAssignInExpressions": "off", "noConfusingLabels": "off", - "noConfusingVoidType": "off" + "noConfusingVoidType": "off", + "noConstEnum": "off" }, "complexity": { "noStaticOnlyClass": "off", diff --git a/src/json-crdt-extensions/peritext/block/LeafBlock.ts b/src/json-crdt-extensions/peritext/block/LeafBlock.ts index 26fac2dcf7..2948263809 100644 --- a/src/json-crdt-extensions/peritext/block/LeafBlock.ts +++ b/src/json-crdt-extensions/peritext/block/LeafBlock.ts @@ -10,7 +10,6 @@ export interface IBlock { } export class LeafBlock extends Block { - // ------------------------------------------------------------------- export public toJson(): PeritextMlElement { 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 a7edc7ad35..a09eb76425 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 @@ -12,14 +12,9 @@ const runTests = (setup: () => Kit) => { 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'], - ]); + 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); @@ -35,15 +30,7 @@ const runTests = (setup: () => Kit) => { expect(html).toEqual([ '', null, - ['p', null, - 'ef', - ['b', null, 'g'], - ['i', null, - ['b', null, 'h'], - ], - ['i', null, 'i'], - 'j', - ], + ['p', null, 'ef', ['b', null, 'g'], ['i', null, ['b', null, 'h']], ['i', null, 'i'], 'j'], ['p', null, 'klm'], ]); }); @@ -84,7 +71,7 @@ const runTests = (setup: () => Kit) => { 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); @@ -100,7 +87,7 @@ const runTests = (setup: () => Kit) => { 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); @@ -114,9 +101,11 @@ const runTests = (setup: () => Kit) => { 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

      '); + 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); diff --git a/src/json-crdt-extensions/peritext/block/__tests__/Fragment-toJson.spec.ts b/src/json-crdt-extensions/peritext/block/__tests__/Fragment-toJson.spec.ts index 2d6588905d..344ff371ac 100644 --- a/src/json-crdt-extensions/peritext/block/__tests__/Fragment-toJson.spec.ts +++ b/src/json-crdt-extensions/peritext/block/__tests__/Fragment-toJson.spec.ts @@ -10,12 +10,7 @@ const runTests = (setup: () => Kit) => { const fragment = peritext.fragment(peritext.rangeAt(4, 10)); fragment.refresh(); const json = fragment.toJson(); - expect(json).toEqual([ - '', - null, - [0, null, 'efghij'], - [0, null, 'klm'], - ]); + expect(json).toEqual(['', null, [0, null, 'efghij'], [0, null, 'klm']]); }); test('can export two paragraphs with inline formatting', () => { @@ -33,12 +28,12 @@ const runTests = (setup: () => Kit) => { expect(json).toEqual([ '', null, - [0, null, + [ + 0, + null, 'ef', [CommonSliceType.b, {inline: true}, 'g'], - [CommonSliceType.i, {inline: true}, - [CommonSliceType.b, {inline: true}, 'h'], - ], + [CommonSliceType.i, {inline: true}, [CommonSliceType.b, {inline: true}, 'h']], [CommonSliceType.i, {inline: true}, 'i'], 'j', ], diff --git a/src/json-crdt-extensions/peritext/block/types.ts b/src/json-crdt-extensions/peritext/block/types.ts index c30d9e3368..f848316418 100644 --- a/src/json-crdt-extensions/peritext/block/types.ts +++ b/src/json-crdt-extensions/peritext/block/types.ts @@ -1,5 +1,9 @@ export type PeritextMlNode = string | PeritextMlElement; -export type PeritextMlElement = [tag: string | number, attrs: null | PeritextMlAttributes, ...children: PeritextMlNode[]]; +export type PeritextMlElement = [ + tag: string | number, + attrs: null | PeritextMlAttributes, + ...children: PeritextMlNode[], +]; export interface PeritextMlAttributes { inline?: boolean; data?: unknown; diff --git a/src/json-crdt-extensions/peritext/editor/Editor.ts b/src/json-crdt-extensions/peritext/editor/Editor.ts index b4c61db275..46e13551e1 100644 --- a/src/json-crdt-extensions/peritext/editor/Editor.ts +++ b/src/json-crdt-extensions/peritext/editor/Editor.ts @@ -702,12 +702,7 @@ export class Editor implements Printable { (behavior << SliceHeaderShift.Behavior) + (start.anchor << SliceHeaderShift.X1Anchor) + (end.anchor << SliceHeaderShift.X2Anchor); - const viewSlice: ViewSlice = [ - header, - start.viewPos(), - end.viewPos(), - type, - ]; + const viewSlice: ViewSlice = [header, start.viewPos(), end.viewPos(), type]; const data = slice.data(); if (data !== void 0) viewSlice.push(data); viewSlices.push(viewSlice); @@ -729,8 +724,10 @@ export class Editor implements Printable { const anchor2: Anchor = (header & SliceHeaderMask.X2Anchor) >>> SliceHeaderShift.X2Anchor; const behavior: SliceBehavior = (header & SliceHeaderMask.Behavior) >>> SliceHeaderShift.Behavior; const range = txt.rangeAt(Math.max(0, x1 - offset + pos), x2 - x1); - if (anchor1 === Anchor.Before) range.start.refBefore(); else range.start.refAfter(); - if (anchor2 === Anchor.Before) range.end.refBefore(); else range.end.refAfter(); + if (anchor1 === Anchor.Before) range.start.refBefore(); + else range.start.refAfter(); + if (anchor2 === Anchor.Before) range.end.refBefore(); + else range.end.refAfter(); txt.savedSlices.ins(range, behavior, type, data); } } diff --git a/src/json-crdt-extensions/peritext/editor/__tests__/Editor-export.spec.ts b/src/json-crdt-extensions/peritext/editor/__tests__/Editor-export.spec.ts index 7e9b2b90e9..7590f9668b 100644 --- a/src/json-crdt-extensions/peritext/editor/__tests__/Editor-export.spec.ts +++ b/src/json-crdt-extensions/peritext/editor/__tests__/Editor-export.spec.ts @@ -17,14 +17,7 @@ const testSuite = (setup: () => Kit) => { const range = peritext.rangeAt(2, 5); peritext.refresh(); const json = editor.export(range); - expect(json).toEqual(['cdefg', 2, [ - [ - expect.any(Number), - 3, - 6, - 'bold', - ], - ]]); + expect(json).toEqual(['cdefg', 2, [[expect.any(Number), 3, 6, 'bold']]]); }); test('range which start in bold text', () => { @@ -34,14 +27,7 @@ const testSuite = (setup: () => Kit) => { editor.cursor.setAt(5, 15); peritext.refresh(); const json = editor.export(editor.cursor); - expect(json).toEqual(['fghijklmnopqrst', 5, [ - [ - expect.any(Number), - 3, - 13, - CommonSliceType.b, - ], - ]]); + expect(json).toEqual(['fghijklmnopqrst', 5, [[expect.any(Number), 3, 13, CommonSliceType.b]]]); }); test('range which ends in bold text', () => { @@ -51,14 +37,7 @@ const testSuite = (setup: () => Kit) => { const range = peritext.rangeAt(0, 5); peritext.refresh(); const json = editor.export(range); - expect(json).toEqual(['abcde', 0, [ - [ - expect.any(Number), - 3, - 13, - CommonSliceType.b, - ], - ]]); + expect(json).toEqual(['abcde', 0, [[expect.any(Number), 3, 13, CommonSliceType.b]]]); }); }); diff --git a/src/json-crdt-extensions/peritext/editor/types.ts b/src/json-crdt-extensions/peritext/editor/types.ts index 497d8510a3..6d5708889c 100644 --- a/src/json-crdt-extensions/peritext/editor/types.ts +++ b/src/json-crdt-extensions/peritext/editor/types.ts @@ -9,16 +9,6 @@ export type CharPredicate = (char: T) => boolean; export type Position = number | [at: number, anchor: 0 | 1] | Point; export type TextRangeUnit = 'point' | 'char' | 'word' | 'line' | 'block' | 'all'; -export type ViewRange = [ - text: string, - textPosition: number, - slices: ViewSlice[], -]; +export type ViewRange = [text: string, textPosition: number, slices: ViewSlice[]]; -export type ViewSlice = [ - header: number, - x1: number, - x2: number, - type: SliceType, - data?: unknown, -]; +export type ViewSlice = [header: number, x1: number, x2: number, type: SliceType, data?: unknown]; diff --git a/src/json-crdt-extensions/peritext/export/export.ts b/src/json-crdt-extensions/peritext/export/export.ts index 67a10e81b7..644161ef32 100644 --- a/src/json-crdt-extensions/peritext/export/export.ts +++ b/src/json-crdt-extensions/peritext/export/export.ts @@ -1,14 +1,14 @@ -import {SliceTypeName} from "../slice"; -import {toHtml as _toHtml} from "../../../json-ml/toHtml"; -import type {JsonMlNode} from "../../../json-ml"; -import type {PeritextMlNode} from "../block/types"; +import {SliceTypeName} from '../slice'; +import {toHtml as _toHtml} from '../../../json-ml/toHtml'; +import type {JsonMlNode} from '../../../json-ml'; +import type {PeritextMlNode} from '../block/types'; export const toJsonMl = (json: PeritextMlNode): JsonMlNode => { if (typeof json === 'string') return json; const [tag, attr, ...children] = json; const namedTag = tag === '' ? tag : SliceTypeName[tag as any]; const htmlTag = namedTag ?? (attr?.inline ? 'span' : 'div'); - const htmlAttr = attr && (attr.data !== void 0) ? {'data-attr': JSON.stringify(attr.data)} : null; + const htmlAttr = attr && attr.data !== void 0 ? {'data-attr': JSON.stringify(attr.data)} : null; const htmlNode: JsonMlNode = [htmlTag, htmlAttr]; const length = children.length; for (let i = 0; i < length; i++) htmlNode.push(toJsonMl(children[i])); diff --git a/src/json-ml/__tests__/toHtml.spec.ts b/src/json-ml/__tests__/toHtml.spec.ts index 83f64a1305..78847933cb 100644 --- a/src/json-ml/__tests__/toHtml.spec.ts +++ b/src/json-ml/__tests__/toHtml.spec.ts @@ -54,32 +54,21 @@ test('can escape attribute values', () => { }); test('can format HTML with tabbing', () => { - const ml: JsonMlNode = ['div', null, - ['hr', {foo: 'bar'}], - ['span', null, 'text'], - ]; + const ml: JsonMlNode = ['div', null, ['hr', {foo: 'bar'}], ['span', null, 'text']]; const html = toHtml(ml, ' '); // console.log(html); expect(html).toBe('
      \n
      \n text\n
      '); }); test('can format HTML fragment with tabbing', () => { - const ml: JsonMlNode = ['', null, - ['hr', {foo: 'bar'}], - ['span', null, 'text'], - ]; + const ml: JsonMlNode = ['', null, ['hr', {foo: 'bar'}], ['span', null, 'text']]; const html = toHtml(ml, ' '); // console.log(html); expect(html).toBe('
      \ntext'); }); test('can format HTML fragment with tabbing - 2', () => { - const ml: JsonMlNode = ['div', null, - ['', null, - ['hr', {foo: 'bar'}], - ['span', null, 'text'], - ], - ]; + const ml: JsonMlNode = ['div', null, ['', null, ['hr', {foo: 'bar'}], ['span', null, 'text']]]; const html = toHtml(ml, ' '); // console.log(html); expect(html).toBe('
      \n
      \n text\n
      '); diff --git a/src/json-ml/toHtml.ts b/src/json-ml/toHtml.ts index 019c2a64a3..2895d079b4 100644 --- a/src/json-ml/toHtml.ts +++ b/src/json-ml/toHtml.ts @@ -13,18 +13,23 @@ export const toHtml = (node: JsonMlNode, tab: string = '', ident: string = ''): const doIdent = !!tab; let childrenStr = ''; let textOnlyChildren = true; - for (let i = 0; i < childrenLength; i++) if (typeof children[i] !== 'string') { - textOnlyChildren = false; - break; - } - if (textOnlyChildren) for (let i = 0; i < childrenLength; i++) - childrenStr += escapeText(children[i] as string); - else for (let i = 0; i < childrenLength; i++) - childrenStr += (doIdent ? ((!isFragment || i) ? '\n' : '') : '') + toHtml(children[i], tab, childrenIdent); + for (let i = 0; i < childrenLength; i++) + if (typeof children[i] !== 'string') { + textOnlyChildren = false; + break; + } + if (textOnlyChildren) for (let i = 0; i < childrenLength; i++) childrenStr += escapeText(children[i] as string); + else + for (let i = 0; i < childrenLength; i++) + childrenStr += (doIdent ? (!isFragment || i ? '\n' : '') : '') + toHtml(children[i], tab, childrenIdent); if (isFragment) return childrenStr; let attrStr = ''; if (attrs) for (const key in attrs) attrStr += ' ' + key + '="' + escapeAttr(attrs[key] + '') + '"'; const htmlHead = '<' + tag + attrStr; - return ident + - (childrenStr ? (htmlHead + '>' + childrenStr + ((doIdent && !textOnlyChildren) ? '\n' + ident : '') + '') : htmlHead + ' />'); + return ( + ident + + (childrenStr + ? htmlHead + '>' + childrenStr + (doIdent && !textOnlyChildren ? '\n' + ident : '') + '' + : htmlHead + ' />') + ); }; diff --git a/src/json-ml/types.ts b/src/json-ml/types.ts index 4ffb98bfc0..640f4caf27 100644 --- a/src/json-ml/types.ts +++ b/src/json-ml/types.ts @@ -11,18 +11,16 @@ export type JsonMlElement = [ * Tag name of the element. An empty string `''` tag represents a *fragment* - * a list of nodes. Similar to a `DocumentFragment` in the DOM, or * `React.Fragment` `<>` in React. - * + * * When converting to HTML, an empty string tag is not rendered and numeric * tags are converted to strings. */ tag: '' | string | number, - /** * Attributes of the element. `null` if there are no attributes. Attribute * object values are converted to strings when formatting to HTML. */ attrs: null | Record, - /** * Child nodes of the element. Can be a mix of strings and elements. */