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 MAX_INPUT_HEIGHT = 160;
const IS_WEB = isWeb;
type WebTextInputKeyPressEvent = NativeSyntheticEvent<
TextInputKeyPressEventData & {
metaKey?: boolean;
ctrlKey?: 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,
) {
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"}`;
markScrollInvestigationRender(investigationComponentId);
const toast = useToast();
@@ -293,7 +295,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
return false;
},
getNativeElement: () => {
if (!IS_WEB) return null;
if (!isWeb) return null;
const current = textInputRef.current as (TextInput & { getNativeRef?: () => unknown }) | null;
const native = typeof current?.getNativeRef === "function" ? current.getNativeRef() : current;
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.
useEffect(() => {
if (!IS_WEB || !autoFocus) return;
if (!isWeb || !autoFocus) return;
return focusWithRetries({
focus: () => textInputRef.current?.focus(),
isFocused: () => {
@@ -371,7 +373,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
onChangeText(nextValue);
}
if (IS_WEB && typeof requestAnimationFrame === "function") {
if (isWeb && typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => {
measureWebInputHeight("dictation");
});
@@ -627,13 +629,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
const webTextareaRef = useRef<HTMLElement | null>(null);
useLayoutEffect(() => {
if (IS_WEB) {
if (isWeb) {
webTextareaRef.current = getWebTextArea() as HTMLElement | null;
}
}, [getWebTextArea]);
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 => {
@@ -647,7 +649,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
}, []);
useEffect(() => {
if (!IS_WEB || !onAddImages) {
if (!isWeb || !onAddImages) {
return;
}
@@ -700,7 +702,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
]);
useEffect(() => {
if (!IS_WEB || typeof ResizeObserver === "undefined") {
if (!isWeb || typeof ResizeObserver === "undefined") {
return;
}
@@ -754,7 +756,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
}, [getWebElement, getWebTextArea]);
useEffect(() => {
if (!IS_WEB) {
if (!isWeb) {
return;
}
const textarea = getWebTextArea() as (HTMLTextAreaElement & TextAreaHandle) | null;
@@ -792,7 +794,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
}, [getWebTextArea]);
function measureWebInputHeight(source: string): boolean {
if (!IS_WEB) return false;
if (!isWeb) return false;
const textarea = getWebTextArea();
if (!textarea || typeof textarea.scrollHeight !== "number") return false;
const scrollHeight = textarea.scrollHeight ?? 0;
@@ -846,7 +848,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>,
) {
const contentHeight = event.nativeEvent.contentSize.height;
if (IS_WEB) {
if (isWeb) {
logWebStickyBottom("composer_content_size_change", {
reportedHeight: contentHeight,
});
@@ -866,7 +868,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
function handleSelectionChange(event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) {
const start = event.nativeEvent.selection?.start ?? 0;
const end = event.nativeEvent.selection?.end ?? start;
if (IS_WEB) {
if (isWeb) {
const textarea = getWebTextArea();
logWebStickyBottom("composer_selection_changed", {
now: getDebugNow(),
@@ -880,12 +882,17 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
onSelectionChangeCallback?.({ start, end });
}
const shouldHandleDesktopSubmit = IS_WEB;
const shouldHandleDesktopSubmit = isWeb;
function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) {
markScrollInvestigationEvent(investigationComponentId, "keyPress");
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)
if (onKeyPressCallback) {
const handled = onKeyPressCallback({
@@ -935,7 +942,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
(nextValue: string) => {
markScrollInvestigationEvent(investigationComponentId, "inputChange");
onChangeText(nextValue);
if (IS_WEB) {
if (isWeb) {
logWebStickyBottom("composer_text_changed", {
valueLength: nextValue.length,
lineCount: nextValue.split("\n").length,
@@ -966,7 +973,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
<View
style={[
styles.removeImageButton,
(hovered || !IS_WEB) && styles.removeImageButtonVisible,
(hovered || !isWeb) && styles.removeImageButtonVisible,
]}
>
<X size={theme.iconSize.md} color="white" />
@@ -998,7 +1005,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
}}
style={[
styles.textInput,
IS_WEB
isWeb
? {
height: inputHeight,
minHeight: MIN_INPUT_HEIGHT,
@@ -1010,12 +1017,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
},
]}
multiline
scrollEnabled={IS_WEB ? inputHeight >= MAX_INPUT_HEIGHT : true}
scrollEnabled={isWeb ? inputHeight >= MAX_INPUT_HEIGHT : true}
onContentSizeChange={handleContentSizeChange}
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
onKeyPress={shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined}
onSelectionChange={handleSelectionChange}
autoFocus={IS_WEB && autoFocus}
autoFocus={isWeb && autoFocus}
/>
{inputScrollbar}
</View>
@@ -1206,7 +1213,7 @@ const styles = StyleSheet.create(((theme: any) => ({
xs: theme.spacing[3],
md: theme.spacing[4],
},
...(IS_WEB
...(isWeb
? {
transitionProperty: "border-color",
transitionDuration: "200ms",
@@ -1225,7 +1232,7 @@ const styles = StyleSheet.create(((theme: any) => ({
borderWidth: 1,
borderColor: theme.colors.borderAccent,
overflow: "hidden",
...(IS_WEB
...(isWeb
? {
cursor: "pointer",
}
@@ -1250,7 +1257,7 @@ const styles = StyleSheet.create(((theme: any) => ({
justifyContent: "center",
backgroundColor: "rgba(0, 0, 0, 0.5)",
opacity: 0,
...(IS_WEB
...(isWeb
? {
transitionProperty: "opacity",
transitionDuration: "150ms",
@@ -1269,7 +1276,7 @@ const styles = StyleSheet.create(((theme: any) => ({
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
lineHeight: theme.fontSize.base * 1.4,
...(IS_WEB
...(isWeb
? {
outlineStyle: "none" as const,
outlineWidth: 0,