fix(chat): improve in-app keyboard avoidance

This commit is contained in:
2026-06-24 15:13:11 +08:00
parent 853ae776f9
commit 08c954903f
7 changed files with 193 additions and 66 deletions
@@ -4,7 +4,7 @@
align-items: stretch;
justify-content: center;
width: 100%;
min-height: 100dvh;
min-height: var(--app-viewport-height, 100dvh);
background: #000000; /* 显式黑,桌面端 500px 外圈为黑色 */
}
@@ -15,5 +15,5 @@
flex-direction: column;
width: 100%;
max-width: 500px;
min-height: 100dvh;
min-height: var(--app-viewport-height, 100dvh);
}
-6
View File
@@ -5,7 +5,6 @@ import Image from "next/image";
import { useRouter } from "next/navigation";
import { useAuthState } from "@/stores/auth/auth-context";
import { useKeyboardHeight } from "@/hooks/use-keyboard-height";
import type { LoginStatus } from "@/data/dto/auth";
import { AppStorage } from "@/data/storage/app/app_storage";
import { AuthStorage } from "@/data/storage/auth/auth_storage";
@@ -43,11 +42,6 @@ export function ChatScreen() {
const [showExternalBrowserDialog, setShowExternalBrowserDialog] =
useState(false);
// 全局副作用:把软键盘高度写到 <html> 的 --keyboard-height CSS 变量,
// 让 .bar 的 padding-bottom 跟着调整,修复 FB IAB / 小米 WebView 输入框被键盘遮挡的问题。
// 详见 src/hooks/use-keyboard-height.ts
useKeyboardHeight();
// isGuest 派生(chat-screen 是唯一知道这个值的地方 —— chat 机器无 isGuest 字段)
const isGuest = deriveIsGuest(authState.loginStatus);
@@ -1,16 +1,16 @@
/* ChatInputBar 输入栏容器样式(与 Dart chat_input_bar.dart 对齐) */
/* 键盘适配(修复 FB IAB / 小米 WebView 不收缩 100dvh 导致输入框被遮挡)
* --keyboard-height 由 src/hooks/use-keyboard-height.ts 写到 <html>,默认 0px
/* 键盘适配:
* --app-viewport-height 会让聊天容器优先贴合 visualViewport
* --chat-input-lift 是 Android 应用内浏览器的兜底抬升值。
* env(safe-area-inset-bottom) 处理小米全面屏手势条 / iPhone home indicator。
* 二者在原生已正确处理键盘的浏览器(iOS Safari、现代 Chrome Android)上都为 0
* 因此无视觉副作用。 */
* 正常浏览器中 --chat-input-lift 为 0,因此无视觉副作用。 */
.bar {
flex: 0 0 auto;
padding: 0
var(--spacing-lg, 16px)
calc(
var(--spacing-lg, 16px) + var(--keyboard-height, 0px) +
var(--spacing-lg, 16px) + var(--chat-input-lift, 0px) +
env(safe-area-inset-bottom, 0px)
);
background: transparent;
@@ -3,6 +3,7 @@ import { useRef, useState } from "react";
import { useChatDispatch } from "@/stores/chat/chat-context";
import { useUserState } from "@/stores/user/user-context";
import { useKeyboardHeight } from "@/hooks";
import { Logger } from "@/utils";
import { ChatInputActionPanel } from "./chat-input-action-panel";
@@ -28,6 +29,11 @@ export function ChatInputBar({ disabled = false }: ChatInputBarProps) {
const isActive = isFocused || isActionPanelOpen;
const voiceMinutesRemaining = currentUser?.voiceMinutesRemaining ?? 0;
useKeyboardHeight({
targetRef: textareaRef,
active: isFocused,
});
const handleInputChange = (value: string) => {
setInput(value);
if (value.trim().length > 0) {
@@ -18,7 +18,6 @@ import {
forwardRef,
type FormEvent,
type KeyboardEvent,
useEffect,
useImperativeHandle,
useRef,
} from "react";
@@ -55,20 +54,6 @@ export const ChatInputTextField = forwardRef<
// 对外暴露与 forwardRef 同名的 ref(指向内部 textarea 节点)
useImperativeHandle(ref, () => innerRef.current as HTMLTextAreaElement, []);
// 获得焦点时把 textarea 滚到可见区域(修复小米/FB IAB 键盘弹起后
// 内容仍被遮挡的场景),rAF 等浏览器完成键盘布局。
useEffect(() => {
const el = innerRef.current;
if (!el) return;
const onFocus = () => {
window.requestAnimationFrame(() => {
el.scrollIntoView({ block: "nearest", behavior: "smooth" });
});
};
el.addEventListener("focus", onFocus);
return () => el.removeEventListener("focus", onFocus);
}, []);
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key !== "Enter") return;
if (e.shiftKey || e.ctrlKey || e.metaKey) return; // 修饰键 → 换行
@@ -3,7 +3,7 @@
.shell {
display: flex;
flex-direction: column;
height: 100dvh;
height: var(--app-viewport-height, 100dvh);
background: var(--color-dark-background, #111);
color: var(--color-text-primary, #fff);
position: relative;
+179 -37
View File
@@ -1,70 +1,212 @@
"use client";
/**
* 软键盘高度 hook(修复 Facebook IAB / 小米 WebView 不收缩 100dvh 导致输入框被遮挡)
* 软键盘避让 hook
*
* 通过 window.visualViewport 的 resize/scroll 事件计算键盘高度:
* keyboardHeight = max(0, innerHeight - (visualViewport.height + offsetTop))
* 写到 <html> 的 CSS 变量 --keyboard-height(默认 0px)。
* 不返回 React state,避免键盘动画期间触发 re-render。
* rAF 合并写入;卸载时清理监听并把变量重置为 0px,防止路由切换后状态泄漏。
* 目标环境:Facebook IAB / 小米 WebView 等软键盘覆盖页面但 viewport 信号不稳定的浏览器。
*
* 行为
* - iOS Safari / 现代 Chrome AndroidvisualViewport.height 随键盘收缩,差值 ≈ 0
* padding 不变,无视觉副作用
* - FB IAB / 小米等不收缩 innerHeight 的 WebView:差值即为键盘高度,CSS 即可适配
* - visualViewport 不可用:降级为 0(保留原行为,不引入新问题)
* 策略
* 1. 持续写入 --app-viewport-height,聊天页高度不再只依赖 100dvh。
* 2. 优先使用 visualViewport 计算键盘高度
* 3. 输入框聚焦后测量目标元素是否越过可见底部,计算额外抬升
* 4. Android 应用内浏览器若 viewport 未可靠变化,启用保守 fallback 抬升
*/
import { useEffect } from "react";
import { type RefObject, useEffect, useRef } from "react";
const VAR_NAME = "--keyboard-height";
import { BrowserDetector, Logger } from "@/utils";
function compute(): number {
if (typeof window === "undefined") return 0;
const vv = window.visualViewport;
if (vv) {
return Math.max(0, window.innerHeight - (vv.height + vv.offsetTop));
}
return 0;
const KEYBOARD_VAR = "--keyboard-height";
const VIEWPORT_VAR = "--app-viewport-height";
const INPUT_LIFT_VAR = "--chat-input-lift";
const FOCUS_GAP_PX = 14;
const FALLBACK_KEYBOARD_RATIO = 0.42;
const FALLBACK_KEYBOARD_MIN = 260;
const FALLBACK_KEYBOARD_MAX = 380;
const RECHECK_DELAYS_MS = [0, 50, 120, 240, 420, 700];
const RECHECK_EVENT = "cozsweet:keyboard-recheck";
const log = new Logger("HooksUseKeyboardHeight");
export interface UseKeyboardHeightOptions {
targetRef?: RefObject<HTMLElement | null>;
active?: boolean;
}
export function useKeyboardHeight(): void {
export function useKeyboardHeight({
targetRef,
active = false,
}: UseKeyboardHeightOptions = {}): void {
const activeRef = useRef(active);
useEffect(() => {
activeRef.current = active;
window.dispatchEvent(new Event(RECHECK_EVENT));
}, [active]);
useEffect(() => {
if (typeof window === "undefined") return;
let rafId: number | null = null;
let last = -1;
let timeoutIds: number[] = [];
let lastKeyboardHeight = -1;
let lastViewportHeight = -1;
let lastInputLift = -1;
const apply = () => {
rafId = null;
const h = Math.round(compute());
if (h === last) return;
last = h;
document.documentElement.style.setProperty(VAR_NAME, `${h}px`);
const metrics = readViewportMetrics();
const keyboardHeight = Math.round(metrics.keyboardHeight);
const viewportHeight = Math.round(metrics.visibleHeight);
const inputLift = Math.round(
activeRef.current
? Math.max(
computeTargetOverlap(targetRef?.current, metrics.visibleBottom),
computeFallbackLift(metrics),
)
: 0,
);
if (keyboardHeight !== lastKeyboardHeight) {
lastKeyboardHeight = keyboardHeight;
document.documentElement.style.setProperty(
KEYBOARD_VAR,
`${keyboardHeight}px`,
);
}
if (viewportHeight !== lastViewportHeight) {
lastViewportHeight = viewportHeight;
document.documentElement.style.setProperty(
VIEWPORT_VAR,
`${viewportHeight}px`,
);
}
if (inputLift !== lastInputLift) {
lastInputLift = inputLift;
document.documentElement.style.setProperty(
INPUT_LIFT_VAR,
`${inputLift}px`,
);
log.debug("[keyboard] apply", {
active: activeRef.current,
keyboardHeight,
viewportHeight,
inputLift,
visualViewport: metrics.hasVisualViewport,
});
}
};
const schedule = () => {
if (rafId != null) return;
rafId = window.requestAnimationFrame(apply);
};
const scheduleRechecks = () => {
timeoutIds.forEach((id) => window.clearTimeout(id));
timeoutIds = RECHECK_DELAYS_MS.map((delay) =>
window.setTimeout(schedule, delay),
);
};
const vv = window.visualViewport;
if (vv) {
vv.addEventListener("resize", schedule);
vv.addEventListener("scroll", schedule);
} else {
window.addEventListener("resize", schedule);
vv.addEventListener("resize", scheduleRechecks);
vv.addEventListener("scroll", scheduleRechecks);
}
window.addEventListener("resize", scheduleRechecks);
window.addEventListener("orientationchange", scheduleRechecks);
window.addEventListener("focusin", scheduleRechecks);
window.addEventListener("focusout", scheduleRechecks);
window.addEventListener(RECHECK_EVENT, scheduleRechecks);
apply();
scheduleRechecks();
return () => {
if (rafId != null) window.cancelAnimationFrame(rafId);
timeoutIds.forEach((id) => window.clearTimeout(id));
if (vv) {
vv.removeEventListener("resize", schedule);
vv.removeEventListener("scroll", schedule);
} else {
window.removeEventListener("resize", schedule);
vv.removeEventListener("resize", scheduleRechecks);
vv.removeEventListener("scroll", scheduleRechecks);
}
document.documentElement.style.setProperty(VAR_NAME, "0px");
window.removeEventListener("resize", scheduleRechecks);
window.removeEventListener("orientationchange", scheduleRechecks);
window.removeEventListener("focusin", scheduleRechecks);
window.removeEventListener("focusout", scheduleRechecks);
window.removeEventListener(RECHECK_EVENT, scheduleRechecks);
resetVars();
};
}, []);
}, [targetRef]);
}
interface ViewportMetrics {
hasVisualViewport: boolean;
layoutHeight: number;
visibleHeight: number;
visibleBottom: number;
keyboardHeight: number;
}
function readViewportMetrics(): ViewportMetrics {
const layoutHeight = window.innerHeight;
const vv = window.visualViewport;
if (!vv) {
return {
hasVisualViewport: false,
layoutHeight,
visibleHeight: layoutHeight,
visibleBottom: layoutHeight,
keyboardHeight: 0,
};
}
const visibleHeight = vv.height;
const visibleBottom = vv.offsetTop + vv.height;
return {
hasVisualViewport: true,
layoutHeight,
visibleHeight,
visibleBottom,
keyboardHeight: Math.max(0, layoutHeight - visibleBottom),
};
}
function computeTargetOverlap(
target: HTMLElement | null | undefined,
visibleBottom: number,
): number {
if (!target) return 0;
const rect = target.getBoundingClientRect();
return Math.max(0, rect.bottom + FOCUS_GAP_PX - visibleBottom);
}
function computeFallbackLift(metrics: ViewportMetrics): number {
if (!shouldUseInAppKeyboardFallback()) return 0;
const viewportAlreadyShrank = metrics.visibleHeight < metrics.layoutHeight * 0.82;
const keyboardAlreadyDetected = metrics.keyboardHeight > 80;
if (viewportAlreadyShrank || keyboardAlreadyDetected) return 0;
return clamp(
Math.round(metrics.layoutHeight * FALLBACK_KEYBOARD_RATIO),
FALLBACK_KEYBOARD_MIN,
FALLBACK_KEYBOARD_MAX,
);
}
function shouldUseInAppKeyboardFallback(): boolean {
if (typeof navigator === "undefined") return false;
const ua = navigator.userAgent;
return /Android/i.test(ua) && BrowserDetector.isInAppBrowser(ua);
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function resetVars(): void {
document.documentElement.style.setProperty(KEYBOARD_VAR, "0px");
document.documentElement.style.setProperty(VIEWPORT_VAR, "100dvh");
document.documentElement.style.setProperty(INPUT_LIFT_VAR, "0px");
}