fix(chat): release keyboard fallback on close

This commit is contained in:
2026-06-24 15:48:29 +08:00
parent a229dcf7d1
commit 890eedf7d9
+81 -9
View File
@@ -8,7 +8,8 @@
* 1. 持续写入 --app-viewport-height,聊天页高度不再只依赖 100dvh。 * 1. 持续写入 --app-viewport-height,聊天页高度不再只依赖 100dvh。
* 2. 优先使用 visualViewport 计算键盘高度。 * 2. 优先使用 visualViewport 计算键盘高度。
* 3. 输入框聚焦后测量目标元素是否越过可见底部,计算额外抬升。 * 3. 输入框聚焦后测量目标元素是否越过可见底部,计算额外抬升。
* 4. 小米系 Android + Facebook IAB 若 viewport 未可靠变化,启用保守 fallback 抬升 * 4. 小米系 Android + Facebook IAB 若 viewport 未可靠变化,启用保守 fallback 抬升
* 键盘收起后若焦点仍停留在 textarea,也要主动释放 fallback。
*/ */
import { type RefObject, useEffect, useRef } from "react"; import { type RefObject, useEffect, useRef } from "react";
@@ -21,6 +22,7 @@ const FOCUS_GAP_PX = 14;
const FALLBACK_KEYBOARD_RATIO = 0.42; const FALLBACK_KEYBOARD_RATIO = 0.42;
const FALLBACK_KEYBOARD_MIN = 260; const FALLBACK_KEYBOARD_MIN = 260;
const FALLBACK_KEYBOARD_MAX = 380; const FALLBACK_KEYBOARD_MAX = 380;
const FALLBACK_CLOSE_GRACE_MS = 900;
const RECHECK_DELAYS_MS = [0, 50, 120, 240, 420, 700]; const RECHECK_DELAYS_MS = [0, 50, 120, 240, 420, 700];
const RECHECK_EVENT = "cozsweet:keyboard-recheck"; const RECHECK_EVENT = "cozsweet:keyboard-recheck";
@@ -36,9 +38,13 @@ export function useKeyboardHeight({
active = false, active = false,
}: UseKeyboardHeightOptions = {}): void { }: UseKeyboardHeightOptions = {}): void {
const activeRef = useRef(active); const activeRef = useRef(active);
const fallbackAllowedRef = useRef(false);
const focusedAtRef = useRef(0);
useEffect(() => { useEffect(() => {
activeRef.current = active; activeRef.current = active;
fallbackAllowedRef.current = active;
focusedAtRef.current = active ? performance.now() : 0;
window.dispatchEvent(new Event(RECHECK_EVENT)); window.dispatchEvent(new Event(RECHECK_EVENT));
}, [active]); }, [active]);
@@ -50,14 +56,27 @@ export function useKeyboardHeight({
let lastKeyboardHeight = -1; let lastKeyboardHeight = -1;
let lastViewportHeight = -1; let lastViewportHeight = -1;
let lastInputLift = -1; let lastInputLift = -1;
let lastMayBeKeyboardClose = false;
const apply = () => { const apply = () => {
rafId = null; rafId = null;
const metrics = readViewportMetrics(); const metrics = readViewportMetrics();
if (
activeRef.current &&
fallbackAllowedRef.current &&
lastMayBeKeyboardClose &&
shouldReleaseFallback(metrics, focusedAtRef.current)
) {
fallbackAllowedRef.current = false;
}
lastMayBeKeyboardClose = false;
const keyboardHeight = Math.round(metrics.keyboardHeight); const keyboardHeight = Math.round(metrics.keyboardHeight);
const viewportHeight = Math.round(metrics.visibleHeight); const viewportHeight = Math.round(metrics.visibleHeight);
const fallbackLift = computeFallbackLift(metrics); const fallbackLift = fallbackAllowedRef.current
? computeFallbackLift(metrics)
: 0;
const inputLift = Math.round( const inputLift = Math.round(
activeRef.current activeRef.current
? Math.max( ? Math.max(
@@ -95,7 +114,9 @@ export function useKeyboardHeight({
viewportHeight, viewportHeight,
inputLift, inputLift,
fallbackLift, fallbackLift,
fallbackAllowed: fallbackAllowedRef.current,
visualViewport: metrics.hasVisualViewport, visualViewport: metrics.hasVisualViewport,
virtualKeyboard: metrics.hasVirtualKeyboard,
}); });
} }
}; };
@@ -105,7 +126,10 @@ export function useKeyboardHeight({
rafId = window.requestAnimationFrame(apply); rafId = window.requestAnimationFrame(apply);
}; };
const scheduleRechecks = () => { const scheduleRechecks = (event?: Event) => {
if (event && isPossibleKeyboardCloseEvent(event)) {
lastMayBeKeyboardClose = true;
}
timeoutIds.forEach((id) => window.clearTimeout(id)); timeoutIds.forEach((id) => window.clearTimeout(id));
timeoutIds = RECHECK_DELAYS_MS.map((delay) => timeoutIds = RECHECK_DELAYS_MS.map((delay) =>
window.setTimeout(schedule, delay), window.setTimeout(schedule, delay),
@@ -117,6 +141,8 @@ export function useKeyboardHeight({
vv.addEventListener("resize", scheduleRechecks); vv.addEventListener("resize", scheduleRechecks);
vv.addEventListener("scroll", scheduleRechecks); vv.addEventListener("scroll", scheduleRechecks);
} }
const virtualKeyboard = getVirtualKeyboard();
virtualKeyboard?.addEventListener("geometrychange", scheduleRechecks);
window.addEventListener("resize", scheduleRechecks); window.addEventListener("resize", scheduleRechecks);
window.addEventListener("orientationchange", scheduleRechecks); window.addEventListener("orientationchange", scheduleRechecks);
window.addEventListener("focusin", scheduleRechecks); window.addEventListener("focusin", scheduleRechecks);
@@ -132,6 +158,7 @@ export function useKeyboardHeight({
vv.removeEventListener("resize", scheduleRechecks); vv.removeEventListener("resize", scheduleRechecks);
vv.removeEventListener("scroll", scheduleRechecks); vv.removeEventListener("scroll", scheduleRechecks);
} }
virtualKeyboard?.removeEventListener("geometrychange", scheduleRechecks);
window.removeEventListener("resize", scheduleRechecks); window.removeEventListener("resize", scheduleRechecks);
window.removeEventListener("orientationchange", scheduleRechecks); window.removeEventListener("orientationchange", scheduleRechecks);
window.removeEventListener("focusin", scheduleRechecks); window.removeEventListener("focusin", scheduleRechecks);
@@ -144,6 +171,7 @@ export function useKeyboardHeight({
interface ViewportMetrics { interface ViewportMetrics {
hasVisualViewport: boolean; hasVisualViewport: boolean;
hasVirtualKeyboard: boolean;
layoutHeight: number; layoutHeight: number;
visibleHeight: number; visibleHeight: number;
visibleBottom: number; visibleBottom: number;
@@ -153,24 +181,36 @@ interface ViewportMetrics {
function readViewportMetrics(): ViewportMetrics { function readViewportMetrics(): ViewportMetrics {
const layoutHeight = window.innerHeight; const layoutHeight = window.innerHeight;
const vv = window.visualViewport; const vv = window.visualViewport;
const virtualKeyboard = getVirtualKeyboard();
const virtualKeyboardRect = virtualKeyboard?.boundingRect ?? null;
const virtualKeyboardHeight = virtualKeyboardRect?.height ?? 0;
if (!vv) { if (!vv) {
const visibleBottom =
virtualKeyboardHeight > 0 ? layoutHeight - virtualKeyboardHeight : layoutHeight;
return { return {
hasVisualViewport: false, hasVisualViewport: false,
hasVirtualKeyboard: virtualKeyboard != null,
layoutHeight, layoutHeight,
visibleHeight: layoutHeight, visibleHeight: visibleBottom,
visibleBottom: layoutHeight, visibleBottom,
keyboardHeight: 0, keyboardHeight: virtualKeyboardHeight,
}; };
} }
const visibleHeight = vv.height; const visualBottom = vv.offsetTop + vv.height;
const visibleBottom = vv.offsetTop + vv.height; const keyboardTop =
virtualKeyboardHeight > 0
? Math.min(virtualKeyboardRect?.y ?? layoutHeight, layoutHeight - virtualKeyboardHeight)
: layoutHeight;
const visibleBottom = Math.min(visualBottom, keyboardTop);
const visibleHeight = Math.min(vv.height, visibleBottom);
return { return {
hasVisualViewport: true, hasVisualViewport: true,
hasVirtualKeyboard: virtualKeyboard != null,
layoutHeight, layoutHeight,
visibleHeight, visibleHeight,
visibleBottom, visibleBottom,
keyboardHeight: Math.max(0, layoutHeight - visibleBottom), keyboardHeight: Math.max(0, layoutHeight - visibleBottom, virtualKeyboardHeight),
}; };
} }
@@ -185,6 +225,7 @@ function computeTargetOverlap(
function computeFallbackLift(metrics: ViewportMetrics): number { function computeFallbackLift(metrics: ViewportMetrics): number {
if (!shouldUseInAppKeyboardFallback()) return 0; if (!shouldUseInAppKeyboardFallback()) return 0;
if (metrics.hasVirtualKeyboard && metrics.keyboardHeight <= 0) return 0;
const viewportAlreadyShrank = metrics.visibleHeight < metrics.layoutHeight * 0.82; const viewportAlreadyShrank = metrics.visibleHeight < metrics.layoutHeight * 0.82;
const keyboardAlreadyDetected = metrics.keyboardHeight > 80; const keyboardAlreadyDetected = metrics.keyboardHeight > 80;
@@ -197,6 +238,26 @@ function computeFallbackLift(metrics: ViewportMetrics): number {
); );
} }
function shouldReleaseFallback(
metrics: ViewportMetrics,
focusedAt: number,
): boolean {
if (focusedAt <= 0) return false;
if (performance.now() - focusedAt < FALLBACK_CLOSE_GRACE_MS) return false;
return !hasDetectedKeyboard(metrics);
}
function hasDetectedKeyboard(metrics: ViewportMetrics): boolean {
return (
metrics.keyboardHeight > 80 ||
metrics.visibleHeight < metrics.layoutHeight * 0.82
);
}
function isPossibleKeyboardCloseEvent(event: Event): boolean {
return event.type === "resize" || event.type === "geometrychange";
}
function shouldUseInAppKeyboardFallback(): boolean { function shouldUseInAppKeyboardFallback(): boolean {
if (typeof navigator === "undefined") return false; if (typeof navigator === "undefined") return false;
const ua = navigator.userAgent; const ua = navigator.userAgent;
@@ -216,3 +277,14 @@ function resetVars(): void {
document.documentElement.style.setProperty(VIEWPORT_VAR, "100dvh"); document.documentElement.style.setProperty(VIEWPORT_VAR, "100dvh");
document.documentElement.style.setProperty(INPUT_LIFT_VAR, "0px"); document.documentElement.style.setProperty(INPUT_LIFT_VAR, "0px");
} }
interface VirtualKeyboardLike extends EventTarget {
readonly boundingRect: DOMRectReadOnly;
}
function getVirtualKeyboard(): VirtualKeyboardLike | null {
const nav = navigator as Navigator & {
virtualKeyboard?: VirtualKeyboardLike;
};
return nav.virtualKeyboard ?? null;
}