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]);
}