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