diff --git a/packages/json-joy/package.json b/packages/json-joy/package.json index 5bccb2bb9e..4d9f38668b 100644 --- a/packages/json-joy/package.json +++ b/packages/json-joy/package.json @@ -92,9 +92,8 @@ "very-small-parser": "^1.14.0" }, "devDependencies": { - "@monaco-editor/react": "^4.7.0", + "@jsonjoy.com/json-random": "workspace:*", "@radix-ui/react-icons": "^1.3.1", - "@types/node": "^24.8.1", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.0", "clipboard-copy": "^4.0.1", @@ -108,7 +107,6 @@ "jest": "^29.7.0", "json-crdt-traces": "https://github.com/streamich/json-crdt-traces#ec825401dc05cbb74b9e0b3c4d6527399f54d54d", "json-logic-js": "^2.0.2", - "monaco-editor": "^0.54.0", "nano-theme": "^1.4.3", "nice-ui": "^1.30.0", "prosemirror-model": "^1.25.1", diff --git a/packages/json-joy/src/json-crdt-extensions/ModelWithExt.ts b/packages/json-joy/src/json-crdt-extensions/ModelWithExt.ts index 873da8520c..0ce0392347 100644 --- a/packages/json-joy/src/json-crdt-extensions/ModelWithExt.ts +++ b/packages/json-joy/src/json-crdt-extensions/ModelWithExt.ts @@ -11,6 +11,7 @@ extensions.register(ext.cnt); extensions.register(ext.mval); extensions.register(ext.peritext); extensions.register(ext.quill); +extensions.register(ext.prosemirror); export {ext}; @@ -31,7 +32,7 @@ export class ModelWithExt { sid?: number, schema?: S, ): Model> => { - const model = Model.load(data, sid, schema); + const model = Model.load(data, sid, schema); model.ext = extensions; return model; }; diff --git a/packages/json-joy/src/json-crdt-extensions/constants.ts b/packages/json-joy/src/json-crdt-extensions/constants.ts index b15b20115b..c0f4b029ec 100644 --- a/packages/json-joy/src/json-crdt-extensions/constants.ts +++ b/packages/json-joy/src/json-crdt-extensions/constants.ts @@ -3,6 +3,7 @@ export enum ExtensionId { cnt = 1, peritext = 2, quill = 3, + prosemirror = 4, } export enum ExtensionName { @@ -10,4 +11,5 @@ export enum ExtensionName { cnt = ExtensionId.cnt, peritext = ExtensionId.peritext, quill = ExtensionId.quill, + prosemirror = ExtensionId.prosemirror, } diff --git a/packages/json-joy/src/json-crdt-extensions/ext.ts b/packages/json-joy/src/json-crdt-extensions/ext.ts index 97d167d4d3..35abd951ff 100644 --- a/packages/json-joy/src/json-crdt-extensions/ext.ts +++ b/packages/json-joy/src/json-crdt-extensions/ext.ts @@ -2,5 +2,6 @@ import {cnt} from './cnt'; import {mval} from './mval'; import {peritext} from './peritext'; import {quill} from './quill-delta'; +import {prosemirror} from './prosemirror'; -export {cnt, mval, peritext, quill}; +export {cnt, mval, peritext, quill, prosemirror}; diff --git a/packages/json-joy/src/json-crdt-extensions/index.ts b/packages/json-joy/src/json-crdt-extensions/index.ts index 0189fd7774..30614033b6 100644 --- a/packages/json-joy/src/json-crdt-extensions/index.ts +++ b/packages/json-joy/src/json-crdt-extensions/index.ts @@ -2,6 +2,7 @@ export * from './mval'; export * from './cnt'; export * from './peritext'; export * from './quill-delta'; +export * from './prosemirror'; export * from './ext'; export * from './ModelWithExt'; export * from './constants'; diff --git a/packages/json-joy/src/json-crdt-extensions/prosemirror/FromPm.ts b/packages/json-joy/src/json-crdt-extensions/prosemirror/FromPm.ts new file mode 100644 index 0000000000..458ca6b0c4 --- /dev/null +++ b/packages/json-joy/src/json-crdt-extensions/prosemirror/FromPm.ts @@ -0,0 +1,94 @@ +import {Anchor} from '../peritext/rga/constants'; +import {SliceHeaderShift, SliceStacking} from '../peritext/slice/constants'; +import type {ViewRange, ViewSlice} from '../peritext/editor/types'; +import type {PmFragment, PmNode, PmTextNode} from './types'; +import type {SliceTypeStep, SliceTypeSteps} from '../peritext'; + +/** + * Converts ProseMirror raw nodes to a {@link ViewRange} flat string with + * annotation ranges, which is the natural view format for a Peritext model. + * + * Usage: + * + * ```typescript + * FromPm.convert(node); + * ``` + */ +export class FromPm { + static readonly convert = (node: PmNode): ViewRange => new FromPm().convert(node); + + private text = ''; + private slices: ViewSlice[] = []; + + private conv(node: PmNode, path: SliceTypeSteps, nodeDiscriminator: number): void { + const text = this.text; + const start = text.length; + let inlineText: string = ''; + const type = node.type.name; + if (type === 'text' && (inlineText = (node as PmTextNode).text || '')) { + this.text += inlineText; + } else { + const content = node.content?.content; + const data = node.attrs; + const step: SliceTypeStep = nodeDiscriminator || data ? [type, nodeDiscriminator, data] : type; + const length = content?.length ?? 0; + const hasNoChildren = length === 0; + const isFirstChildInline = content?.[0]?.type.name === 'text'; + const doEmitSplitMarker = hasNoChildren || isFirstChildInline; + if (doEmitSplitMarker) { + this.text += '\n'; + const header = + (SliceStacking.Marker << SliceHeaderShift.Stacking) + + (Anchor.Before << SliceHeaderShift.X1Anchor) + + (Anchor.Before << SliceHeaderShift.X2Anchor); + const slice: ViewSlice = [header, start, start, [...path, step]]; + this.slices.push(slice); + } + if (length > 0) this.cont([...path, step], content!); + } + const marks = node.marks; + let length = 0; + if (marks && (length = marks.length) > 0) { + const end = start + inlineText.length; + for (let i = 0; i < length; i++) { + const mark = marks[i]; + const type = mark.type.name; + const data = mark.attrs; + let dataEmpty = true; + for (const _ in data) { + dataEmpty = false; + break; + } + const stacking: SliceStacking = dataEmpty ? SliceStacking.One : SliceStacking.Many; + const header = + (stacking << SliceHeaderShift.Stacking) + + (Anchor.Before << SliceHeaderShift.X1Anchor) + + (Anchor.After << SliceHeaderShift.X2Anchor); + const slice: ViewSlice = [header, start, end, type]; + if (!dataEmpty) slice.push(data); + this.slices.push(slice); + } + } + } + + private cont(path: SliceTypeSteps, content: PmFragment['content']): void { + let prevTag: string = ''; + let discriminator: number = 0; + const length = content.length; + for (let i = 0; i < length; i++) { + const child = content[i]; + const tag = child.type.name; + discriminator = tag === prevTag ? discriminator + 1 : 0; + this.conv(child, path, discriminator); + prevTag = tag; + } + } + + public convert(node: PmNode): ViewRange { + const content = node.content?.content; + let length = 0; + if (content && (length = content.length) > 0) this.cont([], content); + const view: ViewRange = [this.text, 0, this.slices]; + return view; + } +} diff --git a/packages/json-joy/src/json-crdt-extensions/prosemirror/ProseMirrorApi.ts b/packages/json-joy/src/json-crdt-extensions/prosemirror/ProseMirrorApi.ts new file mode 100644 index 0000000000..ce43d1a4fb --- /dev/null +++ b/packages/json-joy/src/json-crdt-extensions/prosemirror/ProseMirrorApi.ts @@ -0,0 +1,23 @@ +import {NodeApi} from '../../json-crdt/model/api/nodes'; +import {FromPm} from './FromPm'; +import type {ProseMirrorNode} from './ProseMirrorNode'; +import type {ArrApi, ArrNode, ExtApi, StrApi} from '../../json-crdt'; +import type {SliceNode} from '../peritext/slice/types'; +import type {PmNode} from './types'; + +export class ProseMirrorApi extends NodeApi implements ExtApi { + public text(): StrApi { + return this.api.wrap(this.node.text()); + } + + public slices(): ArrApi> { + return this.api.wrap(this.node.slices()); + } + + public mergePmNode(node: PmNode) { + const txt = this.node.txt; + // TODO: to speed up convert directly to .merge() internal format. + const viewRange = FromPm.convert(node); + txt.editor.merge(viewRange); + } +} diff --git a/packages/json-joy/src/json-crdt-extensions/prosemirror/ProseMirrorNode.ts b/packages/json-joy/src/json-crdt-extensions/prosemirror/ProseMirrorNode.ts new file mode 100644 index 0000000000..94f0bf0db3 --- /dev/null +++ b/packages/json-joy/src/json-crdt-extensions/prosemirror/ProseMirrorNode.ts @@ -0,0 +1,95 @@ +import {type Block, type Inline, LeafBlock, Peritext} from '../peritext'; +import {ExtensionId} from '../constants'; +import {MNEMONIC} from './constants'; +import {ExtNode} from '../../json-crdt/extensions/ExtNode'; +import {Slice} from '../peritext/slice/Slice'; +import type {StrNode} from '../../json-crdt/nodes/str/StrNode'; +import type {ArrNode} from '../../json-crdt/nodes/arr/ArrNode'; +import type {PmAttrs, PmDataNode, PmJsonMark, PmJsonNode, PmJsonTextNode} from './types'; + +export class ProseMirrorNode extends ExtNode { + public readonly txt: Peritext; + + constructor(public readonly data: PmDataNode) { + super(data); + this.txt = new Peritext(data.doc, this.text(), this.slices()); + } + + public text(): StrNode { + return this.data.get(0)!; + } + + public slices(): ArrNode { + return this.data.get(1)!; + } + + // ------------------------------------------------------------------ ExtNode + public readonly extId = ExtensionId.prosemirror; + + public name(): string { + return MNEMONIC; + } + + private _view: PmJsonNode | null = null; + private _viewHash: number = -1; + + private toPM(block: Block | LeafBlock): PmJsonNode { + const content: PmJsonNode['content'] = []; + const node: PmJsonNode = {type: block.tag() + ''}; + if (block instanceof LeafBlock) { + for (let iterator = block.texts0(), inline: Inline | undefined; (inline = iterator()); ) { + const text = inline.text(); + if (!text) continue; + const textNode: PmJsonTextNode = { + type: 'text', + text, + }; + const slices = inline.p1.layers; + const length = slices.length; + if (length > 0) { + const marks: PmJsonMark[] = []; + for (let i = 0; i < length; i++) { + const slice = slices[i]; + if (slice instanceof Slice) { + const tag = slice.type() + ''; + const data = slice.data(); + const mark: PmJsonMark = {type: tag}; + if (data && typeof data === 'object' && !Array.isArray(data)) mark.attrs = data as PmAttrs; + marks.push(mark); + } + } + textNode.marks = marks; + } + content.push(textNode); + } + } else { + const children = block.children; + const length = children.length; + for (let i = 0; i < length; i++) content.push(this.toPM(children[i])); + } + if (content.length) node.content = content; + const data = block.attr(); + if (data && typeof data === 'object') { + for (const _ in data) { + node.attrs = data as PmAttrs; + break; + } + } + return node; + } + + public view(): PmJsonNode { + const {txt} = this; + const hash = txt.refresh(); + if (this._view && hash === this._viewHash) return this._view; + const content: PmJsonNode[] = []; + const node: PmJsonNode = {type: 'doc', content}; + const block = txt.blocks.root; + const children = block.children; + const length = children.length; + for (let i = 0; i < length; i++) content.push(this.toPM(children[i])); + this._viewHash = hash; + this._view = node; + return node; + } +} diff --git a/packages/json-joy/src/json-crdt-extensions/prosemirror/__tests__/FromPm.automated.spec.ts b/packages/json-joy/src/json-crdt-extensions/prosemirror/__tests__/FromPm.automated.spec.ts new file mode 100644 index 0000000000..9cb41c2944 --- /dev/null +++ b/packages/json-joy/src/json-crdt-extensions/prosemirror/__tests__/FromPm.automated.spec.ts @@ -0,0 +1,8 @@ +import * as fixtures from './fixtures'; +import {assertCanConvert} from './setup'; + +for (const [name, fixture] of Object.entries(fixtures)) { + test(name, () => { + assertCanConvert(fixture); + }); +} diff --git a/packages/json-joy/src/json-crdt-extensions/prosemirror/__tests__/FromPm.fuzzer.spec.ts b/packages/json-joy/src/json-crdt-extensions/prosemirror/__tests__/FromPm.fuzzer.spec.ts new file mode 100644 index 0000000000..346ef77f30 --- /dev/null +++ b/packages/json-joy/src/json-crdt-extensions/prosemirror/__tests__/FromPm.fuzzer.spec.ts @@ -0,0 +1,10 @@ +import {assertCanConvert} from './setup'; +import {NodeToViewRangeFuzzer} from './fuzzer'; + +test('fuzzer', () => { + for (let i = 0; i < 100; i++) { + const doc = NodeToViewRangeFuzzer.doc(); + assertCanConvert(doc); + assertCanConvert(doc); + } +}); diff --git a/packages/json-joy/src/json-crdt-extensions/prosemirror/__tests__/FromPm.spec.ts b/packages/json-joy/src/json-crdt-extensions/prosemirror/__tests__/FromPm.spec.ts new file mode 100644 index 0000000000..a137fc3344 --- /dev/null +++ b/packages/json-joy/src/json-crdt-extensions/prosemirror/__tests__/FromPm.spec.ts @@ -0,0 +1,213 @@ +import {s} from '../../../json-crdt-patch'; +import {ModelWithExt as Model, ext} from '../../ModelWithExt'; +import {FromPm} from '../FromPm'; +import {Node} from 'prosemirror-model'; +import {schema, doc, blockquote, p, em, strong, eq, h2, h1} from 'prosemirror-test-builder'; +import {fuzzer1} from './fixtures'; + +describe('FromPm', () => { + describe('convert()', () => { + test('single text paragraph', () => { + const node = doc(p('hello')) as Node; + const viewRange = FromPm.convert(node); + const model = Model.create( + s.obj({ + prose: ext.prosemirror.new(''), + }), + ); + const prosemirror = model.s.prose.toExt(); + prosemirror.node.txt.editor.import(0, viewRange); + const view = prosemirror.view(); + expect(view).toEqual(node.toJSON()); + }); + + test('single text paragraph with a single inline formatting', () => { + const node = doc(p('Text: ', strong('bold'), '!')) as Node; + const viewRange = FromPm.convert(node); + const model = Model.create(ext.prosemirror.new()); + const prosemirror = model.s.toExt(); + prosemirror.node.txt.editor.import(0, viewRange); + prosemirror.node.txt.refresh(); + const view = prosemirror.view(); + expect(view).toEqual(node.toJSON()); + }); + + test('single text paragraph wrapped in inline formatting', () => { + const node = doc(p(strong('bold'))) as Node; + const viewRange = FromPm.convert(node); + const model = Model.create(ext.prosemirror.new()); + const prosemirror = model.s.toExt(); + prosemirror.node.txt.editor.import(0, viewRange); + prosemirror.node.txt.refresh(); + const view = prosemirror.view(); + expect(view).toEqual(node.toJSON()); + }); + + test(' inside ', () => { + const node = doc(p(em(strong('bold')))) as Node; + const viewRange = FromPm.convert(node); + const model = Model.create(ext.prosemirror.new()); + const prosemirror = model.s.toExt(); + prosemirror.node.txt.editor.import(0, viewRange); + prosemirror.node.txt.refresh(); + const view = prosemirror.view(); + expect(view).toEqual(node.toJSON()); + const node2 = Node.fromJSON(schema, view); + expect(view).toEqual(node2.toJSON()); + }); + + test(' inside ', () => { + const node = doc(p(strong(em('bold')))) as Node; + const viewRange = FromPm.convert(node); + const model = Model.create(ext.prosemirror.new()); + const prosemirror = model.s.toExt(); + prosemirror.node.txt.editor.import(0, viewRange); + prosemirror.node.txt.refresh(); + const view = prosemirror.view(); + expect(view).toEqual(node.toJSON()); + const node2 = Node.fromJSON(schema, view); + expect(view).toEqual(node2.toJSON()); + }); + + test('two ', () => { + const node = doc(p('paragraph 1'), p('paragraph 2')) as Node; + const viewRange = FromPm.convert(node); + const model = Model.create(ext.prosemirror.new()); + const prosemirror = model.s.toExt(); + prosemirror.node.txt.editor.import(0, viewRange); + prosemirror.node.txt.refresh(); + const view = prosemirror.view(); + expect(view).toEqual(node.toJSON()); + }); + + test(' and
', () => { + const node = doc(p('paragraph 1'), blockquote('blockquote 2')) as Node; + const viewRange = FromPm.convert(node); + const model = Model.create(ext.prosemirror.new()); + const prosemirror = model.s.toExt(); + prosemirror.node.txt.editor.import(0, viewRange); + prosemirror.node.txt.refresh(); + const view = prosemirror.view(); + expect(view).toEqual(node.toJSON()); + }); + + test('two in
', () => { + const node = doc(blockquote(p('paragraph 1'), p('paragraph 2'))) as Node; + const viewRange = FromPm.convert(node); + const model = Model.create(ext.prosemirror.new()); + const prosemirror = model.s.toExt(); + prosemirror.node.txt.editor.import(0, viewRange); + prosemirror.node.txt.refresh(); + const view = prosemirror.view(); + expect(view).toEqual(node.toJSON()); + }); + + test('two
with each', () => { + const node = doc(blockquote(p('paragraph 1')), blockquote(p('paragraph 2'))) as Node; + const viewRange = FromPm.convert(node); + const model = Model.create(ext.prosemirror.new()); + const prosemirror = model.s.toExt(); + prosemirror.node.txt.editor.import(0, viewRange); + prosemirror.node.txt.refresh(); + const view = prosemirror.view(); + expect(view).toEqual(node.toJSON()); + }); + + test('three
with each', () => { + const node = doc( + blockquote(p('paragraph 1')), + blockquote(p('paragraph 2')), + blockquote(p('paragraph 3')), + ) as Node; + const viewRange = FromPm.convert(node); + const model = Model.create(ext.prosemirror.new()); + const prosemirror = model.s.toExt(); + prosemirror.node.txt.editor.import(0, viewRange); + prosemirror.node.txt.refresh(); + const view = prosemirror.view(); + expect(view).toEqual(node.toJSON()); + const node2 = Node.fromJSON(schema, view); + expect(eq(node, node2)).toBe(true); + }); + + test('empty paragraphs', () => { + const node = doc(p(), p()) as Node; + const viewRange = FromPm.convert(node); + const model = Model.create(ext.prosemirror.new()); + const prosemirror = model.s.toExt(); + prosemirror.node.txt.editor.import(0, viewRange); + prosemirror.node.txt.refresh(); + const view = prosemirror.view(); + expect(view).toEqual(node.toJSON()); + const node2 = Node.fromJSON(schema, view); + expect(eq(node, node2)).toBe(true); + }); + + test('block element

with attributes', () => { + const node = doc(h2('hello world')) as Node; + const viewRange = FromPm.convert(node); + // console.log(JSON.stringify(node.toJSON(), null, 2)); + // console.log(JSON.stringify(viewRange, null, 2)); + const model = Model.create(ext.prosemirror.new()); + const prosemirror = model.s.toExt(); + prosemirror.node.txt.editor.import(0, viewRange); + prosemirror.node.txt.refresh(); + const view = prosemirror.view(); + // console.log(JSON.stringify(view, null, 2)); + expect(view).toEqual(node.toJSON()); + const node2 = Node.fromJSON(schema, view); + expect(eq(node, node2)).toBe(true); + }); + + test('discriminant two levels deep', () => { + const node = doc( + blockquote(blockquote(p('paragraph 1')), blockquote(p('paragraph 2'))), + blockquote(blockquote(p('paragraph 1')), blockquote(p('paragraph 2')), blockquote(p('paragraph 3'))), + blockquote(blockquote(p('paragraph 1')), blockquote(p('paragraph 2'))), + ) as Node; + const viewRange = FromPm.convert(node); + // console.log(JSON.stringify(node.toJSON(), null, 2)); + // console.log(JSON.stringify(viewRange, null, 2)); + const model = Model.create(ext.prosemirror.new()); + const prosemirror = model.s.toExt(); + prosemirror.node.txt.editor.import(0, viewRange); + prosemirror.node.txt.refresh(); + // console.log(prosemirror.node.txt + ''); + const view = prosemirror.view(); + // console.log(JSON.stringify(view, null, 2)); + expect(view).toEqual(node.toJSON()); + }); + + test('can convert a two-block document', () => { + const node = doc( + h1('Hello world'), + blockquote(p('This is a ', strong('Prose'), strong(em('Mirror')), ' editor example.')), + ) as Node; + const viewRange = FromPm.convert(node); + const model = Model.create(ext.prosemirror.new()); + const prosemirror = model.s.toExt(); + prosemirror.node.txt.editor.import(0, viewRange); + prosemirror.node.txt.refresh(); + const view = prosemirror.view(); + expect(view).toEqual(node.toJSON()); + const node2 = Node.fromJSON(schema, view); + expect(eq(node, node2)).toBe(true); + }); + + test('fuzzer 1', () => { + const node = fuzzer1; + const viewRange = FromPm.convert(node); + // console.log(JSON.stringify(viewRange, null, 2)); + const model = Model.create(ext.prosemirror.new()); + const prosemirror = model.s.toExt(); + prosemirror.node.txt.editor.import(0, viewRange); + prosemirror.node.txt.refresh(); + // console.log(prosemirror.node.txt + ''); + const view = prosemirror.view(); + // console.log(JSON.stringify(view, null, 2)); + expect(view).toEqual(node.toJSON()); + const node2 = Node.fromJSON(schema, view); + expect(eq(node, node2)).toBe(true); + }); + }); +}); diff --git a/packages/json-joy/src/json-crdt-extensions/prosemirror/__tests__/ProseMirrorApi.automated.spec.ts b/packages/json-joy/src/json-crdt-extensions/prosemirror/__tests__/ProseMirrorApi.automated.spec.ts new file mode 100644 index 0000000000..ca363c56ac --- /dev/null +++ b/packages/json-joy/src/json-crdt-extensions/prosemirror/__tests__/ProseMirrorApi.automated.spec.ts @@ -0,0 +1,38 @@ +import {Node} from 'prosemirror-model'; +import {ModelWithExt as Model} from '../../../json-crdt-extensions'; +import {ext} from '../../ModelWithExt'; +import * as fixtures from './fixtures'; +import {assertCanMergeInto} from './setup'; +import {schema} from 'prosemirror-test-builder'; + +describe('.mergePmNode()', () => { + describe('can merge into an empty node', () => { + const assertCanMerge = (doc: Node) => { + const model = Model.create(ext.prosemirror.new()); + const prosemirror = model.s.toExt(); + prosemirror.mergePmNode(doc); + prosemirror.node.txt.refresh(); + const view = prosemirror.view(); + // console.log(JSON.stringify(view, null, 2)); + // console.log(JSON.stringify(doc.toJSON(), null, 2)); + // console.log(view); + expect(Node.fromJSON(schema, view).toJSON()).toEqual(doc.toJSON()); + }; + + for (const [name, fixture] of Object.entries(fixtures)) { + test(name, () => { + assertCanMerge(fixture); + }); + } + }); + + describe('can merge from any model to any other', () => { + for (const [name1, fixture1] of Object.entries(fixtures)) { + for (const [name2, fixture2] of Object.entries(fixtures)) { + test(`from ${name1} to ${name2}`, () => { + assertCanMergeInto(fixture1, fixture2); + }); + } + } + }); +}); diff --git a/packages/json-joy/src/json-crdt-extensions/prosemirror/__tests__/ProseMirrorApi.fuzzer.spec.ts b/packages/json-joy/src/json-crdt-extensions/prosemirror/__tests__/ProseMirrorApi.fuzzer.spec.ts new file mode 100644 index 0000000000..f2c8439d9e --- /dev/null +++ b/packages/json-joy/src/json-crdt-extensions/prosemirror/__tests__/ProseMirrorApi.fuzzer.spec.ts @@ -0,0 +1,16 @@ +import type {Node} from 'prosemirror-model'; +import {NodeToViewRangeFuzzer} from './fuzzer'; +import {assertCanMergeTrain} from './setup'; + +test('can merge document train', () => { + for (let i = 0; i < 3; i++) { + const count = 3; + const docs: Node[] = []; + for (let i = 0; i < count; i++) { + const doc = NodeToViewRangeFuzzer.doc(); + // logTree(doc.toJSON()); + docs.push(doc); + } + assertCanMergeTrain(docs); + } +}); diff --git a/packages/json-joy/src/json-crdt-extensions/prosemirror/__tests__/ProseMirrorApi.spec.ts b/packages/json-joy/src/json-crdt-extensions/prosemirror/__tests__/ProseMirrorApi.spec.ts new file mode 100644 index 0000000000..000a1826c2 --- /dev/null +++ b/packages/json-joy/src/json-crdt-extensions/prosemirror/__tests__/ProseMirrorApi.spec.ts @@ -0,0 +1,70 @@ +import * as fixtures from './fixtures'; +import {assertCanMergeTrain, assertCanMergeInto, assertEmptyMerge} from './setup'; + +describe('.mergePmNode()', () => { + test('can merge "twoBlockquotes" fixture into "paragraphs" fixture', () => { + assertCanMergeInto(fixtures.twoBlockquotes, fixtures.paragraphs); + assertCanMergeInto(fixtures.paragraphs, fixtures.twoBlockquotes); + }); + + test('can merge "inlineStyles" fixture into "nestedInlines" fixture', () => { + assertCanMergeInto(fixtures.inlineStyles, fixtures.nestedInlines); + assertCanMergeInto(fixtures.nestedInlines, fixtures.inlineStyles); + }); + + test('can merge "nestedInlinesWithAttributes" fixture into "nestedInlinesWithAttributes2" fixture', () => { + assertCanMergeInto(fixtures.nestedInlinesWithAttributes2, fixtures.nestedInlinesWithAttributes); + assertCanMergeInto(fixtures.nestedInlinesWithAttributes, fixtures.nestedInlinesWithAttributes2); + }); + + test('can merge "paragraph" fixture into "nestedInlinesWithAttributes" fixture', () => { + assertCanMergeInto(fixtures.paragraph, fixtures.nestedInlinesWithAttributes); + assertCanMergeInto(fixtures.nestedInlinesWithAttributes, fixtures.paragraph); + }); + + test('can merge "inlineStyles" fixture into "blockquotes" fixture', () => { + assertCanMergeInto(fixtures.inlineStyles, fixtures.blockquotes); + assertCanMergeInto(fixtures.blockquotes, fixtures.inlineStyles); + }); + + test('can merge "headings" fixture into "realisticDoc" fixture', () => { + assertCanMergeInto(fixtures.headings, fixtures.realisticDoc); + assertCanMergeInto(fixtures.realisticDoc, fixtures.headings); + }); + + test('can merge "realisticDoc" fixture into "realisticDoc" fixture', () => { + assertCanMergeInto(fixtures.realisticDoc, fixtures.realisticDoc); + }); + + test('produces not changes when merging document into equivalent document', () => { + assertEmptyMerge(fixtures.nestedInlinesWithAttributes); + assertEmptyMerge(fixtures.realisticDoc); + assertEmptyMerge(fixtures.fuzzer1); + }); + + test('can merge all docs one into each other', () => { + assertCanMergeTrain([ + fixtures.paragraphs, + fixtures.twoBlockquotes, + fixtures.inlineStyles, + fixtures.nestedInlines, + fixtures.nestedInlinesWithAttributes, + fixtures.nestedInlinesWithAttributes2, + fixtures.blockquotes, + fixtures.headings, + fixtures.realisticDoc, + fixtures.fuzzer1, + + fixtures.nestedInlinesWithAttributes2, + fixtures.nestedInlines, + fixtures.inlineStyles, + fixtures.realisticDoc, + fixtures.nestedInlinesWithAttributes, + fixtures.blockquotes, + fixtures.fuzzer1, + fixtures.twoBlockquotes, + fixtures.headings, + fixtures.paragraphs, + ]); + }); +}); diff --git a/packages/json-joy/src/json-crdt-extensions/prosemirror/__tests__/fixtures.ts b/packages/json-joy/src/json-crdt-extensions/prosemirror/__tests__/fixtures.ts new file mode 100644 index 0000000000..0428bee58f --- /dev/null +++ b/packages/json-joy/src/json-crdt-extensions/prosemirror/__tests__/fixtures.ts @@ -0,0 +1,158 @@ +import {fromJSON} from '../util'; +import {Fragment} from 'prosemirror-model'; +import {schema} from 'prosemirror-test-builder'; +import {doc, blockquote, ul, ol, li, p, h1, h2, h3, em, strong, a} from 'prosemirror-test-builder'; + +export const fuzzer1 = fromJSON( + schema, + { + type: 'doc', + content: [ + { + type: 'ordered_list', + attrs: { + order: 1, + }, + content: [ + { + type: 'list_item', + content: [ + { + type: 'heading', + attrs: { + level: 2, + }, + content: [ + { + type: 'text', + text: 'abc', + }, + ], + }, + { + type: 'ordered_list', + attrs: { + order: 1, + }, + content: [ + { + type: 'list_item', + content: [ + { + type: 'heading', + attrs: { + level: 2, + }, + content: [ + { + type: 'text', + text: 'abc', + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + (nodes) => (nodes ? new (Fragment as any)(nodes) : Fragment.empty), +); + +export const paragraph = doc(p('This is a paragraph.')); + +export const paragraphs = doc( + p('This is a paragraph.'), + p('This is another paragraph.'), + p('This is yet another paragraph.'), +); + +export const twoBlockquotes = doc( + blockquote(p('This is a blockquote.')), + blockquote('This is another blockquote. Without a paragraph.'), +); + +export const blockquotes = doc( + blockquote(p('This is a blockquote.')), + blockquote('This is another blockquote. Without a paragraph.'), + blockquote(p('Blockquote with two paragraphs.'), p('This is the second paragraph of the blockquote.')), +); + +export const list = doc(ul(li(p('Item 1')), li(p('Item 2')), li(p('Item 3')))); + +export const nestedList = doc( + ul( + li(p('Item 1')), + li(p('Item 2'), ul(li(p('Subitem 2.1')), li(p('Subitem 2.2')), li(p('Subitem 2.3')))), + li(p('Item 3')), + ), +); + +export const headings = doc( + h1('Heading 1'), + p('This is a paragraph under heading 1.'), + h2('Heading 2'), + p('This is a paragraph under heading 2.'), + h3('Heading 3'), + p('This is a paragraph under heading 3.'), +); + +export const realisticDoc = doc( + h1('Main Title'), + p('This is the', em('introduction'), 'paragraph. It introduces the document and provides some context.'), + blockquote(p('This is a quote from someone.')), + h2('Section 1'), + p('This is the first section.'), + ul( + li(p('First item in section 1.', ' It has some details.')), + li(p('Second item in section 1.')), + li(p('Third item in section 1.')), + ), + h2('Section 2'), + p('This is the second section.'), + blockquote(p('This is another quote.')), + h3('Subsection 2.1'), + p('This is a subsection under section 2.'), + ol( + li(p('First item in subsection 2.1.')), + li(p('Second item in subsection 2.1.')), + li(p('Third item in subsection 2.1.')), + ), + h3('Subsection 2.2'), + p('This is another subsection under section 2.'), + blockquote(p('This is a quote in subsection 2.2.')), + h1('Conclusion'), + p('This is the conclusion paragraph.'), +); + +export const inlineStyles = doc( + p( + 'This is a paragraph with ', + em('emphasized text'), + ', ', + strong('strong text'), + ', and a link to ', + a({href: 'https://example.com'}, 'example.com'), + '.', + ), +); + +export const nestedInlines = doc( + p( + 'This is a paragraph with ', + em('nested ', strong('inline styles')), + ' and a link to ', + a({href: 'https://example.com'}, strong('example.com')), + '.', + ), +); + +export const nestedInlinesWithAttributes = doc(p(a({href: 'https://example.com'}, strong('example.com')))); + +export const nestedInlinesWithAttributes2 = doc(p(strong(a({href: 'https://example.com'}, 'example.com')))); + +export const nestingOrderOfInlines = doc(p(em(strong('text'))), p(strong(em('text')))); diff --git a/packages/json-joy/src/json-crdt-extensions/prosemirror/__tests__/fuzzer.ts b/packages/json-joy/src/json-crdt-extensions/prosemirror/__tests__/fuzzer.ts new file mode 100644 index 0000000000..e04c6a1826 --- /dev/null +++ b/packages/json-joy/src/json-crdt-extensions/prosemirror/__tests__/fuzzer.ts @@ -0,0 +1,94 @@ +import type {Node} from 'prosemirror-model'; +import {Fuzzer} from '@jsonjoy.com/util/lib/Fuzzer'; +import { + doc, + blockquote, + ul, + ol, + li, + p, + pre, + h1, + h2, + h3, + em, + strong, + type MarkBuilder, + a, +} from 'prosemirror-test-builder'; +import {RandomJson} from '@jsonjoy.com/json-random'; + +export class NodeToViewRangeFuzzer { + public static readonly doc = () => new NodeToViewRangeFuzzer().createDocumentNode(); + + private fuzzer = new Fuzzer(); + + private nodeCount = 0; + private maxNodeCount = 100; + + doContinue(percent = 50): boolean { + if (this.nodeCount >= this.maxNodeCount) return false; + return this.fuzzer.randomInt(1, 100) <= percent; + } + + createInlineNode(): ReturnType { + const builder = this.fuzzer.pick([em, strong, a]); + this.nodeCount++; + if (builder === a) { + return a({href: RandomJson.genString(4)}, ...this.createInlineFragment()); + } else { + return builder(...this.createInlineFragment()); + } + } + + createInlineFragment(percent = 90): (ReturnType | string)[] { + const nodes: (ReturnType | string)[] = []; + const count = Fuzzer.randomInt(1, 5); + for (let i = 0; i < count; i++) { + if (!this.doContinue()) { + this.nodeCount++; + nodes.push(RandomJson.genString(5)); + break; + } else { + nodes.push(this.createInlineNode()); + } + } + return nodes; + } + + createBlockFragment(percent = 60): Node[] { + const nodes: Node[] = []; + while (this.doContinue(percent)) { + const node = this.fuzzer.pick([() => this.createLeafBlockNode(), () => this.createContainerBlockNode()]); + nodes.push(node()); + } + return nodes; + } + + createListFragment(percent = 50): Node[] { + const nodes: Node[] = []; + while (this.doContinue(percent)) { + percent = Math.max(0, percent - 10); + this.nodeCount++; + nodes.push(li(...this.createBlockFragment())); + } + return nodes; + } + + createLeafBlockNode(): Node { + const builder = this.fuzzer.pick([p, pre, h1, h2, h3]); + this.nodeCount++; + return builder(...this.createInlineFragment()); + } + + createContainerBlockNode(): Node { + const builder = this.fuzzer.pick([blockquote, ul, ol]); + return builder === ul || builder === ol + ? builder(...this.createListFragment()) + : builder(...this.createBlockFragment()); + } + + createDocumentNode(): Node { + return doc(...this.createBlockFragment(100)); + } +} diff --git a/packages/json-joy/src/json-crdt-extensions/prosemirror/__tests__/setup.ts b/packages/json-joy/src/json-crdt-extensions/prosemirror/__tests__/setup.ts new file mode 100644 index 0000000000..61faad5139 --- /dev/null +++ b/packages/json-joy/src/json-crdt-extensions/prosemirror/__tests__/setup.ts @@ -0,0 +1,63 @@ +import {ModelWithExt as Model, ext} from '../../ModelWithExt'; +import {FromPm} from '../FromPm'; +import {Node} from 'prosemirror-model'; +import {schema} from 'prosemirror-test-builder'; + +export const assertCanConvert = (doc: Node) => { + const viewRange = FromPm.convert(doc); + const model = Model.create(ext.prosemirror.new()); + const prosemirror = model.s.toExt(); + prosemirror.node.txt.editor.import(0, viewRange); + prosemirror.node.txt.refresh(); + const view = prosemirror.view(); + // console.log(JSON.stringify(view, null, 2)); + // console.log(JSON.stringify(doc.toJSON(), null, 2)); + expect(view).toEqual(doc.toJSON()); +}; + +export const assertCanMergeInto = (doc1: Node, doc2: Node) => { + const model = Model.create(ext.prosemirror.new()); + const prosemirror = model.s.toExt(); + prosemirror.mergePmNode(doc1); + prosemirror.node.txt.refresh(); + const view = prosemirror.view(); + // logTree(view); + // logTree(prosemirror.node.txt.editor.export()); + expect(Node.fromJSON(schema, view).toJSON()).toEqual(doc1.toJSON()); + prosemirror.mergePmNode(doc2); + const view2 = prosemirror.view(); + // logTree(view2); + // logTree(doc2.toJSON()); + expect(Node.fromJSON(schema, view2).toJSON()).toEqual(doc2.toJSON()); + const model2 = Model.create(ext.prosemirror.new()); + const prosemirror2 = model2.s.toExt(); + const viewRange2 = FromPm.convert(doc2); + prosemirror2.node.txt.editor.merge(viewRange2); + prosemirror2.node.txt.refresh(); + expect(prosemirror2.node.txt.editor.export()).toEqual(prosemirror.node.txt.editor.export()); +}; + +export const assertCanMergeTrain = (docs: Node[]) => { + const model = Model.create(ext.prosemirror.new()); + const prosemirror = model.s.toExt(); + for (const doc of docs) { + prosemirror.mergePmNode(doc); + const view = prosemirror.view(); + // logTree(view); + // logTree(prosemirror.node.txt.editor.export()); + expect(Node.fromJSON(schema, view).toJSON()).toEqual(doc.toJSON()); + } +}; + +export const assertEmptyMerge = (doc: Node) => { + const model = Model.create(ext.prosemirror.new()); + const prosemirror = model.s.toExt(); + const patch1 = prosemirror.mergePmNode(doc); + expect(patch1).not.toEqual([void 0, void 0, void 0]); + prosemirror.node.txt.refresh(); + const patch2 = prosemirror.mergePmNode(doc); + expect(patch2).not.toEqual([void 0, void 0, void 0]); + prosemirror.node.txt.refresh(); + const patch3 = prosemirror.mergePmNode(doc); + expect(patch3).not.toEqual([void 0, void 0, void 0]); +}; diff --git a/packages/json-joy/src/json-crdt-extensions/prosemirror/constants.ts b/packages/json-joy/src/json-crdt-extensions/prosemirror/constants.ts new file mode 100644 index 0000000000..31ca6a86e5 --- /dev/null +++ b/packages/json-joy/src/json-crdt-extensions/prosemirror/constants.ts @@ -0,0 +1,3 @@ +import {ExtensionId, ExtensionName} from '../constants'; + +export const MNEMONIC = ExtensionName[ExtensionId.prosemirror]; diff --git a/packages/json-joy/src/json-crdt-extensions/prosemirror/index.ts b/packages/json-joy/src/json-crdt-extensions/prosemirror/index.ts new file mode 100644 index 0000000000..5176ac7f5f --- /dev/null +++ b/packages/json-joy/src/json-crdt-extensions/prosemirror/index.ts @@ -0,0 +1,17 @@ +import {ExtensionId} from '../constants'; +import {ProseMirrorNode} from './ProseMirrorNode'; +import {ProseMirrorApi} from './ProseMirrorApi'; +import {MNEMONIC} from './constants'; +import {Extension} from '../../json-crdt/extensions/Extension'; +import {SCHEMA} from '../peritext/constants'; +import type {PmDataNode} from './types'; + +export {ProseMirrorNode, ProseMirrorApi}; + +export const prosemirror = new Extension< + ExtensionId.prosemirror, + PmDataNode, + ProseMirrorNode, + ProseMirrorApi, + [text?: string] +>(ExtensionId.prosemirror, MNEMONIC, ProseMirrorNode, ProseMirrorApi, (text: string = '') => SCHEMA(text)); diff --git a/packages/json-joy/src/json-crdt-extensions/prosemirror/types.ts b/packages/json-joy/src/json-crdt-extensions/prosemirror/types.ts new file mode 100644 index 0000000000..98f5f41755 --- /dev/null +++ b/packages/json-joy/src/json-crdt-extensions/prosemirror/types.ts @@ -0,0 +1,44 @@ +import type {PeritextDataNode} from '../peritext/types'; + +export type PmDataNode = PeritextDataNode; + +export interface PmJsonNode { + type: string; + attrs?: Record; + content?: (PmJsonNode | PmJsonTextNode)[]; + marks?: PmJsonMark[]; +} + +export interface PmJsonTextNode extends PmJsonNode { + text: string; +} +export interface PmJsonMark { + type: string; + attrs?: PmAttrs; +} + +export type PmAttrs = Record; + +export interface PmNode { + readonly type: PmNodeType; + readonly attrs?: PmAttrs; + readonly content?: PmFragment; + readonly marks?: readonly PmMark[]; +} + +export interface PmTextNode extends PmNode { + readonly text: string; +} + +export interface PmNodeType { + readonly name: string; +} + +export interface PmFragment { + readonly content: readonly (PmNode | PmTextNode)[]; +} + +export interface PmMark { + readonly type: PmNodeType; + readonly attrs?: PmAttrs; +} diff --git a/packages/json-joy/src/json-crdt-extensions/prosemirror/util.ts b/packages/json-joy/src/json-crdt-extensions/prosemirror/util.ts new file mode 100644 index 0000000000..4e90a0b76c --- /dev/null +++ b/packages/json-joy/src/json-crdt-extensions/prosemirror/util.ts @@ -0,0 +1,24 @@ +import type {Fragment, Mark, Node, Schema} from 'prosemirror-model'; +import type {PmJsonTextNode, PmJsonNode} from './types'; + +export const fromJSON = ( + schema: Schema, + json: PmJsonNode | PmJsonTextNode, + createFragment: (nodes?: Node[]) => Fragment, +): Node => { + const {type, attrs, content, marks, text} = json as PmJsonNode & PmJsonTextNode; + const _marks: Mark[] | undefined = Array.isArray(marks) + ? marks.map((mark) => schema.mark(mark.type, mark.attrs)) + : void 0; + return type === 'text' + ? schema.text(typeof text === 'string' ? text : '', _marks) + : (schema as any) + .nodeType(type) + .create( + attrs, + Array.isArray(content) + ? createFragment(content.map((val) => fromJSON(schema, val, createFragment))) + : createFragment(), + _marks, + ); +}; diff --git a/packages/json-joy/src/json-crdt/model/api/types.ts b/packages/json-joy/src/json-crdt/model/api/types.ts index 1789076495..726a91f9a5 100644 --- a/packages/json-joy/src/json-crdt/model/api/types.ts +++ b/packages/json-joy/src/json-crdt/model/api/types.ts @@ -1,4 +1,11 @@ -import type {PeritextNode, PeritextApi, QuillDeltaNode, QuillDeltaApi} from '../../../json-crdt-extensions'; +import type { + PeritextNode, + PeritextApi, + QuillDeltaNode, + QuillDeltaApi, + ProseMirrorNode, + ProseMirrorApi, +} from '../../../json-crdt-extensions'; import type * as types from '../../nodes'; import type * as nodes from './nodes'; import type {Path} from '@jsonjoy.com/json-pointer'; @@ -26,7 +33,9 @@ export type JsonNodeApi = N extends types.ConNode ? PeritextApi : N extends QuillDeltaNode ? QuillDeltaApi - : never; + : N extends ProseMirrorNode + ? ProseMirrorApi + : never; export type ApiOperation = ApiOperationAdd | ApiOperationReplace | ApiOperationMerge | ApiOperationRemove; diff --git a/packages/json-joy/src/json-crdt/schema/types.ts b/packages/json-joy/src/json-crdt/schema/types.ts index 79019a13ab..cb96f4bd1b 100644 --- a/packages/json-joy/src/json-crdt/schema/types.ts +++ b/packages/json-joy/src/json-crdt/schema/types.ts @@ -1,6 +1,6 @@ import type {ExtensionId} from '../../json-crdt-extensions'; import type {MvalNode} from '../../json-crdt-extensions/mval/MvalNode'; -import type {PeritextNode, QuillDeltaNode} from '../../json-crdt-extensions'; +import type {PeritextNode, QuillDeltaNode, ProseMirrorNode} from '../../json-crdt-extensions'; import type {nodes as builder} from '../../json-crdt-patch'; import type {ExtNode} from '../extensions/ExtNode'; import type * as nodes from '../nodes'; @@ -24,15 +24,18 @@ export type SchemaToJsonNode = S extends builder.str ? ExtensionNode : S extends builder.ext ? ExtensionNode - : S extends builder.ext - ? ExtensionNode - : nodes.JsonNode; + : S extends builder.ext + ? ExtensionNode + : S extends builder.ext + ? ExtensionNode + : nodes.JsonNode; export type ExtensionNode> = nodes.VecNode>; -export type ExtensionVecData> = { - __BRAND__: 'ExtVecData'; -} & [header: nodes.ConNode, data: EDataNode]; +export type ExtensionVecData> = {__BRAND__: 'ExtVecData'} & [ + header: nodes.ConNode, + data: EDataNode, +]; // prettier-ignore export type VecNodeExtensionData = N extends nodes.VecNode @@ -56,9 +59,11 @@ export type JsonNodeToSchema = N extends nodes.StrNode ? builder.ext : EDataNode extends QuillDeltaNode ? builder.ext - : EDataNode extends MvalNode - ? builder.ext - : builder.ext + : EDataNode extends ProseMirrorNode + ? builder.ext + : EDataNode extends MvalNode + ? builder.ext + : builder.ext : builder.vec<{[K in keyof T]: JsonNodeToSchema}> : N extends nodes.ObjNode ? builder.obj<{[K in keyof T]: JsonNodeToSchema}> diff --git a/yarn.lock b/yarn.lock index f397c0f8df..bd7c8af546 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7072,11 +7072,10 @@ __metadata: "@jsonjoy.com/json-expression": "workspace:*" "@jsonjoy.com/json-pack": "workspace:*" "@jsonjoy.com/json-pointer": "workspace:*" + "@jsonjoy.com/json-random": "workspace:*" "@jsonjoy.com/json-type": "workspace:*" "@jsonjoy.com/util": "workspace:*" - "@monaco-editor/react": "npm:^4.7.0" "@radix-ui/react-icons": "npm:^1.3.1" - "@types/node": "npm:^24.8.1" "@types/react": "npm:^18.3.11" "@types/react-dom": "npm:^18.3.0" arg: "npm:^5.0.2" @@ -7092,7 +7091,6 @@ __metadata: jest: "npm:^29.7.0" json-crdt-traces: "https://github.com/streamich/json-crdt-traces#ec825401dc05cbb74b9e0b3c4d6527399f54d54d" json-logic-js: "npm:^2.0.2" - monaco-editor: "npm:^0.54.0" nano-css: "npm:^5.6.2" nano-theme: "npm:^1.4.3" nice-ui: "npm:^1.30.0"