@blocknote/core 0.24.1 → 0.25.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 (149) hide show
  1. package/dist/blocknote.cjs +12 -0
  2. package/dist/blocknote.cjs.map +1 -0
  3. package/dist/blocknote.js +5028 -3444
  4. package/dist/blocknote.js.map +1 -1
  5. package/dist/comments.cjs +2 -0
  6. package/dist/comments.cjs.map +1 -0
  7. package/dist/comments.js +593 -0
  8. package/dist/comments.js.map +1 -0
  9. package/dist/style.css +1 -1
  10. package/dist/tsconfig.tsbuildinfo +1 -1
  11. package/dist/webpack-stats.json +1 -1
  12. package/package.json +39 -26
  13. package/src/api/blockManipulation/commands/insertBlocks/__snapshots__/insertBlocks.test.ts.snap +1022 -378
  14. package/src/api/blockManipulation/commands/mergeBlocks/__snapshots__/mergeBlocks.test.ts.snap +730 -270
  15. package/src/api/blockManipulation/commands/moveBlocks/__snapshots__/moveBlocks.test.ts.snap +3100 -1260
  16. package/src/api/blockManipulation/commands/removeBlocks/__snapshots__/removeBlocks.test.ts.snap +438 -162
  17. package/src/api/blockManipulation/commands/replaceBlocks/__snapshots__/replaceBlocks.test.ts.snap +1168 -432
  18. package/src/api/blockManipulation/commands/splitBlock/__snapshots__/splitBlock.test.ts.snap +930 -378
  19. package/src/api/blockManipulation/commands/updateBlock/__snapshots__/updateBlock.test.ts.snap +2485 -1015
  20. package/src/api/blockManipulation/commands/updateBlock/updateBlock.test.ts +28 -1
  21. package/src/api/blockManipulation/commands/updateBlock/updateBlock.ts +1 -1
  22. package/src/api/blockManipulation/selections/__snapshots__/selection.test.ts.snap +292 -108
  23. package/src/api/blockManipulation/setupTestEnv.ts +14 -1
  24. package/src/api/blockManipulation/tables/tables.test.ts +1987 -0
  25. package/src/api/blockManipulation/tables/tables.ts +887 -0
  26. package/src/api/clipboard/__snapshots__/external/pasteEndOfParagraph.html +66 -24
  27. package/src/api/clipboard/__snapshots__/external/pasteEndOfParagraphText.html +66 -24
  28. package/src/api/clipboard/__snapshots__/external/pasteImage.html +66 -24
  29. package/src/api/clipboard/__snapshots__/external/pasteParagraphInCustomBlock.html +66 -24
  30. package/src/api/clipboard/__snapshots__/external/pasteTable.html +132 -48
  31. package/src/api/clipboard/__snapshots__/external/pasteTableInExistingTable.html +136 -44
  32. package/src/api/clipboard/fromClipboard/handleFileInsertion.ts +36 -14
  33. package/src/api/clipboard/toClipboard/copyExtension.ts +2 -3
  34. package/src/api/exporters/html/__snapshots__/table/headerCols/external.html +1 -0
  35. package/src/api/exporters/html/__snapshots__/table/headerCols/internal.html +1 -0
  36. package/src/api/exporters/html/__snapshots__/table/headerRows/external.html +1 -0
  37. package/src/api/exporters/html/__snapshots__/table/headerRows/internal.html +1 -0
  38. package/src/api/exporters/html/__snapshots__/table/headersRows/external.html +1 -0
  39. package/src/api/exporters/html/__snapshots__/table/headersRows/internal.html +1 -0
  40. package/src/api/exporters/html/__snapshots__/table/mixedCellColors/external.html +1 -0
  41. package/src/api/exporters/html/__snapshots__/table/mixedCellColors/internal.html +1 -0
  42. package/src/api/exporters/html/__snapshots__/table/mixedRowspansAndColspans/external.html +1 -0
  43. package/src/api/exporters/html/__snapshots__/table/mixedRowspansAndColspans/internal.html +1 -0
  44. package/src/api/exporters/markdown/__snapshots__/table/headerCols/markdown.md +4 -0
  45. package/src/api/exporters/markdown/__snapshots__/table/headerRows/markdown.md +4 -0
  46. package/src/api/exporters/markdown/__snapshots__/table/mixedCellColors/markdown.md +5 -0
  47. package/src/api/exporters/markdown/__snapshots__/table/mixedRowspansAndColspans/markdown.md +5 -0
  48. package/src/api/nodeConversions/__snapshots__/nodeConversions.test.ts.snap +985 -20
  49. package/src/api/nodeConversions/blockToNode.ts +63 -20
  50. package/src/api/nodeConversions/nodeToBlock.ts +75 -13
  51. package/src/api/parsers/html/__snapshots__/parse-notion-html.json +145 -54
  52. package/src/api/testUtil/cases/defaultSchema.ts +782 -9
  53. package/src/api/testUtil/partialBlockTestUtil.ts +39 -4
  54. package/src/blocks/TableBlockContent/TableBlockContent.ts +11 -5
  55. package/src/blocks/defaultBlockTypeGuards.ts +8 -0
  56. package/src/comments/index.ts +9 -0
  57. package/src/comments/models/User.ts +8 -0
  58. package/src/comments/threadstore/DefaultThreadStoreAuth.ts +106 -0
  59. package/src/comments/threadstore/ThreadStore.ts +134 -0
  60. package/src/comments/threadstore/ThreadStoreAuth.ts +13 -0
  61. package/src/comments/threadstore/TipTapThreadStore.ts +292 -0
  62. package/src/comments/threadstore/yjs/RESTYjsThreadStore.ts +144 -0
  63. package/src/comments/threadstore/yjs/YjsThreadStore.test.ts +294 -0
  64. package/src/comments/threadstore/yjs/YjsThreadStore.ts +340 -0
  65. package/src/comments/threadstore/yjs/YjsThreadStoreBase.ts +48 -0
  66. package/src/comments/threadstore/yjs/yjsHelpers.ts +121 -0
  67. package/src/comments/types.ts +117 -0
  68. package/src/editor/Block.css +16 -8
  69. package/src/editor/BlockNoteEditor.ts +269 -92
  70. package/src/editor/BlockNoteExtensions.ts +24 -1
  71. package/src/editor/BlockNoteTipTapEditor.ts +5 -1
  72. package/src/editor/editor.css +17 -0
  73. package/src/extensions/BackgroundColor/BackgroundColorExtension.ts +1 -1
  74. package/src/extensions/Comments/CommentMark.ts +61 -0
  75. package/src/extensions/Comments/CommentsPlugin.ts +301 -0
  76. package/src/extensions/Comments/userstore/UserStore.ts +72 -0
  77. package/src/extensions/FormattingToolbar/FormattingToolbarPlugin.ts +16 -10
  78. package/src/extensions/LinkToolbar/LinkToolbarPlugin.ts +3 -3
  79. package/src/extensions/ShowSelection/ShowSelectionPlugin.ts +52 -0
  80. package/src/extensions/SideMenu/SideMenuPlugin.ts +22 -9
  81. package/src/extensions/TableHandles/TableHandlesPlugin.ts +409 -57
  82. package/src/extensions/TextAlignment/TextAlignmentExtension.ts +2 -0
  83. package/src/extensions/TextColor/TextColorExtension.ts +1 -1
  84. package/src/extensions/UniqueID/UniqueID.ts +8 -3
  85. package/src/i18n/locales/ar.ts +23 -0
  86. package/src/i18n/locales/de.ts +15 -0
  87. package/src/i18n/locales/en.ts +25 -1
  88. package/src/i18n/locales/es.ts +16 -1
  89. package/src/i18n/locales/fr.ts +23 -0
  90. package/src/i18n/locales/hr.ts +18 -0
  91. package/src/i18n/locales/index.ts +1 -0
  92. package/src/i18n/locales/is.ts +24 -1
  93. package/src/i18n/locales/it.ts +21 -0
  94. package/src/i18n/locales/ja.ts +23 -0
  95. package/src/i18n/locales/ko.ts +23 -0
  96. package/src/i18n/locales/nl.ts +23 -0
  97. package/src/i18n/locales/no.ts +346 -0
  98. package/src/i18n/locales/pl.ts +23 -0
  99. package/src/i18n/locales/pt.ts +23 -0
  100. package/src/i18n/locales/ru.ts +23 -0
  101. package/src/i18n/locales/uk.ts +23 -0
  102. package/src/i18n/locales/vi.ts +23 -0
  103. package/src/i18n/locales/zh.ts +23 -0
  104. package/src/index.ts +6 -4
  105. package/src/schema/blocks/types.ts +32 -2
  106. package/src/util/browser.ts +1 -1
  107. package/src/util/table.ts +107 -0
  108. package/types/src/api/blockManipulation/tables/tables.d.ts +343 -0
  109. package/types/src/api/blockManipulation/tables/tables.test.d.ts +1 -0
  110. package/types/src/api/clipboard/toClipboard/copyExtension.d.ts +1 -1
  111. package/types/src/blocks/TableBlockContent/TableBlockContent.d.ts +1 -2
  112. package/types/src/blocks/defaultBlockTypeGuards.d.ts +3 -0
  113. package/types/src/comments/index.d.ts +9 -0
  114. package/types/src/comments/models/User.d.ts +8 -0
  115. package/types/src/comments/threadstore/DefaultThreadStoreAuth.d.ts +47 -0
  116. package/types/src/comments/threadstore/ThreadStore.d.ts +121 -0
  117. package/types/src/comments/threadstore/ThreadStoreAuth.d.ts +12 -0
  118. package/types/src/comments/threadstore/TipTapThreadStore.d.ts +97 -0
  119. package/types/src/comments/threadstore/yjs/RESTYjsThreadStore.d.ts +83 -0
  120. package/types/src/comments/threadstore/yjs/YjsThreadStore.d.ts +79 -0
  121. package/types/src/comments/threadstore/yjs/YjsThreadStore.test.d.ts +1 -0
  122. package/types/src/comments/threadstore/yjs/YjsThreadStoreBase.d.ts +15 -0
  123. package/types/src/comments/threadstore/yjs/yjsHelpers.d.ts +13 -0
  124. package/types/src/comments/types.d.ts +109 -0
  125. package/types/src/editor/BlockNoteEditor.d.ts +146 -66
  126. package/types/src/editor/BlockNoteExtensions.d.ts +4 -0
  127. package/types/src/extensions/Collaboration/createCollaborationExtensions.d.ts +1 -1
  128. package/types/src/extensions/Comments/CommentMark.d.ts +2 -0
  129. package/types/src/extensions/Comments/CommentsPlugin.d.ts +49 -0
  130. package/types/src/extensions/Comments/userstore/UserStore.d.ts +31 -0
  131. package/types/src/extensions/FormattingToolbar/FormattingToolbarPlugin.d.ts +1 -1
  132. package/types/src/extensions/ShowSelection/ShowSelectionPlugin.d.ts +15 -0
  133. package/types/src/extensions/SideMenu/SideMenuPlugin.d.ts +1 -0
  134. package/types/src/extensions/TableHandles/TableHandlesPlugin.d.ts +66 -1
  135. package/types/src/i18n/locales/de.d.ts +15 -0
  136. package/types/src/i18n/locales/en.d.ts +20 -0
  137. package/types/src/i18n/locales/es.d.ts +15 -0
  138. package/types/src/i18n/locales/hr.d.ts +18 -0
  139. package/types/src/i18n/locales/index.d.ts +1 -0
  140. package/types/src/i18n/locales/it.d.ts +21 -0
  141. package/types/src/i18n/locales/no.d.ts +2 -0
  142. package/types/src/index.d.ts +5 -4
  143. package/types/src/pm-nodes/BlockContainer.d.ts +2 -2
  144. package/types/src/pm-nodes/BlockGroup.d.ts +2 -2
  145. package/types/src/schema/blocks/types.d.ts +23 -2
  146. package/types/src/util/browser.d.ts +1 -1
  147. package/types/src/util/table.d.ts +12 -0
  148. package/dist/blocknote.umd.cjs +0 -11
  149. package/dist/blocknote.umd.cjs.map +0 -1
