@blocknote/core 0.24.2 → 0.25.1

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 (143) hide show
  1. package/dist/blocknote.cjs +12 -0
  2. package/dist/blocknote.cjs.map +1 -0
  3. package/dist/blocknote.js +4784 -3545
  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 +40 -27
  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/toClipboard/copyExtension.ts +2 -3
  33. package/src/api/exporters/html/__snapshots__/table/headerCols/external.html +1 -0
  34. package/src/api/exporters/html/__snapshots__/table/headerCols/internal.html +1 -0
  35. package/src/api/exporters/html/__snapshots__/table/headerRows/external.html +1 -0
  36. package/src/api/exporters/html/__snapshots__/table/headerRows/internal.html +1 -0
  37. package/src/api/exporters/html/__snapshots__/table/headersRows/external.html +1 -0
  38. package/src/api/exporters/html/__snapshots__/table/headersRows/internal.html +1 -0
  39. package/src/api/exporters/html/__snapshots__/table/mixedCellColors/external.html +1 -0
  40. package/src/api/exporters/html/__snapshots__/table/mixedCellColors/internal.html +1 -0
  41. package/src/api/exporters/html/__snapshots__/table/mixedRowspansAndColspans/external.html +1 -0
  42. package/src/api/exporters/html/__snapshots__/table/mixedRowspansAndColspans/internal.html +1 -0
  43. package/src/api/exporters/markdown/__snapshots__/table/headerCols/markdown.md +4 -0
  44. package/src/api/exporters/markdown/__snapshots__/table/headerRows/markdown.md +4 -0
  45. package/src/api/exporters/markdown/__snapshots__/table/mixedCellColors/markdown.md +5 -0
  46. package/src/api/exporters/markdown/__snapshots__/table/mixedRowspansAndColspans/markdown.md +5 -0
  47. package/src/api/nodeConversions/__snapshots__/nodeConversions.test.ts.snap +985 -20
  48. package/src/api/nodeConversions/blockToNode.ts +63 -20
  49. package/src/api/nodeConversions/nodeToBlock.ts +75 -13
  50. package/src/api/parsers/html/__snapshots__/parse-notion-html.json +145 -54
  51. package/src/api/testUtil/cases/defaultSchema.ts +782 -9
  52. package/src/api/testUtil/partialBlockTestUtil.ts +39 -4
  53. package/src/blocks/TableBlockContent/TableBlockContent.ts +11 -5
  54. package/src/blocks/defaultBlockTypeGuards.ts +8 -0
  55. package/src/comments/index.ts +9 -0
  56. package/src/comments/models/User.ts +8 -0
  57. package/src/comments/threadstore/DefaultThreadStoreAuth.ts +106 -0
  58. package/src/comments/threadstore/ThreadStore.ts +134 -0
  59. package/src/comments/threadstore/ThreadStoreAuth.ts +13 -0
  60. package/src/comments/threadstore/TipTapThreadStore.ts +292 -0
  61. package/src/comments/threadstore/yjs/RESTYjsThreadStore.ts +144 -0
  62. package/src/comments/threadstore/yjs/YjsThreadStore.test.ts +294 -0
  63. package/src/comments/threadstore/yjs/YjsThreadStore.ts +340 -0
  64. package/src/comments/threadstore/yjs/YjsThreadStoreBase.ts +48 -0
  65. package/src/comments/threadstore/yjs/yjsHelpers.ts +121 -0
  66. package/src/comments/types.ts +117 -0
  67. package/src/editor/Block.css +16 -8
  68. package/src/editor/BlockNoteEditor.ts +269 -92
  69. package/src/editor/BlockNoteExtensions.ts +24 -1
  70. package/src/editor/BlockNoteTipTapEditor.ts +5 -1
  71. package/src/editor/editor.css +23 -4
  72. package/src/extensions/BackgroundColor/BackgroundColorExtension.ts +1 -1
  73. package/src/extensions/Collaboration/createCollaborationExtensions.ts +8 -17
  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 +9 -5
  78. package/src/extensions/LinkToolbar/LinkToolbarPlugin.ts +3 -3
  79. package/src/extensions/ShowSelection/ShowSelectionPlugin.ts +52 -0
  80. package/src/extensions/SuggestionMenu/getDefaultEmojiPickerItems.ts +34 -17
  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/i18n/locales/ar.ts +23 -0
  85. package/src/i18n/locales/de.ts +15 -0
  86. package/src/i18n/locales/en.ts +25 -1
  87. package/src/i18n/locales/es.ts +16 -1
  88. package/src/i18n/locales/fr.ts +23 -0
  89. package/src/i18n/locales/hr.ts +18 -0
  90. package/src/i18n/locales/is.ts +24 -1
  91. package/src/i18n/locales/it.ts +15 -0
  92. package/src/i18n/locales/ja.ts +23 -0
  93. package/src/i18n/locales/ko.ts +23 -0
  94. package/src/i18n/locales/nl.ts +23 -0
  95. package/src/i18n/locales/no.ts +23 -0
  96. package/src/i18n/locales/pl.ts +23 -0
  97. package/src/i18n/locales/pt.ts +23 -0
  98. package/src/i18n/locales/ru.ts +23 -0
  99. package/src/i18n/locales/uk.ts +23 -0
  100. package/src/i18n/locales/vi.ts +23 -0
  101. package/src/i18n/locales/zh.ts +23 -0
  102. package/src/index.ts +6 -4
  103. package/src/schema/blocks/types.ts +32 -2
  104. package/src/util/browser.ts +1 -1
  105. package/src/util/table.ts +107 -0
  106. package/types/src/api/blockManipulation/tables/tables.d.ts +343 -0
  107. package/types/src/api/blockManipulation/tables/tables.test.d.ts +1 -0
  108. package/types/src/api/clipboard/toClipboard/copyExtension.d.ts +1 -1
  109. package/types/src/blocks/TableBlockContent/TableBlockContent.d.ts +1 -2
  110. package/types/src/blocks/defaultBlockTypeGuards.d.ts +3 -0
  111. package/types/src/comments/index.d.ts +9 -0
  112. package/types/src/comments/models/User.d.ts +8 -0
  113. package/types/src/comments/threadstore/DefaultThreadStoreAuth.d.ts +47 -0
  114. package/types/src/comments/threadstore/ThreadStore.d.ts +121 -0
  115. package/types/src/comments/threadstore/ThreadStoreAuth.d.ts +12 -0
  116. package/types/src/comments/threadstore/TipTapThreadStore.d.ts +97 -0
  117. package/types/src/comments/threadstore/yjs/RESTYjsThreadStore.d.ts +83 -0
  118. package/types/src/comments/threadstore/yjs/YjsThreadStore.d.ts +79 -0
  119. package/types/src/comments/threadstore/yjs/YjsThreadStore.test.d.ts +1 -0
  120. package/types/src/comments/threadstore/yjs/YjsThreadStoreBase.d.ts +15 -0
  121. package/types/src/comments/threadstore/yjs/yjsHelpers.d.ts +13 -0
  122. package/types/src/comments/types.d.ts +109 -0
  123. package/types/src/editor/BlockNoteEditor.d.ts +146 -66
  124. package/types/src/editor/BlockNoteExtensions.d.ts +4 -0
  125. package/types/src/extensions/Collaboration/createCollaborationExtensions.d.ts +1 -1
  126. package/types/src/extensions/Comments/CommentMark.d.ts +2 -0
  127. package/types/src/extensions/Comments/CommentsPlugin.d.ts +49 -0
  128. package/types/src/extensions/Comments/userstore/UserStore.d.ts +31 -0
  129. package/types/src/extensions/ShowSelection/ShowSelectionPlugin.d.ts +15 -0
  130. package/types/src/extensions/TableHandles/TableHandlesPlugin.d.ts +66 -1
  131. package/types/src/i18n/locales/de.d.ts +15 -0
  132. package/types/src/i18n/locales/en.d.ts +20 -0
  133. package/types/src/i18n/locales/es.d.ts +15 -0
  134. package/types/src/i18n/locales/hr.d.ts +18 -0
  135. package/types/src/i18n/locales/it.d.ts +15 -0
  136. package/types/src/index.d.ts +5 -4
  137. package/types/src/pm-nodes/BlockContainer.d.ts +2 -2
  138. package/types/src/pm-nodes/BlockGroup.d.ts +2 -2
  139. package/types/src/schema/blocks/types.d.ts +23 -2
  140. package/types/src/util/browser.d.ts +1 -1
  141. package/types/src/util/table.d.ts +12 -0
  142. package/dist/blocknote.umd.cjs +0 -11
  143. package/dist/blocknote.umd.cjs.map +0 -1
