Skip to content

Commit d18745d

Browse files
committed
fix: keep suggestion menus open during IME composition
1 parent dac995c commit d18745d

7 files changed

Lines changed: 123 additions & 4 deletions

File tree

packages/core/src/extensions/SuggestionMenu/SuggestionMenu.test.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, expect, it } from "vitest";
2+
import { TextSelection } from "prosemirror-state";
23

34
import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
45
import { SuggestionMenu } from "./SuggestionMenu.js";
@@ -46,8 +47,27 @@ function simulateTextInput(editor: BlockNoteEditor, char: string): boolean {
4647
}
4748

4849
function createEditor() {
50+
Object.defineProperty(Element.prototype, "getBoundingClientRect", {
51+
configurable: true,
52+
value: () => ({
53+
x: 0,
54+
y: 0,
55+
width: 0,
56+
height: 0,
57+
top: 0,
58+
right: 0,
59+
bottom: 0,
60+
left: 0,
61+
toJSON() {
62+
return this;
63+
},
64+
}),
65+
});
66+
4967
const editor = BlockNoteEditor.create();
5068
const div = document.createElement("div");
69+
document.body.replaceChildren();
70+
document.body.appendChild(div);
5171
editor.mount(div);
5272
return editor;
5373
}
@@ -188,4 +208,76 @@ describe("SuggestionMenu", () => {
188208

189209
editor._tiptapEditor.destroy();
190210
});
211+
212+
it("should keep suggestion menu open during IME composition selection updates", () => {
213+
const editor = createEditor();
214+
const sm = editor.getExtension(SuggestionMenu)!;
215+
216+
sm.addSuggestionMenu({ triggerCharacter: "@" });
217+
218+
editor.replaceBlocks(editor.document, [
219+
{
220+
id: "paragraph-0",
221+
type: "paragraph",
222+
content: "Hello world",
223+
},
224+
]);
225+
226+
editor.setTextCursorPosition("paragraph-0", "end");
227+
228+
expect(simulateTextInput(editor, "@")).toBe(true);
229+
230+
const view = editor._tiptapEditor.view;
231+
view.dispatch(view.state.tr.insertText("shi"));
232+
233+
expect(getSuggestionPluginState(editor)?.query).toBe("shi");
234+
235+
const cursor = view.state.selection.from;
236+
Object.defineProperty(view, "composing", {
237+
configurable: true,
238+
get: () => true,
239+
});
240+
view.dispatch(
241+
view.state.tr.setSelection(
242+
TextSelection.create(view.state.doc, cursor - 1, cursor),
243+
),
244+
);
245+
246+
expect(getSuggestionPluginState(editor)).toBeDefined();
247+
expect(getSuggestionPluginState(editor)?.composing).toBe(true);
248+
249+
delete (view as any).composing;
250+
editor._tiptapEditor.destroy();
251+
});
252+
253+
it("should still close suggestion menu explicitly during IME composition", () => {
254+
const editor = createEditor();
255+
const sm = editor.getExtension(SuggestionMenu)!;
256+
257+
sm.addSuggestionMenu({ triggerCharacter: "@" });
258+
259+
editor.replaceBlocks(editor.document, [
260+
{
261+
id: "paragraph-0",
262+
type: "paragraph",
263+
content: "Hello world",
264+
},
265+
]);
266+
267+
editor.setTextCursorPosition("paragraph-0", "end");
268+
expect(simulateTextInput(editor, "@")).toBe(true);
269+
270+
const view = editor._tiptapEditor.view;
271+
Object.defineProperty(view, "composing", {
272+
configurable: true,
273+
get: () => true,
274+
});
275+
276+
sm.closeMenu();
277+
278+
expect(getSuggestionPluginState(editor)).toBeUndefined();
279+
280+
delete (view as any).composing;
281+
editor._tiptapEditor.destroy();
282+
});
191283
});

packages/core/src/extensions/SuggestionMenu/SuggestionMenu.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const findBlock = findParentNode((node) => node.type.name === "blockContainer");
1515
export type SuggestionMenuState = UiElementPosition & {
1616
query: string;
1717
ignoreQueryLength?: boolean;
18+
composing?: boolean;
1819
};
1920

2021
class SuggestionMenuView {
@@ -38,6 +39,7 @@ class SuggestionMenuView {
3839
emitUpdate(menuName, {
3940
...this.state,
4041
ignoreQueryLength: this.pluginState?.ignoreQueryLength,
42+
composing: this.pluginState?.composing,
4143
});
4244
};
4345

@@ -146,6 +148,7 @@ type SuggestionPluginState =
146148
query: string;
147149
decorationId: string;
148150
ignoreQueryLength?: boolean;
151+
composing?: boolean;
149152
}
150153
| undefined;
151154

