@blocknote/core 0.41.1 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/dist/BlockNoteSchema-Bi-eeHal.js +3288 -0
  2. package/dist/BlockNoteSchema-Bi-eeHal.js.map +1 -0
  3. package/dist/BlockNoteSchema-DjDaA2C3.cjs +6 -0
  4. package/dist/BlockNoteSchema-DjDaA2C3.cjs.map +1 -0
  5. package/dist/blockToNode-DIfPWLH8.js +1140 -0
  6. package/dist/blockToNode-DIfPWLH8.js.map +1 -0
  7. package/dist/blockToNode-w7H99R6p.cjs +7 -0
  8. package/dist/blockToNode-w7H99R6p.cjs.map +1 -0
  9. package/dist/blocknote.cjs +4 -4
  10. package/dist/blocknote.cjs.map +1 -1
  11. package/dist/blocknote.js +1413 -1366
  12. package/dist/blocknote.js.map +1 -1
  13. package/dist/blocks.cjs +1 -1
  14. package/dist/blocks.js +1 -1
  15. package/dist/comments.cjs +1 -1
  16. package/dist/comments.cjs.map +1 -1
  17. package/dist/comments.js +49 -49
  18. package/dist/comments.js.map +1 -1
  19. package/dist/en-Cl87Uuyf.cjs.map +1 -1
  20. package/dist/en-njEqD7AG.js.map +1 -1
  21. package/dist/locales.cjs.map +1 -1
  22. package/dist/locales.js.map +1 -1
  23. package/dist/tsconfig.tsbuildinfo +1 -0
  24. package/dist/webpack-stats.json +1 -1
  25. package/dist/yjs.cjs +2 -0
  26. package/dist/yjs.cjs.map +1 -0
  27. package/dist/yjs.js +44 -0
  28. package/dist/yjs.js.map +1 -0
  29. package/package.json +30 -25
  30. package/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts +19 -6
  31. package/src/api/blockManipulation/commands/replaceBlocks/util/fixColumnList.ts +173 -0
  32. package/src/api/nodeConversions/nodeToBlock.ts +17 -14
  33. package/src/blocks/Code/block.ts +1 -0
  34. package/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts +2 -1
  35. package/src/blocks/defaultBlockTypeGuards.ts +7 -18
  36. package/src/comments/threadstore/yjs/YjsThreadStoreBase.ts +3 -1
  37. package/src/editor/BlockNoteEditor.test.ts +70 -1
  38. package/src/editor/BlockNoteEditor.ts +55 -4
  39. package/src/editor/managers/ExportManager.ts +1 -1
  40. package/src/extensions/Collaboration/__snapshots__/fork-yjs-snap-editor-forked.json +2 -2
  41. package/src/extensions/Collaboration/__snapshots__/fork-yjs-snap-editor.json +2 -2
  42. package/src/extensions/Collaboration/__snapshots__/fork-yjs-snap-forked.html +1 -1
  43. package/src/extensions/Collaboration/__snapshots__/fork-yjs-snap.html +1 -1
  44. package/src/extensions/Collaboration/schemaMigration/SchemaMigrationPlugin.ts +10 -3
  45. package/src/extensions/Collaboration/schemaMigration/migrationRules/moveColorAttributes.test.ts +130 -0
  46. package/src/extensions/Collaboration/schemaMigration/migrationRules/moveColorAttributes.ts +34 -21
  47. package/src/extensions/Comments/CommentsPlugin.ts +37 -7
  48. package/src/extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts +30 -128
  49. package/src/extensions/SideMenu/SideMenuPlugin.ts +2 -0
  50. package/src/index.ts +1 -0
  51. package/src/schema/inlineContent/createSpec.ts +3 -0
  52. package/src/schema/inlineContent/types.ts +1 -0
  53. package/src/schema/schema.ts +49 -6
  54. package/src/schema/styles/createSpec.ts +6 -0
  55. package/src/schema/styles/types.ts +1 -0
  56. package/src/yjs/index.ts +1 -0
  57. package/src/yjs/utils.test.ts +1023 -0
  58. package/src/yjs/utils.ts +150 -0
  59. package/types/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.d.ts +1 -1
  60. package/types/src/api/blockManipulation/commands/replaceBlocks/util/fixColumnList.d.ts +32 -0
  61. package/types/src/api/nodeConversions/nodeToBlock.d.ts +1 -1
  62. package/types/src/editor/BlockNoteEditor.d.ts +6 -1
  63. package/types/src/editor/managers/ExportManager.d.ts +1 -1
  64. package/types/src/extensions/Collaboration/schemaMigration/migrationRules/moveColorAttributes.test.d.ts +1 -0
  65. package/types/src/extensions/Comments/CommentsPlugin.d.ts +1 -1
  66. package/types/src/index.d.ts +1 -0
  67. package/types/src/schema/inlineContent/createSpec.d.ts +1 -0
  68. package/types/src/schema/inlineContent/types.d.ts +1 -0
  69. package/types/src/schema/styles/createSpec.d.ts +1 -0
  70. package/types/src/schema/styles/types.d.ts +1 -0
  71. package/types/src/yjs/index.d.ts +1 -0
  72. package/types/src/yjs/utils.d.ts +55 -0
  73. package/types/src/yjs/utils.test.d.ts +1 -0
  74. package/dist/BlockNoteSchema-COA0fsXW.cjs +0 -11
  75. package/dist/BlockNoteSchema-COA0fsXW.cjs.map +0 -1
  76. package/dist/BlockNoteSchema-CYRHak18.js +0 -4375
  77. package/dist/BlockNoteSchema-CYRHak18.js.map +0 -1
  78. package/src/extensions/BackgroundColor/BackgroundColorMark.ts +0 -46
  79. package/src/extensions/TextColor/TextColorMark.ts +0 -38
  80. package/types/src/extensions/BackgroundColor/BackgroundColorMark.d.ts +0 -10
  81. package/types/src/extensions/TextColor/TextColorMark.d.ts +0 -10