@@ -0,0 +1,61 @@
1
+ import { Mark, mergeAttributes } from "@tiptap/core";
2
+
3
+ export const CommentMark = Mark.create({
4
+ name: "comment",
5
+ excludes: "",
6
+ inclusive: false,
7
+ keepOnSplit: true,
8
+ group: "blocknoteIgnore", // ignore in blocknote json
9
+
10
+ addAttributes() {
11
+ // Return an object with attribute configuration
12
+ return {
13
+ // orphans are marks that currently don't have an active thread. It could be
14
+ // that users have resolved the thread. Resolved threads by default are not shown in the document,
15
+ // but we need to keep the mark (positioning) data so we can still "revive" it when the thread is unresolved
16
+ // or we enter a "comments" view that includes resolved threads.
17
+ orphan: {
18
+ parseHTML: (element) => !!element.getAttribute("data-orphan"),
19
+ renderHTML: (attributes) => {
20
+ return (attributes as { orphan: boolean }).orphan
21
+ ? {
22
+ "data-orphan": "true",
23
+ }
24
+ : {};
25
+ },
26
+ default: false,
27
+ },
28
+ threadId: {
29
+ parseHTML: (element) => element.getAttribute("data-bn-thread-id"),
30
+ renderHTML: (attributes) => {
31
+ return {
32
+ "data-bn-thread-id": (attributes as { threadId: string }).threadId,
33
+ };
34
+ },
35
+ default: "",
36
+ },
37
+ };
38
+ },
39
+
40
+ renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, any> }) {
41
+ return [
42
+ "span",
43
+ mergeAttributes(HTMLAttributes, {
44
+ class: "bn-thread-mark",
45
+ }),
46
+ ];
47
+ },
48
+
49
+ parseHTML() {
50
+ return [{ tag: "span.bn-thread-mark" }];
51
+ },
52
+
53
+ extendMarkSchema(extension) {
54
+ if (extension.name === "comment") {
55
+ return {
56
+ blocknoteIgnore: true,
57
+ };
58
+ }
59
+ return {};
60
+ },
61
+ });
@@ -0,0 +1,301 @@
1
+ import { Node } from "prosemirror-model";
2
+ import { Plugin, PluginKey } from "prosemirror-state";
3
+ import { Decoration, DecorationSet } from "prosemirror-view";
4
+ import { getRelativeSelection, ySyncPluginKey } from "y-prosemirror";
5
+ import type {
6
+ CommentBody,
7
+ ThreadData,
8
+ ThreadStore,
9
+ User,
10
+ } from "../../comments/index.js";
11
+ import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
12
+ import { EventEmitter } from "../../util/EventEmitter.js";
13
+ import { UserStore } from "./userstore/UserStore.js";
14
+
15
+ const PLUGIN_KEY = new PluginKey(`blocknote-comments`);
16
+ const SET_SELECTED_THREAD_ID = "SET_SELECTED_THREAD_ID";
17
+
18
+ type CommentsPluginState = {
19
+ /**
20
+ * Store the positions of all threads in the document.
21
+ * this can be used later to implement a floating sidebar
22
+ */
23
+ threadPositions: Map<string, { from: number; to: number }>;
24
+ /**
25
+ * Decorations to be rendered, specifically to indicate the selected thread
26
+ */
27
+ decorations: DecorationSet;
28
+ };
29
+
30
+ /**
31
+ * Get a new state (theadPositions and decorations) from the current document state
32
+ */
33
+ function updateState(
34
+ doc: Node,
35
+ selectedThreadId: string | undefined,
36
+ markType: string
37
+ ): CommentsPluginState {
38
+ const threadPositions = new Map<string, { from: number; to: number }>();
39
+ const decorations: Decoration[] = [];
40
+ // find all thread marks and store their position + create decoration for selected thread
41
+ doc.descendants((node, pos) => {
42
+ node.marks.forEach((mark) => {
43
+ if (mark.type.name === markType) {
44
+ const thisThreadId = (mark.attrs as { threadId: string | undefined })
45
+ .threadId;
46
+ if (!thisThreadId) {
47
+ return;
48
+ }
49
+ const from = pos;
50
+ const to = from + node.nodeSize;
51
+
52
+ // FloatingThreads component uses "to" as the position, so always store the largest "to" found
53
+ // AnchoredThreads component uses "from" as the position, so always store the smallest "from" found
54
+ const currentPosition = threadPositions.get(thisThreadId) ?? {
55
+ from: Infinity,
56
+ to: 0,
57
+ };
58
+ threadPositions.set(thisThreadId, {
59
+ from: Math.min(from, currentPosition.from),
60
+ to: Math.max(to, currentPosition.to),
61
+ });
62
+
63
+ if (selectedThreadId === thisThreadId) {
64
+ decorations.push(
65
+ Decoration.inline(from, to, {
66
+ class: "bn-thread-mark-selected",
67
+ })
68
+ );
69
+ }
70
+ }
71
+ });
72
+ });
73
+ return {
74
+ decorations: DecorationSet.create(doc, decorations),
75
+ threadPositions,
76
+ };
77
+ }
78
+
79
+ export class CommentsPlugin extends EventEmitter<any> {
80
+ public readonly plugin: Plugin;
81
+ public readonly userStore: UserStore<User>;
82
+
83
+ private pendingComment = false;
84
+ private selectedThreadId: string | undefined;
85
+
86
+ private emitStateUpdate() {
87
+ this.emit("update", {
88
+ selectedThreadId: this.selectedThreadId,
89
+ pendingComment: this.pendingComment,
90
+ });
91
+ }
92
+
93
+ /**
94
+ * when a thread is resolved or deleted, we need to update the marks to reflect the new state
95
+ */
96
+ private updateMarksFromThreads = (threads: Map<string, ThreadData>) => {
97
+ const ttEditor = this.editor._tiptapEditor;
98
+
99
+ ttEditor.state.doc.descendants((node, pos) => {
100
+ node.marks.forEach((mark) => {
101
+ if (mark.type.name === this.markType) {
102
+ const markType = mark.type;
103
+ const markThreadId = mark.attrs.threadId;
104
+ const thread = threads.get(markThreadId);
105
+ const isOrphan = !!(!thread || thread.resolved || thread.deletedAt);
106
+
107
+ if (isOrphan !== mark.attrs.orphan) {
108
+ const { tr } = ttEditor.state;
109
+ const trimmedFrom = Math.max(pos, 0);
110
+ const trimmedTo = Math.min(
111
+ pos + node.nodeSize,
112
+ ttEditor.state.doc.content.size - 1
113
+ );
114
+ tr.removeMark(trimmedFrom, trimmedTo, markType);
115
+ tr.addMark(
116
+ trimmedFrom,
117
+ trimmedTo,
118
+ markType.create({
119
+ ...mark.attrs,
120
+ orphan: isOrphan,
121
+ })
122
+ );
123
+ ttEditor.dispatch(tr);
124
+
125
+ if (isOrphan && this.selectedThreadId === markThreadId) {
126
+ // unselect
127
+ this.selectedThreadId = undefined;
128
+ this.emitStateUpdate();
129
+ }
130
+ }
131
+ }
132
+ });
133
+ });
134
+ };
135
+
136
+ constructor(
137
+ private readonly editor: BlockNoteEditor<any, any, any>,
138
+ public readonly threadStore: ThreadStore,
139
+ private readonly markType: string
140
+ ) {
141
+ super();
142
+
143
+ if (!editor.resolveUsers) {
144
+ throw new Error("resolveUsers is required for comments");
145
+ }
146
+ this.userStore = new UserStore<User>(editor.resolveUsers);
147
+
148
+ // Note: Plugins are currently not destroyed when the editor is destroyed.
149
+ // We should unsubscribe from the threadStore when the editor is destroyed.
150
+ this.threadStore.subscribe(this.updateMarksFromThreads);
151
+
152
+ editor.onCreate(() => {
153
+ // Need to wait for TipTap editor state to be initialized
154
+ this.updateMarksFromThreads(this.threadStore.getThreads());
155
+ editor.onSelectionChange(() => {
156
+ if (this.pendingComment) {
157
+ this.pendingComment = false;
158
+ this.emitStateUpdate();
159
+ }
160
+ });
161
+ });
162
+
163
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
164
+ const self = this;
165
+
166
+ this.plugin = new Plugin<CommentsPluginState>({
167
+ key: PLUGIN_KEY,
168
+ state: {
169
+ init() {
170
+ return {
171
+ threadPositions: new Map<string, { from: number; to: number }>(),
172
+ decorations: DecorationSet.empty,
173
+ };
174
+ },
175
+ apply(tr, state) {
176
+ const action = tr.getMeta(PLUGIN_KEY);
177
+ if (!tr.docChanged && !action) {
178
+ return state;
179
+ }
180
+
181
+ // The doc changed or the selected thread changed
182
+ return updateState(tr.doc, self.selectedThreadId, markType);
183
+ },
184
+ },
185
+ props: {
186
+ decorations(state) {
187
+ return PLUGIN_KEY.getState(state)?.decorations ?? DecorationSet.empty;
188
+ },
189
+ /**
190
+ * Handle click on a thread mark and mark it as selected
191
+ */
192
+ handleClick: (view, pos, event) => {
193
+ if (event.button !== 0) {
194
+ return;
195
+ }
196
+
197
+ const node = view.state.doc.nodeAt(pos);
198
+
199
+ if (!node) {
200
+ self.selectThread(undefined);
201
+ return;
202
+ }
203
+
204
+ const commentMark = node.marks.find(
205
+ (mark) => mark.type.name === markType && mark.attrs.orphan !== true
206
+ );
207
+
208
+ const threadId = commentMark?.attrs.threadId as string | undefined;
209
+ self.selectThread(threadId);
210
+ },
211
+ },
212
+ });
213
+ }
214
+
215
+ /**
216
+ * Subscribe to state updates
217
+ */
218
+ public onUpdate(
219
+ callback: (state: {
220
+ pendingComment: boolean;
221
+ selectedThreadId: string | undefined;
222
+ }) => void
223
+ ) {
224
+ return this.on("update", callback);
225
+ }
226
+
227
+ /**
228
+ * Set the selected thread
229
+ */
230
+ public selectThread(threadId: string | undefined) {
231
+ if (this.selectedThreadId === threadId) {
232
+ return;
233
+ }
234
+ this.selectedThreadId = threadId;
235
+ this.emitStateUpdate();
236
+ this.editor.dispatch(
237
+ this.editor.prosemirrorView!.state.tr.setMeta(PLUGIN_KEY, {
238
+ name: SET_SELECTED_THREAD_ID,
239
+ })
240
+ );
241
+ }
242
+
243
+ /**
244
+ * Start a pending comment (e.g.: when clicking the "Add comment" button)
245
+ */
246
+ public startPendingComment() {
247
+ this.pendingComment = true;
248
+ this.emitStateUpdate();
249
+ }
250
+
251
+ /**
252
+ * Stop a pending comment (e.g.: user closes the comment composer)
253
+ */
254
+ public stopPendingComment() {
255
+ this.pendingComment = false;
256
+ this.emitStateUpdate();
257
+ }
258
+
259
+ /**
260
+ * Create a thread at the current selection
261
+ */
262
+ public async createThread(options: {
263
+ initialComment: {
264
+ body: CommentBody;
265
+ metadata?: any;
266
+ };
267
+ metadata?: any;
268
+ }) {
269
+ const thread = await this.threadStore.createThread(options);
270
+
271
+ if (this.threadStore.addThreadToDocument) {
272
+ // creating the mark is handled by the store
273
+ // this is useful if we don't have write-access to the document.
274
+ // We can then offload the responsibility of creating the mark to the server.
275
+ // (e.g.: RESTYjsThreadStore)
276
+ const view = this.editor.prosemirrorView!;
277
+ const pmSelection = view.state.selection;
278
+
279
+ const ystate = ySyncPluginKey.getState(view.state);
280
+
281
+ const selection = {
282
+ prosemirror: {
283
+ head: pmSelection.head,
284
+ anchor: pmSelection.anchor,
285
+ },
286
+ yjs: getRelativeSelection(ystate.binding, view.state),
287
+ };
288
+
289
+ await this.threadStore.addThreadToDocument({
290
+ threadId: thread.id,
291
+ selection,
292
+ });
293
+ } else {
294
+ // we create the mark directly in the document
295
+ this.editor._tiptapEditor.commands.setMark(this.markType, {
296
+ orphan: false,
297
+ threadId: thread.id,
298
+ });
299
+ }
300
+ }
301
+ }
@@ -0,0 +1,72 @@
1
+ import type { User } from "../../../comments/index.js";
2
+ import { EventEmitter } from "../../../util/EventEmitter.js";
3
+
4
+ /**
5
+ * The `UserStore` is used to retrieve and cache information about users.
6
+ *
7
+ * It does this by calling `resolveUsers` (which is user-defined in the Editor Options)
8
+ * for users that are not yet cached.
9
+ */
10
+ export class UserStore<U extends User> extends EventEmitter<any> {
11
+ private userCache: Map<string, U> = new Map();
12
+
13
+ // avoid duplicate loads
14
+ private loadingUsers = new Set<string>();
15
+
16
+ public constructor(
17
+ private readonly resolveUsers: (userIds: string[]) => Promise<U[]>
18
+ ) {
19
+ super();
20
+ }
21
+
22
+ /**
23
+ * Load information about users based on an array of user ids.
24
+ */
25
+ public async loadUsers(userIds: string[]) {
26
+ const missingUsers = userIds.filter(
27
+ (id) => !this.userCache.has(id) && !this.loadingUsers.has(id)
28
+ );
29
+
30
+ if (missingUsers.length === 0) {
31
+ return;
32
+ }
33
+
34
+ for (const id of missingUsers) {
35
+ this.loadingUsers.add(id);
36
+ }
37
+
38
+ try {
39
+ const users = await this.resolveUsers(missingUsers);
40
+ for (const user of users) {
41
+ this.userCache.set(user.id, user);
42
+ }
43
+ this.emit("update", this.userCache);
44
+ } finally {
45
+ for (const id of missingUsers) {
46
+ // delete the users from the loading set
47
+ // on a next call to `loadUsers` we will either
48
+ // return the cached user or retry loading the user if the request failed failed
49
+ this.loadingUsers.delete(id);
50
+ }
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Retrieve information about a user based on their id, if cached.
56
+ *
57
+ * The user will have to be loaded via `loadUsers` first
58
+ */
59
+ public getUser(userId: string): U | undefined {
60
+ return this.userCache.get(userId);
61
+ }
62
+
63
+ /**
64
+ * Subscribe to changes in the user store.
65
+ *
66
+ * @param cb - The callback to call when the user store changes.
67
+ * @returns A function to unsubscribe from the user store.
68
+ */
69
+ public subscribe(cb: (users: Map<string, U>) => void): () => void {
70
+ return this.on("update", cb);
71
+ }
72
+ }
@@ -25,7 +25,7 @@ export class FormattingToolbarView implements PluginView {
25
25
  state: EditorState;
26
26
  from: number;
27
27
  to: number;
28
- }) => boolean = ({ state, from, to, view }) => {
28
+ }) => boolean = ({ state, from, to }) => {
29
29
  const { doc, selection } = state;
30
30
  const { empty } = selection;
31
31
 
@@ -43,8 +43,7 @@ export class FormattingToolbarView implements PluginView {
43
43
  return false;
44
44
  }
45
45
 
46
- // check view.hasFocus so that the toolbar doesn't show up when the editor is not focused or when for example a code block is focused
47
- return !(!view.hasFocus() || empty || isEmptyTextBlock);
46
+ return !(empty || isEmptyTextBlock);
48
47
  };
