diff --git a/packages/collab-web/CHANGELOG.md b/packages/collab-web/CHANGELOG.md index e57be1b78..d736435cc 100644 --- a/packages/collab-web/CHANGELOG.md +++ b/packages/collab-web/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Prevented IME composition (Korean/Japanese/Chinese) from duplicating the last character when pressing Enter to commit in the collab web composer. ([#6163](https://github.com/can1357/oh-my-pi/issues/6163)) + ## [17.0.1] - 2026-07-16 ### Fixed diff --git a/packages/collab-web/src/components/shell/Composer.tsx b/packages/collab-web/src/components/shell/Composer.tsx index 5f3e45885..69b2ca033 100644 --- a/packages/collab-web/src/components/shell/Composer.tsx +++ b/packages/collab-web/src/components/shell/Composer.tsx @@ -1,5 +1,5 @@ import { SendHorizontal, Square } from "lucide-react"; -import type { KeyboardEvent, ReactNode } from "react"; +import type { KeyboardEvent, ReactNode, RefObject } from "react"; import { useCallback, useLayoutEffect, useRef, useState } from "react"; import type { GuestClient, GuestSnapshot } from "../../lib/client"; @@ -21,6 +21,39 @@ function autosize(el: HTMLTextAreaElement | null): void { el.style.overflowY = el.scrollHeight > max ? "auto" : "hidden"; } +/** + * Decides whether an Enter keydown should commit the composer. Returns `false` while an IME + * composition is active so the keystroke confirms the composition instead of submitting. + * `nativeEvent.isComposing` covers most browsers; `composing` bridges WebKit, which fires the + * confirming Enter keydown *after* `compositionend`. + */ +export function shouldSubmitOnEnter(e: KeyboardEvent, composing: boolean): boolean { + if (e.key !== "Enter" || e.shiftKey) return false; + return !(e.nativeEvent.isComposing || composing); +} + +/** + * Tracks IME composition state via a ref the keydown handler reads synchronously. The + * `compositionend` reset is deferred a tick because WebKit dispatches the confirming Enter + * keydown after `compositionend`, when `nativeEvent.isComposing` is already `false`. + */ +function useCompositionGuard(): { + composingRef: RefObject; + onCompositionStart(): void; + onCompositionEnd(): void; +} { + const composingRef = useRef(false); + const onCompositionStart = useCallback((): void => { + composingRef.current = true; + }, []); + const onCompositionEnd = useCallback((): void => { + setTimeout(() => { + composingRef.current = false; + }, 0); + }, []); + return { composingRef, onCompositionStart, onCompositionEnd }; +} + interface AskEditorProps { prefill: string | undefined; onSubmit(value: string): void; @@ -34,13 +67,14 @@ interface AskEditorProps { function AskEditor({ prefill, onSubmit }: AskEditorProps): ReactNode { const [draft, setDraft] = useState(prefill ?? ""); const taRef = useRef(null); + const { composingRef, onCompositionStart, onCompositionEnd } = useCompositionGuard(); useLayoutEffect(() => { autosize(taRef.current); }, [draft]); const onKeyDown = (e: KeyboardEvent): void => { - if (e.key === "Enter" && !e.shiftKey) { + if (shouldSubmitOnEnter(e, composingRef.current)) { e.preventDefault(); onSubmit(draft); } @@ -54,6 +88,8 @@ function AskEditor({ prefill, onSubmit }: AskEditorProps): ReactNode { value={draft} onChange={e => setDraft(e.target.value)} onKeyDown={onKeyDown} + onCompositionStart={onCompositionStart} + onCompositionEnd={onCompositionEnd} placeholder="type your response…" rows={1} spellCheck={false} @@ -75,6 +111,7 @@ function AskEditor({ prefill, onSubmit }: AskEditorProps): ReactNode { export function Composer({ client, snapshot }: ComposerProps): ReactNode { const [text, setText] = useState(""); const taRef = useRef(null); + const { composingRef, onCompositionStart, onCompositionEnd } = useCompositionGuard(); const live = snapshot.phase === "live"; const readOnly = snapshot.readOnly; @@ -96,7 +133,7 @@ export function Composer({ client, snapshot }: ComposerProps): ReactNode { }, [client, live, readOnly, text]); const onKeyDown = (e: KeyboardEvent): void => { - if (e.key === "Enter" && !e.shiftKey) { + if (shouldSubmitOnEnter(e, composingRef.current)) { e.preventDefault(); send(); } @@ -167,6 +204,8 @@ export function Composer({ client, snapshot }: ComposerProps): ReactNode { value={text} onChange={e => setText(e.target.value)} onKeyDown={onKeyDown} + onCompositionStart={onCompositionStart} + onCompositionEnd={onCompositionEnd} placeholder={ readOnly ? "read-only session — watching only" diff --git a/packages/collab-web/test/composer.test.tsx b/packages/collab-web/test/composer.test.tsx index 79c8a37e0..b25e2596f 100644 --- a/packages/collab-web/test/composer.test.tsx +++ b/packages/collab-web/test/composer.test.tsx @@ -1,8 +1,9 @@ import { describe, expect, it } from "bun:test"; +import type { KeyboardEvent } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import type { GuestSnapshot } from "../src/lib/client"; import { GuestClient } from "../src/lib/client"; -import { Composer } from "../src/components/shell/Composer"; +import { Composer, shouldSubmitOnEnter } from "../src/components/shell/Composer"; import { encodeBase64Url } from "../src/lib/link"; const LINK = `roomroomroom1234#${encodeBase64Url(new Uint8Array(32))}`; @@ -77,3 +78,35 @@ describe("Composer host UI requests", () => { expect(submit.disabled).toBe(false); }); }); + +type KeyEvt = KeyboardEvent; + +function keydown(key: string, opts: { shiftKey?: boolean; isComposing?: boolean } = {}): KeyEvt { + return { + key, + shiftKey: opts.shiftKey ?? false, + nativeEvent: { isComposing: opts.isComposing ?? false }, + } as KeyEvt; +} + +describe("shouldSubmitOnEnter IME guard", () => { + it("submits on a plain Enter with no composition", () => { + expect(shouldSubmitOnEnter(keydown("Enter"), false)).toBe(true); + }); + + it("does not submit while nativeEvent.isComposing is true", () => { + expect(shouldSubmitOnEnter(keydown("Enter", { isComposing: true }), false)).toBe(false); + }); + + it("does not submit while the WebKit composing ref is still set", () => { + expect(shouldSubmitOnEnter(keydown("Enter"), true)).toBe(false); + }); + + it("does not submit on Shift+Enter (newline)", () => { + expect(shouldSubmitOnEnter(keydown("Enter", { shiftKey: true }), false)).toBe(false); + }); + + it("ignores non-Enter keys", () => { + expect(shouldSubmitOnEnter(keydown("a"), false)).toBe(false); + }); +});