@blocknote/core 0.8.2 → 0.8.3

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 (68) hide show
  1. package/README.md +4 -0
  2. package/dist/blocknote.js +1777 -1849
  3. package/dist/blocknote.js.map +1 -1
  4. package/dist/blocknote.umd.cjs +4 -4
  5. package/dist/blocknote.umd.cjs.map +1 -1
  6. package/dist/style.css +1 -1
  7. package/package.json +2 -2
  8. package/src/BlockNoteEditor.ts +89 -39
  9. package/src/BlockNoteExtensions.ts +1 -58
  10. package/src/api/formatConversions/__snapshots__/formatConversions.test.ts.snap +10 -10
  11. package/src/api/formatConversions/formatConversions.test.ts +587 -605
  12. package/src/api/nodeConversions/__snapshots__/nodeConversions.test.ts.snap +15 -15
  13. package/src/api/nodeConversions/nodeConversions.test.ts +90 -94
  14. package/src/extensions/Blocks/api/blockTypes.ts +3 -2
  15. package/src/extensions/Blocks/helpers/getBlockInfoFromPos.ts +6 -0
  16. package/src/extensions/FormattingToolbar/FormattingToolbarPlugin.ts +101 -114
  17. package/src/extensions/HyperlinkToolbar/HyperlinkToolbarPlugin.ts +184 -149
  18. package/src/extensions/Placeholder/PlaceholderExtension.ts +2 -2
  19. package/src/extensions/{DraggableBlocks/DraggableBlocksPlugin.ts → SideMenu/SideMenuPlugin.ts} +181 -164
  20. package/src/extensions/SlashMenu/BaseSlashMenuItem.ts +7 -30
  21. package/src/extensions/SlashMenu/SlashMenuPlugin.ts +51 -0
  22. package/src/extensions/SlashMenu/defaultSlashMenuItems.ts +109 -0
  23. package/src/extensions/UniqueID/UniqueID.ts +29 -30
  24. package/src/index.ts +9 -8
  25. package/src/node_modules/.vitest/results.json +1 -0
  26. package/src/shared/BaseUiElementTypes.ts +8 -0
  27. package/src/shared/EditorElement.ts +0 -16
  28. package/src/shared/EventEmitter.ts +58 -0
  29. package/src/shared/plugins/suggestion/SuggestionItem.ts +3 -6
  30. package/src/shared/plugins/suggestion/SuggestionPlugin.ts +341 -403
  31. package/types/src/BlockNoteEditor.d.ts +18 -11
  32. package/types/src/BlockNoteExtensions.d.ts +0 -19
  33. package/types/src/EventEmitter.d.ts +11 -0
  34. package/types/src/extensions/Blocks/api/blockTypes.d.ts +3 -2
  35. package/types/src/extensions/DraggableBlocks/BlockSideMenuFactoryTypes.d.ts +0 -17
  36. package/types/src/extensions/DraggableBlocks/DraggableBlocksPlugin.d.ts +26 -20
  37. package/types/src/extensions/FormattingToolbar/FormattingToolbarPlugin.d.ts +18 -24
  38. package/types/src/extensions/HyperlinkToolbar/HyperlinkToolbarFactoryTypes.d.ts +0 -12
  39. package/types/src/extensions/HyperlinkToolbar/HyperlinkToolbarPlugin.d.ts +37 -10
  40. package/types/src/extensions/SideMenu/MultipleNodeSelection.d.ts +24 -0
  41. package/types/src/extensions/SideMenu/SideMenuPlugin.d.ts +79 -0
  42. package/types/src/extensions/SlashMenu/BaseSlashMenuItem.d.ts +5 -18
  43. package/types/src/extensions/SlashMenu/SlashMenuPlugin.d.ts +13 -0
  44. package/types/src/extensions/SlashMenu/defaultSlashMenuItems.d.ts +1 -69
  45. package/types/src/extensions/SlashMenu/index.d.ts +2 -3
  46. package/types/src/index.d.ts +9 -8
  47. package/types/src/shared/BaseUiElementTypes.d.ts +7 -0
  48. package/types/src/shared/EditorElement.d.ts +0 -10
  49. package/types/src/shared/EventEmitter.d.ts +11 -0
  50. package/types/src/shared/plugins/suggestion/SuggestionItem.d.ts +2 -7
  51. package/types/src/shared/plugins/suggestion/SuggestionPlugin.d.ts +12 -43
  52. package/src/extensions/DraggableBlocks/BlockSideMenuFactoryTypes.ts +0 -29
  53. package/src/extensions/DraggableBlocks/DraggableBlocksExtension.ts +0 -37
  54. package/src/extensions/FormattingToolbar/FormattingToolbarExtension.ts +0 -37
  55. package/src/extensions/FormattingToolbar/FormattingToolbarFactoryTypes.ts +0 -18
  56. package/src/extensions/HyperlinkToolbar/HyperlinkMark.ts +0 -28
  57. package/src/extensions/HyperlinkToolbar/HyperlinkToolbarFactoryTypes.ts +0 -19
  58. package/src/extensions/SlashMenu/SlashMenuExtension.ts +0 -53
  59. package/src/extensions/SlashMenu/defaultSlashMenuItems.tsx +0 -195
  60. package/src/extensions/SlashMenu/index.ts +0 -5
  61. package/src/shared/plugins/suggestion/SuggestionsMenuFactoryTypes.ts +0 -21
  62. package/types/src/CustomBlock.d.ts +0 -15
  63. package/types/src/extensions/Blocks/nodes/BlockContent/TableContent/TableCol.d.ts +0 -2
  64. package/types/src/extensions/Blocks/nodes/BlockContent/TableContent/TableContent.d.ts +0 -2
  65. package/types/src/extensions/Blocks/nodes/BlockContent/TableContent/TableRow.d.ts +0 -2
  66. package/types/src/extensions/Placeholder/localisation/index.d.ts +0 -2
  67. package/types/src/extensions/Placeholder/localisation/translation.d.ts +0 -51
  68. /package/src/extensions/{DraggableBlocks → SideMenu}/MultipleNodeSelection.ts +0 -0
