From c5d8294ab41a8649bb49d04fe542f24a7bd1074b Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Tue, 4 Nov 2025 17:13:50 +0100 Subject: [PATCH 1/4] fix: make changes to the schema migration --- .../schemaMigration/SchemaMigrationPlugin.ts | 13 ++++- .../migrationRules/moveColorAttributes.ts | 55 ++++++++++++------- 2 files changed, 44 insertions(+), 24 deletions(-) diff --git a/packages/core/src/extensions/Collaboration/schemaMigration/SchemaMigrationPlugin.ts b/packages/core/src/extensions/Collaboration/schemaMigration/SchemaMigrationPlugin.ts index 992ee4559f..fc2962dd6b 100644 --- a/packages/core/src/extensions/Collaboration/schemaMigration/SchemaMigrationPlugin.ts +++ b/packages/core/src/extensions/Collaboration/schemaMigration/SchemaMigrationPlugin.ts @@ -1,5 +1,4 @@ import { Plugin, PluginKey } from "@tiptap/pm/state"; -import { ySyncPluginKey } from "y-prosemirror"; import * as Y from "yjs"; import { BlockNoteExtension } from "../../../editor/BlockNoteExtension.js"; @@ -31,8 +30,12 @@ export class SchemaMigrationPlugin extends BlockNoteExtension { } if ( - transactions.length !== 1 || - !transactions[0].getMeta(ySyncPluginKey) + // If any of the transactions are not due to a yjs sync, we don't need to run the migration + !transactions.some((tr) => tr.getMeta("y-sync$")) || + // If none of the transactions result in a document change, we don't need to run the migration + transactions.every((tr) => !tr.docChanged) || + // If the fragment is still empty, we can't run the migration (since it has not yet been applied to the Y.Doc) + !fragment.firstChild ) { return undefined; } @@ -44,6 +47,10 @@ export class SchemaMigrationPlugin extends BlockNoteExtension { this.migrationDone = true; + if (!tr.docChanged) { + return undefined; + } + return tr; }, }), diff --git a/packages/core/src/extensions/Collaboration/schemaMigration/migrationRules/moveColorAttributes.ts b/packages/core/src/extensions/Collaboration/schemaMigration/migrationRules/moveColorAttributes.ts index 69dc3b5964..0866c3523c 100644 --- a/packages/core/src/extensions/Collaboration/schemaMigration/migrationRules/moveColorAttributes.ts +++ b/packages/core/src/extensions/Collaboration/schemaMigration/migrationRules/moveColorAttributes.ts @@ -23,14 +23,13 @@ const traverseElement = ( export const moveColorAttributes: MigrationRule = (fragment, tr) => { // Stores necessary info for all `blockContainer` nodes which still have // `textColor` or `backgroundColor` attributes that need to be moved. - const targetBlockContainers: Record< + const targetBlockContainers: Map< string, { - textColor?: string; - backgroundColor?: string; + textColor: string | undefined; + backgroundColor: string | undefined; } - > = {}; - + > = new Map(); // Finds all elements which still have `textColor` or `backgroundColor` // attributes in the current Yjs fragment. fragment.forEach((element) => { @@ -40,39 +39,53 @@ export const moveColorAttributes: MigrationRule = (fragment, tr) => { element.nodeName === "blockContainer" && element.hasAttribute("id") ) { + const textColor = element.getAttribute("textColor"); + const backgroundColor = element.getAttribute("backgroundColor"); + const colors = { - textColor: element.getAttribute("textColor"), - backgroundColor: element.getAttribute("backgroundColor"), + textColor: + textColor === defaultProps.textColor.default + ? undefined + : textColor, + backgroundColor: + backgroundColor === defaultProps.backgroundColor.default + ? undefined + : backgroundColor, }; - if (colors.textColor === defaultProps.textColor.default) { - colors.textColor = undefined; - } - if (colors.backgroundColor === defaultProps.backgroundColor.default) { - colors.backgroundColor = undefined; - } - if (colors.textColor || colors.backgroundColor) { - targetBlockContainers[element.getAttribute("id")!] = colors; + targetBlockContainers.set(element.getAttribute("id")!, colors); } } }); } }); + if (targetBlockContainers.size === 0) { + return false; + } + // Appends transactions to add the `textColor` and `backgroundColor` // attributes found on each `blockContainer` node to move them to the child // `blockContent` node. tr.doc.descendants((node, pos) => { if ( node.type.name === "blockContainer" && - targetBlockContainers[node.attrs.id] + targetBlockContainers.has(node.attrs.id) ) { - tr = tr.setNodeMarkup( - pos + 1, - undefined, - targetBlockContainers[node.attrs.id], - ); + const el = tr.doc.nodeAt(pos + 1); + if (!el) { + throw new Error("No element found"); + } + + tr.setNodeMarkup(pos + 1, undefined, { + // preserve existing attributes + ...el.attrs, + // add the textColor and backgroundColor attributes + ...targetBlockContainers.get(node.attrs.id), + }); } }); + + return true; }; From 7f5e760b72ab080810621c5140f23d3a95f1c06b Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Wed, 5 Nov 2025 13:52:49 +0100 Subject: [PATCH 2/4] test: add test case for moving the color attributes --- .../moveColorAttributes.test.ts | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 packages/core/src/extensions/Collaboration/schemaMigration/migrationRules/moveColorAttributes.test.ts diff --git a/packages/core/src/extensions/Collaboration/schemaMigration/migrationRules/moveColorAttributes.test.ts b/packages/core/src/extensions/Collaboration/schemaMigration/migrationRules/moveColorAttributes.test.ts new file mode 100644 index 0000000000..7c930e3d83 --- /dev/null +++ b/packages/core/src/extensions/Collaboration/schemaMigration/migrationRules/moveColorAttributes.test.ts @@ -0,0 +1,76 @@ +import { expect, it } from "vitest"; +import * as Y from "yjs"; +import { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; +import { moveColorAttributes } from "./moveColorAttributes.js"; +import { prosemirrorJSONToYXmlFragment } from "y-prosemirror"; + +it("can move color attributes on older documents", async () => { + const doc = new Y.Doc(); + const fragment = doc.getXmlFragment("doc"); + const editor = BlockNoteEditor.create({ + initialContent: [ + { + type: "paragraph", + content: "Welcome to this demo!", + }, + ], + }); + + // Because this was a previous schema, we are creating the YFragment manually + const blockGroup = new Y.XmlElement("blockGroup"); + const el = new Y.XmlElement("blockContainer"); + el.setAttribute("id", "0"); + el.setAttribute("backgroundColor", "red"); + el.setAttribute("textColor", "blue"); + const para = new Y.XmlElement("paragraph"); + para.setAttribute("textAlignment", "left"); + para.insert(0, [new Y.XmlText("Welcome to this demo!")]); + el.insert(0, [para]); + blockGroup.insert(0, [el]); + fragment.insert(0, [blockGroup]); + + // Note that the blockContainer has the color attributes, but the paragraph does not. + expect(fragment.toJSON()).toMatchInlineSnapshot( + `"Welcome to this demo!"`, + ); + + const tr = editor.prosemirrorState.tr; + moveColorAttributes(fragment, tr); + // Note that the color attributes have been moved to the paragraph. + expect(JSON.stringify(tr.doc.toJSON())).toMatchInlineSnapshot( + `"{"type":"doc","content":[{"type":"blockGroup","content":[{"type":"blockContainer","attrs":{"id":"0"},"content":[{"type":"paragraph","attrs":{"backgroundColor":"red","textColor":"blue","textAlignment":"left"},"content":[{"type":"text","text":"Welcome to this demo!"}]}]}]}]}"`, + ); +}); + +it("does not move color attributes on newer documents", async () => { + const doc = new Y.Doc(); + const fragment = doc.getXmlFragment("doc"); + const editor = BlockNoteEditor.create({ + initialContent: [ + { + type: "paragraph", + content: "Welcome to this demo!", + props: { + backgroundColor: "red", + textColor: "blue", + }, + }, + ], + }); + + prosemirrorJSONToYXmlFragment( + editor.pmSchema, + JSON.parse(JSON.stringify(editor.prosemirrorState.doc.toJSON())), + fragment, + ); + + expect(fragment.toJSON()).toMatchInlineSnapshot( + // The color attributes are on the paragraph, not the blockContainer. + `"Welcome to this demo!"`, + ); + + const tr = editor.prosemirrorState.tr; + moveColorAttributes(fragment, tr); + // The document will be unchanged because the color attributes are already on the paragraph. + expect(tr.docChanged).toBe(false); +}); From a0bd4ef1bb3cc207747ceef9d40ba4a06aa6fda7 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Wed, 5 Nov 2025 14:09:02 +0100 Subject: [PATCH 3/4] test: add more tests --- .../moveColorAttributes.test.ts | 56 ++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/packages/core/src/extensions/Collaboration/schemaMigration/migrationRules/moveColorAttributes.test.ts b/packages/core/src/extensions/Collaboration/schemaMigration/migrationRules/moveColorAttributes.test.ts index 7c930e3d83..aa7ffec6d6 100644 --- a/packages/core/src/extensions/Collaboration/schemaMigration/migrationRules/moveColorAttributes.test.ts +++ b/packages/core/src/extensions/Collaboration/schemaMigration/migrationRules/moveColorAttributes.test.ts @@ -53,6 +53,8 @@ it("does not move color attributes on newer documents", async () => { props: { backgroundColor: "red", textColor: "blue", + // Set to non-default value to ensure it is not overridden by the migration rule. + textAlignment: "right", }, }, ], @@ -66,7 +68,7 @@ it("does not move color attributes on newer documents", async () => { expect(fragment.toJSON()).toMatchInlineSnapshot( // The color attributes are on the paragraph, not the blockContainer. - `"Welcome to this demo!"`, + `"Welcome to this demo!"`, ); const tr = editor.prosemirrorState.tr; @@ -74,3 +76,55 @@ it("does not move color attributes on newer documents", async () => { // The document will be unchanged because the color attributes are already on the paragraph. expect(tr.docChanged).toBe(false); }); + +it("can move color attributes on older documents multiple times", async () => { + const doc = new Y.Doc(); + const fragment = doc.getXmlFragment("doc"); + const editor = BlockNoteEditor.create({ + initialContent: [ + { + type: "paragraph", + content: "Welcome to this demo!", + }, + ], + }); + + // Because this was a previous schema, we are creating the YFragment manually + const blockGroup = new Y.XmlElement("blockGroup"); + const el = new Y.XmlElement("blockContainer"); + el.setAttribute("id", "0"); + el.setAttribute("backgroundColor", "red"); + el.setAttribute("textColor", "blue"); + const para = new Y.XmlElement("paragraph"); + para.setAttribute("textAlignment", "left"); + para.insert(0, [new Y.XmlText("Welcome to this demo!")]); + el.insert(0, [para]); + blockGroup.insert(0, [el]); + fragment.insert(0, [blockGroup]); + + // Note that the blockContainer has the color attributes, but the paragraph does not. + expect(fragment.toJSON()).toMatchInlineSnapshot( + `"Welcome to this demo!"`, + ); + + const tr = editor.prosemirrorState.tr; + moveColorAttributes(fragment, tr); + // Note that the color attributes have been moved to the paragraph. + expect(JSON.stringify(tr.doc.toJSON())).toMatchInlineSnapshot( + `"{"type":"doc","content":[{"type":"blockGroup","content":[{"type":"blockContainer","attrs":{"id":"0"},"content":[{"type":"paragraph","attrs":{"backgroundColor":"red","textColor":"blue","textAlignment":"left"},"content":[{"type":"text","text":"Welcome to this demo!"}]}]}]}]}"`, + ); + + el.setAttribute("backgroundColor", "green"); + el.setAttribute("textColor", "yellow"); + + expect(fragment.toJSON()).toMatchInlineSnapshot( + `"Welcome to this demo!"`, + ); + + const nextTr = editor.prosemirrorState.tr; + moveColorAttributes(fragment, nextTr); + // Note that the color attributes have been moved to the paragraph. + expect(JSON.stringify(nextTr.doc.toJSON())).toMatchInlineSnapshot( + `"{"type":"doc","content":[{"type":"blockGroup","content":[{"type":"blockContainer","attrs":{"id":"0"},"content":[{"type":"paragraph","attrs":{"backgroundColor":"green","textColor":"yellow","textAlignment":"left"},"content":[{"type":"text","text":"Welcome to this demo!"}]}]}]}]}"`, + ); +}); From 1d88147dc0b1362500f14b3092a414a4065cbb71 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Mon, 10 Nov 2025 11:27:11 +0100 Subject: [PATCH 4/4] chore: update lockfile --- pnpm-lock.yaml | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 76df1a86e1..f885682517 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -188,7 +188,7 @@ importers: specifier: ^3.2.1 version: 3.13.0 '@tiptap/core': - specifier: ^3.10.2 + specifier: ^3.0.0 version: 3.10.2(@tiptap/pm@3.10.2) '@uppy/core': specifier: ^3.13.1 @@ -3789,7 +3789,7 @@ importers: specifier: ^6.0.22 version: 6.0.22(react@19.2.0) '@tiptap/core': - specifier: ^3.10.2 + specifier: ^3.0.0 version: 3.10.2(@tiptap/pm@3.10.2) react: specifier: ^19.2.0 @@ -4456,7 +4456,7 @@ importers: specifier: 3.13.0 version: 3.13.0 '@tiptap/core': - specifier: ^3.10.2 + specifier: ^3.0.0 version: 3.10.2(@tiptap/pm@3.10.2) '@tiptap/extension-bold': specifier: ^3.7.2 @@ -4492,7 +4492,7 @@ importers: specifier: ^3.7.2 version: 3.7.2(@tiptap/core@3.10.2(@tiptap/pm@3.10.2)) '@tiptap/pm': - specifier: ^3.10.2 + specifier: ^3.0.0 version: 3.10.2 emoji-mart: specifier: ^5.6.0 @@ -4705,10 +4705,10 @@ importers: specifier: ^0.27.16 version: 0.27.16(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@tiptap/core': - specifier: ^3.10.2 + specifier: ^3.0.0 version: 3.10.2(@tiptap/pm@3.10.2) '@tiptap/pm': - specifier: ^3.10.2 + specifier: ^3.0.0 version: 3.10.2 '@tiptap/react': specifier: ^3.10.2 @@ -4784,10 +4784,10 @@ importers: specifier: 0.41.1 version: link:../react '@tiptap/core': - specifier: ^3.10.2 + specifier: ^3.0.0 version: 3.10.2(@tiptap/pm@3.10.2) '@tiptap/pm': - specifier: ^3.10.2 + specifier: ^3.0.0 version: 3.10.2 jsdom: specifier: ^25.0.1 @@ -4963,7 +4963,7 @@ importers: specifier: ^0.26.28 version: 0.26.28(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@tiptap/core': - specifier: ^3.10.2 + specifier: ^3.0.0 version: 3.10.2(@tiptap/pm@3.10.2) ai: specifier: ^5.0.76 @@ -5282,7 +5282,7 @@ importers: specifier: 0.41.1 version: link:../react '@tiptap/core': - specifier: ^3.10.2 + specifier: ^3.0.0 version: 3.10.2(@tiptap/pm@3.10.2) prosemirror-model: specifier: ^1.25.4 @@ -5695,7 +5695,7 @@ importers: specifier: 1.51.1 version: 1.51.1 '@tiptap/pm': - specifier: ^3.10.2 + specifier: ^3.0.0 version: 3.10.2 '@types/node': specifier: ^20.19.22 @@ -24183,7 +24183,7 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 22.15.2 + '@types/node': 20.19.22 merge-stream: 2.0.0 supports-color: 8.1.1