From 94fa3e9b7a558181a8b3d114419c8d5b94131cf6 Mon Sep 17 00:00:00 2001 From: chenhang Date: Wed, 1 Jul 2026 16:13:46 +0800 Subject: [PATCH] fix(chat): simplify Xiaomi Facebook keyboard lift --- .../chat/components/chat-input-bar.module.css | 3 +- .../__tests__/environment.test.ts | 1 - src/hooks/use-keyboard-height/constants.ts | 22 +- src/hooks/use-keyboard-height/css-vars.ts | 14 +- src/hooks/use-keyboard-height/environment.ts | 8 - src/hooks/use-keyboard-height/index.ts | 372 +++++++++--------- .../keyboard-event-listeners.ts | 35 -- .../use-keyboard-height/release-events.ts | 11 - src/hooks/use-keyboard-height/types.ts | 10 - .../use-keyboard-height/viewport-metrics.ts | 150 ------- .../use-keyboard-height/virtual-keyboard.ts | 11 - 11 files changed, 199 insertions(+), 438 deletions(-) delete mode 100644 src/hooks/use-keyboard-height/keyboard-event-listeners.ts delete mode 100644 src/hooks/use-keyboard-height/release-events.ts delete mode 100644 src/hooks/use-keyboard-height/viewport-metrics.ts delete mode 100644 src/hooks/use-keyboard-height/virtual-keyboard.ts diff --git a/src/app/chat/components/chat-input-bar.module.css b/src/app/chat/components/chat-input-bar.module.css index 4773c34c..fa8a617a 100644 --- a/src/app/chat/components/chat-input-bar.module.css +++ b/src/app/chat/components/chat-input-bar.module.css @@ -1,8 +1,7 @@ /* ChatInputBar 输入栏容器样式(与 Dart chat_input_bar.dart 对齐) */ /* 键盘适配: - * --app-viewport-height 会让聊天容器优先贴合 visualViewport。 - * --chat-input-lift 是 Android 应用内浏览器的兜底抬升值。 + * --chat-input-lift 是小米 Android + Facebook IAB 的专用抬升值。 * env(safe-area-inset-bottom) 处理小米全面屏手势条 / iPhone home indicator。 * 正常浏览器中 --chat-input-lift 为 0,因此无视觉副作用。 */ .bar { diff --git a/src/hooks/use-keyboard-height/__tests__/environment.test.ts b/src/hooks/use-keyboard-height/__tests__/environment.test.ts index 5e28b450..50a2d9c5 100644 --- a/src/hooks/use-keyboard-height/__tests__/environment.test.ts +++ b/src/hooks/use-keyboard-height/__tests__/environment.test.ts @@ -22,7 +22,6 @@ function makeEnvironment( isXiaomiFamilyDevice: true, isFacebookInAppBrowser: true, hasVisualViewport: true, - hasVirtualKeyboard: true, usesXiaomiFacebookFallback: true, ...overrides, }; diff --git a/src/hooks/use-keyboard-height/constants.ts b/src/hooks/use-keyboard-height/constants.ts index e210445a..ad681dba 100644 --- a/src/hooks/use-keyboard-height/constants.ts +++ b/src/hooks/use-keyboard-height/constants.ts @@ -1,19 +1,9 @@ -export const KEYBOARD_VAR = "--keyboard-height"; -export const VIEWPORT_VAR = "--app-viewport-height"; export const INPUT_LIFT_VAR = "--chat-input-lift"; export const FOCUS_GAP_PX = 14; -export const FALLBACK_KEYBOARD_RATIO = 0.42; -export const FALLBACK_KEYBOARD_MIN = 260; -export const FALLBACK_KEYBOARD_MAX = 380; -export const FALLBACK_CLOSE_GRACE_MS = 900; -export const RECHECK_DELAYS_MS = [0, 50, 120, 240, 420, 700]; -export const KEYBOARD_RELEASE_EVENT_TYPES = new Set([ - "resize", - "scroll", - "geometrychange", - "focusout", - "blur", - "visibilitychange", -]); -export const RECHECK_EVENT = "cozsweet:keyboard-recheck"; +export const FALLBACK_INPUT_LIFT_PX = 320; +export const KEYBOARD_VISIBLE_THRESHOLD_PX = 80; +export const KEYBOARD_VISIBLE_RATIO = 0.82; +export const KEYBOARD_RELEASE_GRACE_MS = 900; +export const KEYBOARD_MEASURE_DELAYS_MS = [0, 100, 250, 500]; +export const KEYBOARD_POLL_INTERVAL_MS = 300; diff --git a/src/hooks/use-keyboard-height/css-vars.ts b/src/hooks/use-keyboard-height/css-vars.ts index a6a3d526..95b9c64e 100644 --- a/src/hooks/use-keyboard-height/css-vars.ts +++ b/src/hooks/use-keyboard-height/css-vars.ts @@ -1,19 +1,9 @@ -import { INPUT_LIFT_VAR, KEYBOARD_VAR, VIEWPORT_VAR } from "./constants"; - -export function setKeyboardHeightVar(height: number): void { - document.documentElement.style.setProperty(KEYBOARD_VAR, `${height}px`); -} - -export function setViewportHeightVar(height: number): void { - document.documentElement.style.setProperty(VIEWPORT_VAR, `${height}px`); -} +import { INPUT_LIFT_VAR } from "./constants"; export function setInputLiftVar(lift: number): void { document.documentElement.style.setProperty(INPUT_LIFT_VAR, `${lift}px`); } -export function resetKeyboardVars(): void { - document.documentElement.style.setProperty(KEYBOARD_VAR, "0px"); - document.documentElement.style.setProperty(VIEWPORT_VAR, "100dvh"); +export function resetInputLiftVar(): void { document.documentElement.style.setProperty(INPUT_LIFT_VAR, "0px"); } diff --git a/src/hooks/use-keyboard-height/environment.ts b/src/hooks/use-keyboard-height/environment.ts index 0b2d3736..876a1b69 100644 --- a/src/hooks/use-keyboard-height/environment.ts +++ b/src/hooks/use-keyboard-height/environment.ts @@ -1,7 +1,6 @@ import { BrowserDetector, PlatformDetector } from "@/utils"; import type { KeyboardAdaptEnvironment } from "./types"; -import { getVirtualKeyboard } from "./virtual-keyboard"; export function getKeyboardAdaptEnvironment( ua: string | null = null, @@ -28,7 +27,6 @@ export function getKeyboardAdaptEnvironment( isFacebookInAppBrowser, hasVisualViewport: typeof window !== "undefined" && window.visualViewport != null, - hasVirtualKeyboard: getVirtualKeyboard() != null, usesXiaomiFacebookFallback: platform.isAndroid && isXiaomiFamilyDevice && isFacebookInAppBrowser, }; @@ -39,9 +37,3 @@ export function shouldEnableKeyboardAdaptation( ): boolean { return environment.usesXiaomiFacebookFallback; } - -export function shouldUseInAppKeyboardFallback(): boolean { - if (typeof navigator === "undefined") return false; - return getKeyboardAdaptEnvironment(navigator.userAgent) - .usesXiaomiFacebookFallback; -} diff --git a/src/hooks/use-keyboard-height/index.ts b/src/hooks/use-keyboard-height/index.ts index 922e4d1e..96cce276 100644 --- a/src/hooks/use-keyboard-height/index.ts +++ b/src/hooks/use-keyboard-height/index.ts @@ -1,29 +1,33 @@ "use client"; /** - * 软键盘避让 hook。 + * 小米 Android + Facebook IAB 专用输入框避让 hook。 * - * 目标环境:小米系 Android 设备上的 Facebook IAB。 + * 这不是通用软键盘方案。它只解决一个已知 WebView 问题: + * 输入框 focus 后键盘弹出,但 Facebook IAB 没有稳定调整页面 viewport, + * 导致底部输入框被键盘遮挡。 * * 策略: - * 1. 持续写入 --app-viewport-height,聊天页高度不再只依赖 100dvh。 - * 2. 优先使用 visualViewport 计算键盘高度。 - * 3. 输入框聚焦后测量目标元素是否越过可见底部,计算额外抬升。 - * 4. 小米系 Android + Facebook IAB 若 viewport 未可靠变化,启用保守 fallback 抬升; - * 键盘收起后若焦点仍停留在 textarea,也要主动释放 fallback。 + * - 非小米 Android Facebook IAB:完全不生效。 + * - focus 后短期多次测量输入框是否被 visibleViewport 遮挡。 + * - 若 viewport 没有可靠变化,则使用固定 fallback 抬升。 + * - 聚焦期间轻量轮询;一旦检测到 viewport 恢复,主动 blur 并清除抬升。 */ import { useEffect, useRef } from "react"; import { Logger } from "@/utils"; import { - RECHECK_DELAYS_MS, - RECHECK_EVENT, + FALLBACK_INPUT_LIFT_PX, + FOCUS_GAP_PX, + KEYBOARD_MEASURE_DELAYS_MS, + KEYBOARD_POLL_INTERVAL_MS, + KEYBOARD_RELEASE_GRACE_MS, + KEYBOARD_VISIBLE_RATIO, + KEYBOARD_VISIBLE_THRESHOLD_PX, } from "./constants"; import { - resetKeyboardVars, + resetInputLiftVar, setInputLiftVar, - setKeyboardHeightVar, - setViewportHeightVar, } from "./css-vars"; import { getKeyboardAdaptEnvironment, @@ -33,59 +37,35 @@ import { blurKeyboardTarget, isKeyboardTargetActive, } from "./keyboard-target"; -import { addKeyboardRecheckListeners } from "./keyboard-event-listeners"; -import { isPossibleKeyboardCloseEvent } from "./release-events"; -import type { UseKeyboardHeightOptions, ViewportMetrics } from "./types"; -import { - computeFallbackLift, - computeTargetOverlap, - formatMetrics, - getFocusedDurationMs, - getLiftSource, - getReleaseSkipReason, - readViewportMetrics, - shouldReleaseKeyboardState, -} from "./viewport-metrics"; +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, onKeyboardDismiss, }: UseKeyboardHeightOptions = {}): void { - const activeRef = useRef(active); const onKeyboardDismissRef = useRef(onKeyboardDismiss); - const fallbackAllowedRef = useRef(false); - const focusedAtRef = useRef(0); useEffect(() => { onKeyboardDismissRef.current = onKeyboardDismiss; }, [onKeyboardDismiss]); - useEffect(() => { - activeRef.current = active; - fallbackAllowedRef.current = active; - focusedAtRef.current = active ? performance.now() : 0; - window.dispatchEvent(new Event(RECHECK_EVENT)); - }, [active]); - useEffect(() => { if (typeof window === "undefined") return; - let rafId: number | null = null; - let timeoutIds: number[] = []; - let lastKeyboardHeight = -1; - let lastViewportHeight = -1; - let lastInputLift = -1; - let lastMayBeKeyboardClose = false; - let fallbackWasApplied = false; - let lastEventType = "initial"; const environment = getKeyboardAdaptEnvironment(); - - log.debug("[keyboard] environment", environment); if (!shouldEnableKeyboardAdaptation(environment)) { - resetKeyboardVars(); + resetInputLiftVar(); log.debug("[keyboard] disabled", { reason: "not-xiaomi-facebook-iab", environment, @@ -93,159 +73,187 @@ export function useKeyboardHeight({ return; } - const apply = () => { - rafId = null; + let rafId: number | null = null; + let pollId: number | null = null; + let timeoutIds: number[] = []; + let released = false; + let keyboardWasDetected = false; + let lastInputLift = -1; + const focusedAt = active ? performance.now() : 0; - const metrics = readViewportMetrics(); - let activeNow = isKeyboardTargetActive( - targetRef?.current, - activeRef.current, - ); - const shouldTryRelease = - activeNow && - lastMayBeKeyboardClose && - shouldReleaseKeyboardState(metrics, focusedAtRef.current); + log.debug("[keyboard] environment", environment); - if (activeNow && lastMayBeKeyboardClose && !shouldTryRelease) { - log.debug("[keyboard] release skipped", { - reason: getReleaseSkipReason(metrics, focusedAtRef.current), - lastEventType, - environment, - active: activeNow, - reactActive: activeRef.current, - fallbackAllowed: fallbackAllowedRef.current, - lastInputLift, - fallbackWasApplied, - focusedForMs: getFocusedDurationMs(focusedAtRef.current), - metrics: formatMetrics(metrics), - }); - } - - if (shouldTryRelease) { - log.debug("[keyboard] release confirmed", { - lastEventType, - environment, - active: activeNow, - reactActive: activeRef.current, - fallbackAllowed: fallbackAllowedRef.current, - lastInputLift, - fallbackWasApplied, - focusedForMs: getFocusedDurationMs(focusedAtRef.current), - metrics: formatMetrics(metrics), - }); - releaseKeyboardState("keyboard-close-detected", metrics); - activeNow = false; - } - lastMayBeKeyboardClose = false; - - const keyboardHeight = Math.round(metrics.keyboardHeight); - const viewportHeight = Math.round(metrics.visibleHeight); - const fallbackLift = fallbackAllowedRef.current - ? computeFallbackLift(metrics) - : 0; - const inputLift = Math.round( - activeNow - ? Math.max( - computeTargetOverlap(targetRef?.current, metrics.visibleBottom), - fallbackLift, - ) - : 0, - ); - fallbackWasApplied = - fallbackLift > 0 && - inputLift >= fallbackLift && - fallbackAllowedRef.current; - - if (keyboardHeight !== lastKeyboardHeight) { - lastKeyboardHeight = keyboardHeight; - setKeyboardHeightVar(keyboardHeight); - } - - if (viewportHeight !== lastViewportHeight) { - lastViewportHeight = viewportHeight; - setViewportHeightVar(viewportHeight); - } - - if (inputLift !== lastInputLift) { - lastInputLift = inputLift; - setInputLiftVar(inputLift); - log.debug("[keyboard] apply", { - environment, - active: activeNow, - reactActive: activeRef.current, - inputLift, - fallbackLift, - fallbackAllowed: fallbackAllowedRef.current, - source: getLiftSource(inputLift, fallbackLift), - metrics: formatMetrics(metrics), - }); - } - }; - - const schedule = () => { - if (rafId != null) return; - rafId = window.requestAnimationFrame(apply); - }; - - const scheduleRechecks = (event?: Event) => { - lastEventType = event?.type ?? "manual"; - const hadInputLift = fallbackWasApplied || lastInputLift > 0; - const possibleKeyboardClose = - event != null && isPossibleKeyboardCloseEvent(event, hadInputLift); - log.debug("[keyboard] schedule rechecks", { - eventType: lastEventType, - possibleKeyboardClose, - hadInputLift, - fallbackWasApplied, - lastInputLift, - active: activeRef.current, - fallbackAllowed: fallbackAllowedRef.current, - focusedForMs: getFocusedDurationMs(focusedAtRef.current), - }); - if (possibleKeyboardClose) { - lastMayBeKeyboardClose = true; - log.debug("[keyboard] possible close detected", { - eventType: lastEventType, - hadInputLift, - fallbackWasApplied, - lastInputLift, - focusedForMs: getFocusedDurationMs(focusedAtRef.current), - }); - } + const clearTimers = () => { timeoutIds.forEach((id) => window.clearTimeout(id)); - timeoutIds = RECHECK_DELAYS_MS.map((delay) => - window.setTimeout(schedule, delay), - ); + timeoutIds = []; + if (pollId != null) { + window.clearInterval(pollId); + pollId = null; + } }; - const releaseKeyboardState = ( - reason: string, - metrics: ViewportMetrics, - ): void => { - activeRef.current = false; - fallbackAllowedRef.current = false; - focusedAtRef.current = 0; + 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 release = (reason: string) => { + if (released) return; + released = true; + clearTimers(); + applyLift(0, reason); blurKeyboardTarget(targetRef?.current); onKeyboardDismissRef.current?.(); log.debug("[keyboard] released", { reason, - environment, - metrics: formatMetrics(metrics), + snapshot: formatSnapshot(readKeyboardSnapshot()), }); }; - const removeKeyboardRecheckListeners = - addKeyboardRecheckListeners(scheduleRechecks); + const measure = (reason: string) => { + rafId = null; - scheduleRechecks(); + const target = targetRef?.current; + const activeNow = isKeyboardTargetActive(target, active); + const snapshot = readKeyboardSnapshot(); + if (snapshot.keyboardVisible) keyboardWasDetected = true; + + if (!activeNow) { + release("target-inactive"); + return; + } + + const focusedFor = focusedAt > 0 ? performance.now() - focusedAt : 0; + if ( + keyboardWasDetected && + !snapshot.keyboardVisible && + focusedFor >= KEYBOARD_RELEASE_GRACE_MS + ) { + release("viewport-restored"); + 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"); + }; + + const handleFocusOut = () => { + release("focusout"); + }; + + const handlePointerDown = (event: PointerEvent | TouchEvent) => { + const target = targetRef?.current; + if (!target || event.target === target) return; + if (event.target instanceof Node && target.contains(event.target)) return; + release("outside-interaction"); + }; + + if (!active) { + release("react-inactive"); + return; + } + + const visualViewport = window.visualViewport; + visualViewport?.addEventListener("resize", handleViewportSignal); + visualViewport?.addEventListener("scroll", handleViewportSignal); + window.addEventListener("resize", handleViewportSignal); + window.addEventListener("orientationchange", handleViewportSignal); + window.addEventListener("focusout", handleFocusOut); + window.addEventListener("blur", handleFocusOut); + document.addEventListener("visibilitychange", handleViewportSignal); + document.addEventListener("pointerdown", handlePointerDown, true); + document.addEventListener("touchstart", handlePointerDown, true); + + scheduleInitialMeasures(); + pollId = window.setInterval( + () => scheduleMeasure("poll"), + KEYBOARD_POLL_INTERVAL_MS, + ); return () => { if (rafId != null) window.cancelAnimationFrame(rafId); - timeoutIds.forEach((id) => window.clearTimeout(id)); - removeKeyboardRecheckListeners(); - resetKeyboardVars(); + clearTimers(); + visualViewport?.removeEventListener("resize", handleViewportSignal); + visualViewport?.removeEventListener("scroll", handleViewportSignal); + window.removeEventListener("resize", handleViewportSignal); + window.removeEventListener("orientationchange", handleViewportSignal); + window.removeEventListener("focusout", handleFocusOut); + window.removeEventListener("blur", handleFocusOut); + document.removeEventListener("visibilitychange", handleViewportSignal); + document.removeEventListener("pointerdown", handlePointerDown, true); + document.removeEventListener("touchstart", handlePointerDown, true); + resetInputLiftVar(); }; - }, [targetRef]); + }, [ + 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 }; diff --git a/src/hooks/use-keyboard-height/keyboard-event-listeners.ts b/src/hooks/use-keyboard-height/keyboard-event-listeners.ts deleted file mode 100644 index 363b6b38..00000000 --- a/src/hooks/use-keyboard-height/keyboard-event-listeners.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { RECHECK_EVENT } from "./constants"; -import { getVirtualKeyboard } from "./virtual-keyboard"; - -export function addKeyboardRecheckListeners(handler: (event?: Event) => void) { - const vv = window.visualViewport; - if (vv) { - vv.addEventListener("resize", handler); - vv.addEventListener("scroll", handler); - } - - const virtualKeyboard = getVirtualKeyboard(); - virtualKeyboard?.addEventListener("geometrychange", handler); - window.addEventListener("resize", handler); - window.addEventListener("orientationchange", handler); - window.addEventListener("focusin", handler); - window.addEventListener("focusout", handler); - window.addEventListener("blur", handler); - document.addEventListener("visibilitychange", handler); - window.addEventListener(RECHECK_EVENT, handler); - - return () => { - if (vv) { - vv.removeEventListener("resize", handler); - vv.removeEventListener("scroll", handler); - } - virtualKeyboard?.removeEventListener("geometrychange", handler); - window.removeEventListener("resize", handler); - window.removeEventListener("orientationchange", handler); - window.removeEventListener("focusin", handler); - window.removeEventListener("focusout", handler); - window.removeEventListener("blur", handler); - document.removeEventListener("visibilitychange", handler); - window.removeEventListener(RECHECK_EVENT, handler); - }; -} diff --git a/src/hooks/use-keyboard-height/release-events.ts b/src/hooks/use-keyboard-height/release-events.ts deleted file mode 100644 index 368f2520..00000000 --- a/src/hooks/use-keyboard-height/release-events.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { KEYBOARD_RELEASE_EVENT_TYPES } from "./constants"; - -export function isPossibleKeyboardCloseEvent( - event: Event, - hadInputLift: boolean, -): boolean { - if (!hadInputLift) { - return event.type === "resize" || event.type === "geometrychange"; - } - return KEYBOARD_RELEASE_EVENT_TYPES.has(event.type); -} diff --git a/src/hooks/use-keyboard-height/types.ts b/src/hooks/use-keyboard-height/types.ts index c93016f3..d25e36a5 100644 --- a/src/hooks/use-keyboard-height/types.ts +++ b/src/hooks/use-keyboard-height/types.ts @@ -6,15 +6,6 @@ export interface UseKeyboardHeightOptions { onKeyboardDismiss?: () => void; } -export interface ViewportMetrics { - hasVisualViewport: boolean; - hasVirtualKeyboard: boolean; - layoutHeight: number; - visibleHeight: number; - visibleBottom: number; - keyboardHeight: number; -} - export interface KeyboardAdaptEnvironment { platform: string; osName: string; @@ -31,6 +22,5 @@ export interface KeyboardAdaptEnvironment { isXiaomiFamilyDevice: boolean; isFacebookInAppBrowser: boolean; hasVisualViewport: boolean; - hasVirtualKeyboard: boolean; usesXiaomiFacebookFallback: boolean; } diff --git a/src/hooks/use-keyboard-height/viewport-metrics.ts b/src/hooks/use-keyboard-height/viewport-metrics.ts deleted file mode 100644 index a4bd7b84..00000000 --- a/src/hooks/use-keyboard-height/viewport-metrics.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { - FALLBACK_CLOSE_GRACE_MS, - FALLBACK_KEYBOARD_MAX, - FALLBACK_KEYBOARD_MIN, - FALLBACK_KEYBOARD_RATIO, - FOCUS_GAP_PX, -} from "./constants"; -import { shouldUseInAppKeyboardFallback } from "./environment"; -import type { ViewportMetrics } from "./types"; -import { getVirtualKeyboard } from "./virtual-keyboard"; - -export function readViewportMetrics(): ViewportMetrics { - const layoutHeight = window.innerHeight; - const vv = window.visualViewport; - const virtualKeyboard = getVirtualKeyboard(); - const virtualKeyboardRect = virtualKeyboard?.boundingRect ?? null; - const virtualKeyboardHeight = virtualKeyboardRect?.height ?? 0; - - if (!vv) { - const visibleBottom = - virtualKeyboardHeight > 0 - ? layoutHeight - virtualKeyboardHeight - : layoutHeight; - return { - hasVisualViewport: false, - hasVirtualKeyboard: virtualKeyboard != null, - layoutHeight, - visibleHeight: visibleBottom, - visibleBottom, - keyboardHeight: virtualKeyboardHeight, - }; - } - - const visualBottom = 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 { - hasVisualViewport: true, - hasVirtualKeyboard: virtualKeyboard != null, - layoutHeight, - visibleHeight, - visibleBottom, - keyboardHeight: Math.max( - 0, - layoutHeight - visibleBottom, - virtualKeyboardHeight, - ), - }; -} - -export 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); -} - -export 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, - ); -} - -export function shouldReleaseKeyboardState( - metrics: ViewportMetrics, - focusedAt: number, -): boolean { - if (focusedAt <= 0) return false; - if (performance.now() - focusedAt < FALLBACK_CLOSE_GRACE_MS) return false; - return !hasDetectedKeyboard(metrics); -} - -export function getFocusedDurationMs(focusedAt: number): number | null { - if (focusedAt <= 0) return null; - return Math.round(performance.now() - focusedAt); -} - -export function getReleaseSkipReason( - metrics: ViewportMetrics, - focusedAt: number, -): string { - if (focusedAt <= 0) return "not-focused-by-hook"; - if (performance.now() - focusedAt < FALLBACK_CLOSE_GRACE_MS) { - return "within-close-grace-window"; - } - if (hasDetectedKeyboard(metrics)) return "keyboard-still-detected"; - return "unknown"; -} - -export function didViewportMetricsChange( - previous: ViewportMetrics | null, - current: ViewportMetrics, -): boolean { - if (!previous) return true; - return ( - Math.round(previous.layoutHeight) !== Math.round(current.layoutHeight) || - Math.round(previous.visibleHeight) !== Math.round(current.visibleHeight) || - Math.round(previous.visibleBottom) !== Math.round(current.visibleBottom) || - Math.round(previous.keyboardHeight) !== Math.round(current.keyboardHeight) - ); -} - -export function hasDetectedKeyboard(metrics: ViewportMetrics): boolean { - return ( - metrics.keyboardHeight > 80 || - metrics.visibleHeight < metrics.layoutHeight * 0.82 - ); -} - -export function formatMetrics(metrics: ViewportMetrics) { - return { - hasVisualViewport: metrics.hasVisualViewport, - hasVirtualKeyboard: metrics.hasVirtualKeyboard, - layoutHeight: Math.round(metrics.layoutHeight), - visibleHeight: Math.round(metrics.visibleHeight), - visibleBottom: Math.round(metrics.visibleBottom), - keyboardHeight: Math.round(metrics.keyboardHeight), - }; -} - -export function getLiftSource(inputLift: number, fallbackLift: number): string { - if (inputLift <= 0) return "none"; - if (fallbackLift > 0 && inputLift === fallbackLift) { - return "xiaomi-facebook-fallback"; - } - if (fallbackLift > 0) return "overlap-and-fallback"; - return "target-overlap"; -} - -function clamp(value: number, min: number, max: number): number { - return Math.min(max, Math.max(min, value)); -} diff --git a/src/hooks/use-keyboard-height/virtual-keyboard.ts b/src/hooks/use-keyboard-height/virtual-keyboard.ts deleted file mode 100644 index 0137d1d1..00000000 --- a/src/hooks/use-keyboard-height/virtual-keyboard.ts +++ /dev/null @@ -1,11 +0,0 @@ -interface VirtualKeyboardLike extends EventTarget { - readonly boundingRect: DOMRectReadOnly; -} - -export function getVirtualKeyboard(): VirtualKeyboardLike | null { - if (typeof navigator === "undefined") return null; - const nav = navigator as Navigator & { - virtualKeyboard?: VirtualKeyboardLike; - }; - return nav.virtualKeyboard ?? null; -}