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:
co-authored by
Mohamed Boudra
parent
a2d652d8d8
commit
8023e1f0c3
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user