Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 191 additions & 0 deletions packages/core/src/extensions/SuggestionMenu/SuggestionMenu.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
37 changes: 27 additions & 10 deletions packages/core/src/extensions/SuggestionMenu/SuggestionMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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");

/**
Expand All @@ -162,19 +172,19 @@ const suggestionMenuPluginKey = new PluginKey("SuggestionMenuPlugin");
* - This version handles key events differently
*/
export const SuggestionMenu = createExtension(({ editor }) => {
const triggerCharacters: string[] = [];
const suggestionMenus = new Map<string, SuggestionMenuOptions>();
let view: SuggestionMenuView | undefined = undefined;
const store = createStore<
(SuggestionMenuState & { triggerCharacter: string }) | undefined
>(undefined);
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();
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -34,6 +37,7 @@ export function GridSuggestionMenuController<
triggerCharacter: string;
getItems?: GetItemsType;
columns: number;
shouldOpen?: SuggestionMenuOptions["shouldOpen"];
minQueryLength?: number;
floatingUIOptions?: FloatingUIOptions;
} & (ItemType<GetItemsType> extends DefaultReactGridSuggestionItem
Expand Down Expand Up @@ -62,6 +66,7 @@ export function GridSuggestionMenuController<
triggerCharacter,
gridSuggestionMenuComponent,
columns,
shouldOpen,
minQueryLength,
onItemClick,
getItems,
Expand Down Expand Up @@ -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, {
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -30,6 +31,7 @@ export function SuggestionMenuController<
props: {
triggerCharacter: string;
getItems?: GetItemsType;
shouldOpen?: SuggestionMenuOptions["shouldOpen"];
minQueryLength?: number;
floatingUIOptions?: FloatingUIOptions;
} & (ItemType<GetItemsType> extends DefaultReactSuggestionItem
Expand Down Expand Up @@ -57,6 +59,7 @@ export function SuggestionMenuController<
const {
triggerCharacter,
suggestionMenuComponent,
shouldOpen,
minQueryLength,
onItemClick,
getItems,
Expand Down Expand Up @@ -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, {
Expand Down
7 changes: 6 additions & 1 deletion packages/react/src/editor/BlockNoteDefaultUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,12 @@ export function BlockNoteDefaultUI(props: BlockNoteDefaultUIProps) {
{editor.getExtension(LinkToolbarExtension) &&
props.linkToolbar !== false && <LinkToolbarController />}
{editor.getExtension(SuggestionMenu) && props.slashMenu !== false && (
<SuggestionMenuController triggerCharacter="/" />
<SuggestionMenuController
triggerCharacter="/"
shouldOpen={(state) =>
!state.selection.$from.parent.type.isInGroup("tableContent")
}
/>
)}
{editor.getExtension(SuggestionMenu) && props.emojiPicker !== false && (
<GridSuggestionMenuController
Expand Down
Loading