diff --git a/apps/client/src/services/shortcuts.spec.ts b/apps/client/src/services/shortcuts.spec.ts index 1a20f9a84..ec9a0a581 100644 --- a/apps/client/src/services/shortcuts.spec.ts +++ b/apps/client/src/services/shortcuts.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; -import shortcuts, { keyMatches, matchesShortcut } from "./shortcuts.js"; +import shortcuts, { keyMatches, matchesShortcut, isIMEComposing } from "./shortcuts.js"; // Mock utils module vi.mock("./utils.js", () => ({ @@ -320,4 +320,36 @@ describe("shortcuts", () => { expect(event.preventDefault).not.toHaveBeenCalled(); }); }); + + describe('isIMEComposing', () => { + it('should return true when event.isComposing is true', () => { + const event = { isComposing: true, keyCode: 65 } as KeyboardEvent; + expect(isIMEComposing(event)).toBe(true); + }); + + it('should return true when keyCode is 229', () => { + const event = { isComposing: false, keyCode: 229 } as KeyboardEvent; + expect(isIMEComposing(event)).toBe(true); + }); + + it('should return true when both isComposing is true and keyCode is 229', () => { + const event = { isComposing: true, keyCode: 229 } as KeyboardEvent; + expect(isIMEComposing(event)).toBe(true); + }); + + it('should return false for normal keys', () => { + const event = { isComposing: false, keyCode: 65 } as KeyboardEvent; + expect(isIMEComposing(event)).toBe(false); + }); + + it('should return false when isComposing is undefined and keyCode is not 229', () => { + const event = { keyCode: 13 } as KeyboardEvent; + expect(isIMEComposing(event)).toBe(false); + }); + + it('should handle null/undefined events gracefully', () => { + expect(isIMEComposing(null as any)).toBe(false); + expect(isIMEComposing(undefined as any)).toBe(false); + }); + }); }); diff --git a/apps/client/src/services/shortcuts.ts b/apps/client/src/services/shortcuts.ts index c0e136c6c..a2aca5d80 100644 --- a/apps/client/src/services/shortcuts.ts +++ b/apps/client/src/services/shortcuts.ts @@ -40,6 +40,24 @@ for (let i = 1; i <= 19; i++) { keyMap[`f${i}`] = [`F${i}`]; } +/** + * Check if IME (Input Method Editor) is composing + * This is used to prevent keyboard shortcuts from firing during IME composition + * @param e - The keyboard event to check + * @returns true if IME is currently composing, false otherwise + */ +export function isIMEComposing(e: KeyboardEvent): boolean { + // Handle null/undefined events gracefully + if (!e) { + return false; + } + + // Standard check for composition state + // e.isComposing is true when IME is actively composing + // e.keyCode === 229 is a fallback for older browsers where 229 indicates IME processing + return e.isComposing || e.keyCode === 229; +} + function removeGlobalShortcut(namespace: string) { bindGlobalShortcut("", null, namespace); } @@ -68,6 +86,13 @@ function bindElShortcut($el: JQuery, keyboardShortcut: st } const e = evt as KeyboardEvent; + + // Skip processing if IME is composing to prevent shortcuts from + // interfering with text input in CJK languages + if (isIMEComposing(e)) { + return; + } + if (matchesShortcut(e, keyboardShortcut)) { e.preventDefault(); e.stopPropagation(); diff --git a/apps/client/src/widgets/find.ts b/apps/client/src/widgets/find.ts index e2f52d58a..68d61541f 100644 --- a/apps/client/src/widgets/find.ts +++ b/apps/client/src/widgets/find.ts @@ -8,6 +8,7 @@ import NoteContextAwareWidget from "./note_context_aware_widget.js"; import attributeService from "../services/attributes.js"; import FindInText from "./find_in_text.js"; import FindInCode from "./find_in_code.js"; +import { isIMEComposing } from "../services/shortcuts.js"; import FindInHtml from "./find_in_html.js"; import type { EventData } from "../components/app_context.js"; @@ -162,6 +163,11 @@ export default class FindWidget extends NoteContextAwareWidget { this.$replaceButton.on("click", () => this.replace()); this.$input.on("keydown", async (e) => { + // Skip processing during IME composition + if (isIMEComposing(e.originalEvent as KeyboardEvent)) { + return; + } + if ((e.metaKey || e.ctrlKey) && (e.key === "F" || e.key === "f")) { // If ctrl+f is pressed when the findbox is shown, select the // whole input to find diff --git a/apps/client/src/widgets/note_title.tsx b/apps/client/src/widgets/note_title.tsx index e2f30bcfc..bce3b289d 100644 --- a/apps/client/src/widgets/note_title.tsx +++ b/apps/client/src/widgets/note_title.tsx @@ -8,6 +8,7 @@ import "./note_title.css"; import { isLaunchBarConfig } from "../services/utils"; import appContext from "../components/app_context"; import branches from "../services/branches"; +import { isIMEComposing } from "../services/shortcuts"; export default function NoteTitleWidget() { const { note, noteId, componentId, viewScope, noteContext, parentComponent } = useNoteContext(); @@ -78,6 +79,12 @@ export default function NoteTitleWidget() { spacedUpdate.scheduleUpdate(); }} onKeyDown={(e) => { + // Skip processing if IME is composing to prevent interference + // with text input in CJK languages + if (isIMEComposing(e)) { + return; + } + // Focus on the note content when pressing enter. if (e.key === "Enter") { e.preventDefault(); diff --git a/apps/client/src/widgets/quick_search.ts b/apps/client/src/widgets/quick_search.ts index 95728b99e..bd26f634a 100644 --- a/apps/client/src/widgets/quick_search.ts +++ b/apps/client/src/widgets/quick_search.ts @@ -4,7 +4,7 @@ import linkService from "../services/link.js"; import froca from "../services/froca.js"; import utils from "../services/utils.js"; import appContext from "../components/app_context.js"; -import shortcutService from "../services/shortcuts.js"; +import shortcutService, { isIMEComposing } from "../services/shortcuts.js"; import { t } from "../services/i18n.js"; import { Dropdown, Tooltip } from "bootstrap"; @@ -172,6 +172,14 @@ export default class QuickSearchWidget extends BasicWidget { if (utils.isMobile()) { this.$searchString.keydown((e) => { + // Skip processing if IME is composing to prevent interference + // with text input in CJK languages + // Note: jQuery wraps the native event, so we access originalEvent + const originalEvent = e.originalEvent as KeyboardEvent; + if (originalEvent && isIMEComposing(originalEvent)) { + return; + } + if (e.which === 13) { if (this.$dropdownMenu.is(":visible")) { this.search(); // just update already visible dropdown diff --git a/apps/client/src/widgets/ribbon/components/AttributeEditor.tsx b/apps/client/src/widgets/ribbon/components/AttributeEditor.tsx index 85eb706fc..08241f931 100644 --- a/apps/client/src/widgets/ribbon/components/AttributeEditor.tsx +++ b/apps/client/src/widgets/ribbon/components/AttributeEditor.tsx @@ -13,6 +13,7 @@ import attribute_parser, { Attribute } from "../../../services/attribute_parser" import ActionButton from "../../react/ActionButton"; import { escapeQuotes, getErrorMessage } from "../../../services/utils"; import link from "../../../services/link"; +import { isIMEComposing } from "../../../services/shortcuts"; import froca from "../../../services/froca"; import contextMenu from "../../../menus/context_menu"; import type { CommandData, FilteredCommandNames } from "../../../components/app_context"; @@ -287,6 +288,11 @@ export default function AttributeEditor({ api, note, componentId, notePath, ntxI ref={wrapperRef} style="position: relative; padding-top: 10px; padding-bottom: 10px" onKeyDown={(e) => { + // Skip processing during IME composition + if (isIMEComposing(e)) { + return; + } + if (e.key === "Enter") { // allow autocomplete to fill the result textarea setTimeout(() => save(), 100);