Skip to content

Commit 173b8bb

Browse files
committed
add callback and rename variables
1 parent d45628a commit 173b8bb

File tree

5 files changed

+136
-43
lines changed

5 files changed

+136
-43
lines changed

packages/core/src/extensions/SuggestionMenu/SuggestionMenu.test.ts

Lines changed: 88 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, it } from "vitest";
22

33
import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
4+
import { SuggestionMenu } from "./SuggestionMenu.js";
45

56
/**
67
* @vitest-environment jsdom
@@ -28,22 +29,20 @@ function getSuggestionPluginState(editor: BlockNoteEditor) {
2829
}
2930

3031
/**
31-
* Simulates typing a trigger character and dispatching the suggestion menu
32-
* meta, mirroring what `handleTextInput` does when the user types "/".
32+
* Calls the `handleTextInput` prop of the SuggestionMenu plugin directly,
33+
* which mirrors what ProseMirror would do when the user types a character.
34+
* This allows us to test the `shouldTrigger` filtering path.
3335
*/
34-
function triggerSuggestionMenu(editor: BlockNoteEditor, char: string) {
36+
function simulateTextInput(editor: BlockNoteEditor, char: string): boolean {
3537
const plugin = findSuggestionPlugin(editor);
3638
const view = editor._tiptapEditor.view;
37-
// First insert the trigger character (like handleTextInput does)
38-
view.dispatch(view.state.tr.insertText(char));
39-
// Then dispatch the meta to activate the suggestion menu
40-
view.dispatch(
41-
view.state.tr
42-
.setMeta(plugin, {
43-
triggerCharacter: char,
44-
})
45-
.scrollIntoView(),
46-
);
39+
const from = view.state.selection.from;
40+
const to = view.state.selection.to;
41+
const handler = plugin.props.handleTextInput;
42+
if (!handler) {
43+
throw new Error("handleTextInput not found on SuggestionMenu plugin");
44+
}
45+
return (handler as any)(view, from, to, char) as boolean;
4746
}
4847

4948
function createEditor() {
@@ -56,6 +55,10 @@ function createEditor() {
5655
describe("SuggestionMenu", () => {
5756
it("should open suggestion menu in a paragraph", () => {
5857
const editor = createEditor();
58+
const sm = editor.getExtension(SuggestionMenu)!;
59+
60+
// Register "/" trigger character (no filter)
61+
sm.addSuggestionMenu({ triggerCharacter: "/" });
5962

6063
editor.replaceBlocks(editor.document, [
6164
{
@@ -70,8 +73,11 @@ describe("SuggestionMenu", () => {
7073
// Verify we start with no active suggestion menu
7174
expect(getSuggestionPluginState(editor)).toBeUndefined();
7275

73-
// Trigger the suggestion menu
74-
triggerSuggestionMenu(editor, "/");
76+
// Simulate typing "/" — handleTextInput should trigger the menu
77+
const handled = simulateTextInput(editor, "/");
78+
79+
// The input should be handled (menu opened)
80+
expect(handled).toBe(true);
7581

7682
// Plugin state should now be defined (menu opened)
7783
const pluginState = getSuggestionPluginState(editor);
@@ -81,8 +87,17 @@ describe("SuggestionMenu", () => {
8187
editor._tiptapEditor.destroy();
8288
});
8389

84-
it("should not open suggestion menu in table content", () => {
90+
it("should not open suggestion menu in table content when shouldTrigger returns false", () => {
8591
const editor = createEditor();
92+
const sm = editor.getExtension(SuggestionMenu)!;
93+
94+
// Register "/" with a shouldTrigger filter that blocks table content.
95+
// This mirrors what BlockNoteDefaultUI does.
96+
sm.addSuggestionMenu({
97+
triggerCharacter: "/",
98+
shouldOpen: (state) =>
99+
!state.selection.$from.parent.type.isInGroup("tableContent"),
100+
});
86101

87102
editor.replaceBlocks(editor.document, [
88103
{
@@ -105,21 +120,72 @@ describe("SuggestionMenu", () => {
105120
// Place cursor inside a table cell
106121
editor.setTextCursorPosition("table-0", "start");
107122

108-
// Verify the cursor is inside table content (the parent node is
109-
// a tableParagraph which belongs to the "tableContent" group)
123+
// Verify the cursor is inside table content
110124
const $from = editor._tiptapEditor.state.selection.$from;
111125
expect($from.parent.type.isInGroup("tableContent")).toBe(true);
112126

113127
// Verify we start with no active suggestion menu
114128
expect(getSuggestionPluginState(editor)).toBeUndefined();
115129

116-
// Attempt to trigger the suggestion menu
117-
triggerSuggestionMenu(editor, "/");
130+
// Simulate typing "/" — shouldTrigger should prevent the menu from opening
131+
const handled = simulateTextInput(editor, "/");
132+
133+
// handleTextInput should return false (not handled) because
134+
// shouldTrigger rejected the context
135+
expect(handled).toBe(false);
136+
137+
// Plugin state should remain undefined
138+
expect(getSuggestionPluginState(editor)).toBeUndefined();
139+
140+
editor._tiptapEditor.destroy();
141+
});
142+
143+
it("should still allow suggestion menus without shouldTrigger in table content", () => {
144+
const editor = createEditor();
145+
const sm = editor.getExtension(SuggestionMenu)!;
146+
147+
// Register "@" WITHOUT a shouldTrigger filter — should still work in tables
148+
sm.addSuggestionMenu({ triggerCharacter: "@" });
149+
150+
editor.replaceBlocks(editor.document, [
151+
{
152+
id: "table-0",
153+
type: "table",
154+
content: {
155+
type: "tableContent",
156+
rows: [
157+
{
158+
cells: ["Cell 1", "Cell 2", "Cell 3"],
159+
},
160+
{
161+
cells: ["Cell 4", "Cell 5", "Cell 6"],
162+
},
163+
],
164+
},
165+
},
166+
]);
167+
168+
// Place cursor inside a table cell
169+
editor.setTextCursorPosition("table-0", "start");
170+
171+
// Verify the cursor is inside table content
172+
const $from = editor._tiptapEditor.state.selection.$from;
173+
expect($from.parent.type.isInGroup("tableContent")).toBe(true);
118174

119-
// Plugin state should remain undefined because the cursor is inside
120-
// table content, and the fix prevents the menu from activating there
175+
// Verify we start with no active suggestion menu
121176
expect(getSuggestionPluginState(editor)).toBeUndefined();
122177

178+
// Simulate typing "@" — no shouldTrigger filter, so it should still work
179+
const handled = simulateTextInput(editor, "@");
180+
181+
// The input should be handled (menu opened)
182+
expect(handled).toBe(true);
183+
184+
// Plugin state should now be defined
185+
const pluginState = getSuggestionPluginState(editor);
186+
expect(pluginState).toBeDefined();
187+
expect(pluginState.triggerCharacter).toBe("@");
188+
123189
editor._tiptapEditor.destroy();
124190
});
125191
});

packages/core/src/extensions/SuggestionMenu/SuggestionMenu.ts

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@ import { EditorState, Plugin, PluginKey } from "prosemirror-state";
33
import { Decoration, DecorationSet, EditorView } from "prosemirror-view";
44

55
import { trackPosition } from "../../api/positionMapping.js";
6+
import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
67
import {
78
createExtension,
89
createStore,
910
} from "../../editor/BlockNoteExtension.js";
1011
import { UiElementPosition } from "../../extensions-shared/UiElementPosition.js";
11-
import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
1212

1313
const findBlock = findParentNode((node) => node.type.name === "blockContainer");
1414

@@ -149,6 +149,16 @@ type SuggestionPluginState =
149149
}
150150
| undefined;
151151

152+
export type SuggestionMenuOptions = {
153+
triggerCharacter: string;
154+
/**
155+
* Optional callback to determine whether the suggestion menu should be
156+
* opened in the current editor state. Return `false` to prevent the
157+
* menu from opening (e.g. when the cursor is inside table content).
158+
*/
159+
shouldOpen?: (state: EditorState) => boolean;
160+
};
161+
152162
const suggestionMenuPluginKey = new PluginKey("SuggestionMenuPlugin");
153163

154164
/**
@@ -162,19 +172,19 @@ const suggestionMenuPluginKey = new PluginKey("SuggestionMenuPlugin");
162172
* - This version handles key events differently
163173
*/
164174
export const SuggestionMenu = createExtension(({ editor }) => {
165-
const triggerCharacters: string[] = [];
175+
const suggestionMenus = new Map<string, SuggestionMenuOptions>();
166176
let view: SuggestionMenuView | undefined = undefined;
167177
const store = createStore<
168178
(SuggestionMenuState & { triggerCharacter: string }) | undefined
169179
>(undefined);
170180
return {
171181
key: "suggestionMenu",
172182
store,
173-
addTriggerCharacter: (triggerCharacter: string) => {
174-
triggerCharacters.push(triggerCharacter);
183+
addSuggestionMenu: (options: SuggestionMenuOptions) => {
184+
suggestionMenus.set(options.triggerCharacter, options);
175185
},
176-
removeTriggerCharacter: (triggerCharacter: string) => {
177-
triggerCharacters.splice(triggerCharacters.indexOf(triggerCharacter), 1);
186+
removeSuggestionMenu: (triggerCharacter: string) => {
187+
suggestionMenus.delete(triggerCharacter);
178188
},
179189
closeMenu: () => {
180190
view?.closeMenu();
@@ -238,11 +248,8 @@ export const SuggestionMenu = createExtension(({ editor }) => {
238248
_oldState,
239249
newState,
240250
): SuggestionPluginState => {
241-
// Ignore transactions in code blocks or table content
242-
if (
243-
transaction.selection.$from.parent.type.spec.code ||
244-
transaction.selection.$from.parent.type.isInGroup("tableContent")
245-
) {
251+
// Ignore transactions in code blocks.
252+
if (transaction.selection.$from.parent.type.spec.code) {
246253
return prev;
247254
}
248255

@@ -329,13 +336,20 @@ export const SuggestionMenu = createExtension(({ editor }) => {
329336
// only on insert
330337
if (from === to) {
331338
const doc = view.state.doc;
332-
for (const str of triggerCharacters) {
339+
for (const [triggerChar, menuOptions] of suggestionMenus) {
333340
const snippet =
334-
str.length > 1
335-
? doc.textBetween(from - str.length, from) + text
341+
triggerChar.length > 1
342+
? doc.textBetween(from - triggerChar.length, from) + text
336343
: text;
337344

338-
if (str === snippet) {
345+
if (triggerChar === snippet) {
346+
// Check the per-suggestion-menu filter before activating.
347+
if (
348+
menuOptions.shouldOpen &&
349+
!menuOptions.shouldOpen(view.state)
350+
) {
351+
continue;
352+
}
339353
view.dispatch(view.state.tr.insertText(text));
340354
view.dispatch(
341355
view.state.tr

packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { BlockSchema, InlineContentSchema, StyleSchema } from "@blocknote/core";
2-
import { SuggestionMenu } from "@blocknote/core/extensions";
2+
import {
3+
SuggestionMenu,
4+
SuggestionMenuOptions,
5+
} from "@blocknote/core/extensions";
36
import { autoPlacement, offset, shift, size } from "@floating-ui/react";
47
import { FC, useEffect, useMemo } from "react";
58

@@ -34,6 +37,7 @@ export function GridSuggestionMenuController<
3437
triggerCharacter: string;
3538
getItems?: GetItemsType;
3639
columns: number;
40+
shouldOpen?: SuggestionMenuOptions["shouldOpen"];
3741
minQueryLength?: number;
3842
floatingUIOptions?: FloatingUIOptions;
3943
} & (ItemType<GetItemsType> extends DefaultReactGridSuggestionItem
@@ -62,6 +66,7 @@ export function GridSuggestionMenuController<
6266
triggerCharacter,
6367
gridSuggestionMenuComponent,
6468
columns,
69+
shouldOpen,
6570
minQueryLength,
6671
onItemClick,
6772
getItems,
@@ -90,8 +95,8 @@ export function GridSuggestionMenuController<
9095
const suggestionMenu = useExtension(SuggestionMenu);
9196

9297
useEffect(() => {
93-
suggestionMenu.addTriggerCharacter(triggerCharacter);
94-
}, [suggestionMenu, triggerCharacter]);
98+
suggestionMenu.addSuggestionMenu({ triggerCharacter, shouldOpen });
99+
}, [suggestionMenu, triggerCharacter, shouldOpen]);
95100

96101
const state = useExtensionState(SuggestionMenu);
97102
const reference = useExtensionState(SuggestionMenu, {

packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { BlockSchema, InlineContentSchema, StyleSchema } from "@blocknote/core";
22
import {
33
SuggestionMenu as SuggestionMenuExtension,
4+
SuggestionMenuOptions,
45
filterSuggestionItems,
56
} from "@blocknote/core/extensions";
67
import { autoPlacement, offset, shift, size } from "@floating-ui/react";
@@ -30,6 +31,7 @@ export function SuggestionMenuController<
3031
props: {
3132
triggerCharacter: string;
3233
getItems?: GetItemsType;
34+
shouldOpen?: SuggestionMenuOptions["shouldOpen"];
3335
minQueryLength?: number;
3436
floatingUIOptions?: FloatingUIOptions;
3537
} & (ItemType<GetItemsType> extends DefaultReactSuggestionItem
@@ -57,6 +59,7 @@ export function SuggestionMenuController<
5759
const {
5860
triggerCharacter,
5961
suggestionMenuComponent,
62+
shouldOpen,
6063
minQueryLength,
6164
onItemClick,
6265
getItems,
@@ -85,8 +88,8 @@ export function SuggestionMenuController<
8588
const suggestionMenu = useExtension(SuggestionMenuExtension);
8689

8790
useEffect(() => {
88-
suggestionMenu.addTriggerCharacter(triggerCharacter);
89-
}, [suggestionMenu, triggerCharacter]);
91+
suggestionMenu.addSuggestionMenu({ triggerCharacter, shouldOpen });
92+
}, [suggestionMenu, triggerCharacter, shouldOpen]);
9093

9194
const state = useExtensionState(SuggestionMenuExtension);
9295
const reference = useExtensionState(SuggestionMenuExtension, {

packages/react/src/editor/BlockNoteDefaultUI.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,12 @@ export function BlockNoteDefaultUI(props: BlockNoteDefaultUIProps) {
9292
{editor.getExtension(LinkToolbarExtension) &&
9393
props.linkToolbar !== false && <LinkToolbarController />}
9494
{editor.getExtension(SuggestionMenu) && props.slashMenu !== false && (
95-
<SuggestionMenuController triggerCharacter="/" />
95+
<SuggestionMenuController
96+
triggerCharacter="/"
97+
shouldOpen={(state) =>
98+
!state.selection.$from.parent.type.isInGroup("tableContent")
99+
}
100+
/>
96101
)}
97102
{editor.getExtension(SuggestionMenu) && props.emojiPicker !== false && (
98103
<GridSuggestionMenuController

0 commit comments

Comments
 (0)