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/Block.ts b/src/json-crdt-extensions/peritext/block/Block.ts index 5f5483e782..b3c2703e22 100644 --- a/src/json-crdt-extensions/peritext/block/Block.ts +++ b/src/json-crdt-extensions/peritext/block/Block.ts @@ -12,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 {JsonMlNode} from '../../../json-ml'; +import type {PeritextMlAttributes, PeritextMlElement} from './types'; export interface IBlock { readonly path: Path; @@ -52,6 +52,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'; + // } + // } + + // 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; } @@ -143,8 +170,14 @@ export class Block extends Range implements IBlock, Printable, S // ------------------------------------------------------------------- export - toJsonMl(): JsonMlNode { - throw new Error('not implemented'); + 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()); + 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..be021219b3 100644 --- a/src/json-crdt-extensions/peritext/block/Fragment.ts +++ b/src/json-crdt-extensions/peritext/block/Fragment.ts @@ -10,7 +10,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 {PeritextMlElement} from './types'; /** * A *fragment* represents a structural slice of a rich-text document. A @@ -32,8 +32,10 @@ export class Fragment extends Range implements Printable, Stateful { // ------------------------------------------------------------------- export - toJsonMl(): JsonMlNode { - throw new Error('not implemented'); + public toJson(): PeritextMlElement { + const node = this.root.toJson(); + node[0] = ''; + return node; } // ---------------------------------------------------------------- Printable diff --git a/src/json-crdt-extensions/peritext/block/Inline.ts b/src/json-crdt-extensions/peritext/block/Inline.ts index 726c2c3ea7..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,8 +245,25 @@ export class Inline extends Range implements Printable { // ------------------------------------------------------------------- export - toJsonMl(): JsonMlNode { - throw new Error('not implemented'); + 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, {inline: true}, node]; + else { + 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; } // ---------------------------------------------------------------- Printable diff --git a/src/json-crdt-extensions/peritext/block/LeafBlock.ts b/src/json-crdt-extensions/peritext/block/LeafBlock.ts index 07e5ed02ae..2948263809 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 {PeritextMlAttributes, PeritextMlElement, PeritextMlNode} from './types'; export interface IBlock { readonly path: Path; @@ -9,6 +10,19 @@ export interface IBlock { } export class LeafBlock extends Block { + // ------------------------------------------------------------------- export + + 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); + } + return node; + } + // ---------------------------------------------------------------- Printable public toStringName(): string { return 'LeafBlock'; 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 new file mode 100644 index 0000000000..a09eb76425 --- /dev/null +++ b/src/json-crdt-extensions/peritext/block/__tests__/Fragment-export.spec.ts @@ -0,0 +1,142 @@ +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'], + ]); + }); + }); + + 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.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-toJson.spec.ts b/src/json-crdt-extensions/peritext/block/__tests__/Fragment-toJson.spec.ts new file mode 100644 index 0000000000..344ff371ac --- /dev/null +++ b/src/json-crdt-extensions/peritext/block/__tests__/Fragment-toJson.spec.ts @@ -0,0 +1,47 @@ +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(); + const json = fragment.toJson(); + expect(json).toEqual([ + '', + null, + [ + 0, + null, + 'ef', + [CommonSliceType.b, {inline: true}, 'g'], + [CommonSliceType.i, {inline: true}, [CommonSliceType.b, {inline: true}, 'h']], + [CommonSliceType.i, {inline: true}, 'i'], + 'j', + ], + [0, null, 'klm'], + ]); + }); +}; + +describe('Fragment.toJson()', () => { + runAlphabetKitTestSuite(runTests); +}); 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..f848316418 --- /dev/null +++ b/src/json-crdt-extensions/peritext/block/types.ts @@ -0,0 +1,10 @@ +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-crdt-extensions/peritext/editor/Editor.ts b/src/json-crdt-extensions/peritext/editor/Editor.ts index a63911b76d..46e13551e1 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, SliceHeaderMask, SliceHeaderShift} from '../slice/constants'; import {EditorSlices} from './EditorSlices'; import {next, prev} from 'sonic-forest/lib/util'; import {isLetter, isPunctuation, isWhitespace} from './util'; @@ -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'; /** @@ -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. @@ -666,6 +678,60 @@ 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 offset = r.start.viewPos(); + const viewSlices: ViewSlice[] = []; + const view: ViewRange = [text, offset, viewSlices]; + const overlay = this.txt.overlay; + const slices = overlay.findOverlapping(r); + for (const slice of slices) { + const behavior = slice.behavior; + switch (behavior) { + case SliceBehavior.One: + 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 = [header, start.viewPos(), end.viewPos(), type]; + const data = slice.data(); + if (data !== void 0) viewSlice.push(data); + viewSlices.push(viewSlice); + } + } + } + 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 new file mode 100644 index 0000000000..7590f9668b --- /dev/null +++ b/src/json-crdt-extensions/peritext/editor/__tests__/Editor-export.spec.ts @@ -0,0 +1,72 @@ +import {type Kit, runAlphabetKitTestSuite} from '../../__tests__/setup'; +import {CommonSliceType} from '../../slice'; + +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', 0, []]); + }); + + test('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', 2, [[expect.any(Number), 3, 6, 'bold']]]); + }); + + 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); + }); + }); +}; + +runAlphabetKitTestSuite(testSuite); diff --git a/src/json-crdt-extensions/peritext/editor/types.ts b/src/json-crdt-extensions/peritext/editor/types.ts index 178634c2fb..6d5708889c 100644 --- a/src/json-crdt-extensions/peritext/editor/types.ts +++ b/src/json-crdt-extensions/peritext/editor/types.ts @@ -1,5 +1,6 @@ import type {UndefIterator} from '../../../util/iterator'; import type {Point} from '../rga/Point'; +import type {SliceType} from '../slice'; import type {ChunkSlice} from '../util/ChunkSlice'; export type CharIterator = UndefIterator>; @@ -7,3 +8,7 @@ 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 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 new file mode 100644 index 0000000000..644161ef32 --- /dev/null +++ b/src/json-crdt-extensions/peritext/export/export.ts @@ -0,0 +1,21 @@ +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 htmlNode: JsonMlNode = [htmlTag, htmlAttr]; + const length = children.length; + 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-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-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'; 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 diff --git a/src/json-ml/__tests__/toHtml.spec.ts b/src/json-ml/__tests__/toHtml.spec.ts index 8fa3ba2bb0..78847933cb 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,24 @@ 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 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
      '); +}); diff --git a/src/json-ml/toHtml.ts b/src/json-ml/toHtml.ts index 750cf2e0f4..2895d079b4 100644 --- a/src/json-ml/toHtml.ts +++ b/src/json-ml/toHtml.ts @@ -4,14 +4,32 @@ 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 = ''; + const isFragment = !tag; + const childrenIdent = ident + (isFragment ? '' : tab); + const doIdent = !!tab; 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]) + '"'; - return '<' + tag + attrStr + '>' + 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 + '>' + childrenStr + (doIdent && !textOnlyChildren ? '\n' + ident : '') + '' + : htmlHead + ' />') + ); }; diff --git a/src/json-ml/types.ts b/src/json-ml/types.ts index 8c2564bd11..640f4caf27 100644 --- a/src/json-ml/types.ts +++ b/src/json-ml/types.ts @@ -1 +1,28 @@ -export type JsonMlNode = string | [tag: string, attrs: null | Record, ...children: JsonMlNode[]]; +/** + * Represents a node in the JsonML tree. Can be a string or an element. + */ +export type JsonMlNode = string | JsonMlElement; + +/** + * 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[], +];