@@ -260,6 +263,7 @@ export const SuggestionMenu = createExtension(({ editor }) => {
260263
deleteTriggerCharacter?: boolean;
261264
ignoreQueryLength?: boolean;
262265
} | null = transaction.getMeta(suggestionMenuPluginKey);
266+
const composing = editor._tiptapEditor.view.composing;
263267

264268
if (
265269
typeof suggestionPluginTransactionMeta === "object" &&
@@ -289,6 +293,7 @@ export const SuggestionMenu = createExtension(({ editor }) => {
289293
decorationId: `id_${Math.floor(Math.random() * 0xffffffff)}`,
290294
ignoreQueryLength:
291295
suggestionPluginTransactionMeta?.ignoreQueryLength,
296+
composing,
292297
};
293298
}
294299

@@ -300,7 +305,9 @@ export const SuggestionMenu = createExtension(({ editor }) => {
300305
// Checks if the menu should be hidden.
301306
if (
302307
// Highlighting text should hide the menu.
303-
newState.selection.from !== newState.selection.to ||
308+
(!composing &&
309+
!prev.composing &&
310+
newState.selection.from !== newState.selection.to) ||
304311
// Transactions with plugin metadata should hide the menu.
305312
suggestionPluginTransactionMeta === null ||
306313
// Certain mouse events should hide the menu.
@@ -319,7 +326,15 @@ export const SuggestionMenu = createExtension(({ editor }) => {
319326
return undefined;
320327
}
321328

329+
if (composing) {
330+
return {
331+
...prev,
332+
composing,
333+
};
334+
}
335+
322336
const next = { ...prev };
337+
next.composing = composing;
323338

324339
// Updates the current query.
325340
next.query = newState.doc.textBetween(

packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ export function GridSuggestionMenuController<
184184
query={state.query}
185185
closeMenu={suggestionMenu.closeMenu}
186186
clearQuery={suggestionMenu.clearQuery}
187+
composing={state.composing}
187188
getItems={getItemsOrDefault}
188189
columns={columns}
189190
gridSuggestionMenuComponent={

packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuWrapper.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export function GridSuggestionMenuWrapper<Item>(props: {
1212
query: string;
1313
closeMenu: () => void;
1414
clearQuery: () => void;
15+
composing?: boolean;
1516
getItems: (query: string) => Promise<Item[]>;
1617
columns: number;
1718
onItemClick?: (item: Item) => void;
@@ -31,6 +32,7 @@ export function GridSuggestionMenuWrapper<Item>(props: {
3132
query,
3233
clearQuery,
3334
closeMenu,
35+
composing,
3436
onItemClick,
3537
columns,
3638
} = props;
@@ -49,7 +51,7 @@ export function GridSuggestionMenuWrapper<Item>(props: {
4951
getItems,
5052
);
5153

52-
useCloseSuggestionMenuNoItems(items, usedQuery, closeMenu);
54+
useCloseSuggestionMenuNoItems(items, usedQuery, closeMenu, 3, composing);
5355

5456
const { selectedIndex } = useGridSuggestionMenuKeyboardNavigation(
5557
editor,

packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ export function SuggestionMenuController<
177177
query={state.query}
178178
closeMenu={suggestionMenu.closeMenu}
179179
clearQuery={suggestionMenu.clearQuery}
180+
composing={state.composing}
180181
getItems={getItemsOrDefault}
181182
suggestionMenuComponent={
182183
suggestionMenuComponent || SuggestionMenu<ItemType<GetItemsType>>

packages/react/src/components/SuggestionMenu/SuggestionMenuWrapper.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export function SuggestionMenuWrapper<Item>(props: {
1212
query: string;
1313
closeMenu: () => void;
1414
clearQuery: () => void;
15+
composing?: boolean;
1516
getItems: (query: string) => Promise<Item[]>;
1617
onItemClick?: (item: Item) => void;
1718
suggestionMenuComponent: FC<SuggestionMenuProps<Item>>;
@@ -30,6 +31,7 @@ export function SuggestionMenuWrapper<Item>(props: {
3031
query,
3132
clearQuery,
3233
closeMenu,
34+
composing,
3335
onItemClick,
3436
} = props;
3537

@@ -47,7 +49,7 @@ export function SuggestionMenuWrapper<Item>(props: {
4749
getItems,
4850
);
4951

50-
useCloseSuggestionMenuNoItems(items, usedQuery, closeMenu);
52+
useCloseSuggestionMenuNoItems(items, usedQuery, closeMenu, 3, composing);
5153

5254
const { selectedIndex } = useSuggestionMenuKeyboardNavigation(
5355
editor,

packages/react/src/components/SuggestionMenu/hooks/useCloseSuggestionMenuNoItems.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export function useCloseSuggestionMenuNoItems<Item>(
88
usedQuery: string | undefined,
99
closeMenu: () => void,
1010
invalidQueries = 3,
11+
disabled = false,
1112
) {
1213
const lastUsefulQueryLength = useRef(0);
1314

@@ -16,6 +17,11 @@ export function useCloseSuggestionMenuNoItems<Item>(
1617
return;
1718
}
1819

20+
if (disabled) {
21+
lastUsefulQueryLength.current = usedQuery.length;
22+
return;
23+
}
24+
1925
if (items.length > 0) {
2026
lastUsefulQueryLength.current = usedQuery.length;
2127
} else if (
@@ -24,5 +30,5 @@ export function useCloseSuggestionMenuNoItems<Item>(
2430
) {
2531
closeMenu();
2632
}
27-
}, [closeMenu, invalidQueries, items.length, usedQuery]);
33+
}, [closeMenu, disabled, invalidQueries, items.length, usedQuery]);
2834
}

0 commit comments

Comments
 (0)