@@ -10,6 +10,15 @@
10
10
  --N40: #dfe1e6; /* Light neutral used for subtle borders and text on dark background */
11
11
  }
12
12
 
13
+ .bn-comment-editor {
14
+ width: 100%;
15
+ padding: 0;
16
+ }
17
+
18
+ .bn-comment-editor .bn-editor {
19
+ padding: 0;
20
+ }
21
+
13
22
  /*
14
23
  bn-root should be applied to all top-level elements
15
24
 
@@ -77,11 +86,11 @@ Tippy popups that are appended to document.body directly
77
86
  opacity: 0.001;
78
87
  }
79
88
 
80
- .collaboration-cursor__base {
89
+ .bn-editor .bn-collaboration-cursor__base {
81
90
  position: relative;
82
91
  }
83
92
 
84
- .collaboration-cursor__caret {
93
+ .bn-editor .bn-collaboration-cursor__base .bn-collaboration-cursor__caret {
85
94
  position: absolute;
86
95
  width: 2px;
87
96
  top: 1px;
@@ -89,7 +98,7 @@ Tippy popups that are appended to document.body directly
89
98
  left: -1px;
90
99
  }
91
100
 
92
- .collaboration-cursor__label {
101
+ .bn-editor .bn-collaboration-cursor__base .bn-collaboration-cursor__label {
93
102
  pointer-events: none;
94
103
  border-radius: 0 1.5px 1.5px 0;
95
104
  font-size: 12px;
@@ -110,7 +119,9 @@ Tippy popups that are appended to document.body directly
110
119
  transition: all 0.2s;
111
120
  }
112
121
 
113
- .collaboration-cursor__base[data-active] .collaboration-cursor__label {
122
+ .bn-editor
123
+ .bn-collaboration-cursor__base[data-active]
124
+ .bn-collaboration-cursor__label {
114
125
  color: #0d0d0d;
115
126
  max-height: 1.1rem;
116
127
  max-width: 20rem;
@@ -179,3 +190,11 @@ Tippy popups that are appended to document.body directly
179
190
  .prosemirror-dropcursor-vertical {
180
191
  transition-property: left, right;
181
192
  }
193
+
194
+ /*
195
+ For the ShowSelectionPlugin
196
+ */
197
+ [data-show-selection] {
198
+ background-color: highlight;
199
+ padding: 2px 0;
200
+ }
@@ -7,7 +7,7 @@ export const BackgroundColorExtension = Extension.create({
7
7
  addGlobalAttributes() {
8
8
  return [
9
9
  {
10
- types: ["blockContainer"],
10
+ types: ["blockContainer", "tableCell", "tableHeader"],
11
11
  attributes: {
12
12
  backgroundColor: {
13
13
  default: defaultProps.backgroundColor.default,
@@ -65,16 +65,16 @@ export const createCollaborationExtensions = (collaboration: {
65
65
  const renderCursor = (user: { name: string; color: string }) => {
66
66
  const cursorElement = document.createElement("span");
67
67
 
68
- cursorElement.classList.add("collaboration-cursor__base");
68
+ cursorElement.classList.add("bn-collaboration-cursor__base");
69
69
 
70
70
  const caretElement = document.createElement("span");
71
71
  caretElement.setAttribute("contentedEditable", "false");
72
- caretElement.classList.add("collaboration-cursor__caret");
72
+ caretElement.classList.add("bn-collaboration-cursor__caret");
73
73
  caretElement.setAttribute("style", `background-color: ${user.color}`);
74
74
 
75
75
  const labelElement = document.createElement("span");
76
76
 
77
- labelElement.classList.add("collaboration-cursor__label");
77
+ labelElement.classList.add("bn-collaboration-cursor__label");
78
78
  labelElement.setAttribute("style", `background-color: ${user.color}`);
79
79
  labelElement.insertBefore(document.createTextNode(user.name), null);
80
80
 
@@ -87,19 +87,10 @@ export const createCollaborationExtensions = (collaboration: {
87
87
  return cursorElement;
88
88
  };
89
89
 
90
- const render = (user: { color: string; name: string }) => {
91
- const clientState = [...awareness.getStates().entries()].find(
92
- (state) => state[1].user === user
93
- );
94
-
95
- if (!clientState) {
96
- throw new Error(
97
- "Could not find client state for user, " + JSON.stringify(user)
98
- );
99
- }
100
-
101
- const clientID = clientState[0];
102
-
90
+ const render = (
91
+ user: { color: string; name: string },
92
+ clientID: number
93
+ ) => {
103
94
  let cursorData = cursors.get(clientID);
104
95
 
105
96
  if (!cursorData) {
@@ -146,7 +137,7 @@ export const createCollaborationExtensions = (collaboration: {
146
137
  tiptapExtensions.push(
147
138
  CollaborationCursor.configure({
148
139
  user: collaboration.user,
149
- render,
140
+ render: render as any, // tiptap type not compatible with latest y-prosemirror
150
141
  provider: collaboration.provider,
151
142
  })
152
143
  );
@@ -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(
@@ -156,15 +155,20 @@ export class FormattingToolbarView implements PluginView {
156
155
  const from = Math.min(...ranges.map((range) => range.$from.pos));
157
156
  const to = Math.max(...ranges.map((range) => range.$to.pos));
158
157
 
159
- const shouldShow = this.shouldShow?.({
158
+ const shouldShow = this.shouldShow({
160
159
  view,
161
160
  state,
162
161
  from,
163
162
  to,
164
163
  });
165
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
+
166
170
  // Checks if menu should be shown/updated.
167
- if (!this.preventShow && (shouldShow || this.preventHide)) {
171
+ if (!this.preventShow && (shouldShow || this.preventHide) && !jsdom) {
168
172
  // Unlike other UI elements, we don't prevent the formatting toolbar from
169
173
  // showing when the editor is not editable. This is because some buttons,
170
174
  // e.g. the download file button, should still be accessible. Therefore,
@@ -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
+ }