diff --git a/src/app/chat/chat-screen.tsx b/src/app/chat/chat-screen.tsx index 61294ea9..fbfccabd 100644 --- a/src/app/chat/chat-screen.tsx +++ b/src/app/chat/chat-screen.tsx @@ -5,6 +5,7 @@ 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"; @@ -42,6 +43,11 @@ 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 850c2856..6b71f89d 100644 --- a/src/app/chat/components/chat-input-bar.module.css +++ b/src/app/chat/components/chat-input-bar.module.css @@ -1,12 +1,21 @@ /* ChatInputBar 输入栏容器样式(与 Dart chat_input_bar.dart 对齐) */ -/* 外层:focused 时加 padding-top + 粉色背景 + 阴影 */ +/* 键盘适配(修复 FB IAB / 小米 WebView 不收缩 100dvh 导致输入框被遮挡): + * --keyboard-height 由 src/hooks/use-keyboard-height.ts 写到 ,默认 0px。 + * env(safe-area-inset-bottom) 处理小米全面屏手势条 / iPhone home indicator。 + * 二者在原生已正确处理键盘的浏览器(iOS Safari、现代 Chrome Android)上都为 0, + * 因此无视觉副作用。 */ .bar { flex: 0 0 auto; - padding: 0 var(--spacing-lg, 16px) var(--spacing-lg, 16px); + padding: 0 + var(--spacing-lg, 16px) + calc( + var(--spacing-lg, 16px) + var(--keyboard-height, 0px) + + env(safe-area-inset-bottom, 0px) + ); background: transparent; - transition: padding-top 0.2s ease, background-color 0.2s ease, - box-shadow 0.2s ease; + transition: padding-top 0.2s ease, padding-bottom 0.2s ease, + background-color 0.2s ease, box-shadow 0.2s ease; } .barFocused { diff --git a/src/app/chat/components/chat-input-text-field.tsx b/src/app/chat/components/chat-input-text-field.tsx index 7700b48f..bad579b8 100644 --- a/src/app/chat/components/chat-input-text-field.tsx +++ b/src/app/chat/components/chat-input-text-field.tsx @@ -18,6 +18,7 @@ import { forwardRef, type FormEvent, type KeyboardEvent, + useEffect, useImperativeHandle, useRef, } from "react"; @@ -54,6 +55,20 @@ 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/layout.tsx b/src/app/layout.tsx index 306b731c..77c28045 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -32,6 +32,10 @@ export const metadata: Metadata = { export const viewport: Viewport = { // 浏览器顶栏 / Android PWA 顶栏颜色 themeColor: "#f84d96", + // 让页面延伸到 notch / home indicator 区域, + // env(safe-area-inset-bottom) 才能取到非零值(见 chat-input-bar.module.css)。 + // Next.js 16 的 Viewport 类型未列出此键,但 generate-viewport.md 允许附加原始 meta 值。 + viewportFit: "cover", }; export default function RootLayout({ diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 4b61d1d6..722f038a 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -3,4 +3,5 @@ */ export * from "./use-breakpoint"; +export * from "./use-keyboard-height"; export * from "./use-pull-to-refresh"; diff --git a/src/hooks/use-keyboard-height.ts b/src/hooks/use-keyboard-height.ts new file mode 100644 index 00000000..f1538e36 --- /dev/null +++ b/src/hooks/use-keyboard-height.ts @@ -0,0 +1,70 @@ +"use client"; +/** + * 软键盘高度 hook(修复 Facebook IAB / 小米 WebView 不收缩 100dvh 导致输入框被遮挡) + * + * 通过 window.visualViewport 的 resize/scroll 事件计算键盘高度: + * keyboardHeight = max(0, innerHeight - (visualViewport.height + offsetTop)) + * 写到 的 CSS 变量 --keyboard-height(默认 0px)。 + * 不返回 React state,避免键盘动画期间触发 re-render。 + * rAF 合并写入;卸载时清理监听并把变量重置为 0px,防止路由切换后状态泄漏。 + * + * 行为: + * - iOS Safari / 现代 Chrome Android:visualViewport.height 随键盘收缩,差值 ≈ 0, + * padding 不变,无视觉副作用。 + * - FB IAB / 小米等不收缩 innerHeight 的 WebView:差值即为键盘高度,CSS 即可适配。 + * - visualViewport 不可用:降级为 0(保留原行为,不引入新问题)。 + */ +import { useEffect } from "react"; + +const VAR_NAME = "--keyboard-height"; + +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; +} + +export function useKeyboardHeight(): void { + useEffect(() => { + if (typeof window === "undefined") return; + + let rafId: number | null = null; + let last = -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 schedule = () => { + if (rafId != null) return; + rafId = window.requestAnimationFrame(apply); + }; + + const vv = window.visualViewport; + if (vv) { + vv.addEventListener("resize", schedule); + vv.addEventListener("scroll", schedule); + } else { + window.addEventListener("resize", schedule); + } + + apply(); + + return () => { + if (rafId != null) window.cancelAnimationFrame(rafId); + if (vv) { + vv.removeEventListener("resize", schedule); + vv.removeEventListener("scroll", schedule); + } else { + window.removeEventListener("resize", schedule); + } + document.documentElement.style.setProperty(VAR_NAME, "0px"); + }; + }, []); +}