From 70cb4db53953cb69fb3270e264326606015e02f1 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Fri, 20 Feb 2026 16:31:37 +0100 Subject: [PATCH 1/3] fix: disable slash menu in table content #2408 --- .../core/src/extensions/SuggestionMenu/SuggestionMenu.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.ts b/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.ts index 029103600a..75e905da68 100644 --- a/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.ts +++ b/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.ts @@ -238,8 +238,11 @@ export const SuggestionMenu = createExtension(({ editor }) => { _oldState, newState, ): SuggestionPluginState => { - // Ignore transactions in code blocks. - if (transaction.selection.$from.parent.type.spec.code) { + // Ignore transactions in code blocks or table content + if ( + transaction.selection.$from.parent.type.spec.code || + transaction.selection.$from.parent.type.isInGroup("tableContent") + ) { return prev; } From d45628a5382f4b69ef3d7aa3684c6a46f3fe6465 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Fri, 20 Feb 2026 16:41:02 +0100 Subject: [PATCH 2/3] test: add a test case --- .../SuggestionMenu/SuggestionMenu.test.ts | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 packages/core/src/extensions/SuggestionMenu/SuggestionMenu.test.ts diff --git a/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.test.ts b/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.test.ts new file mode 100644 index 0000000000..68d3e84208 --- /dev/null +++ b/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; + +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; + +/** + * @vitest-environment jsdom + */ + +/** + * Find the SuggestionMenu ProseMirror plugin instance from the editor state. + * We need to do this because the PluginKey is not exported, and creating a new + * PluginKey with the same name gives a different instance. + */ +function findSuggestionPlugin(editor: BlockNoteEditor) { + const state = editor._tiptapEditor.state; + const plugin = state.plugins.find( + (p) => (p as any).key === "SuggestionMenuPlugin$", + ); + if (!plugin) { + throw new Error("SuggestionMenuPlugin not found in editor state"); + } + return plugin; +} + +function getSuggestionPluginState(editor: BlockNoteEditor) { + const plugin = findSuggestionPlugin(editor); + return plugin.getState(editor._tiptapEditor.state); +} + +/** + * Simulates typing a trigger character and dispatching the suggestion menu + * meta, mirroring what `handleTextInput` does when the user types "/". + */ +function triggerSuggestionMenu(editor: BlockNoteEditor, char: string) { + const plugin = findSuggestionPlugin(editor); + const view = editor._tiptapEditor.view; + // First insert the trigger character (like handleTextInput does) + view.dispatch(view.state.tr.insertText(char)); + // Then dispatch the meta to activate the suggestion menu + view.dispatch( + view.state.tr + .setMeta(plugin, { + triggerCharacter: char, + }) + .scrollIntoView(), + ); +} + +function createEditor() { + const editor = BlockNoteEditor.create(); + const div = document.createElement("div"); + editor.mount(div); + return editor; +} + +describe("SuggestionMenu", () => { + it("should open suggestion menu in a paragraph", () => { + const editor = createEditor(); + + editor.replaceBlocks(editor.document, [ + { + id: "paragraph-0", + type: "paragraph", + content: "Hello world", + }, + ]); + + editor.setTextCursorPosition("paragraph-0", "end"); + + // Verify we start with no active suggestion menu + expect(getSuggestionPluginState(editor)).toBeUndefined(); + + // Trigger the suggestion menu + triggerSuggestionMenu(editor, "/"); + + // Plugin state should now be defined (menu opened) + const pluginState = getSuggestionPluginState(editor); + expect(pluginState).toBeDefined(); + expect(pluginState.triggerCharacter).toBe("/"); + + editor._tiptapEditor.destroy(); + }); + + it("should not open suggestion menu in table content", () => { + const editor = createEditor(); + + editor.replaceBlocks(editor.document, [ + { + id: "table-0", + type: "table", + content: { + type: "tableContent", + rows: [ + { + cells: ["Cell 1", "Cell 2", "Cell 3"], + }, + { + cells: ["Cell 4", "Cell 5", "Cell 6"], + }, + ], + }, + }, + ]); + + // Place cursor inside a table cell + editor.setTextCursorPosition("table-0", "start"); + + // Verify the cursor is inside table content (the parent node is + // a tableParagraph which belongs to the "tableContent" group) + const $from = editor._tiptapEditor.state.selection.$from; + expect($from.parent.type.isInGroup("tableContent")).toBe(true); + + // Verify we start with no active suggestion menu + expect(getSuggestionPluginState(editor)).toBeUndefined(); + + // Attempt to trigger the suggestion menu + triggerSuggestionMenu(editor, "/"); + + // Plugin state should remain undefined because the cursor is inside + // table content, and the fix prevents the menu from activating there + expect(getSuggestionPluginState(editor)).toBeUndefined(); + + editor._tiptapEditor.destroy(); + }); +}); From 173b8bb61e8e7024c4fc1fda4aad131dfa07e671 Mon Sep 17 00:00:00 2001 From: yousefed Date: Thu, 26 Feb 2026 14:25:22 +0100 Subject: [PATCH 3/3] add callback and rename variables --- .../SuggestionMenu/SuggestionMenu.test.ts | 110 ++++++++++++++---- .../SuggestionMenu/SuggestionMenu.ts | 44 ++++--- .../GridSuggestionMenuController.tsx | 11 +- .../SuggestionMenuController.tsx | 7 +- .../react/src/editor/BlockNoteDefaultUI.tsx | 7 +- 5 files changed, 136 insertions(+), 43 deletions(-) diff --git a/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.test.ts b/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.test.ts index 68d3e84208..8b946f63db 100644 --- a/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.test.ts +++ b/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { SuggestionMenu } from "./SuggestionMenu.js"; /** * @vitest-environment jsdom @@ -28,22 +29,20 @@ function getSuggestionPluginState(editor: BlockNoteEditor) { } /** - * Simulates typing a trigger character and dispatching the suggestion menu - * meta, mirroring what `handleTextInput` does when the user types "/". + * Calls the `handleTextInput` prop of the SuggestionMenu plugin directly, + * which mirrors what ProseMirror would do when the user types a character. + * This allows us to test the `shouldTrigger` filtering path. */ -function triggerSuggestionMenu(editor: BlockNoteEditor, char: string) { +function simulateTextInput(editor: BlockNoteEditor, char: string): boolean { const plugin = findSuggestionPlugin(editor); const view = editor._tiptapEditor.view; - // First insert the trigger character (like handleTextInput does) - view.dispatch(view.state.tr.insertText(char)); - // Then dispatch the meta to activate the suggestion menu - view.dispatch( - view.state.tr - .setMeta(plugin, { - triggerCharacter: char, - }) - .scrollIntoView(), - ); + const from = view.state.selection.from; + const to = view.state.selection.to; + const handler = plugin.props.handleTextInput; + if (!handler) { + throw new Error("handleTextInput not found on SuggestionMenu plugin"); + } + return (handler as any)(view, from, to, char) as boolean; } function createEditor() { @@ -56,6 +55,10 @@ function createEditor() { describe("SuggestionMenu", () => { it("should open suggestion menu in a paragraph", () => { const editor = createEditor(); + const sm = editor.getExtension(SuggestionMenu)!; + + // Register "/" trigger character (no filter) + sm.addSuggestionMenu({ triggerCharacter: "/" }); editor.replaceBlocks(editor.document, [ { @@ -70,8 +73,11 @@ describe("SuggestionMenu", () => { // Verify we start with no active suggestion menu expect(getSuggestionPluginState(editor)).toBeUndefined(); - // Trigger the suggestion menu - triggerSuggestionMenu(editor, "/"); + // Simulate typing "/" — handleTextInput should trigger the menu + const handled = simulateTextInput(editor, "/"); + + // The input should be handled (menu opened) + expect(handled).toBe(true); // Plugin state should now be defined (menu opened) const pluginState = getSuggestionPluginState(editor); @@ -81,8 +87,17 @@ describe("SuggestionMenu", () => { editor._tiptapEditor.destroy(); }); - it("should not open suggestion menu in table content", () => { + it("should not open suggestion menu in table content when shouldTrigger returns false", () => { const editor = createEditor(); + const sm = editor.getExtension(SuggestionMenu)!; + + // Register "/" with a shouldTrigger filter that blocks table content. + // This mirrors what BlockNoteDefaultUI does. + sm.addSuggestionMenu({ + triggerCharacter: "/", + shouldOpen: (state) => + !state.selection.$from.parent.type.isInGroup("tableContent"), + }); editor.replaceBlocks(editor.document, [ { @@ -105,21 +120,72 @@ describe("SuggestionMenu", () => { // Place cursor inside a table cell editor.setTextCursorPosition("table-0", "start"); - // Verify the cursor is inside table content (the parent node is - // a tableParagraph which belongs to the "tableContent" group) + // Verify the cursor is inside table content const $from = editor._tiptapEditor.state.selection.$from; expect($from.parent.type.isInGroup("tableContent")).toBe(true); // Verify we start with no active suggestion menu expect(getSuggestionPluginState(editor)).toBeUndefined(); - // Attempt to trigger the suggestion menu - triggerSuggestionMenu(editor, "/"); + // Simulate typing "/" — shouldTrigger should prevent the menu from opening + const handled = simulateTextInput(editor, "/"); + + // handleTextInput should return false (not handled) because + // shouldTrigger rejected the context + expect(handled).toBe(false); + + // Plugin state should remain undefined + expect(getSuggestionPluginState(editor)).toBeUndefined(); + + editor._tiptapEditor.destroy(); + }); + + it("should still allow suggestion menus without shouldTrigger in table content", () => { + const editor = createEditor(); + const sm = editor.getExtension(SuggestionMenu)!; + + // Register "@" WITHOUT a shouldTrigger filter — should still work in tables + sm.addSuggestionMenu({ triggerCharacter: "@" }); + + editor.replaceBlocks(editor.document, [ + { + id: "table-0", + type: "table", + content: { + type: "tableContent", + rows: [ + { + cells: ["Cell 1", "Cell 2", "Cell 3"], + }, + { + cells: ["Cell 4", "Cell 5", "Cell 6"], + }, + ], + }, + }, + ]); + + // Place cursor inside a table cell + editor.setTextCursorPosition("table-0", "start"); + + // Verify the cursor is inside table content + const $from = editor._tiptapEditor.state.selection.$from; + expect($from.parent.type.isInGroup("tableContent")).toBe(true); - // Plugin state should remain undefined because the cursor is inside - // table content, and the fix prevents the menu from activating there + // Verify we start with no active suggestion menu expect(getSuggestionPluginState(editor)).toBeUndefined(); + // Simulate typing "@" — no shouldTrigger filter, so it should still work + const handled = simulateTextInput(editor, "@"); + + // The input should be handled (menu opened) + expect(handled).toBe(true); + + // Plugin state should now be defined + const pluginState = getSuggestionPluginState(editor); + expect(pluginState).toBeDefined(); + expect(pluginState.triggerCharacter).toBe("@"); + editor._tiptapEditor.destroy(); }); }); diff --git a/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.ts b/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.ts index 75e905da68..dde8307f95 100644 --- a/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.ts +++ b/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.ts @@ -3,12 +3,12 @@ import { EditorState, Plugin, PluginKey } from "prosemirror-state"; import { Decoration, DecorationSet, EditorView } from "prosemirror-view"; import { trackPosition } from "../../api/positionMapping.js"; +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { createExtension, createStore, } from "../../editor/BlockNoteExtension.js"; import { UiElementPosition } from "../../extensions-shared/UiElementPosition.js"; -import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; const findBlock = findParentNode((node) => node.type.name === "blockContainer"); @@ -149,6 +149,16 @@ type SuggestionPluginState = } | undefined; +export type SuggestionMenuOptions = { + triggerCharacter: string; + /** + * Optional callback to determine whether the suggestion menu should be + * opened in the current editor state. Return `false` to prevent the + * menu from opening (e.g. when the cursor is inside table content). + */ + shouldOpen?: (state: EditorState) => boolean; +}; + const suggestionMenuPluginKey = new PluginKey("SuggestionMenuPlugin"); /** @@ -162,7 +172,7 @@ const suggestionMenuPluginKey = new PluginKey("SuggestionMenuPlugin"); * - This version handles key events differently */ export const SuggestionMenu = createExtension(({ editor }) => { - const triggerCharacters: string[] = []; + const suggestionMenus = new Map(); let view: SuggestionMenuView | undefined = undefined; const store = createStore< (SuggestionMenuState & { triggerCharacter: string }) | undefined @@ -170,11 +180,11 @@ export const SuggestionMenu = createExtension(({ editor }) => { return { key: "suggestionMenu", store, - addTriggerCharacter: (triggerCharacter: string) => { - triggerCharacters.push(triggerCharacter); + addSuggestionMenu: (options: SuggestionMenuOptions) => { + suggestionMenus.set(options.triggerCharacter, options); }, - removeTriggerCharacter: (triggerCharacter: string) => { - triggerCharacters.splice(triggerCharacters.indexOf(triggerCharacter), 1); + removeSuggestionMenu: (triggerCharacter: string) => { + suggestionMenus.delete(triggerCharacter); }, closeMenu: () => { view?.closeMenu(); @@ -238,11 +248,8 @@ export const SuggestionMenu = createExtension(({ editor }) => { _oldState, newState, ): SuggestionPluginState => { - // Ignore transactions in code blocks or table content - if ( - transaction.selection.$from.parent.type.spec.code || - transaction.selection.$from.parent.type.isInGroup("tableContent") - ) { + // Ignore transactions in code blocks. + if (transaction.selection.$from.parent.type.spec.code) { return prev; } @@ -329,13 +336,20 @@ export const SuggestionMenu = createExtension(({ editor }) => { // only on insert if (from === to) { const doc = view.state.doc; - for (const str of triggerCharacters) { + for (const [triggerChar, menuOptions] of suggestionMenus) { const snippet = - str.length > 1 - ? doc.textBetween(from - str.length, from) + text + triggerChar.length > 1 + ? doc.textBetween(from - triggerChar.length, from) + text : text; - if (str === snippet) { + if (triggerChar === snippet) { + // Check the per-suggestion-menu filter before activating. + if ( + menuOptions.shouldOpen && + !menuOptions.shouldOpen(view.state) + ) { + continue; + } view.dispatch(view.state.tr.insertText(text)); view.dispatch( view.state.tr diff --git a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx index 08db23e7e4..4d9c58fe6b 100644 --- a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx +++ b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx @@ -1,5 +1,8 @@ import { BlockSchema, InlineContentSchema, StyleSchema } from "@blocknote/core"; -import { SuggestionMenu } from "@blocknote/core/extensions"; +import { + SuggestionMenu, + SuggestionMenuOptions, +} from "@blocknote/core/extensions"; import { autoPlacement, offset, shift, size } from "@floating-ui/react"; import { FC, useEffect, useMemo } from "react"; @@ -34,6 +37,7 @@ export function GridSuggestionMenuController< triggerCharacter: string; getItems?: GetItemsType; columns: number; + shouldOpen?: SuggestionMenuOptions["shouldOpen"]; minQueryLength?: number; floatingUIOptions?: FloatingUIOptions; } & (ItemType extends DefaultReactGridSuggestionItem @@ -62,6 +66,7 @@ export function GridSuggestionMenuController< triggerCharacter, gridSuggestionMenuComponent, columns, + shouldOpen, minQueryLength, onItemClick, getItems, @@ -90,8 +95,8 @@ export function GridSuggestionMenuController< const suggestionMenu = useExtension(SuggestionMenu); useEffect(() => { - suggestionMenu.addTriggerCharacter(triggerCharacter); - }, [suggestionMenu, triggerCharacter]); + suggestionMenu.addSuggestionMenu({ triggerCharacter, shouldOpen }); + }, [suggestionMenu, triggerCharacter, shouldOpen]); const state = useExtensionState(SuggestionMenu); const reference = useExtensionState(SuggestionMenu, { diff --git a/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx b/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx index 8b16805c16..9e00ece9ad 100644 --- a/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx +++ b/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx @@ -1,6 +1,7 @@ import { BlockSchema, InlineContentSchema, StyleSchema } from "@blocknote/core"; import { SuggestionMenu as SuggestionMenuExtension, + SuggestionMenuOptions, filterSuggestionItems, } from "@blocknote/core/extensions"; import { autoPlacement, offset, shift, size } from "@floating-ui/react"; @@ -30,6 +31,7 @@ export function SuggestionMenuController< props: { triggerCharacter: string; getItems?: GetItemsType; + shouldOpen?: SuggestionMenuOptions["shouldOpen"]; minQueryLength?: number; floatingUIOptions?: FloatingUIOptions; } & (ItemType extends DefaultReactSuggestionItem @@ -57,6 +59,7 @@ export function SuggestionMenuController< const { triggerCharacter, suggestionMenuComponent, + shouldOpen, minQueryLength, onItemClick, getItems, @@ -85,8 +88,8 @@ export function SuggestionMenuController< const suggestionMenu = useExtension(SuggestionMenuExtension); useEffect(() => { - suggestionMenu.addTriggerCharacter(triggerCharacter); - }, [suggestionMenu, triggerCharacter]); + suggestionMenu.addSuggestionMenu({ triggerCharacter, shouldOpen }); + }, [suggestionMenu, triggerCharacter, shouldOpen]); const state = useExtensionState(SuggestionMenuExtension); const reference = useExtensionState(SuggestionMenuExtension, { diff --git a/packages/react/src/editor/BlockNoteDefaultUI.tsx b/packages/react/src/editor/BlockNoteDefaultUI.tsx index 121d6886a1..6ea66d094e 100644 --- a/packages/react/src/editor/BlockNoteDefaultUI.tsx +++ b/packages/react/src/editor/BlockNoteDefaultUI.tsx @@ -92,7 +92,12 @@ export function BlockNoteDefaultUI(props: BlockNoteDefaultUIProps) { {editor.getExtension(LinkToolbarExtension) && props.linkToolbar !== false && } {editor.getExtension(SuggestionMenu) && props.slashMenu !== false && ( - + + !state.selection.$from.parent.type.isInGroup("tableContent") + } + /> )} {editor.getExtension(SuggestionMenu) && props.emojiPicker !== false && (