From c9dc7011e7df844ca7aa7ce2053dad65764e08b5 Mon Sep 17 00:00:00 2001 From: Laurens Lavaert Date: Tue, 2 Jul 2024 16:03:27 +0000 Subject: [PATCH 1/2] feat: allow marks to exist on all nodes that support it --- src/lib.js | 18 +++++++++-- src/plugins/sync-plugin.js | 66 +++++++++++++++++++++++++++++++++----- 2 files changed, 73 insertions(+), 11 deletions(-) diff --git a/src/lib.js b/src/lib.js index 820c13d3..2802c93c 100644 --- a/src/lib.js +++ b/src/lib.js @@ -1,4 +1,4 @@ -import { updateYFragment, createNodeFromYElement } from './plugins/sync-plugin.js' // eslint-disable-line +import { updateYFragment, createNodeFromYElement, MarkPrefix } from './plugins/sync-plugin.js' // eslint-disable-line import { ySyncPluginKey } from './plugins/keys.js' import * as Y from 'yjs' import { EditorView } from 'prosemirror-view' // eslint-disable-line @@ -416,8 +416,20 @@ export function yXmlFragmentToProsemirrorJSON (xmlFragment) { } const attrs = item.getAttributes() - if (Object.keys(attrs).length) { - response.attrs = attrs + + // Add all non-mark attributes to the element + for (const key of Object.keys(attrs).filter((key) => !key.startsWith(MarkPrefix))) { + if (!response.attrs) response.attrs = {} + response.attrs[key] = attrs[key] + } + + // Check whether the attrs contains marks, if so, add them to the response + if (Object.keys(attrs).some((key) => key.startsWith(MarkPrefix))) { + response.marks = Object.keys(attrs) + .filter((key) => key.startsWith(MarkPrefix)) + .map((key) => { + return attrs[key] + }) } const children = item.toArray() diff --git a/src/plugins/sync-plugin.js b/src/plugins/sync-plugin.js index b548aef7..2fc7d183 100644 --- a/src/plugins/sync-plugin.js +++ b/src/plugins/sync-plugin.js @@ -20,6 +20,9 @@ import * as random from 'lib0/random' import * as environment from 'lib0/environment' import * as dom from 'lib0/dom' import * as eventloop from 'lib0/eventloop' +import * as f from 'lib0/function' + +export const MarkPrefix = '_mark_' /** * @param {Y.Item} item @@ -723,7 +726,22 @@ export const createNodeFromYElement = ( : { type: 'added' } } } - const node = schema.node(el.nodeName, attrs, children) + const nodeAttrs = {} + const nodeMarks = [] + + for (const key in attrs) { + if (key.startsWith(MarkPrefix)) { + const markName = key.replace(MarkPrefix, '') + const markValue = attrs[key] + if (isObject(markValue)) { + nodeMarks.push(schema.mark(markName, /** @type {Object} */ (markValue).attrs)) + } + } else { + nodeAttrs[key] = attrs[key] + } + } + + const node = schema.node(el.nodeName, nodeAttrs, children, nodeMarks) mapping.set(el, node) return node } catch (e) { @@ -802,12 +820,16 @@ const createTypeFromTextNodes = (nodes, mapping) => { */ const createTypeFromElementNode = (node, mapping) => { const type = new Y.XmlElement(node.type.name) + const nodeMarksAttr = nodeMarksToAttributes(node.marks) for (const key in node.attrs) { const val = node.attrs[key] if (val !== null && key !== 'ychange') { type.setAttribute(key, val) } } + for (const key in nodeMarksAttr) { + type.setAttribute(key, nodeMarksAttr[key]) + } type.insert( 0, normalizePNodeContent(node).map((n) => @@ -835,7 +857,7 @@ const equalAttrs = (pattrs, yattrs) => { const keys = Object.keys(pattrs).filter((key) => pattrs[key] !== null) let eq = keys.length === - Object.keys(yattrs).filter((key) => yattrs[key] !== null).length + Object.keys(yattrs).filter((key) => yattrs[key] !== null && !key.startsWith(MarkPrefix)).length for (let i = 0; i < keys.length && eq; i++) { const key = keys[i] const l = pattrs[key] @@ -846,6 +868,20 @@ const equalAttrs = (pattrs, yattrs) => { return eq } +const equalMarks = (pmarks, yattrs) => { + const keys = Object.keys(yattrs).filter((key) => key.startsWith(MarkPrefix)) + let eq = + keys.length === pmarks.length + const pMarkAttr = nodeMarksToAttributes(pmarks) + for (let i = 0; i < keys.length && eq; i++) { + const key = keys[i] + const l = pMarkAttr[key] + const r = yattrs[key] + eq = key === 'ychange' || f.equalityDeep(l, r) + } + return eq +} + /** * @typedef {Array|PModel.Node>} NormalizedPNodeContent */ @@ -900,7 +936,8 @@ const equalYTypePNode = (ytype, pnode) => { ) { const normalizedContent = normalizePNodeContent(pnode) return ytype._length === normalizedContent.length && - equalAttrs(ytype.getAttributes(), pnode.attrs) && + equalAttrs(pnode.attrs, ytype.getAttributes()) && + equalMarks(pnode.marks, ytype.getAttributes()) && ytype.toArray().every((ychild, i) => equalYTypePNode(ychild, normalizedContent[i]) ) @@ -1017,6 +1054,16 @@ const marksToAttributes = (marks) => { return pattrs } +const nodeMarksToAttributes = (marks) => { + const pattrs = {} + marks.forEach((mark) => { + if (mark.type.name !== 'ychange') { + pattrs[`${MarkPrefix}${mark.type.name}`] = mark.toJSON() + } + }) + return pattrs +} + /** * Update a yDom node by syncing the current content of the prosemirror node. * @@ -1042,10 +1089,13 @@ export const updateYFragment = (y, yDomFragment, pNode, mapping) => { if (yDomFragment instanceof Y.XmlElement) { const yDomAttrs = yDomFragment.getAttributes() const pAttrs = pNode.attrs - for (const key in pAttrs) { - if (pAttrs[key] !== null) { - if (yDomAttrs[key] !== pAttrs[key] && key !== 'ychange') { - yDomFragment.setAttribute(key, pAttrs[key]) + const pNodeMarksAttr = nodeMarksToAttributes(pNode.marks) + const attrs = { ...pAttrs, ...pNodeMarksAttr } + + for (const key in attrs) { + if (attrs[key] !== null) { + if (yDomAttrs[key] !== attrs[key] && key !== 'ychange') { + yDomFragment.setAttribute(key, attrs[key]) } } else { yDomFragment.removeAttribute(key) @@ -1053,7 +1103,7 @@ export const updateYFragment = (y, yDomFragment, pNode, mapping) => { } // remove all keys that are no longer in pAttrs for (const key in yDomAttrs) { - if (pAttrs[key] === undefined) { + if (attrs[key] === undefined) { yDomFragment.removeAttribute(key) } } From fd0bb448403a237298b696c67d10063e38c3a22c Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Fri, 22 Nov 2024 14:24:51 +0100 Subject: [PATCH 2/2] test: add test for marks on nodes --- tests/y-prosemirror.test.js | 65 ++++++++++++++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/tests/y-prosemirror.test.js b/tests/y-prosemirror.test.js index 730818c3..477a5d90 100644 --- a/tests/y-prosemirror.test.js +++ b/tests/y-prosemirror.test.js @@ -23,7 +23,7 @@ import { findWrapping } from 'prosemirror-transform' import { schema as complexSchema } from './complexSchema.js' import * as promise from 'lib0/promise' -const schema = /** @type {any} */ (basicSchema.schema) +const schema = /** @type {import('prosemirror-model').Schema} */ (basicSchema.schema) /** * Verify that update events in plugins are only fired once. @@ -452,6 +452,69 @@ export const testAddToHistoryIgnore = (_tc) => { ) } +/** + * @param {t.TestCase} tc + */ +export const testAddMarkToNode = (_tc) => { + const view = createNewProsemirrorView(new Y.Doc()) + view.dispatch( + view.state.tr.insert( + 0, + /** @type {any} */ (schema.node('image', { src: 'http://example.com', alt: null, title: null }, [], [schema.mark('link', { href: 'https://yjs.dev', title: null })])) + ) + ) + // convert to JSON and back because the ProseMirror schema messes with the constructors + const stateJSON = JSON.parse(JSON.stringify(view.state.doc.toJSON())) + // test if transforming back and forth from yXmlFragment works + const xml = new Y.XmlFragment() + prosemirrorJSONToYXmlFragment(/** @type {any} */ (schema), stateJSON, xml) + const doc = new Y.Doc() + doc.getMap('root').set('firstDoc', xml) + // test if transforming back and forth from Yjs doc works + const backandforth = JSON.parse(JSON.stringify(yXmlFragmentToProsemirrorJSON(xml))) + // Add the missing alt and title attributes, these are defaults for the image mark + backandforth.content[0].content[0].attrs.alt = null + backandforth.content[0].content[0].attrs.title = null + + t.assert(backandforth.content[0].content[0].marks.length === 1, 'node has one mark') + t.compare(stateJSON, backandforth, 'marks on nodes are preserved') +} + +/** + * @param {t.TestCase} tc + */ +export const testRemoveMarkFromNode = (_tc) => { + const view = createNewProsemirrorView(new Y.Doc()) + view.dispatch( + view.state.tr.insert( + 0, + /** @type {any} */ (schema.node('image', { src: 'http://example.com', alt: null, title: null }, [], [schema.mark('link', { href: 'https://yjs.dev', title: null })])) + ) + ) + t.assert(view.state.doc.content.firstChild.content.firstChild.marks.length === 1, 'node has one mark') + + view.dispatch( + view.state.tr.removeNodeMark(1, schema.marks.link) + ) + t.assert(view.state.doc.content.firstChild.content.firstChild.marks.length === 0, 'node has no marks') + // convert to JSON and back because the ProseMirror schema messes with the constructors + const stateJSON = JSON.parse(JSON.stringify(view.state.doc.toJSON())) + // test if transforming back and forth from yXmlFragment works + const xml = new Y.XmlFragment() + prosemirrorJSONToYXmlFragment(/** @type {any} */ (schema), stateJSON, xml) + const doc = new Y.Doc() + doc.getMap('root').set('firstDoc', xml) + + // test if transforming back and forth from Yjs doc works + const backandforth = JSON.parse(JSON.stringify(yXmlFragmentToProsemirrorJSON(xml))) + // Add the missing alt and title attributes, these are defaults for the image mark + backandforth.content[0].content[0].attrs.alt = null + backandforth.content[0].content[0].attrs.title = null + + t.assert(backandforth.content[0].content[0].marks === undefined, 'node has no marks') + t.compare(stateJSON, backandforth) +} + const createNewProsemirrorViewWithSchema = (y, schema, undoManager = false) => { const view = new EditorView(null, { // @ts-ignore