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.test.ts b/packages/core/src/extensions/Collaboration/schemaMigration/migrationRules/moveColorAttributes.test.ts
new file mode 100644
index 0000000000..aa7ffec6d6
--- /dev/null
+++ b/packages/core/src/extensions/Collaboration/schemaMigration/migrationRules/moveColorAttributes.test.ts
@@ -0,0 +1,130 @@
+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",
+ // Set to non-default value to ensure it is not overridden by the migration rule.
+ textAlignment: "right",
+ },
+ },
+ ],
+ });
+
+ 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);
+});
+
+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!"}]}]}]}]}"`,
+ );
+});
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;
};
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