fix: skip Enter key handling during IME composition (#270)

* fix: skip Enter key handling during IME composition

During CJK input (Chinese, Japanese, Korean), pressing Enter while
composing characters should confirm character selection, not send the
message. Check isComposing and keyCode 229 on key events before
handling Enter, matching react-native-web's own IME detection logic.

Fixes #241, #264

* fix: scope IME composition guard to web-only handler

Move isComposing check to top of handleDesktopKeyPress so it never
leaks into cross-platform onKeyPress interfaces. This keeps CJK IME
handling explicit and web-gated.

* refactor: remove IS_WEB alias, use isWeb from platform.ts directly

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
cjh-store
2026-04-13 18:07:30 +08:00
committed by GitHub
co-authored by Mohamed Boudra
parent a2d652d8d8
commit 8023e1f0c3
+30 -23
View File
@@ -115,13 +115,15 @@ export interface MessageInputRef {
const MIN_INPUT_HEIGHT = 30; const MIN_INPUT_HEIGHT = 30;
const MAX_INPUT_HEIGHT = 160; const MAX_INPUT_HEIGHT = 160;
const IS_WEB = isWeb;
type WebTextInputKeyPressEvent = NativeSyntheticEvent< type WebTextInputKeyPressEvent = NativeSyntheticEvent<
TextInputKeyPressEventData & { TextInputKeyPressEventData & {
metaKey?: boolean; metaKey?: boolean;
ctrlKey?: boolean; ctrlKey?: boolean;
shiftKey?: boolean; shiftKey?: boolean;
// Web-only: present on DOM KeyboardEvent during IME composition (CJK input).
isComposing?: boolean;
keyCode?: number;
} }
>; >;
@@ -222,7 +224,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
ref, ref,
) { ) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const buttonIconSize = IS_WEB ? theme.iconSize.md : theme.iconSize.lg; const buttonIconSize = isWeb ? theme.iconSize.md : theme.iconSize.lg;
const investigationComponentId = `MessageInput:${voiceServerId ?? "unknown-server"}:${voiceAgentId ?? "unknown-agent"}`; const investigationComponentId = `MessageInput:${voiceServerId ?? "unknown-server"}:${voiceAgentId ?? "unknown-agent"}`;
markScrollInvestigationRender(investigationComponentId); markScrollInvestigationRender(investigationComponentId);
const toast = useToast(); const toast = useToast();
@@ -293,7 +295,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
return false; return false;
}, },
getNativeElement: () => { getNativeElement: () => {
if (!IS_WEB) return null; if (!isWeb) return null;
const current = textInputRef.current as (TextInput & { getNativeRef?: () => unknown }) | null; const current = textInputRef.current as (TextInput & { getNativeRef?: () => unknown }) | null;
const native = typeof current?.getNativeRef === "function" ? current.getNativeRef() : current; const native = typeof current?.getNativeRef === "function" ? current.getNativeRef() : current;
return native instanceof HTMLElement ? native : null; return native instanceof HTMLElement ? native : null;
@@ -328,7 +330,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
// Autofocus on web when autoFocus is true, and re-run when focus key changes. // Autofocus on web when autoFocus is true, and re-run when focus key changes.
useEffect(() => { useEffect(() => {
if (!IS_WEB || !autoFocus) return; if (!isWeb || !autoFocus) return;
return focusWithRetries({ return focusWithRetries({
focus: () => textInputRef.current?.focus(), focus: () => textInputRef.current?.focus(),
isFocused: () => { isFocused: () => {
@@ -371,7 +373,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
onChangeText(nextValue); onChangeText(nextValue);
} }
if (IS_WEB && typeof requestAnimationFrame === "function") { if (isWeb && typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => { requestAnimationFrame(() => {
measureWebInputHeight("dictation"); measureWebInputHeight("dictation");
}); });
@@ -627,13 +629,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
const webTextareaRef = useRef<HTMLElement | null>(null); const webTextareaRef = useRef<HTMLElement | null>(null);
useLayoutEffect(() => { useLayoutEffect(() => {
if (IS_WEB) { if (isWeb) {
webTextareaRef.current = getWebTextArea() as HTMLElement | null; webTextareaRef.current = getWebTextArea() as HTMLElement | null;
} }
}, [getWebTextArea]); }, [getWebTextArea]);
const inputScrollbar = useWebElementScrollbar(webTextareaRef, { const inputScrollbar = useWebElementScrollbar(webTextareaRef, {
enabled: IS_WEB && inputHeight >= MAX_INPUT_HEIGHT, enabled: isWeb && inputHeight >= MAX_INPUT_HEIGHT,
}); });
const getWebElement = useCallback((target: "root" | "wrapper"): HTMLElement | null => { const getWebElement = useCallback((target: "root" | "wrapper"): HTMLElement | null => {
@@ -647,7 +649,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!IS_WEB || !onAddImages) { if (!isWeb || !onAddImages) {
return; return;
} }
@@ -700,7 +702,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
]); ]);
useEffect(() => { useEffect(() => {
if (!IS_WEB || typeof ResizeObserver === "undefined") { if (!isWeb || typeof ResizeObserver === "undefined") {
return; return;
} }
@@ -754,7 +756,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
}, [getWebElement, getWebTextArea]); }, [getWebElement, getWebTextArea]);
useEffect(() => { useEffect(() => {
if (!IS_WEB) { if (!isWeb) {
return; return;
} }
const textarea = getWebTextArea() as (HTMLTextAreaElement & TextAreaHandle) | null; const textarea = getWebTextArea() as (HTMLTextAreaElement & TextAreaHandle) | null;
@@ -792,7 +794,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
}, [getWebTextArea]); }, [getWebTextArea]);
function measureWebInputHeight(source: string): boolean { function measureWebInputHeight(source: string): boolean {
if (!IS_WEB) return false; if (!isWeb) return false;
const textarea = getWebTextArea(); const textarea = getWebTextArea();
if (!textarea || typeof textarea.scrollHeight !== "number") return false; if (!textarea || typeof textarea.scrollHeight !== "number") return false;
const scrollHeight = textarea.scrollHeight ?? 0; const scrollHeight = textarea.scrollHeight ?? 0;
@@ -846,7 +848,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>, event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>,
) { ) {
const contentHeight = event.nativeEvent.contentSize.height; const contentHeight = event.nativeEvent.contentSize.height;
if (IS_WEB) { if (isWeb) {
logWebStickyBottom("composer_content_size_change", { logWebStickyBottom("composer_content_size_change", {
reportedHeight: contentHeight, reportedHeight: contentHeight,
}); });
@@ -866,7 +868,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
function handleSelectionChange(event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) { function handleSelectionChange(event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) {
const start = event.nativeEvent.selection?.start ?? 0; const start = event.nativeEvent.selection?.start ?? 0;
const end = event.nativeEvent.selection?.end ?? start; const end = event.nativeEvent.selection?.end ?? start;
if (IS_WEB) { if (isWeb) {
const textarea = getWebTextArea(); const textarea = getWebTextArea();
logWebStickyBottom("composer_selection_changed", { logWebStickyBottom("composer_selection_changed", {
now: getDebugNow(), now: getDebugNow(),
@@ -880,12 +882,17 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
onSelectionChangeCallback?.({ start, end }); onSelectionChangeCallback?.({ start, end });
} }
const shouldHandleDesktopSubmit = IS_WEB; const shouldHandleDesktopSubmit = isWeb;
function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) { function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) {
markScrollInvestigationEvent(investigationComponentId, "keyPress"); markScrollInvestigationEvent(investigationComponentId, "keyPress");
if (!shouldHandleDesktopSubmit) return; if (!shouldHandleDesktopSubmit) return;
// IME composition in progress (e.g. CJK input) — all key events belong to the
// IME, not the app. keyCode 229 is a Chromium fallback for when isComposing is
// cleared before the keydown fires.
if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return;
// Allow parent to intercept key events (e.g., for autocomplete navigation) // Allow parent to intercept key events (e.g., for autocomplete navigation)
if (onKeyPressCallback) { if (onKeyPressCallback) {
const handled = onKeyPressCallback({ const handled = onKeyPressCallback({
@@ -935,7 +942,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
(nextValue: string) => { (nextValue: string) => {
markScrollInvestigationEvent(investigationComponentId, "inputChange"); markScrollInvestigationEvent(investigationComponentId, "inputChange");
onChangeText(nextValue); onChangeText(nextValue);
if (IS_WEB) { if (isWeb) {
logWebStickyBottom("composer_text_changed", { logWebStickyBottom("composer_text_changed", {
valueLength: nextValue.length, valueLength: nextValue.length,
lineCount: nextValue.split("\n").length, lineCount: nextValue.split("\n").length,
@@ -966,7 +973,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
<View <View
style={[ style={[
styles.removeImageButton, styles.removeImageButton,
(hovered || !IS_WEB) && styles.removeImageButtonVisible, (hovered || !isWeb) && styles.removeImageButtonVisible,
]} ]}
> >
<X size={theme.iconSize.md} color="white" /> <X size={theme.iconSize.md} color="white" />
@@ -998,7 +1005,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
}} }}
style={[ style={[
styles.textInput, styles.textInput,
IS_WEB isWeb
? { ? {
height: inputHeight, height: inputHeight,
minHeight: MIN_INPUT_HEIGHT, minHeight: MIN_INPUT_HEIGHT,
@@ -1010,12 +1017,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
}, },
]} ]}
multiline multiline
scrollEnabled={IS_WEB ? inputHeight >= MAX_INPUT_HEIGHT : true} scrollEnabled={isWeb ? inputHeight >= MAX_INPUT_HEIGHT : true}
onContentSizeChange={handleContentSizeChange} onContentSizeChange={handleContentSizeChange}
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled} editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
onKeyPress={shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined} onKeyPress={shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined}
onSelectionChange={handleSelectionChange} onSelectionChange={handleSelectionChange}
autoFocus={IS_WEB && autoFocus} autoFocus={isWeb && autoFocus}
/> />
{inputScrollbar} {inputScrollbar}
</View> </View>
@@ -1206,7 +1213,7 @@ const styles = StyleSheet.create(((theme: any) => ({
xs: theme.spacing[3], xs: theme.spacing[3],
md: theme.spacing[4], md: theme.spacing[4],
}, },
...(IS_WEB ...(isWeb
? { ? {
transitionProperty: "border-color", transitionProperty: "border-color",
transitionDuration: "200ms", transitionDuration: "200ms",
@@ -1225,7 +1232,7 @@ const styles = StyleSheet.create(((theme: any) => ({
borderWidth: 1, borderWidth: 1,
borderColor: theme.colors.borderAccent, borderColor: theme.colors.borderAccent,
overflow: "hidden", overflow: "hidden",
...(IS_WEB ...(isWeb
? { ? {
cursor: "pointer", cursor: "pointer",
} }
@@ -1250,7 +1257,7 @@ const styles = StyleSheet.create(((theme: any) => ({
justifyContent: "center", justifyContent: "center",
backgroundColor: "rgba(0, 0, 0, 0.5)", backgroundColor: "rgba(0, 0, 0, 0.5)",
opacity: 0, opacity: 0,
...(IS_WEB ...(isWeb
? { ? {
transitionProperty: "opacity", transitionProperty: "opacity",
transitionDuration: "150ms", transitionDuration: "150ms",
@@ -1269,7 +1276,7 @@ const styles = StyleSheet.create(((theme: any) => ({
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
lineHeight: theme.fontSize.base * 1.4, lineHeight: theme.fontSize.base * 1.4,
...(IS_WEB ...(isWeb
? { ? {
outlineStyle: "none" as const, outlineStyle: "none" as const,
outlineWidth: 0, outlineWidth: 0,