Merge PR #6229: fix(collab-web): guard composer submit against IME composition (@roboomp)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<HTMLTextAreaElement>, 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<boolean>;
|
||||
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<HTMLTextAreaElement | null>(null);
|
||||
const { composingRef, onCompositionStart, onCompositionEnd } = useCompositionGuard();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
autosize(taRef.current);
|
||||
}, [draft]);
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): 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<HTMLTextAreaElement | null>(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<HTMLTextAreaElement>): 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"
|
||||
|
||||
@@ -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<HTMLTextAreaElement>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user