Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions apps/web/src/__tests__/ime-enter.test.jsx
Original file line number Diff line number Diff line change
@@ -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(
<PromptDialog open title="Rename" defaultValue="にほん" onConfirm={onConfirm} onCancel={() => {}} />,
);
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);
});
});
3 changes: 2 additions & 1 deletion apps/web/src/extensions/MathNodes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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") {
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/ui/cards/cardsView/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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();
}
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/ui/diagnostics/consolePanel/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/ui/dialogs/confirmDialog/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(); }
}}
/>
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/ui/dialogs/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 (
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/ui/find/findReplace/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")}
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/ui/find/searchPanel/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/ui/graph/graphView/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/ui/importExport/urlImportDialog/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"
Expand Down
20 changes: 20 additions & 0 deletions apps/web/src/ui/shared/utils/ime.ts
Original file line number Diff line number Diff line change
@@ -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;
}
5 changes: 3 additions & 2 deletions apps/web/src/ui/workspace/folderTree/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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]!"
Expand Down Expand Up @@ -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]!"
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/ui/workspace/workspacePanel/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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]!"
Expand Down
Loading