49
48
 
50
49
  constructor(
@@ -67,7 +66,7 @@ export class FormattingToolbarView implements PluginView {
67
66
  };
68
67
 
69
68
  pmView.dom.addEventListener("mousedown", this.viewMousedownHandler);
70
- pmView.dom.addEventListener("mouseup", this.viewMouseupHandler);
69
+ pmView.root.addEventListener("mouseup", this.mouseupHandler);
71
70
  pmView.dom.addEventListener("dragstart", this.dragHandler);
72
71
  pmView.dom.addEventListener("dragover", this.dragHandler);
73
72
  pmView.dom.addEventListener("blur", this.blurHandler);
@@ -113,9 +112,11 @@ export class FormattingToolbarView implements PluginView {
113
112
  this.preventShow = true;
114
113
  };
115
114
 
116
- viewMouseupHandler = () => {
117
- this.preventShow = false;
118
- setTimeout(() => this.update(this.pmView));
115
+ mouseupHandler = () => {
116
+ if (this.preventShow) {
117
+ this.preventShow = false;
118
+ setTimeout(() => this.update(this.pmView));
119
+ }
119
120
  };
120
121
 
121
122
  // For dragging the whole editor.
@@ -154,15 +155,20 @@ export class FormattingToolbarView implements PluginView {
154
155
  const from = Math.min(...ranges.map((range) => range.$from.pos));
155
156
  const to = Math.max(...ranges.map((range) => range.$to.pos));
156
157
 
157
- const shouldShow = this.shouldShow?.({
158
+ const shouldShow = this.shouldShow({
158
159
  view,
159
160
  state,
160
161
  from,
161
162
  to,
162
163
  });
163
164
 
165
+ // in jsdom, Range.prototype.getClientRects is not implemented,
166
+ // this would cause `getSelectionBoundingBox` to fail
167
+ // we can just ignore jsdom for now and not show the toolbar
168
+ const jsdom = typeof Range.prototype.getClientRects === "undefined";
169
+
164
170
  // Checks if menu should be shown/updated.
165
- if (!this.preventShow && (shouldShow || this.preventHide)) {
171
+ if (!this.preventShow && (shouldShow || this.preventHide) && !jsdom) {
166
172
  // Unlike other UI elements, we don't prevent the formatting toolbar from
167
173
  // showing when the editor is not editable. This is because some buttons,
168
174
  // e.g. the download file button, should still be accessible. Therefore,
@@ -193,7 +199,7 @@ export class FormattingToolbarView implements PluginView {
193
199
 
194
200
  destroy() {
195
201
  this.pmView.dom.removeEventListener("mousedown", this.viewMousedownHandler);
196
- this.pmView.dom.removeEventListener("mouseup", this.viewMouseupHandler);
202
+ this.pmView.root.removeEventListener("mouseup", this.mouseupHandler);
197
203
  this.pmView.dom.removeEventListener("dragstart", this.dragHandler);
198
204
  this.pmView.dom.removeEventListener("dragover", this.dragHandler);
199
205
  this.pmView.dom.removeEventListener("blur", this.blurHandler);
@@ -52,7 +52,7 @@ class LinkToolbarView implements PluginView {
52
52
 
53
53
  this.startMenuUpdateTimer = () => {
54
54
  this.menuUpdateTimer = setTimeout(() => {
55
- this.update(this.pmView);
55
+ this.update(this.pmView, undefined, true);
56
56
  }, 250);
57
57
  };
58
58
 
@@ -190,7 +190,7 @@ class LinkToolbarView implements PluginView {
190
190
  }
191
191
  }
192
192
 
193
- update(view: EditorView, oldState?: EditorState) {
193
+ update(view: EditorView, oldState?: EditorState, fromMouseOver = false) {
194
194
  const { state } = view;
195
195
 
196
196
  const isSame =
@@ -235,7 +235,7 @@ class LinkToolbarView implements PluginView {
235
235
  }
236
236
  }
237
237
 
238
- if (this.mouseHoveredLinkMark) {
238
+ if (this.mouseHoveredLinkMark && fromMouseOver) {
239
239
  this.linkMark = this.mouseHoveredLinkMark;
240
240
  this.linkMarkRange = this.mouseHoveredLinkMarkRange;
241
241
  }
@@ -0,0 +1,52 @@
1
+ import { Plugin, PluginKey } from "prosemirror-state";
2
+ import { Decoration, DecorationSet } from "prosemirror-view";
3
+ import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
4
+
5
+ const PLUGIN_KEY = new PluginKey(`blocknote-show-selection`);
6
+
7
+ /**
8
+ * Plugin that shows adds a decoration around the current selection
9
+ * This can be used to highlight the current selection in the UI even when the
10
+ * text editor is not focused.
11
+ */
12
+ export class ShowSelectionPlugin {
13
+ public readonly plugin: Plugin;
14
+ private enabled = false;
15
+
16
+ public constructor(private readonly editor: BlockNoteEditor<any, any, any>) {
17
+ this.plugin = new Plugin({
18
+ key: PLUGIN_KEY,
19
+ props: {
20
+ decorations: (state) => {
21
+ const { doc, selection } = state;
22
+
23
+ if (!this.enabled) {
24
+ return DecorationSet.empty;
25
+ }
26
+
27
+ const dec = Decoration.inline(selection.from, selection.to, {
28
+ "data-show-selection": "true",
29
+ });
30
+
31
+ return DecorationSet.create(doc, [dec]);
32
+ },
33
+ },
34
+ });
35
+ }
36
+
37
+ public setEnabled(enabled: boolean) {
38
+ if (this.enabled === enabled) {
39
+ return;
40
+ }
41
+
42
+ this.enabled = enabled;
43
+
44
+ this.editor.prosemirrorView?.dispatch(
45
+ this.editor.prosemirrorView?.state.tr.setMeta(PLUGIN_KEY, {})
46
+ );
47
+ }
48
+
49
+ public getEnabled() {
50
+ return this.enabled;
51
+ }
52
+ }
@@ -178,6 +178,11 @@ export class SideMenuView<
178
178
  this.onDrop as EventListener,
179
179
  true
180
180
  );
181
+ this.pmView.root.addEventListener(
182
+ "dragend",
183
+ this.onDragEnd as EventListener,
184
+ true
185
+ );
181
186
  initializeESMDependencies();
182
187
 
183
188
  // Shows or updates menu position whenever the cursor moves, if the menu isn't frozen.
@@ -300,8 +305,8 @@ export class SideMenuView<
300
305
  // a block from a different editor is being dropped, this causes some
301
306
  // issues that the code below fixes:
302
307
  if (!this.isDragOrigin && this.pmView.dom === parentEditorElement) {
303
- // 1. Because the editor selection is unrelated to the dragged content,
304
- // we don't want PM to delete its content. Therefore, we collapse the
308
+ // Because the editor selection is unrelated to the dragged content, we
309
+ // don't want PM to delete its content. Therefore, we collapse the
305
310
  // selection.
306
311
  this.pmView.dispatch(
307
312
  this.pmView.state.tr.setSelection(
@@ -312,8 +317,8 @@ export class SideMenuView<
312
317
  )
313
318
  );
314
319
  } else if (this.isDragOrigin && this.pmView.dom !== parentEditorElement) {
315
- // 2. Because the editor from which the block originates doesn't get a
316
- // drop event on it, PM doesn't delete its selected content. Therefore, we
320
+ // Because the editor from which the block originates doesn't get a drop
321
+ // event on it, PM doesn't delete its selected content. Therefore, we
317
322
  // need to do so manually.
318
323
  //
319
324
  // Note: Deleting the selected content from the editor from which the
@@ -328,11 +333,6 @@ export class SideMenuView<
328
333
  0
329
334
  );
330
335
  }
331
- // 3. PM only clears `view.dragging` on the editor that the block was
332
- // dropped, so we manually have to clear it on all the others. However,
333
- // PM also needs to read `view.dragging` while handling the event, so we
334
- // use a `setTimeout` to ensure it's only cleared after that.
335
- setTimeout(() => (this.pmView.dragging = null), 0);
336
336
  }
337
337
 
338
338
  if (
@@ -360,6 +360,14 @@ export class SideMenuView<
360
360
  }
361
361
  };
362
362
 
363
+ onDragEnd = () => {
364
+ // When the user starts dragging a block, `view.dragging` is set on all
365
+ // BlockNote editors. However, when the drag ends, only the editor that the
366
+ // drag originated in automatically clears `view.dragging`. Therefore, we
367
+ // have to manually clear it on all editors.
368
+ this.pmView.dragging = null;
369
+ };
370
+
363
371
  /**
364
372
  * If a block is being dragged, ProseMirror usually gets the context of what's
365
373
  * being dragged from `view.dragging`, which is automatically set when a
@@ -580,6 +588,11 @@ export class SideMenuView<
580
588
  this.onDrop as EventListener,
581
589
  true
582
590
  );
591
+ this.pmView.root.removeEventListener(
592
+ "dragend",
593
+ this.onDragEnd as EventListener,
594
+ true
595
+ );
583
596
  this.pmView.root.removeEventListener(
584
597
  "keydown",
585
598
  this.onKeyDown as EventListener,