@@ -1,146 +1,59 @@
1
- import { Editor, Range } from "@tiptap/core";
2
1
  import { EditorState, Plugin, PluginKey } from "prosemirror-state";
3
2
  import { Decoration, DecorationSet, EditorView } from "prosemirror-view";
4
- import { findBlock } from "../../../extensions/Blocks/helpers/findBlock";
5
- import {
6
- SuggestionsMenu,
7
- SuggestionsMenuDynamicParams,
8
- SuggestionsMenuFactory,
9
- SuggestionsMenuStaticParams,
10
- } from "./SuggestionsMenuFactoryTypes";
11
- import { SuggestionItem } from "./SuggestionItem";
12
3
  import { BlockNoteEditor } from "../../../BlockNoteEditor";
13
4
  import { BlockSchema } from "../../../extensions/Blocks/api/blockTypes";
5
+ import { findBlock } from "../../../extensions/Blocks/helpers/findBlock";
6
+ import { BaseUiElementState } from "../../BaseUiElementTypes";
7
+ import { SuggestionItem } from "./SuggestionItem";
14
8
 
15
- export type SuggestionPluginOptions<
16
- T extends SuggestionItem,
17
- BSchema extends BlockSchema
18
- > = {
19
- /**
20
- * The name of the plugin.
21
- *
22
- * Used for ensuring that the plugin key is unique when more than one instance of the SuggestionPlugin is used.
23
- */
24
- pluginKey: PluginKey;
25
-
26
- /**
27
- * The BlockNote editor.
28
- */
29
- editor: BlockNoteEditor<BSchema>;
30
-
31
- /**
32
- * The character that should trigger the suggestion menu to pop up (e.g. a '/' for commands), when typed by the user.
33
- */
34
- defaultTriggerCharacter: string;
35
-
36
- suggestionsMenuFactory: SuggestionsMenuFactory<T>;
37
-
38
- /**
39
- * The callback that gets executed when an item is selected by the user.
40
- *
41
- * **NOTE:** The command text is not removed automatically from the editor by this plugin,
42
- * this should be done manually. The `editor` and `range` properties passed
43
- * to the callback function might come in handy when doing this.
44
- */
45
- onSelectItem?: (props: { item: T; editor: BlockNoteEditor<BSchema> }) => void;
46
-
47
- /**
48
- * A function that should supply the plugin with items to suggest, based on a certain query string.
49
- */
50
- items?: (query: string) => T[];
51
-
52
- allow?: (props: { editor: Editor; range: Range }) => boolean;
53
- };
54
-
55
- type SuggestionPluginState<T extends SuggestionItem> = {
56
- // True when the menu is shown, false when hidden.
57
- active: boolean;
58
- // The character that triggered the menu being shown. Allowing the trigger to be different to the default
59
- // trigger allows other extensions to open it programmatically.
60
- triggerCharacter: string | undefined;
61
- // The editor position just after the trigger character, i.e. where the user query begins. Used to figure out
62
- // which menu items to show and can also be used to delete the trigger character.
63
- queryStartPos: number | undefined;
64
- // The items that should be shown in the menu.
65
- items: T[];
66
- // The index of the item in the menu that's currently hovered using the keyboard.
67
- keyboardHoveredItemIndex: number | undefined;
68
- // The number of characters typed after the last query that matched with at least 1 item. Used to close the
69
- // menu if the user keeps entering queries that don't return any results.
70
- notFoundCount: number | undefined;
71
- decorationId: string | undefined;
72
- };
73
-
74
- function getDefaultPluginState<
75
- T extends SuggestionItem
76
- >(): SuggestionPluginState<T> {
77
- return {
78
- active: false,
79
- triggerCharacter: undefined,
80
- queryStartPos: undefined,
81
- items: [] as T[],
82
- keyboardHoveredItemIndex: undefined,
83
- notFoundCount: 0,
84
- decorationId: undefined,
9
+ export type SuggestionsMenuState<T extends SuggestionItem> =
10
+ BaseUiElementState & {
11
+ // The suggested items to display.
12
+ filteredItems: T[];
13
+ // The index of the suggested item that's currently hovered by the keyboard.
14
+ keyboardHoveredItemIndex: number;
85
15
  };
86
- }
87
16
 
88
- type SuggestionPluginViewOptions<
89
- T extends SuggestionItem,
90
- BSchema extends BlockSchema
91
- > = {
92
- editor: BlockNoteEditor<BSchema>;
93
- pluginKey: PluginKey;
94
- onSelectItem: (props: { item: T; editor: BlockNoteEditor<BSchema> }) => void;
95
- suggestionsMenuFactory: SuggestionsMenuFactory<T>;
96
- };
97
-
98
- class SuggestionPluginView<
17
+ class SuggestionsMenuView<
99
18
  T extends SuggestionItem,
100
19
  BSchema extends BlockSchema
101
20
  > {
102
- editor: BlockNoteEditor<BSchema>;
103
- pluginKey: PluginKey;
104
-
105
- suggestionsMenu: SuggestionsMenu<T>;
21
+ private suggestionsMenuState?: SuggestionsMenuState<T>;
22
+ public updateSuggestionsMenu: () => void;
106
23
 
107
24
  pluginState: SuggestionPluginState<T>;
108
- itemCallback: (item: T) => void;
109
-
110
- private lastPosition: DOMRect | undefined;
111
-
112
- constructor({
113
- editor,
114
- pluginKey,
115
- onSelectItem: selectItemCallback = () => {},
116
- suggestionsMenuFactory,
117
- }: SuggestionPluginViewOptions<T, BSchema>) {
118
- this.editor = editor;
119
- this.pluginKey = pluginKey;
120
25
 
26
+ constructor(
27
+ private readonly editor: BlockNoteEditor<BSchema>,
28
+ private readonly pluginKey: PluginKey,
29
+ updateSuggestionsMenu: (
30
+ suggestionsMenuState: SuggestionsMenuState<T>
31
+ ) => void = () => {}
32
+ ) {
121
33
  this.pluginState = getDefaultPluginState<T>();
122
34
 
123
- this.itemCallback = (item: T) => {
124
- editor._tiptapEditor
125
- .chain()
126
- .focus()
127
- .deleteRange({
128
- from:
129
- this.pluginState.queryStartPos! -
130
- this.pluginState.triggerCharacter!.length,
131
- to: editor._tiptapEditor.state.selection.from,
132
- })
133
- .run();
35
+ this.updateSuggestionsMenu = () => {
36
+ if (!this.suggestionsMenuState) {
37
+ throw new Error("Attempting to update uninitialized suggestions menu");
38
+ }
134
39
 
135
- selectItemCallback({
136
- item: item,
137
- editor: editor,
138
- });
40
+ updateSuggestionsMenu(this.suggestionsMenuState);
139
41
  };
140
42
 
141
- this.suggestionsMenu = suggestionsMenuFactory(this.getStaticParams());
43
+ document.addEventListener("scroll", this.handleScroll);
142
44
  }
143
45
 
46
+ handleScroll = () => {
47
+ if (this.suggestionsMenuState?.show) {
48
+ const decorationNode = document.querySelector(
49
+ `[data-decoration-id="${this.pluginState.decorationId}"]`
50
+ );
51
+ this.suggestionsMenuState.referencePos =
52
+ decorationNode!.getBoundingClientRect();
53
+ this.updateSuggestionsMenu();
54
+ }
55
+ };
56
+
144
57
  update(view: EditorView, prevState: EditorState) {
145
58
  const prev = this.pluginKey.getState(prevState);
146
59
  const next = this.pluginKey.getState(view.state);
@@ -160,61 +73,64 @@ class SuggestionPluginView<
160
73
  this.pluginState = stopped ? prev : next;
161
74
 
162
75
  if (stopped || !this.editor.isEditable) {
163
- this.suggestionsMenu.hide();
76
+ this.suggestionsMenuState!.show = false;
77
+ this.updateSuggestionsMenu();
164
78
 
165
- // Listener stops focus moving to the menu on click.
166
- this.suggestionsMenu.element!.removeEventListener("mousedown", (event) =>
167
- event.preventDefault()
168
- );
79
+ return;
169
80
  }
170
81
 
171
- if (changed) {
172
- this.suggestionsMenu.render(this.getDynamicParams(), false);
173
- }
82
+ const decorationNode = document.querySelector(
83
+ `[data-decoration-id="${this.pluginState.decorationId}"]`
84
+ );
174
85
 
175
- if (started && this.editor.isEditable) {
176
- this.suggestionsMenu.render(this.getDynamicParams(), true);
86
+ if (this.editor.isEditable) {
87
+ this.suggestionsMenuState = {
88
+ show: true,
89
+ referencePos: decorationNode!.getBoundingClientRect(),
90
+ filteredItems: this.pluginState.items,
91
+ keyboardHoveredItemIndex: this.pluginState.keyboardHoveredItemIndex!,
92
+ };
177
93
 
178
- // Listener stops focus moving to the menu on click.
179
- this.suggestionsMenu.element!.addEventListener("mousedown", (event) =>
180
- event.preventDefault()
181
- );
94
+ this.updateSuggestionsMenu();
182
95
  }
183
96
  }
184
97
 
185
- getStaticParams(): SuggestionsMenuStaticParams<T> {
186
- return {
187
- itemCallback: (item: T) => this.itemCallback(item),
188
- getReferenceRect: () => {
189
- const decorationNode = document.querySelector(
190
- `[data-decoration-id="${this.pluginState.decorationId}"]`
191
- );
192
-
193
- if (!decorationNode) {
194
- if (this.lastPosition === undefined) {
195
- throw new Error(
196
- "Attempted to access trigger character reference rect before rendering suggestions menu."
197
- );
198
- }
199
-
200
- return this.lastPosition;
201
- }
202
-
203
- const triggerCharacterBoundingBox =
204
- decorationNode.getBoundingClientRect();
205
- this.lastPosition = triggerCharacterBoundingBox;
206
-
207
- return triggerCharacterBoundingBox;
208
- },
209
- };
98
+ destroy() {
99
+ document.removeEventListener("scroll", this.handleScroll);
210
100
  }
101
+ }
211
102
 
212
- getDynamicParams(): SuggestionsMenuDynamicParams<T> {
213
- return {
214
- items: this.pluginState.items,
215
- keyboardHoveredItemIndex: this.pluginState.keyboardHoveredItemIndex!,
216
- };
217
- }
103
+ type SuggestionPluginState<T extends SuggestionItem> = {
104
+ // True when the menu is shown, false when hidden.
105
+ active: boolean;
106
+ // The character that triggered the menu being shown. Allowing the trigger to be different to the default
107
+ // trigger allows other extensions to open it programmatically.
108
+ triggerCharacter: string | undefined;
109
+ // The editor position just after the trigger character, i.e. where the user query begins. Used to figure out
110
+ // which menu items to show and can also be used to delete the trigger character.
111
+ queryStartPos: number | undefined;
112
+ // The items that should be shown in the menu.
113
+ items: T[];
114
+ // The index of the item in the menu that's currently hovered using the keyboard.
115
+ keyboardHoveredItemIndex: number | undefined;
116
+ // The number of characters typed after the last query that matched with at least 1 item. Used to close the
117
+ // menu if the user keeps entering queries that don't return any results.
118
+ notFoundCount: number | undefined;
119
+ decorationId: string | undefined;
120
+ };
121
+
122
+ function getDefaultPluginState<
123
+ T extends SuggestionItem
124
+ >(): SuggestionPluginState<T> {
125
+ return {
126
+ active: false,
127
+ triggerCharacter: undefined,
128
+ queryStartPos: undefined,
129
+ items: [] as T[],
130
+ keyboardHoveredItemIndex: undefined,
131
+ notFoundCount: 0,
132
+ decorationId: undefined,
133
+ };
218
134
  }
219
135
 
220
136
  /**
@@ -226,271 +142,293 @@ class SuggestionPluginView<
226
142
  * - This version supports generic items instead of only strings (to allow for more advanced filtering for example)
227
143
  * - This version hides some unnecessary complexity from the user of the plugin.
228
144
  * - This version handles key events differently
229
- *
230
- * @param options options for configuring the plugin
231
- * @returns the prosemirror plugin
232
145
  */
233
- export function createSuggestionPlugin<
146
+ export const setupSuggestionsMenu = <
234
147
  T extends SuggestionItem,
235
148
  BSchema extends BlockSchema
236
- >({
237
- pluginKey,
238
- editor,
239
- defaultTriggerCharacter,
240
- suggestionsMenuFactory,
241
- onSelectItem: selectItemCallback = () => {},
242
- items = () => [],
243
- }: SuggestionPluginOptions<T, BSchema>) {
149
+ >(
150
+ editor: BlockNoteEditor<BSchema>,
151
+ updateSuggestionsMenu: (
152
+ suggestionsMenuState: SuggestionsMenuState<T>
153
+ ) => void,
154
+
155
+ pluginKey: PluginKey,
156
+ defaultTriggerCharacter: string,
157
+ items: (query: string) => T[] = () => [],
158
+ onSelectItem: (props: {
159
+ item: T;
160
+ editor: BlockNoteEditor<BSchema>;
161
+ }) => void = () => {}
162
+ ) => {
244
163
  // Assertions
245
164
  if (defaultTriggerCharacter.length !== 1) {
246
165
  throw new Error("'char' should be a single character");
247
166
  }
248
167
 
168
+ let suggestionsPluginView: SuggestionsMenuView<T, BSchema>;
169
+
249
170
  const deactivate = (view: EditorView) => {
250
171
  view.dispatch(view.state.tr.setMeta(pluginKey, { deactivate: true }));
251
172
  };
252
173
 
253
- // Plugin key is passed in as a parameter, so it can be exported and used in the DraggableBlocksPlugin.
254
- return new Plugin({
255
- key: pluginKey,
174
+ return {
175
+ plugin: new Plugin({
176
+ key: pluginKey,
256
177
 
257
- view: (view: EditorView) =>
258
- new SuggestionPluginView<T, BSchema>({
259
- editor: editor,
260
- pluginKey: pluginKey,
261
- onSelectItem: (props: {
262
- item: T;
263
- editor: BlockNoteEditor<BSchema>;
264
- }) => {
265
- deactivate(view);
266
- selectItemCallback(props);
267
- },
268
- suggestionsMenuFactory: suggestionsMenuFactory,
269
- }),
178
+ view: () => {
179
+ suggestionsPluginView = new SuggestionsMenuView<T, BSchema>(
180
+ editor,
181
+ pluginKey,
270
182
 
271
- state: {
272
- // Initialize the plugin's internal state.
273
- init(): SuggestionPluginState<T> {
274
- return getDefaultPluginState<T>();
183
+ updateSuggestionsMenu
184
+ );
185
+ return suggestionsPluginView;
275
186
  },
276
187
 
277
- // Apply changes to the plugin state from an editor transaction.
278
- apply(transaction, prev, oldState, newState): SuggestionPluginState<T> {
279
- // TODO: More clearly define which transactions should be ignored.
280
- if (transaction.getMeta("orderedListIndexing") !== undefined) {
281
- return prev;
282
- }
283
-
284
- // Checks if the menu should be shown.
285
- if (transaction.getMeta(pluginKey)?.activate) {
286
- return {
287
- active: true,
288
- triggerCharacter:
289
- transaction.getMeta(pluginKey)?.triggerCharacter || "",
290
- queryStartPos: newState.selection.from,
291
- items: items(""),
292
- keyboardHoveredItemIndex: 0,
293
- // TODO: Maybe should be 1 if the menu has no possible items? Probably redundant since a menu with no items
294
- // is useless in practice.
295
- notFoundCount: 0,
296
- decorationId: `id_${Math.floor(Math.random() * 0xffffffff)}`,
297
- };
298
- }
299
-
300
- // Checks if the menu is hidden, in which case it doesn't need to be hidden or updated.
301
- if (!prev.active) {
302
- return prev;
303
- }
304
-
305
- const next = { ...prev };
306
-
307
- // Updates which menu items to show by checking which items the current query (the text between the trigger
308
- // character and caret) matches with.
309
- next.items = items(
310
- newState.doc.textBetween(prev.queryStartPos!, newState.selection.from)
311
- );
188
+ state: {
189
+ // Initialize the plugin's internal state.
190
+ init(): SuggestionPluginState<T> {
191
+ return getDefaultPluginState<T>();
192
+ },
193
+
194
+ // Apply changes to the plugin state from an editor transaction.
195
+ apply(transaction, prev, oldState, newState): SuggestionPluginState<T> {
196
+ // TODO: More clearly define which transactions should be ignored.
197
+ if (transaction.getMeta("orderedListIndexing") !== undefined) {
198
+ return prev;
199
+ }
200
+
201
+ // Checks if the menu should be shown.
202
+ if (transaction.getMeta(pluginKey)?.activate) {
203
+ return {
204
+ active: true,
205
+ triggerCharacter:
206
+ transaction.getMeta(pluginKey)?.triggerCharacter || "",
207
+ queryStartPos: newState.selection.from,
208
+ items: items(""),
209
+ keyboardHoveredItemIndex: 0,
210
+ // TODO: Maybe should be 1 if the menu has no possible items? Probably redundant since a menu with no items
211
+ // is useless in practice.
212
+ notFoundCount: 0,
213
+ decorationId: `id_${Math.floor(Math.random() * 0xffffffff)}`,
214
+ };
215
+ }
216
+
217
+ // Checks if the menu is hidden, in which case it doesn't need to be hidden or updated.
218
+ if (!prev.active) {
219
+ return prev;
220
+ }
312
221
 
313
- // Updates notFoundCount if the query doesn't match any items.
314
- next.notFoundCount = 0;
315
- if (next.items.length === 0) {
316
- // Checks how many characters were typed or deleted since the last transaction, and updates the notFoundCount
317
- // accordingly. Also ensures the notFoundCount does not become negative.
318
- next.notFoundCount = Math.max(
319
- 0,
320
- prev.notFoundCount! +
321
- (newState.selection.from - oldState.selection.from)
222
+ const next = { ...prev };
223
+
224
+ // Updates which menu items to show by checking which items the current query (the text between the trigger
225
+ // character and caret) matches with.
226
+ next.items = items(
227
+ newState.doc.textBetween(
228
+ prev.queryStartPos!,
229
+ newState.selection.from
230
+ )
322
231
  );
323
- }
324
-
325
- // Hides the menu. This is done after items and notFoundCount are already updated as notFoundCount is needed to
326
- // check if the menu should be hidden.
327
- if (
328
- // Highlighting text should hide the menu.
329
- newState.selection.from !== newState.selection.to ||
330
- // Transactions with plugin metadata {deactivate: true} should hide the menu.
331
- transaction.getMeta(pluginKey)?.deactivate ||
332
- // Certain mouse events should hide the menu.
333
- // TODO: Change to global mousedown listener.
334
- transaction.getMeta("focus") ||
335
- transaction.getMeta("blur") ||
336
- transaction.getMeta("pointer") ||
337
- // Moving the caret before the character which triggered the menu should hide it.
338
- (prev.active && newState.selection.from < prev.queryStartPos!) ||
339
- // Entering more than 3 characters, after the last query that matched with at least 1 menu item, should hide
340
- // the menu.
341
- next.notFoundCount > 3
342
- ) {
343
- return getDefaultPluginState<T>();
344
- }
345
-
346
- // Updates keyboardHoveredItemIndex if necessary.
347
- if (
348
- transaction.getMeta(pluginKey)?.selectedItemIndexChanged !== undefined
349
- ) {
350
- let newIndex =
351
- transaction.getMeta(pluginKey).selectedItemIndexChanged;
352
-
353
- // Allows selection to jump between first and last items.
354
- if (newIndex < 0) {
355
- newIndex = prev.items.length - 1;
356
- } else if (newIndex >= prev.items.length) {
357
- newIndex = 0;
232
+
233
+ // Updates notFoundCount if the query doesn't match any items.
234
+ next.notFoundCount = 0;
235
+ if (next.items.length === 0) {
236
+ // Checks how many characters were typed or deleted since the last transaction, and updates the notFoundCount
237
+ // accordingly. Also ensures the notFoundCount does not become negative.
238
+ next.notFoundCount = Math.max(
239
+ 0,
240
+ prev.notFoundCount! +
241
+ (newState.selection.from - oldState.selection.from)
242
+ );
358
243
  }
359
244
 
360
- next.keyboardHoveredItemIndex = newIndex;
361
- }
245
+ // Hides the menu. This is done after items and notFoundCount are already updated as notFoundCount is needed to
246
+ // check if the menu should be hidden.
247
+ if (
248
+ // Highlighting text should hide the menu.
249
+ newState.selection.from !== newState.selection.to ||
250
+ // Transactions with plugin metadata {deactivate: true} should hide the menu.
251
+ transaction.getMeta(pluginKey)?.deactivate ||
252
+ // Certain mouse events should hide the menu.
253
+ // TODO: Change to global mousedown listener.
254
+ transaction.getMeta("focus") ||
255
+ transaction.getMeta("blur") ||
256
+ transaction.getMeta("pointer") ||
257
+ // Moving the caret before the character which triggered the menu should hide it.
258
+ (prev.active && newState.selection.from < prev.queryStartPos!) ||
259
+ // Entering more than 3 characters, after the last query that matched with at least 1 menu item, should hide
260
+ // the menu.
261
+ next.notFoundCount > 3
262
+ ) {
263
+ return getDefaultPluginState<T>();
264
+ }
362
265
 
363
- return next;
266
+ // Updates keyboardHoveredItemIndex if the up or down arrow key was
267
+ // pressed, or resets it if the keyboard cursor moved.
268
+ if (
269
+ transaction.getMeta(pluginKey)?.selectedItemIndexChanged !==
270
+ undefined
271
+ ) {
272
+ let newIndex =
273
+ transaction.getMeta(pluginKey).selectedItemIndexChanged;
274
+
275
+ // Allows selection to jump between first and last items.
276
+ if (newIndex < 0) {
277
+ newIndex = prev.items.length - 1;
278
+ } else if (newIndex >= prev.items.length) {
279
+ newIndex = 0;
280
+ }
281
+
282
+ next.keyboardHoveredItemIndex = newIndex;
283
+ } else if (oldState.selection.from !== newState.selection.from) {
284
+ next.keyboardHoveredItemIndex = 0;
285
+ }
286
+
287
+ return next;
288
+ },
364
289
  },
365
- },
366
290
 
367
- props: {
368
- handleKeyDown(view, event) {
369
- const menuIsActive = (this as Plugin).getState(view.state).active;
370
-
371
- // Shows the menu if the default trigger character was pressed and the menu isn't active.
372
- if (event.key === defaultTriggerCharacter && !menuIsActive) {
373
- view.dispatch(
374
- view.state.tr
375
- .insertText(defaultTriggerCharacter)
376
- .scrollIntoView()
377
- .setMeta(pluginKey, {
378
- activate: true,
379
- triggerCharacter: defaultTriggerCharacter,
291
+ props: {
292
+ handleKeyDown(view, event) {
293
+ const menuIsActive = (this as Plugin).getState(view.state).active;
294
+
295
+ // Shows the menu if the default trigger character was pressed and the menu isn't active.
296
+ if (event.key === defaultTriggerCharacter && !menuIsActive) {
297
+ view.dispatch(
298
+ view.state.tr
299
+ .insertText(defaultTriggerCharacter)
300
+ .scrollIntoView()
301
+ .setMeta(pluginKey, {
302
+ activate: true,
303
+ triggerCharacter: defaultTriggerCharacter,
304
+ })
305
+ );
306
+
307
+ return true;
308
+ }
309
+
310
+ // Doesn't handle other keystrokes if the menu isn't active.
311
+ if (!menuIsActive) {
312
+ return false;
313
+ }
314
+
315
+ // Handles keystrokes for navigating the menu.
316
+ const {
317
+ triggerCharacter,
318
+ queryStartPos,
319
+ items,
320
+ keyboardHoveredItemIndex,
321
+ } = pluginKey.getState(view.state);
322
+
323
+ // Moves the keyboard selection to the previous item.
324
+ if (event.key === "ArrowUp") {
325
+ view.dispatch(
326
+ view.state.tr.setMeta(pluginKey, {
327
+ selectedItemIndexChanged: keyboardHoveredItemIndex - 1,
380
328
  })
381
- );
329
+ );
330
+ return true;
331
+ }
332
+
333
+ // Moves the keyboard selection to the next item.
334
+ if (event.key === "ArrowDown") {
335
+ view.dispatch(
336
+ view.state.tr.setMeta(pluginKey, {
337
+ selectedItemIndexChanged: keyboardHoveredItemIndex + 1,
338
+ })
339
+ );
340
+ return true;
341
+ }
382
342
 
383
- return true;
384
- }
343
+ // Selects an item and closes the menu.
344
+ if (event.key === "Enter") {
345
+ deactivate(view);
346
+ editor._tiptapEditor
347
+ .chain()
348
+ .focus()
349
+ .deleteRange({
350
+ from: queryStartPos! - triggerCharacter!.length,
351
+ to: editor._tiptapEditor.state.selection.from,
352
+ })
353
+ .run();
354
+
355
+ onSelectItem({
356
+ item: items[keyboardHoveredItemIndex],
357
+ editor: editor,
358
+ });
359
+
360
+ return true;
361
+ }
362
+
363
+ // Closes the menu.
364
+ if (event.key === "Escape") {
365
+ deactivate(view);
366
+ return true;
367
+ }
385
368
 
386
- // Doesn't handle other keystrokes if the menu isn't active.
387
- if (!menuIsActive) {
388
369
  return false;
389
- }
390
-
391
- // Handles keystrokes for navigating the menu.
392
- const {
393
- triggerCharacter,
394
- queryStartPos,
395
- items,
396
- keyboardHoveredItemIndex,
397
- } = pluginKey.getState(view.state);
398
-
399
- // Moves the keyboard selection to the previous item.
400
- if (event.key === "ArrowUp") {
401
- view.dispatch(
402
- view.state.tr.setMeta(pluginKey, {
403
- selectedItemIndexChanged: keyboardHoveredItemIndex - 1,
404
- })
405
- );
406
- return true;
407
- }
408
-
409
- // Moves the keyboard selection to the next item.
410
- if (event.key === "ArrowDown") {
411
- view.dispatch(
412
- view.state.tr.setMeta(pluginKey, {
413
- selectedItemIndexChanged: keyboardHoveredItemIndex + 1,
414
- })
415
- );
416
- return true;
417
- }
418
-
419
- // Selects an item and closes the menu.
420
- if (event.key === "Enter") {
421
- deactivate(view);
422
- editor._tiptapEditor
423
- .chain()
424
- .focus()
425
- .deleteRange({
426
- from: queryStartPos! - triggerCharacter!.length,
427
- to: editor._tiptapEditor.state.selection.from,
428
- })
429
- .run();
430
-
431
- selectItemCallback({
432
- item: items[keyboardHoveredItemIndex],
433
- editor: editor,
434
- });
435
-
436
- return true;
437
- }
438
-
439
- // Closes the menu.
440
- if (event.key === "Escape") {
441
- deactivate(view);
442
- return true;
443
- }
444
-
445
- return false;
446
- },
370
+ },
447
371
 
448
- // Hides menu in cases where mouse click does not cause an editor state change.
449
- handleClick(view) {
450
- deactivate(view);
451
- },
372
+ // Setup decorator on the currently active suggestion.
373
+ decorations(state) {
374
+ const { active, decorationId, queryStartPos, triggerCharacter } = (
375
+ this as Plugin
376
+ ).getState(state);
452
377
 
453
- // Setup decorator on the currently active suggestion.
454
- decorations(state) {
455
- const { active, decorationId, queryStartPos, triggerCharacter } = (
456
- this as Plugin
457
- ).getState(state);
458
-
459
- if (!active) {
460
- return null;
461
- }
462
-
463
- // If the menu was opened programmatically by another extension, it may not use a trigger character. In this
464
- // case, the decoration is set on the whole block instead, as the decoration range would otherwise be empty.
465
- if (triggerCharacter === "") {
466
- const blockNode = findBlock(state.selection);
467
- if (blockNode) {
468
- return DecorationSet.create(state.doc, [
469
- Decoration.node(
470
- blockNode.pos,
471
- blockNode.pos + blockNode.node.nodeSize,
472
- {
473
- nodeName: "span",
474
- class: "suggestion-decorator",
475
- "data-decoration-id": decorationId,
476
- }
477
- ),
478
- ]);
378
+ if (!active) {
379
+ return null;
479
380
  }
480
- }
481
- // Creates an inline decoration around the trigger character.
482
- return DecorationSet.create(state.doc, [
483
- Decoration.inline(
484
- queryStartPos - triggerCharacter.length,
485
- queryStartPos,
486
- {
487
- nodeName: "span",
488
- class: "suggestion-decorator",
489
- "data-decoration-id": decorationId,
381
+
382
+ // If the menu was opened programmatically by another extension, it may not use a trigger character. In this
383
+ // case, the decoration is set on the whole block instead, as the decoration range would otherwise be empty.
384
+ if (triggerCharacter === "") {
385
+ const blockNode = findBlock(state.selection);
386
+ if (blockNode) {
387
+ return DecorationSet.create(state.doc, [
388
+ Decoration.node(
389
+ blockNode.pos,
390
+ blockNode.pos + blockNode.node.nodeSize,
391
+ {
392
+ nodeName: "span",
393
+ class: "suggestion-decorator",
394
+ "data-decoration-id": decorationId,
395
+ }
396
+ ),
397
+ ]);
490
398
  }
491
- ),
492
- ]);
399
+ }
400
+ // Creates an inline decoration around the trigger character.
401
+ return DecorationSet.create(state.doc, [
402
+ Decoration.inline(
403
+ queryStartPos - triggerCharacter.length,
404
+ queryStartPos,
405
+ {
406
+ nodeName: "span",
407
+ class: "suggestion-decorator",
408
+ "data-decoration-id": decorationId,
409
+ }
410
+ ),
411
+ ]);
412
+ },
493
413
  },
414
+ }),
415
+ itemCallback: (item: T) => {
416
+ deactivate(editor._tiptapEditor.view);
417
+ editor._tiptapEditor
418
+ .chain()
419
+ .focus()
420
+ .deleteRange({
421
+ from:
422
+ suggestionsPluginView.pluginState.queryStartPos! -
423
+ suggestionsPluginView.pluginState.triggerCharacter!.length,
424
+ to: editor._tiptapEditor.state.selection.from,
425
+ })
426
+ .run();
427
+
428
+ onSelectItem({
429
+ item: item,
430
+ editor: editor,
431
+ });
494
432
  },
495
- });
496
- }
433
+ };
434
+ };