"use client"; /** * 特定 Android 设备 + Facebook IAB 专用输入框避让 hook。 * * 这不是通用软键盘方案。它只解决一个已知 WebView 问题: * 输入框 focus 后键盘弹出,但 Facebook IAB 没有稳定调整页面 viewport, * 导致底部输入框被键盘遮挡。 * * 策略: * - 非目标 Android Facebook IAB 设备:完全不生效。 * - focus 后短期多次测量输入框是否被 visibleViewport 遮挡。 * - 若 viewport 没有可靠变化,则使用固定 fallback 抬升。 * - 聚焦期间轻量轮询,持续保持输入框避让。 */ import { useEffect } from "react"; import { Logger } from "@/utils"; import { FALLBACK_INPUT_LIFT_PX, FOCUS_GAP_PX, KEYBOARD_MEASURE_DELAYS_MS, KEYBOARD_POLL_INTERVAL_MS, KEYBOARD_VISIBLE_RATIO, KEYBOARD_VISIBLE_THRESHOLD_PX, } from "./constants"; import { resetInputLiftVar, setInputLiftVar, } from "./css-vars"; import { getKeyboardAdaptEnvironment, shouldEnableKeyboardAdaptation, } from "./environment"; import { isKeyboardTargetActive } from "./keyboard-target"; import type { UseKeyboardHeightOptions } from "./types"; const log = new Logger("HooksUseKeyboardHeight"); interface KeyboardSnapshot { layoutHeight: number; visibleHeight: number; visibleBottom: number; keyboardHeight: number; keyboardVisible: boolean; } export function useKeyboardHeight({ targetRef, active = false, }: UseKeyboardHeightOptions = {}): void { useEffect(() => { if (typeof window === "undefined") return; const environment = getKeyboardAdaptEnvironment(); if (!shouldEnableKeyboardAdaptation(environment)) { resetInputLiftVar(); log.debug("[keyboard] disabled", { reason: "not-android-facebook-fallback-device", environment, }); return; } let rafId: number | null = null; let pollId: number | null = null; let timeoutIds: number[] = []; let lastInputLift = -1; log.debug("[keyboard] environment", environment); const clearTimers = () => { timeoutIds.forEach((id) => window.clearTimeout(id)); timeoutIds = []; if (pollId != null) { window.clearInterval(pollId); pollId = null; } }; const applyLift = (lift: number, reason: string) => { const nextLift = Math.max(0, Math.round(lift)); if (nextLift === lastInputLift) return; lastInputLift = nextLift; setInputLiftVar(nextLift); log.debug("[keyboard] apply", { reason, inputLift: nextLift, fallback: nextLift === FALLBACK_INPUT_LIFT_PX, snapshot: formatSnapshot(readKeyboardSnapshot()), }); }; const measure = (reason: string) => { rafId = null; const target = targetRef?.current; const activeNow = isKeyboardTargetActive(target, active); const snapshot = readKeyboardSnapshot(); if (!activeNow) { log.debug("[keyboard] skip inactive target", { reason, snapshot: formatSnapshot(snapshot), }); return; } const overlapLift = computeTargetOverlap(target, snapshot.visibleBottom); const fallbackLift = snapshot.keyboardVisible ? 0 : FALLBACK_INPUT_LIFT_PX; applyLift(Math.max(overlapLift, fallbackLift), reason); }; const scheduleMeasure = (reason: string) => { if (rafId != null) return; rafId = window.requestAnimationFrame(() => measure(reason)); }; const scheduleInitialMeasures = () => { timeoutIds = KEYBOARD_MEASURE_DELAYS_MS.map((delay) => window.setTimeout(() => scheduleMeasure(`focus-${delay}`), delay), ); }; const handleViewportSignal = () => { scheduleMeasure("viewport-signal"); }; if (!active) { return; } const visualViewport = window.visualViewport; visualViewport?.addEventListener("resize", handleViewportSignal); visualViewport?.addEventListener("scroll", handleViewportSignal); window.addEventListener("resize", handleViewportSignal); window.addEventListener("orientationchange", handleViewportSignal); document.addEventListener("visibilitychange", handleViewportSignal); scheduleInitialMeasures(); pollId = window.setInterval( () => scheduleMeasure("poll"), KEYBOARD_POLL_INTERVAL_MS, ); return () => { if (rafId != null) window.cancelAnimationFrame(rafId); clearTimers(); visualViewport?.removeEventListener("resize", handleViewportSignal); visualViewport?.removeEventListener("scroll", handleViewportSignal); window.removeEventListener("resize", handleViewportSignal); window.removeEventListener("orientationchange", handleViewportSignal); document.removeEventListener("visibilitychange", handleViewportSignal); resetInputLiftVar(); }; }, [active, targetRef]); } function readKeyboardSnapshot(): KeyboardSnapshot { const layoutHeight = window.innerHeight; const visualViewport = window.visualViewport; const visibleHeight = visualViewport?.height ?? layoutHeight; const visibleBottom = visualViewport ? visualViewport.offsetTop + visualViewport.height : layoutHeight; const keyboardHeight = Math.max(0, layoutHeight - visibleBottom); const keyboardVisible = keyboardHeight > KEYBOARD_VISIBLE_THRESHOLD_PX || visibleHeight < layoutHeight * KEYBOARD_VISIBLE_RATIO; return { layoutHeight, visibleHeight, visibleBottom, keyboardHeight, keyboardVisible, }; } 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 formatSnapshot(snapshot: KeyboardSnapshot) { return { layoutHeight: Math.round(snapshot.layoutHeight), visibleHeight: Math.round(snapshot.visibleHeight), visibleBottom: Math.round(snapshot.visibleBottom), keyboardHeight: Math.round(snapshot.keyboardHeight), keyboardVisible: snapshot.keyboardVisible, }; } export type { UseKeyboardHeightOptions };