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..8b946f63db --- /dev/null +++ b/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from "vitest"; + +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { SuggestionMenu } from "./SuggestionMenu.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); +} + +/** + * 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 simulateTextInput(editor: BlockNoteEditor, char: string): boolean { + const plugin = findSuggestionPlugin(editor); + const view = editor._tiptapEditor.view; + 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() { + 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(); + const sm = editor.getExtension(SuggestionMenu)!; + + // Register "/" trigger character (no filter) + sm.addSuggestionMenu({ triggerCharacter: "/" }); + + 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(); + + // 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); + expect(pluginState).toBeDefined(); + expect(pluginState.triggerCharacter).toBe("/"); + + editor._tiptapEditor.destroy(); + }); + + 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, [ + { + 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); + + // Verify we start with no active suggestion menu + expect(getSuggestionPluginState(editor)).toBeUndefined(); + + // 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); + + // 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 029103600a..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(); @@ -326,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 && (