diff --git a/packages/react/src/components/SuggestionMenu/SuggestionMenu.test.tsx b/packages/react/src/components/SuggestionMenu/SuggestionMenu.test.tsx index 6b056718f6..365d1d7c3f 100644 --- a/packages/react/src/components/SuggestionMenu/SuggestionMenu.test.tsx +++ b/packages/react/src/components/SuggestionMenu/SuggestionMenu.test.tsx @@ -1,5 +1,19 @@ -import { expect, it } from "vitest"; +import { act } from "react"; +import { createRoot, Root } from "react-dom/client"; +import { afterEach, expect, it, vi } from "vitest"; import { SuggestionMenuController } from "./SuggestionMenuController.js"; +import { useCloseSuggestionMenuNoItems } from "./hooks/useCloseSuggestionMenuNoItems.js"; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +let root: Root | undefined; + +afterEach(() => { + act(() => { + root?.unmount(); + }); + root = undefined; +}); it("has good typing", () => { // invalid, because DefaultSuggestionItem doesn't have a title property, so the default MantineSuggestionMenu doesn't wrok @@ -40,3 +54,55 @@ it("has good typing", () => { expect(menu).toBeDefined(); }); + +it("keeps the suggestion menu open while an IME query is composing", async () => { + const closeMenu = vi.fn(); + const container = document.createElement("div"); + + function TestHook(props: { isComposing: boolean; usedQuery: string }) { + useCloseSuggestionMenuNoItems( + [], + props.usedQuery, + closeMenu, + 3, + props.isComposing, + ); + return null; + } + + root = createRoot(container); + + await act(async () => { + root!.render(); + }); + + expect(closeMenu).not.toHaveBeenCalled(); +}); + +it("keeps the suggestion menu open when composition ends before the committed query is loaded", async () => { + const closeMenu = vi.fn(); + const container = document.createElement("div"); + + function TestHook(props: { isComposing: boolean; usedQuery: string }) { + useCloseSuggestionMenuNoItems( + [], + props.usedQuery, + closeMenu, + 3, + props.isComposing, + ); + return null; + } + + root = createRoot(container); + + await act(async () => { + root!.render(); + }); + + await act(async () => { + root!.render(); + }); + + expect(closeMenu).not.toHaveBeenCalled(); +}); diff --git a/packages/react/src/components/SuggestionMenu/SuggestionMenuWrapper.tsx b/packages/react/src/components/SuggestionMenu/SuggestionMenuWrapper.tsx index 391bcb1b34..3ab1bb13be 100644 --- a/packages/react/src/components/SuggestionMenu/SuggestionMenuWrapper.tsx +++ b/packages/react/src/components/SuggestionMenu/SuggestionMenuWrapper.tsx @@ -1,5 +1,5 @@ import { BlockSchema, InlineContentSchema, StyleSchema } from "@blocknote/core"; -import { FC, useCallback, useEffect } from "react"; +import { FC, useCallback, useEffect, useState } from "react"; import { useBlockNoteContext } from "../../editor/BlockNoteContext.js"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; @@ -32,6 +32,7 @@ export function SuggestionMenuWrapper(props: { closeMenu, onItemClick, } = props; + const [isComposing, setIsComposing] = useState(false); const onItemClickCloseMenu = useCallback( (item: Item) => { @@ -47,7 +48,7 @@ export function SuggestionMenuWrapper(props: { getItems, ); - useCloseSuggestionMenuNoItems(items, usedQuery, closeMenu); + useCloseSuggestionMenuNoItems(items, usedQuery, closeMenu, 3, isComposing); const { selectedIndex } = useSuggestionMenuKeyboardNavigation( editor, @@ -72,6 +73,57 @@ export function SuggestionMenuWrapper(props: { }; }, [setContentEditableProps]); + useEffect(() => { + let previousCompositionStart: + | ((event: CompositionEvent) => void) + | undefined; + let previousCompositionEnd: ((event: CompositionEvent) => void) | undefined; + + const handleCompositionStart = (event: CompositionEvent) => { + previousCompositionStart?.(event); + setIsComposing(true); + }; + const handleCompositionEnd = (event: CompositionEvent) => { + previousCompositionEnd?.(event); + setIsComposing(false); + }; + + setContentEditableProps((p = {}) => { + previousCompositionStart = p.onCompositionStart; + previousCompositionEnd = p.onCompositionEnd; + + return { + ...p, + onCompositionStart: handleCompositionStart, + onCompositionEnd: handleCompositionEnd, + }; + }); + + return () => { + setContentEditableProps((p = {}) => { + const next = { ...p }; + + if (next.onCompositionStart === handleCompositionStart) { + if (previousCompositionStart) { + next.onCompositionStart = previousCompositionStart; + } else { + delete next.onCompositionStart; + } + } + + if (next.onCompositionEnd === handleCompositionEnd) { + if (previousCompositionEnd) { + next.onCompositionEnd = previousCompositionEnd; + } else { + delete next.onCompositionEnd; + } + } + + return next; + }); + }; + }, [setContentEditableProps]); + // set selected item (activedescendent) attributes when selected item changes useEffect(() => { setContentEditableProps((p) => ({ diff --git a/packages/react/src/components/SuggestionMenu/hooks/useCloseSuggestionMenuNoItems.ts b/packages/react/src/components/SuggestionMenu/hooks/useCloseSuggestionMenuNoItems.ts index c389461917..ada1caa2ca 100644 --- a/packages/react/src/components/SuggestionMenu/hooks/useCloseSuggestionMenuNoItems.ts +++ b/packages/react/src/components/SuggestionMenu/hooks/useCloseSuggestionMenuNoItems.ts @@ -8,14 +8,27 @@ export function useCloseSuggestionMenuNoItems( usedQuery: string | undefined, closeMenu: () => void, invalidQueries = 3, + isComposing = false, ) { const lastUsefulQueryLength = useRef(0); + const composingQuery = useRef(undefined); useEffect(() => { if (usedQuery === undefined) { return; } + if (isComposing) { + composingQuery.current = usedQuery; + return; + } + + if (composingQuery.current === usedQuery) { + return; + } + + composingQuery.current = undefined; + if (items.length > 0) { lastUsefulQueryLength.current = usedQuery.length; } else if ( @@ -24,5 +37,5 @@ export function useCloseSuggestionMenuNoItems( ) { closeMenu(); } - }, [closeMenu, invalidQueries, items.length, usedQuery]); + }, [closeMenu, invalidQueries, isComposing, items.length, usedQuery]); }