diff --git a/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.test.ts b/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.test.ts index d97860fcff..1f4a37b23f 100644 --- a/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.test.ts +++ b/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { TextSelection } from "prosemirror-state"; import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { SuggestionMenu } from "./SuggestionMenu.js"; @@ -46,8 +47,27 @@ function simulateTextInput(editor: BlockNoteEditor, char: string): boolean { } function createEditor() { + Object.defineProperty(Element.prototype, "getBoundingClientRect", { + configurable: true, + value: () => ({ + x: 0, + y: 0, + width: 0, + height: 0, + top: 0, + right: 0, + bottom: 0, + left: 0, + toJSON() { + return this; + }, + }), + }); + const editor = BlockNoteEditor.create(); const div = document.createElement("div"); + document.body.replaceChildren(); + document.body.appendChild(div); editor.mount(div); return editor; } @@ -188,4 +208,76 @@ describe("SuggestionMenu", () => { editor._tiptapEditor.destroy(); }); + + it("should keep suggestion menu open during IME composition selection updates", () => { + const editor = createEditor(); + const sm = editor.getExtension(SuggestionMenu)!; + + sm.addSuggestionMenu({ triggerCharacter: "@" }); + + editor.replaceBlocks(editor.document, [ + { + id: "paragraph-0", + type: "paragraph", + content: "Hello world", + }, + ]); + + editor.setTextCursorPosition("paragraph-0", "end"); + + expect(simulateTextInput(editor, "@")).toBe(true); + + const view = editor._tiptapEditor.view; + view.dispatch(view.state.tr.insertText("shi")); + + expect(getSuggestionPluginState(editor)?.query).toBe("shi"); + + const cursor = view.state.selection.from; + Object.defineProperty(view, "composing", { + configurable: true, + get: () => true, + }); + view.dispatch( + view.state.tr.setSelection( + TextSelection.create(view.state.doc, cursor - 1, cursor), + ), + ); + + expect(getSuggestionPluginState(editor)).toBeDefined(); + expect(getSuggestionPluginState(editor)?.composing).toBe(true); + + delete (view as any).composing; + editor._tiptapEditor.destroy(); + }); + + it("should still close suggestion menu explicitly during IME composition", () => { + const editor = createEditor(); + const sm = editor.getExtension(SuggestionMenu)!; + + sm.addSuggestionMenu({ triggerCharacter: "@" }); + + editor.replaceBlocks(editor.document, [ + { + id: "paragraph-0", + type: "paragraph", + content: "Hello world", + }, + ]); + + editor.setTextCursorPosition("paragraph-0", "end"); + expect(simulateTextInput(editor, "@")).toBe(true); + + const view = editor._tiptapEditor.view; + Object.defineProperty(view, "composing", { + configurable: true, + get: () => true, + }); + + sm.closeMenu(); + + expect(getSuggestionPluginState(editor)).toBeUndefined(); + + delete (view as any).composing; + editor._tiptapEditor.destroy(); + }); }); diff --git a/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.ts b/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.ts index 4809607e6e..48355143f8 100644 --- a/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.ts +++ b/packages/core/src/extensions/SuggestionMenu/SuggestionMenu.ts @@ -15,6 +15,7 @@ const findBlock = findParentNode((node) => node.type.name === "blockContainer"); export type SuggestionMenuState = UiElementPosition & { query: string; ignoreQueryLength?: boolean; + composing?: boolean; }; class SuggestionMenuView { @@ -38,6 +39,7 @@ class SuggestionMenuView { emitUpdate(menuName, { ...this.state, ignoreQueryLength: this.pluginState?.ignoreQueryLength, + composing: this.pluginState?.composing, }); }; @@ -146,6 +148,7 @@ type SuggestionPluginState = query: string; decorationId: string; ignoreQueryLength?: boolean; + composing?: boolean; } | undefined; @@ -260,6 +263,7 @@ export const SuggestionMenu = createExtension(({ editor }) => { deleteTriggerCharacter?: boolean; ignoreQueryLength?: boolean; } | null = transaction.getMeta(suggestionMenuPluginKey); + const composing = editor.prosemirrorView.composing; if ( typeof suggestionPluginTransactionMeta === "object" && @@ -289,6 +293,7 @@ export const SuggestionMenu = createExtension(({ editor }) => { decorationId: `id_${Math.floor(Math.random() * 0xffffffff)}`, ignoreQueryLength: suggestionPluginTransactionMeta?.ignoreQueryLength, + composing, }; } @@ -300,7 +305,9 @@ export const SuggestionMenu = createExtension(({ editor }) => { // Checks if the menu should be hidden. if ( // Highlighting text should hide the menu. - newState.selection.from !== newState.selection.to || + (!composing && + !prev.composing && + newState.selection.from !== newState.selection.to) || // Transactions with plugin metadata should hide the menu. suggestionPluginTransactionMeta === null || // Certain mouse events should hide the menu. @@ -319,7 +326,15 @@ export const SuggestionMenu = createExtension(({ editor }) => { return undefined; } + if (composing) { + return { + ...prev, + composing, + }; + } + const next = { ...prev }; + next.composing = composing; // Updates the current query. next.query = newState.doc.textBetween( diff --git a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx index a0fdcb61d4..e59f93a257 100644 --- a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx +++ b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx @@ -184,6 +184,7 @@ export function GridSuggestionMenuController< query={state.query} closeMenu={suggestionMenu.closeMenu} clearQuery={suggestionMenu.clearQuery} + composing={state.composing} getItems={getItemsOrDefault} columns={columns} gridSuggestionMenuComponent={ diff --git a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuWrapper.tsx b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuWrapper.tsx index 173b36c4ea..57a67efa86 100644 --- a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuWrapper.tsx +++ b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuWrapper.tsx @@ -12,6 +12,7 @@ export function GridSuggestionMenuWrapper(props: { query: string; closeMenu: () => void; clearQuery: () => void; + composing?: boolean; getItems: (query: string) => Promise; columns: number; onItemClick?: (item: Item) => void; @@ -31,6 +32,7 @@ export function GridSuggestionMenuWrapper(props: { query, clearQuery, closeMenu, + composing, onItemClick, columns, } = props; @@ -49,7 +51,7 @@ export function GridSuggestionMenuWrapper(props: { getItems, ); - useCloseSuggestionMenuNoItems(items, usedQuery, closeMenu); + useCloseSuggestionMenuNoItems(items, usedQuery, closeMenu, 3, composing); const { selectedIndex } = useGridSuggestionMenuKeyboardNavigation( editor, diff --git a/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx b/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx index 8b4c81e6f7..c8823361dc 100644 --- a/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx +++ b/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx @@ -177,6 +177,7 @@ export function SuggestionMenuController< query={state.query} closeMenu={suggestionMenu.closeMenu} clearQuery={suggestionMenu.clearQuery} + composing={state.composing} getItems={getItemsOrDefault} suggestionMenuComponent={ suggestionMenuComponent || SuggestionMenu> diff --git a/packages/react/src/components/SuggestionMenu/SuggestionMenuWrapper.tsx b/packages/react/src/components/SuggestionMenu/SuggestionMenuWrapper.tsx index 391bcb1b34..b2b2adaeaf 100644 --- a/packages/react/src/components/SuggestionMenu/SuggestionMenuWrapper.tsx +++ b/packages/react/src/components/SuggestionMenu/SuggestionMenuWrapper.tsx @@ -12,6 +12,7 @@ export function SuggestionMenuWrapper(props: { query: string; closeMenu: () => void; clearQuery: () => void; + composing?: boolean; getItems: (query: string) => Promise; onItemClick?: (item: Item) => void; suggestionMenuComponent: FC>; @@ -30,6 +31,7 @@ export function SuggestionMenuWrapper(props: { query, clearQuery, closeMenu, + composing, onItemClick, } = props; @@ -47,7 +49,7 @@ export function SuggestionMenuWrapper(props: { getItems, ); - useCloseSuggestionMenuNoItems(items, usedQuery, closeMenu); + useCloseSuggestionMenuNoItems(items, usedQuery, closeMenu, 3, composing); const { selectedIndex } = useSuggestionMenuKeyboardNavigation( editor, diff --git a/packages/react/src/components/SuggestionMenu/hooks/useCloseSuggestionMenuNoItems.ts b/packages/react/src/components/SuggestionMenu/hooks/useCloseSuggestionMenuNoItems.ts index c389461917..60d22c88d3 100644 --- a/packages/react/src/components/SuggestionMenu/hooks/useCloseSuggestionMenuNoItems.ts +++ b/packages/react/src/components/SuggestionMenu/hooks/useCloseSuggestionMenuNoItems.ts @@ -8,6 +8,7 @@ export function useCloseSuggestionMenuNoItems( usedQuery: string | undefined, closeMenu: () => void, invalidQueries = 3, + disabled = false, ) { const lastUsefulQueryLength = useRef(0); @@ -16,6 +17,11 @@ export function useCloseSuggestionMenuNoItems( return; } + if (disabled) { + lastUsefulQueryLength.current = usedQuery.length; + return; + } + if (items.length > 0) { lastUsefulQueryLength.current = usedQuery.length; } else if ( @@ -24,5 +30,5 @@ export function useCloseSuggestionMenuNoItems( ) { closeMenu(); } - }, [closeMenu, invalidQueries, items.length, usedQuery]); + }, [closeMenu, disabled, invalidQueries, items.length, usedQuery]); }