diff --git a/src/app/_components/core/mobile-shell.module.css b/src/app/_components/core/mobile-shell.module.css index ffc8034e..d6e67e6a 100644 --- a/src/app/_components/core/mobile-shell.module.css +++ b/src/app/_components/core/mobile-shell.module.css @@ -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); } diff --git a/src/app/chat/chat-screen.tsx b/src/app/chat/chat-screen.tsx index fbfccabd..61294ea9 100644 --- a/src/app/chat/chat-screen.tsx +++ b/src/app/chat/chat-screen.tsx @@ -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); - // 全局副作用:把软键盘高度写到 的 --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); diff --git a/src/app/chat/components/chat-input-bar.module.css b/src/app/chat/components/chat-input-bar.module.css index 6b71f89d..da564701 100644 --- a/src/app/chat/components/chat-input-bar.module.css +++ b/src/app/chat/components/chat-input-bar.module.css @@ -1,16 +1,16 @@ /* ChatInputBar 输入栏容器样式(与 Dart chat_input_bar.dart 对齐) */ -/* 键盘适配(修复 FB IAB / 小米 WebView 不收缩 100dvh 导致输入框被遮挡): - * --keyboard-height 由 src/hooks/use-keyboard-height.ts 写到 ,默认 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; diff --git a/src/app/chat/components/chat-input-bar.tsx b/src/app/chat/components/chat-input-bar.tsx index 119bb30a..c7beff49 100644 --- a/src/app/chat/components/chat-input-bar.tsx +++ b/src/app/chat/components/chat-input-bar.tsx @@ -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) { diff --git a/src/app/chat/components/chat-input-text-field.tsx b/src/app/chat/components/chat-input-text-field.tsx index bad579b8..7700b48f 100644 --- a/src/app/chat/components/chat-input-text-field.tsx +++ b/src/app/chat/components/chat-input-text-field.tsx @@ -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) => { if (e.key !== "Enter") return; if (e.shiftKey || e.ctrlKey || e.metaKey) return; // 修饰键 → 换行 diff --git a/src/app/chat/components/chat-screen.module.css b/src/app/chat/components/chat-screen.module.css index 6024d423..22cd59d2 100644 --- a/src/app/chat/components/chat-screen.module.css +++ b/src/app/chat/components/chat-screen.module.css @@ -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; diff --git a/src/hooks/use-keyboard-height.ts b/src/hooks/use-keyboard-height.ts index f1538e36..f4570a63 100644 --- a/src/hooks/use-keyboard-height.ts +++ b/src/hooks/use-keyboard-height.ts @@ -1,70 +1,212 @@ "use client"; /** - * 软键盘高度 hook(修复 Facebook IAB / 小米 WebView 不收缩 100dvh 导致输入框被遮挡) + * 软键盘避让 hook。 * - * 通过 window.visualViewport 的 resize/scroll 事件计算键盘高度: - * keyboardHeight = max(0, innerHeight - (visualViewport.height + offsetTop)) - * 写到 的 CSS 变量 --keyboard-height(默认 0px)。 - * 不返回 React state,避免键盘动画期间触发 re-render。 - * rAF 合并写入;卸载时清理监听并把变量重置为 0px,防止路由切换后状态泄漏。 + * 目标环境:Facebook IAB / 小米 WebView 等软键盘覆盖页面但 viewport 信号不稳定的浏览器。 * - * 行为: - * - iOS Safari / 现代 Chrome Android:visualViewport.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; + 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"); }