@@ -50,7 +50,7 @@ export function editorHasBlockWithType<
50
50
  if (typeof propSpec === "string") {
51
51
  if (
52
52
  editor.schema.blockSpecs[blockType].config.propSchema[propName]
53
- .default &&
53
+ .default !== undefined &&
54
54
  typeof editor.schema.blockSpecs[blockType].config.propSchema[propName]
55
55
  .default !== propSpec
56
56
  ) {
@@ -58,7 +58,8 @@ export function editorHasBlockWithType<
58
58
  }
59
59
 
60
60
  if (
61
- editor.schema.blockSpecs[blockType].config.propSchema[propName].type &&
61
+ editor.schema.blockSpecs[blockType].config.propSchema[propName].type !==
62
+ undefined &&
62
63
  editor.schema.blockSpecs[blockType].config.propSchema[propName].type !==
63
64
  propSpec
64
65
  ) {
@@ -97,23 +98,11 @@ export function editorHasBlockWithType<
97
98
  .values === "object" &&
98
99
  typeof propSpec.values === "object"
99
100
  ) {
100
- if (
101
- editor.schema.blockSpecs[blockType].config.propSchema[propName].values
102
- .length !== propSpec.values.length
103
- ) {
104
- return false;
105
- }
106
-
107
- for (
108
- let i = 0;
109
- i <
110
- editor.schema.blockSpecs[blockType].config.propSchema[propName].values
111
- .length;
112
- i++
113
- ) {
101
+ for (const value of propSpec.values) {
114
102
  if (
115
- editor.schema.blockSpecs[blockType].config.propSchema[propName]
116
- .values[i] !== propSpec.values[i]
103
+ !editor.schema.blockSpecs[blockType].config.propSchema[
104
+ propName
105
+ ].values.includes(value)
117
106
  ) {
118
107
  return false;
119
108
  }
@@ -29,7 +29,9 @@ export abstract class YjsThreadStoreBase extends ThreadStore {
29
29
  public getThreads(): Map<string, ThreadData> {
30
30
  const threadMap = new Map<string, ThreadData>();
31
31
  this.threadsYMap.forEach((yThread, id) => {
32
- threadMap.set(id, yMapToThread(yThread));
32
+ if (yThread instanceof Y.Map) {
33
+ threadMap.set(id, yMapToThread(yThread));
34
+ }
33
35
  });
34
36
  return threadMap;
35
37
  }
@@ -5,6 +5,7 @@ import {
5
5
  } from "../api/getBlockInfoFromPos.js";
6
6
  import { BlockNoteEditor } from "./BlockNoteEditor.js";
7
7
  import { BlockNoteExtension } from "./BlockNoteExtension.js";
8
+ import * as Y from "yjs";
8
9
 
9
10
  /**
10
11
  * @vitest-environment jsdom
@@ -104,7 +105,7 @@ it("block prop types", () => {
104
105
  }
105
106
  });
106
107
 
107
- it("onMount and onUnmount", () => {
108
+ it("onMount and onUnmount", async () => {
108
109
  const editor = BlockNoteEditor.create();
109
110
  let mounted = false;
110
111
  let unmounted = false;
@@ -118,6 +119,10 @@ it("onMount and onUnmount", () => {
118
119
  expect(mounted).toBe(true);
119
120
  expect(unmounted).toBe(false);
120
121
  editor.unmount();
122
+ // expect the unmount event to not have been triggered yet, since it waits 2 ticks
123
+ expect(unmounted).toBe(false);
124
+ // wait 3 ticks to ensure the unmount event is triggered
125
+ await new Promise((resolve) => setTimeout(resolve, 3));
121
126
  expect(mounted).toBe(true);
122
127
  expect(unmounted).toBe(true);
123
128
  });
@@ -142,3 +147,67 @@ it("onCreate event", () => {
142
147
  });
143
148
  expect(created).toBe(true);
144
149
  });
150
+
151
+ it("sets an initial block id when using Y.js", async () => {
152
+ const doc = new Y.Doc();
153
+ const fragment = doc.getXmlFragment("doc");
154
+ let transactionCount = 0;
155
+ const editor = BlockNoteEditor.create({
156
+ collaboration: {
157
+ fragment,
158
+ user: { name: "Hello", color: "#FFFFFF" },
159
+ provider: null,
160
+ },
161
+ _tiptapOptions: {
162
+ onTransaction: () => {
163
+ transactionCount++;
164
+ },
165
+ },
166
+ });
167
+
168
+ editor.mount(document.createElement("div"));
169
+
170
+ expect(editor.prosemirrorState.doc.toJSON()).toMatchInlineSnapshot(`
171
+ {
172
+ "content": [
173
+ {
174
+ "content": [
175
+ {
176
+ "attrs": {
177
+ "id": "initialBlockId",
178
+ },
179
+ "content": [
180
+ {
181
+ "attrs": {
182
+ "backgroundColor": "default",
183
+ "textAlignment": "left",
184
+ "textColor": "default",
185
+ },
186
+ "type": "paragraph",
187
+ },
188
+ ],
189
+ "type": "blockContainer",
190
+ },
191
+ ],
192
+ "type": "blockGroup",
193
+ },
194
+ ],
195
+ "type": "doc",
196
+ }
197
+ `);
198
+ expect(transactionCount).toBe(1);
199
+ // The fragment should not be modified yet, since the editor's content is only the initial content
200
+ expect(fragment.toJSON()).toMatchInlineSnapshot(`""`);
201
+
202
+ editor.replaceBlocks(editor.document, [
203
+ {
204
+ type: "paragraph",
205
+ content: [{ text: "Hello", styles: {}, type: "text" }],
206
+ },
207
+ ]);
208
+ expect(transactionCount).toBe(2);
209
+ // Only after a real modification is made, will the fragment be updated
210
+ expect(fragment.toJSON()).toMatchInlineSnapshot(
211
+ `"<blockgroup><blockcontainer id="0"><paragraph backgroundColor="default" textAlignment="left" textColor="default">Hello</paragraph></blockcontainer><blockcontainer id="1"><paragraph backgroundColor="default" textAlignment="left" textColor="default"></paragraph></blockcontainer></blockgroup>"`,
212
+ );
213
+ });
@@ -917,6 +917,25 @@ export class BlockNoteEditor<
917
917
  );
918
918
  }
919
919
 
920
+ // When y-prosemirror creates an empty document, the `blockContainer` node is created with an `id` of `null`.
921
+ // This causes the unique id extension to generate a new id for the initial block, which is not what we want
922
+ // Since it will be randomly generated & cause there to be more updates to the ydoc
923
+ // This is a hack to make it so that anytime `schema.doc.createAndFill` is called, the initial block id is already set to "initialBlockId"
924
+ let cache: Node | undefined = undefined;
925
+ const oldCreateAndFill = this.pmSchema.nodes.doc.createAndFill;
926
+ this.pmSchema.nodes.doc.createAndFill = (...args: any) => {
927
+ if (cache) {
928
+ return cache;
929
+ }
930
+ const ret = oldCreateAndFill.apply(this.pmSchema.nodes.doc, args)!;
931
+
932
+ // create a copy that we can mutate (otherwise, assigning attrs is not safe and corrupts the pm state)
933
+ const jsonNode = JSON.parse(JSON.stringify(ret.toJSON()));
934
+ jsonNode.content[0].content[0].attrs.id = "initialBlockId";
935
+
936
+ cache = Node.fromJSON(this.pmSchema, jsonNode);
937
+ return cache;
938
+ };
920
939
  this.pmSchema.cached.blockNoteEditor = this;
921
940
 
922
941
  // Initialize managers
@@ -1035,15 +1054,40 @@ export class BlockNoteEditor<
1035
1054
  * @warning Not needed to call manually when using React, use BlockNoteView to take care of mounting
1036
1055
  */
1037
1056
  public mount = (element: HTMLElement) => {
1038
- // TODO: Fix typing for this in a TipTap PR
1039
- this._tiptapEditor.mount({ mount: element } as any);
1057
+ if (
1058
+ // If the editor is scheduled for destruction, and
1059
+ this.scheduledDestructionTimeout &&
1060
+ // If the editor is being remounted to the same element as the one which is scheduled for destruction,
1061
+ // then just cancel the destruction timeout
1062
+ this.prosemirrorView.dom === element
1063
+ ) {
1064
+ clearTimeout(this.scheduledDestructionTimeout);
1065
+ this.scheduledDestructionTimeout = undefined;
1066
+ return;
1067
+ }
1068
+
1069
+ this._tiptapEditor.mount({ mount: element });
1040
1070
  };
1041
1071
 
1072
+ /**
1073
+ * Timeout to schedule the {@link unmount}ing of the editor.
1074
+ */
1075
+ private scheduledDestructionTimeout:
1076
+ | ReturnType<typeof setTimeout>
1077
+ | undefined = undefined;
1078
+
1042
1079
  /**
1043
1080
  * Unmount the editor from the DOM element it is bound to
1044
1081
  */
1045
1082
  public unmount = () => {
1046
- this._tiptapEditor.unmount();
1083
+ // Due to how React's StrictMode works, it will `unmount` & `mount` the component twice in development mode.
1084
+ // This can result in the editor being unmounted mid-rendering the content of node views.
1085
+ // To avoid this, we only ever schedule the `unmount`ing of the editor when we've seen whether React "meant" to actually unmount the editor (i.e. not calling mount one tick later).
1086
+ // So, we wait two ticks to see if the component is still meant to be unmounted, and if not, we actually unmount the editor.
1087
+ this.scheduledDestructionTimeout = setTimeout(() => {
1088
+ this._tiptapEditor.unmount();
1089
+ this.scheduledDestructionTimeout = undefined;
1090
+ }, 1);
1047
1091
  };
1048
1092
 
1049
1093
  /**
@@ -1082,6 +1126,13 @@ export class BlockNoteEditor<
1082
1126
  this.prosemirrorView.focus();
1083
1127
  }
1084
1128
 
1129
+ public blur() {
1130
+ if (this.headless) {
1131
+ return;
1132
+ }
1133
+ this.prosemirrorView.dom.blur();
1134
+ }
1135
+
1085
1136
  public onUploadStart(callback: (blockId?: string) => void) {
1086
1137
  this.onUploadStartCallbacks.push(callback);
1087
1138
 
@@ -1482,7 +1533,7 @@ export class BlockNoteEditor<
1482
1533
  * @returns The blocks, serialized as an HTML string.
1483
1534
  */
1484
1535
  public blocksToFullHTML(
1485
- blocks: PartialBlock<BSchema, ISchema, SSchema>[],
1536
+ blocks: PartialBlock<BSchema, ISchema, SSchema>[] = this.document,
1486
1537
  ): string {
1487
1538
  return this._exportManager.blocksToFullHTML(blocks);
1488
1539
  }
@@ -54,7 +54,7 @@ export class ExportManager<
54
54
  * @returns The blocks, serialized as an HTML string.
55
55
  */
56
56
  public blocksToFullHTML(
57
- blocks: PartialBlock<BSchema, ISchema, SSchema>[],
57
+ blocks: PartialBlock<BSchema, ISchema, SSchema>[] = this.editor.document,
58
58
  ): string {
59
59
  const exporter = createInternalHTMLSerializer(
60
60
  this.editor.pmSchema,
@@ -8,7 +8,7 @@
8
8
  "type": "text",
9
9
  },
10
10
  ],
11
- "id": "3",
11
+ "id": "2",
12
12
  "props": {
13
13
  "backgroundColor": "default",
14
14
  "textAlignment": "left",
@@ -19,7 +19,7 @@
19
19
  {
20
20
  "children": [],
21
21
  "content": [],
22
- "id": "4",
22
+ "id": "3",
23
23
  "props": {
24
24
  "backgroundColor": "default",
25
25
  "textAlignment": "left",
@@ -8,7 +8,7 @@
8
8
  "type": "text",
9
9
  },
10
10
  ],
11
- "id": "1",
11
+ "id": "0",
12
12
  "props": {
13
13
  "backgroundColor": "default",
14
14
  "textAlignment": "left",
@@ -19,7 +19,7 @@
19
19
  {
20
20
  "children": [],
21
21
  "content": [],
22
- "id": "2",
22
+ "id": "1",
23
23
  "props": {
24
24
  "backgroundColor": "default",
25
25
  "textAlignment": "left",
@@ -1 +1 @@
1
- <blockgroup><blockcontainer id="3"><paragraph backgroundColor="default" textAlignment="left" textColor="default">Hello World</paragraph></blockcontainer><blockcontainer id="4"><paragraph backgroundColor="default" textAlignment="left" textColor="default"></paragraph></blockcontainer></blockgroup>
1
+ <blockgroup><blockcontainer id="2"><paragraph backgroundColor="default" textAlignment="left" textColor="default">Hello World</paragraph></blockcontainer><blockcontainer id="3"><paragraph backgroundColor="default" textAlignment="left" textColor="default"></paragraph></blockcontainer></blockgroup>
@@ -1 +1 @@
1
- <blockgroup><blockcontainer id="1"><paragraph backgroundColor="default" textAlignment="left" textColor="default">Hello</paragraph></blockcontainer><blockcontainer id="2"><paragraph backgroundColor="default" textAlignment="left" textColor="default"></paragraph></blockcontainer></blockgroup>
1
+ <blockgroup><blockcontainer id="0"><paragraph backgroundColor="default" textAlignment="left" textColor="default">Hello</paragraph></blockcontainer><blockcontainer id="1"><paragraph backgroundColor="default" textAlignment="left" textColor="default"></paragraph></blockcontainer></blockgroup>
@@ -1,5 +1,4 @@
1
1
  import { Plugin, PluginKey } from "@tiptap/pm/state";
2
- import { ySyncPluginKey } from "y-prosemirror";
3
2
  import * as Y from "yjs";
4
3
 
5
4
  import { BlockNoteExtension } from "../../../editor/BlockNoteExtension.js";
@@ -31,8 +30,12 @@ export class SchemaMigrationPlugin extends BlockNoteExtension {
31
30
  }
32
31
 
33
32
  if (
34
- transactions.length !== 1 ||
35
- !transactions[0].getMeta(ySyncPluginKey)
33
+ // If any of the transactions are not due to a yjs sync, we don't need to run the migration
34
+ !transactions.some((tr) => tr.getMeta("y-sync$")) ||
35
+ // If none of the transactions result in a document change, we don't need to run the migration
36
+ transactions.every((tr) => !tr.docChanged) ||
37
+ // If the fragment is still empty, we can't run the migration (since it has not yet been applied to the Y.Doc)
38
+ !fragment.firstChild
36
39
  ) {
37
40
  return undefined;
38
41
  }
@@ -44,6 +47,10 @@ export class SchemaMigrationPlugin extends BlockNoteExtension {
44
47
 
45
48
  this.migrationDone = true;
46
49
 
50
+ if (!tr.docChanged) {
51
+ return undefined;
52
+ }
53
+
47
54
  return tr;
48
55
  },
49
56
  }),
@@ -0,0 +1,130 @@
1
+ import { expect, it } from "vitest";
2
+ import * as Y from "yjs";
3
+ import { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js";
4
+ import { moveColorAttributes } from "./moveColorAttributes.js";
5
+ import { prosemirrorJSONToYXmlFragment } from "y-prosemirror";
6
+
7
+ it("can move color attributes on older documents", async () => {
8
+ const doc = new Y.Doc();
9
+ const fragment = doc.getXmlFragment("doc");
10
+ const editor = BlockNoteEditor.create({
11
+ initialContent: [
12
+ {
13
+ type: "paragraph",
14
+ content: "Welcome to this demo!",
15
+ },
16
+ ],
17
+ });
18
+
19
+ // Because this was a previous schema, we are creating the YFragment manually
20
+ const blockGroup = new Y.XmlElement("blockGroup");
21
+ const el = new Y.XmlElement("blockContainer");
22
+ el.setAttribute("id", "0");
23
+ el.setAttribute("backgroundColor", "red");
24
+ el.setAttribute("textColor", "blue");
25
+ const para = new Y.XmlElement("paragraph");
26
+ para.setAttribute("textAlignment", "left");
27
+ para.insert(0, [new Y.XmlText("Welcome to this demo!")]);
28
+ el.insert(0, [para]);
29
+ blockGroup.insert(0, [el]);
30
+ fragment.insert(0, [blockGroup]);
31
+
32
+ // Note that the blockContainer has the color attributes, but the paragraph does not.
33
+ expect(fragment.toJSON()).toMatchInlineSnapshot(
34
+ `"<blockgroup><blockcontainer backgroundColor="red" id="0" textColor="blue"><paragraph textAlignment="left">Welcome to this demo!</paragraph></blockcontainer></blockgroup>"`,
35
+ );
36
+
37
+ const tr = editor.prosemirrorState.tr;
38
+ moveColorAttributes(fragment, tr);
39
+ // Note that the color attributes have been moved to the paragraph.
40
+ expect(JSON.stringify(tr.doc.toJSON())).toMatchInlineSnapshot(
41
+ `"{"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!"}]}]}]}]}"`,
42
+ );
43
+ });
44
+
45
+ it("does not move color attributes on newer documents", async () => {
46
+ const doc = new Y.Doc();
47
+ const fragment = doc.getXmlFragment("doc");
48
+ const editor = BlockNoteEditor.create({
49
+ initialContent: [
50
+ {
51
+ type: "paragraph",
52
+ content: "Welcome to this demo!",
53
+ props: {
54
+ backgroundColor: "red",
55
+ textColor: "blue",
56
+ // Set to non-default value to ensure it is not overridden by the migration rule.
57
+ textAlignment: "right",
58
+ },
59
+ },
60
+ ],
61
+ });
62
+
63
+ prosemirrorJSONToYXmlFragment(
64
+ editor.pmSchema,
65
+ JSON.parse(JSON.stringify(editor.prosemirrorState.doc.toJSON())),
66
+ fragment,
67
+ );
68
+
69
+ expect(fragment.toJSON()).toMatchInlineSnapshot(
70
+ // The color attributes are on the paragraph, not the blockContainer.
71
+ `"<blockgroup><blockcontainer id="0"><paragraph backgroundColor="red" textAlignment="right" textColor="blue">Welcome to this demo!</paragraph></blockcontainer></blockgroup>"`,
72
+ );
73
+
74
+ const tr = editor.prosemirrorState.tr;
75
+ moveColorAttributes(fragment, tr);
76
+ // The document will be unchanged because the color attributes are already on the paragraph.
77
+ expect(tr.docChanged).toBe(false);
78
+ });
79
+
80
+ it("can move color attributes on older documents multiple times", async () => {
81
+ const doc = new Y.Doc();
82
+ const fragment = doc.getXmlFragment("doc");
83
+ const editor = BlockNoteEditor.create({
84
+ initialContent: [
85
+ {
86
+ type: "paragraph",
87
+ content: "Welcome to this demo!",
88
+ },
89
+ ],
90
+ });
91
+
92
+ // Because this was a previous schema, we are creating the YFragment manually
93
+ const blockGroup = new Y.XmlElement("blockGroup");
94
+ const el = new Y.XmlElement("blockContainer");
95
+ el.setAttribute("id", "0");
96
+ el.setAttribute("backgroundColor", "red");
97
+ el.setAttribute("textColor", "blue");
98
+ const para = new Y.XmlElement("paragraph");
99
+ para.setAttribute("textAlignment", "left");
100
+ para.insert(0, [new Y.XmlText("Welcome to this demo!")]);
101
+ el.insert(0, [para]);
102
+ blockGroup.insert(0, [el]);
103
+ fragment.insert(0, [blockGroup]);
104
+
105
+ // Note that the blockContainer has the color attributes, but the paragraph does not.
106
+ expect(fragment.toJSON()).toMatchInlineSnapshot(
107
+ `"<blockgroup><blockcontainer backgroundColor="red" id="0" textColor="blue"><paragraph textAlignment="left">Welcome to this demo!</paragraph></blockcontainer></blockgroup>"`,
108
+ );
109
+
110
+ const tr = editor.prosemirrorState.tr;
111
+ moveColorAttributes(fragment, tr);
112
+ // Note that the color attributes have been moved to the paragraph.
113
+ expect(JSON.stringify(tr.doc.toJSON())).toMatchInlineSnapshot(
114
+ `"{"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!"}]}]}]}]}"`,
115
+ );
116
+
117
+ el.setAttribute("backgroundColor", "green");
118
+ el.setAttribute("textColor", "yellow");
119
+
120
+ expect(fragment.toJSON()).toMatchInlineSnapshot(
121
+ `"<blockgroup><blockcontainer backgroundColor="green" id="0" textColor="yellow"><paragraph textAlignment="left">Welcome to this demo!</paragraph></blockcontainer></blockgroup>"`,
122
+ );
123
+
124
+ const nextTr = editor.prosemirrorState.tr;
125
+ moveColorAttributes(fragment, nextTr);
126
+ // Note that the color attributes have been moved to the paragraph.
127
+ expect(JSON.stringify(nextTr.doc.toJSON())).toMatchInlineSnapshot(
128
+ `"{"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!"}]}]}]}]}"`,
129
+ );
130
+ });
@@ -23,14 +23,13 @@ const traverseElement = (
23
23
  export const moveColorAttributes: MigrationRule = (fragment, tr) => {
24
24
  // Stores necessary info for all `blockContainer` nodes which still have
25
25
  // `textColor` or `backgroundColor` attributes that need to be moved.
26
- const targetBlockContainers: Record<
26
+ const targetBlockContainers: Map<
27
27
  string,
28
28
  {
29
- textColor?: string;
30
- backgroundColor?: string;
29
+ textColor: string | undefined;
30
+ backgroundColor: string | undefined;
31
31
  }
32
- > = {};
33
-
32
+ > = new Map();
34
33
  // Finds all elements which still have `textColor` or `backgroundColor`
35
34
  // attributes in the current Yjs fragment.
36
35
  fragment.forEach((element) => {
@@ -40,39 +39,53 @@ export const moveColorAttributes: MigrationRule = (fragment, tr) => {
40
39
  element.nodeName === "blockContainer" &&
41
40
  element.hasAttribute("id")
42
41
  ) {
42
+ const textColor = element.getAttribute("textColor");
43
+ const backgroundColor = element.getAttribute("backgroundColor");
44
+
43
45
  const colors = {
44
- textColor: element.getAttribute("textColor"),
45
- backgroundColor: element.getAttribute("backgroundColor"),
46
+ textColor:
47
+ textColor === defaultProps.textColor.default
48
+ ? undefined
49
+ : textColor,
50
+ backgroundColor:
51
+ backgroundColor === defaultProps.backgroundColor.default
52
+ ? undefined
53
+ : backgroundColor,
46
54
  };
47
55
 
48
- if (colors.textColor === defaultProps.textColor.default) {
49
- colors.textColor = undefined;
50
- }
51
- if (colors.backgroundColor === defaultProps.backgroundColor.default) {
52
- colors.backgroundColor = undefined;
53
- }
54
-
55
56
  if (colors.textColor || colors.backgroundColor) {
56
- targetBlockContainers[element.getAttribute("id")!] = colors;
57
+ targetBlockContainers.set(element.getAttribute("id")!, colors);
57
58
  }
58
59
  }
59
60
  });
60
61
  }
61
62
  });
62
63
 
64
+ if (targetBlockContainers.size === 0) {
65
+ return false;
66
+ }
67
+
63
68
  // Appends transactions to add the `textColor` and `backgroundColor`
64
69
  // attributes found on each `blockContainer` node to move them to the child
65
70
  // `blockContent` node.
66
71
  tr.doc.descendants((node, pos) => {
67
72
  if (
68
73
  node.type.name === "blockContainer" &&
69
- targetBlockContainers[node.attrs.id]
74
+ targetBlockContainers.has(node.attrs.id)
70
75
  ) {
71
- tr = tr.setNodeMarkup(
72
- pos + 1,
73
- undefined,
74
- targetBlockContainers[node.attrs.id],
75
- );
76
+ const el = tr.doc.nodeAt(pos + 1);
77
+ if (!el) {
78
+ throw new Error("No element found");
79
+ }
80
+
81
+ tr.setNodeMarkup(pos + 1, undefined, {
82
+ // preserve existing attributes
83
+ ...el.attrs,
84
+ // add the textColor and backgroundColor attributes
85
+ ...targetBlockContainers.get(node.attrs.id),
86
+ });
76
87
  }
77
88
  });
89
+
90
+ return true;
78
91
  };
@@ -12,6 +12,7 @@ import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
12
12
  import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js";
13
13
  import { CustomBlockNoteSchema } from "../../schema/schema.js";
14
14
  import { UserStore } from "./userstore/UserStore.js";
15
+ import { getMarkRange } from "@tiptap/core";
15
16
 
16
17
  const PLUGIN_KEY = new PluginKey(`blocknote-comments`);
17
18
  const SET_SELECTED_THREAD_ID = "SET_SELECTED_THREAD_ID";
@@ -239,13 +240,42 @@ export class CommentsPlugin extends BlockNoteExtension {
239
240
  return;
240
241
  }
241
242
 
242
- const commentMark = node.marks.find(
243
- (mark) =>
244
- mark.type.name === markType && mark.attrs.orphan !== true,
245
- );
243
+ const markInSchema = view.state.schema.marks[markType];
244
+ const resolvedPos = view.state.doc.resolve(pos);
245
+ const commentMark = node.marks
246
+ .filter(
247
+ (mark) =>
248
+ mark.type.name === markType && mark.attrs.orphan !== true,
249
+ )
250
+ .map((mark) => {
251
+ // get the range of this mark within the document, to check how close it is to the click position
252
+ const range = getMarkRange(
253
+ resolvedPos,
254
+ markInSchema,
255
+ mark.attrs,
256
+ )!;
257
+
258
+ return {
259
+ mark,
260
+ // calculate how far the mark is from the click position
261
+ distance:
262
+ (Math.abs(range.from - pos) + Math.abs(range.to - pos)) / 2,
263
+ // calculate the length of text the mark spans
264
+ length: range.to - range.from,
265
+ };
266
+ })
267
+ // This allows us to not have comments which are unreachable because they are completely overlapped by other comments (issue #2073)
268
+ .sort((a, b) => {
269
+ // Find the mark which is closest to the click position
270
+ if (a.distance !== b.distance) {
271
+ return a.distance - b.distance;
272
+ }
273
+ // Otherwise, select the mark which spans the smallest amount of text (most likely to be unreachable)
274
+ return a.length - b.length;
275
+ })[0]?.mark;
246
276
 
247
277
  const threadId = commentMark?.attrs.threadId as string | undefined;
248
- self.selectThread(threadId, false);
278
+ self.selectThread(threadId);
249
279
  },
250
280
  },
251
281
  }),
@@ -268,7 +298,7 @@ export class CommentsPlugin extends BlockNoteExtension {
268
298
  /**
269
299
  * Set the selected thread
270
300
  */
271
- public selectThread(threadId: string | undefined, scrollToThread = true) {
301
+ public selectThread(threadId: string | undefined) {
272
302
  if (this.selectedThreadId === threadId) {
273
303
  return;
274
304
  }
@@ -280,7 +310,7 @@ export class CommentsPlugin extends BlockNoteExtension {
280
310
  }),
281
311
  );
282
312
 
283
- if (threadId && scrollToThread) {
313
+ if (threadId) {
284
314
  const selectedThreadPosition = this.threadPositions.get(threadId);
285
315
 
286
316
  if (!selectedThreadPosition) {