diff --git a/apps/web/src/__tests__/ime-enter.test.jsx b/apps/web/src/__tests__/ime-enter.test.jsx new file mode 100644 index 0000000..d36f841 --- /dev/null +++ b/apps/web/src/__tests__/ime-enter.test.jsx @@ -0,0 +1,81 @@ +/** + * Enter during an IME composition (Japanese, Chinese, Korean) confirms the + * input method's candidate — it must never submit, rename or close anything. + * Every Enter handler on a text field guards with `isImeComposing()`. + */ +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { readFileSync } from "fs"; +import { join } from "path"; +import { describe, expect, it, vi } from "vitest"; +import { isImeComposing } from "../ui/shared/utils/ime"; +import { PromptDialog } from "../ui/dialogs"; + +const read = (p) => readFileSync(join(process.cwd(), p), "utf8"); + +describe("isImeComposing", () => { + it("is true while the native event is composing (Chrome, Firefox)", () => { + expect(isImeComposing(new KeyboardEvent("keydown", { key: "Enter", isComposing: true }))).toBe(true); + }); + + it("is true for keyCode 229 — Safari's confirming keydown, after compositionend", () => { + expect(isImeComposing(new KeyboardEvent("keydown", { key: "Enter", keyCode: 229 }))).toBe(true); + }); + + it("is false for an ordinary Enter", () => { + expect(isImeComposing(new KeyboardEvent("keydown", { key: "Enter", keyCode: 13 }))).toBe(false); + }); + + it("reads React's synthetic event through nativeEvent", () => { + const nativeEvent = new KeyboardEvent("keydown", { key: "Enter", isComposing: true }); + expect(isImeComposing({ key: "Enter", nativeEvent })).toBe(true); + }); +}); + +describe("a text field's Enter during composition", () => { + function renderPrompt() { + const onConfirm = vi.fn(); + render( + {}} />, + ); + return { onConfirm, input: screen.getByRole("textbox") }; + } + + it("does not submit while the input method is composing", () => { + const { onConfirm, input } = renderPrompt(); + fireEvent.keyDown(input, { key: "Enter", isComposing: true }); + fireEvent.keyDown(input, { key: "Enter", keyCode: 229 }); + expect(onConfirm).not.toHaveBeenCalled(); + }); + + it("still submits on Enter once the composition has ended", () => { + const { onConfirm, input } = renderPrompt(); + fireEvent.change(input, { target: { value: "日本" } }); + fireEvent.keyDown(input, { key: "Enter", keyCode: 13 }); + expect(onConfirm).toHaveBeenCalledWith("日本"); + }); +}); + +describe("every text-field Enter handler checks for composition", () => { + // [file, number of guarded Enter handlers]. A new Enter handler on a text + // field must call isImeComposing() too — or be added here once it does. + const HANDLERS = [ + ["src/ui/workspace/folderTree/index.tsx", 2], + ["src/ui/workspace/workspacePanel/index.tsx", 1], + ["src/ui/dialogs/index.tsx", 1], + ["src/ui/dialogs/confirmDialog/index.tsx", 1], + ["src/ui/find/searchPanel/index.tsx", 1], + ["src/ui/find/findReplace/index.tsx", 2], + ["src/ui/importExport/urlImportDialog/index.tsx", 1], + ["src/ui/graph/graphView/index.tsx", 1], + ["src/ui/cards/cardsView/index.tsx", 1], + ["src/extensions/MathNodes.tsx", 1], + ["src/ui/diagnostics/consolePanel/index.tsx", 1], + ]; + + it.each(HANDLERS)("%s", (file, count) => { + const src = read(file); + expect(src).toMatch(/import \{ isImeComposing \} from "[^"]*ui\/shared\/utils\/ime"/); + expect(src.match(/isImeComposing\(e\)/g)?.length ?? 0).toBe(count); + }); +}); diff --git a/apps/web/src/extensions/MathNodes.tsx b/apps/web/src/extensions/MathNodes.tsx index 10538d2..87a158e 100644 --- a/apps/web/src/extensions/MathNodes.tsx +++ b/apps/web/src/extensions/MathNodes.tsx @@ -3,6 +3,7 @@ import { NodeViewWrapper, ReactNodeViewRenderer } from "@tiptap/react"; import { useEffect, useRef, useState } from "react"; import { cachedImport } from "../utils/lazy-import"; import { useT } from "../i18n"; +import { isImeComposing } from "../ui/shared/utils/ime"; /** * TipTap nodes that render LaTeX with KaTeX (lazy-loaded). The LaTeX source is @@ -154,7 +155,7 @@ function MathView({ onChange={(e: any) => setDraft(e.target.value)} onBlur={commit} onKeyDown={(e: any) => { - if (e.key === "Enter" && (inline || !e.shiftKey)) { + if (e.key === "Enter" && !isImeComposing(e) && (inline || !e.shiftKey)) { e.preventDefault(); commit(); } else if (e.key === "Escape") { diff --git a/apps/web/src/ui/cards/cardsView/index.tsx b/apps/web/src/ui/cards/cardsView/index.tsx index be4fffc..f6ce902 100644 --- a/apps/web/src/ui/cards/cardsView/index.tsx +++ b/apps/web/src/ui/cards/cardsView/index.tsx @@ -38,6 +38,7 @@ import { import { useIsDarkTheme } from "@/theme/useThemeMode"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { t, useT } from "@/i18n"; +import { isImeComposing } from "@/ui/shared/utils/ime"; /** * Cards — a Google-Keep-style board over the whole library. @@ -790,7 +791,7 @@ export function CardsView({ onKeyDown={(e) => { // Enter moves to the body rather than submitting — a title-only // note is rarely what someone means on the first keystroke. - if (e.key === "Enter") { + if (e.key === "Enter" && !isImeComposing(e)) { e.preventDefault(); (e.currentTarget.parentElement?.querySelector("textarea") as HTMLTextAreaElement | null)?.focus(); } diff --git a/apps/web/src/ui/diagnostics/consolePanel/index.tsx b/apps/web/src/ui/diagnostics/consolePanel/index.tsx index d1867e9..03cce05 100644 --- a/apps/web/src/ui/diagnostics/consolePanel/index.tsx +++ b/apps/web/src/ui/diagnostics/consolePanel/index.tsx @@ -6,6 +6,7 @@ import { type ConsoleResult, } from "@/store/diagnostics/console-commands"; import { useT } from "@/i18n"; +import { isImeComposing } from "@/ui/shared/utils/ime"; interface ConsoleEntry { input: string; @@ -79,7 +80,7 @@ export function ConsolePanel({ ctx }: ConsolePanelProps) { value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={(e) => { - if (e.key === "Enter") { + if (e.key === "Enter" && !isImeComposing(e)) { e.preventDefault(); submit(); } diff --git a/apps/web/src/ui/dialogs/confirmDialog/index.tsx b/apps/web/src/ui/dialogs/confirmDialog/index.tsx index 4808af5..c7ba349 100644 --- a/apps/web/src/ui/dialogs/confirmDialog/index.tsx +++ b/apps/web/src/ui/dialogs/confirmDialog/index.tsx @@ -12,6 +12,7 @@ import { Input } from "@/components/ui/input"; import { X } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { useT } from "@/i18n"; +import { isImeComposing } from "@/ui/shared/utils/ime"; /** * The small dialog's footer. The primitive lays its two buttons out as a @@ -203,7 +204,7 @@ export function useNewDocPrompt() { placeholder={t("dialogs.documentTitle")} className="mt-1" onKeyDown={(e) => { - if (e.key === "Enter") handleConfirm(); + if (e.key === "Enter" && !isImeComposing(e)) handleConfirm(); if (e.key === "Escape") { e.preventDefault(); handleCancel(); } }} /> diff --git a/apps/web/src/ui/dialogs/index.tsx b/apps/web/src/ui/dialogs/index.tsx index f6ada80..8eaadce 100644 --- a/apps/web/src/ui/dialogs/index.tsx +++ b/apps/web/src/ui/dialogs/index.tsx @@ -11,6 +11,7 @@ import { import { Input } from "@/components/ui/input"; import React, { useEffect, useRef, useState } from "react"; import { useT } from "@/i18n"; +import { isImeComposing } from "@/ui/shared/utils/ime"; // Lazy KaTeX (matches MathNodes.tsx) so the equation dialog doesn't pull KaTeX // into the initial bundle. The CSS is imported globally in main/embed entries. @@ -68,7 +69,7 @@ export function PromptDialog({ }; const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter") handleConfirm(); + if (e.key === "Enter" && !isImeComposing(e)) handleConfirm(); }; return ( diff --git a/apps/web/src/ui/find/findReplace/index.tsx b/apps/web/src/ui/find/findReplace/index.tsx index 300b478..d0f616f 100644 --- a/apps/web/src/ui/find/findReplace/index.tsx +++ b/apps/web/src/ui/find/findReplace/index.tsx @@ -32,6 +32,7 @@ import { useUIStore } from "@/store/preferences/uiStore"; import { isAltGraphInput } from "@/utils/alt-graph"; import { MAX_PREVIEW_WIDGETS } from "../utils/search-colors"; import { t, useT } from "@/i18n"; +import { isImeComposing } from "@/ui/shared/utils/ime"; export interface MatchPosition { pos: number; len: number; @@ -1023,7 +1024,7 @@ export function FindReplaceBar({ value={findText} onChange={(e) => setFindText(e.target.value)} onKeyDown={(e) => { - if (e.key === "Enter") { e.preventDefault(); doFindNext(e.shiftKey ? -1 : 1); } + if (e.key === "Enter" && !isImeComposing(e)) { e.preventDefault(); doFindNext(e.shiftKey ? -1 : 1); } }} placeholder={isMd ? t("find.findInMarkdownPlaceholder") : t("find.findInRichTextPlaceholder")} // A placeholder is not a label: it vanishes once text is typed @@ -1201,7 +1202,7 @@ export function FindReplaceBar({ value={replaceText} onChange={(e) => setReplaceText(e.target.value)} onKeyDown={(e) => { - if (e.key === "Enter") doReplace(); + if (e.key === "Enter" && !isImeComposing(e)) doReplace(); }} placeholder={t("find.replaceWithPlaceholder")} aria-label={t("find.replaceWith")} diff --git a/apps/web/src/ui/find/searchPanel/index.tsx b/apps/web/src/ui/find/searchPanel/index.tsx index 68bf61e..9022f1e 100644 --- a/apps/web/src/ui/find/searchPanel/index.tsx +++ b/apps/web/src/ui/find/searchPanel/index.tsx @@ -3,6 +3,7 @@ import { FileTextIcon, SearchIcon } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; import type { SearchResult } from "@/store/search/search"; import { useT } from "@/i18n"; +import { isImeComposing } from "@/ui/shared/utils/ime"; interface SearchPanelProps { open: boolean; @@ -56,6 +57,8 @@ export function SearchPanel({ open, search, onOpenResult, onClose }: SearchPanel }; const onKeyDown = (e: React.KeyboardEvent) => { + // Enter, Escape and the arrows all belong to the input method mid-composition. + if (isImeComposing(e)) return; if (e.key === "Escape") { e.preventDefault(); onClose(); diff --git a/apps/web/src/ui/graph/graphView/index.tsx b/apps/web/src/ui/graph/graphView/index.tsx index 6598574..590b02c 100644 --- a/apps/web/src/ui/graph/graphView/index.tsx +++ b/apps/web/src/ui/graph/graphView/index.tsx @@ -43,6 +43,7 @@ import { } from "@/store/graph/graph-physics"; import { arrowD, BATCH_UNITS, circleD, edgeD, edgeGeometry } from "@/store/graph/graph-draw"; import { useT } from "@/i18n"; +import { isImeComposing } from "@/ui/shared/utils/ime"; export interface GraphNode { id: string; @@ -1167,7 +1168,7 @@ function RawGraphView({ graph, folders = [], activeDocId, onNavigate, onClose, o type="text" value={query} onChange={(e) => setQuery(e.target.value)} - onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); focusSearch(); } }} + onKeyDown={(e) => { if (e.key === "Enter" && !isImeComposing(e)) { e.preventDefault(); focusSearch(); } }} placeholder={t("graph.searchNotes")} aria-label={t("graph.searchNotesInTheMap")} className="h-8 w-full rounded-md border border-border bg-background pl-7 pr-7 text-base md:text-xs outline-none focus-visible:ring-1 focus-visible:ring-primary" diff --git a/apps/web/src/ui/importExport/urlImportDialog/index.tsx b/apps/web/src/ui/importExport/urlImportDialog/index.tsx index 975cc81..c0ec8e7 100644 --- a/apps/web/src/ui/importExport/urlImportDialog/index.tsx +++ b/apps/web/src/ui/importExport/urlImportDialog/index.tsx @@ -33,6 +33,7 @@ import { ArchiveEncryptedError, ArchiveWrongPasswordError } from "@/store/import import { APP_NAME } from "@/constants"; import { useDialog } from "@/ui/dialogs/dialogProvider"; import { useT } from "@/i18n"; +import { isImeComposing } from "@/ui/shared/utils/ime"; interface UrlImportDialogProps { open: boolean; @@ -224,7 +225,7 @@ export function UrlImportDialog({ value={url} onChange={(e) => setUrl(e.target.value)} onKeyDown={(e) => { - if (e.key === "Enter") handlePrimary(); + if (e.key === "Enter" && !isImeComposing(e)) handlePrimary(); }} placeholder={t("dialogs.urlPlaceholder")} inputMode="url" diff --git a/apps/web/src/ui/shared/utils/ime.ts b/apps/web/src/ui/shared/utils/ime.ts new file mode 100644 index 0000000..af3b210 --- /dev/null +++ b/apps/web/src/ui/shared/utils/ime.ts @@ -0,0 +1,20 @@ +import type { KeyboardEvent as ReactKeyboardEvent } from "react"; + +/** + * Is this keydown part of an IME composition (Japanese, Chinese, Korean, and + * some voice-dictation inputs)? + * + * People confirm an input-method candidate with Enter. A handler that reads + * that Enter as "submit" commits the raw reading — renaming a note to 日本 + * saved "にほん" and closed the box before the conversion could be chosen. + * Every Enter handler on a text field returns early when this is true. + * + * `isComposing` covers Chrome and Firefox. Safari fires the confirming keydown + * AFTER `compositionend`, with `isComposing` already false, and flags it only + * with the legacy `keyCode` 229 ("IME is processing this key"), so both are + * checked. Accepts React's synthetic event or a native one. + */ +export function isImeComposing(e: KeyboardEvent | ReactKeyboardEvent): boolean { + const native = "nativeEvent" in e ? e.nativeEvent : e; + return native.isComposing || native.keyCode === 229; +} diff --git a/apps/web/src/ui/workspace/folderTree/index.tsx b/apps/web/src/ui/workspace/folderTree/index.tsx index 94fa6d0..82262f4 100644 --- a/apps/web/src/ui/workspace/folderTree/index.tsx +++ b/apps/web/src/ui/workspace/folderTree/index.tsx @@ -71,6 +71,7 @@ import { useFocusRowAfterRender, useWindowedList } from "../hooks/use-windowed-l import { passShiftContextMenuToBrowser } from "@/ui/shared/utils/native-context-menu"; import { SharedDocBadge, sharedStateLabel, sharedStateOf, type SharedState } from "../sharedBadge"; import { useT } from "@/i18n"; +import { isImeComposing } from "@/ui/shared/utils/ime"; export type ExportDocFormat = "md" | "html" | "docx" | "json"; @@ -686,7 +687,7 @@ const FolderRow = React.memo(function FolderRow({ onChange={(e) => setDraft(e.target.value)} onBlur={commit} onKeyDown={(e) => { - if (e.key === "Enter") commit(); + if (e.key === "Enter" && !isImeComposing(e)) commit(); if (e.key === "Escape") { e.preventDefault(); handlers.endRename(rowKey); } }} className="h-6 flex-1 text-[12px]!" @@ -828,7 +829,7 @@ const DocRow = React.memo(function DocRow({ onChange={(e) => setDraft(e.target.value)} onBlur={commit} onKeyDown={(e) => { - if (e.key === "Enter") commit(); + if (e.key === "Enter" && !isImeComposing(e)) commit(); if (e.key === "Escape") { e.preventDefault(); setDraft(title); handlers.endRename(rowKey); } }} className="h-6 flex-1 text-[12px]!" diff --git a/apps/web/src/ui/workspace/workspacePanel/index.tsx b/apps/web/src/ui/workspace/workspacePanel/index.tsx index d02792e..d25f4f8 100644 --- a/apps/web/src/ui/workspace/workspacePanel/index.tsx +++ b/apps/web/src/ui/workspace/workspacePanel/index.tsx @@ -59,6 +59,7 @@ import { useFocusRowAfterRender, useWindowedList } from "../hooks/use-windowed-l import { DOCNAV_WIDTH_MIN, docNavWidthMax, useUIStore } from "@/store/preferences/uiStore"; import { passShiftContextMenuToBrowser } from "@/ui/shared/utils/native-context-menu"; import { t, useT } from "@/i18n"; +import { isImeComposing } from "@/ui/shared/utils/ime"; /** Keyboard resize step for the panel separator. */ const WIDTH_STEP = 16; @@ -917,7 +918,7 @@ function FlatDocList({ onChange={(e) => setEditTitle(e.target.value)} onBlur={() => { onRenameSubmit(); focusRow(doc.id); }} onKeyDown={(e) => { - if (e.key === "Enter") { onRenameSubmit(); focusRow(doc.id); } + if (e.key === "Enter" && !isImeComposing(e)) { onRenameSubmit(); focusRow(doc.id); } if (e.key === "Escape") { e.preventDefault(); onRenameCancel(); focusRow(doc.id); } }} className="h-7 text-[12px]!"