diff --git a/src/hooks/use-keyboard-height.ts b/src/hooks/use-keyboard-height.ts deleted file mode 100644 index 795d8dfe..00000000 --- a/src/hooks/use-keyboard-height.ts +++ /dev/null @@ -1,481 +0,0 @@ -"use client"; -/** - * 软键盘避让 hook。 - * - * 目标环境:小米系设备上的 Facebook IAB 等软键盘覆盖页面但 viewport 信号不稳定的浏览器。 - * - * 策略: - * 1. 持续写入 --app-viewport-height,聊天页高度不再只依赖 100dvh。 - * 2. 优先使用 visualViewport 计算键盘高度。 - * 3. 输入框聚焦后测量目标元素是否越过可见底部,计算额外抬升。 - * 4. 小米系 Android + Facebook IAB 若 viewport 未可靠变化,启用保守 fallback 抬升; - * 键盘收起后若焦点仍停留在 textarea,也要主动释放 fallback。 - */ -import { type RefObject, useEffect, useRef } from "react"; - -import { BrowserDetector, Logger, PlatformDetector } from "@/utils"; - -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 FALLBACK_CLOSE_GRACE_MS = 900; -const RECHECK_DELAYS_MS = [0, 50, 120, 240, 420, 700]; -const KEYBOARD_RELEASE_EVENT_TYPES = new Set([ - "resize", - "scroll", - "geometrychange", - "focusout", - "blur", - "visibilitychange", -]); -const RECHECK_EVENT = "cozsweet:keyboard-recheck"; - -const log = new Logger("HooksUseKeyboardHeight"); - -export interface UseKeyboardHeightOptions { - targetRef?: RefObject; - active?: boolean; - onKeyboardDismiss?: () => void; -} - -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)) { - resetVars(); - log.debug("[keyboard] disabled", { - reason: "regular-browser", - environment, - }); - return; - } - - const apply = () => { - rafId = null; - - const metrics = readViewportMetrics(); - let activeNow = isKeyboardTargetActive(targetRef?.current, activeRef.current); - const shouldTryRelease = - activeNow && - lastMayBeKeyboardClose && - shouldReleaseKeyboardState(metrics, focusedAtRef.current); - - 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), - }); - } - - if (shouldTryRelease) { - 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; - 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", { - 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), - }); - } - timeoutIds.forEach((id) => window.clearTimeout(id)); - timeoutIds = RECHECK_DELAYS_MS.map((delay) => - window.setTimeout(schedule, delay), - ); - }; - - const releaseKeyboardState = ( - reason: string, - metrics: ViewportMetrics, - ): void => { - activeRef.current = false; - fallbackAllowedRef.current = false; - focusedAtRef.current = 0; - blurKeyboardTarget(targetRef?.current); - onKeyboardDismissRef.current?.(); - log.debug("[keyboard] released", { - reason, - environment, - metrics: formatMetrics(metrics), - }); - }; - - const vv = window.visualViewport; - if (vv) { - vv.addEventListener("resize", scheduleRechecks); - vv.addEventListener("scroll", scheduleRechecks); - } - const virtualKeyboard = getVirtualKeyboard(); - virtualKeyboard?.addEventListener("geometrychange", scheduleRechecks); - window.addEventListener("resize", scheduleRechecks); - window.addEventListener("orientationchange", scheduleRechecks); - window.addEventListener("focusin", scheduleRechecks); - window.addEventListener("focusout", scheduleRechecks); - window.addEventListener("blur", scheduleRechecks); - document.addEventListener("visibilitychange", scheduleRechecks); - window.addEventListener(RECHECK_EVENT, scheduleRechecks); - - scheduleRechecks(); - - return () => { - if (rafId != null) window.cancelAnimationFrame(rafId); - timeoutIds.forEach((id) => window.clearTimeout(id)); - if (vv) { - vv.removeEventListener("resize", scheduleRechecks); - vv.removeEventListener("scroll", scheduleRechecks); - } - virtualKeyboard?.removeEventListener("geometrychange", scheduleRechecks); - window.removeEventListener("resize", scheduleRechecks); - window.removeEventListener("orientationchange", scheduleRechecks); - window.removeEventListener("focusin", scheduleRechecks); - window.removeEventListener("focusout", scheduleRechecks); - window.removeEventListener("blur", scheduleRechecks); - document.removeEventListener("visibilitychange", scheduleRechecks); - window.removeEventListener(RECHECK_EVENT, scheduleRechecks); - resetVars(); - }; - }, [targetRef]); -} - -interface ViewportMetrics { - hasVisualViewport: boolean; - hasVirtualKeyboard: boolean; - layoutHeight: number; - visibleHeight: number; - visibleBottom: number; - keyboardHeight: number; -} - -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), - }; -} - -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 shouldReleaseKeyboardState( - metrics: ViewportMetrics, - focusedAt: number, -): boolean { - if (focusedAt <= 0) return false; - if (performance.now() - focusedAt < FALLBACK_CLOSE_GRACE_MS) return false; - return !hasDetectedKeyboard(metrics); -} - -function getFocusedDurationMs(focusedAt: number): number | null { - if (focusedAt <= 0) return null; - return Math.round(performance.now() - focusedAt); -} - -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"; -} - -function hasDetectedKeyboard(metrics: ViewportMetrics): boolean { - return ( - metrics.keyboardHeight > 80 || - metrics.visibleHeight < metrics.layoutHeight * 0.82 - ); -} - -function isPossibleKeyboardCloseEvent( - event: Event, - hadInputLift: boolean, -): boolean { - if (!hadInputLift) { - return event.type === "resize" || event.type === "geometrychange"; - } - return KEYBOARD_RELEASE_EVENT_TYPES.has(event.type); -} - -function isKeyboardTargetActive( - target: HTMLElement | null | undefined, - reactActive: boolean, -): boolean { - return reactActive || (target != null && document.activeElement === target); -} - -function blurKeyboardTarget(target: HTMLElement | null | undefined): void { - if (!target || document.activeElement !== target) return; - target.blur(); -} - -function shouldUseInAppKeyboardFallback(): boolean { - if (typeof navigator === "undefined") return false; - const ua = navigator.userAgent; - return getKeyboardAdaptEnvironment(ua).usesXiaomiFacebookFallback; -} - -function shouldEnableKeyboardAdaptation( - environment: ReturnType, -): boolean { - return environment.isInAppBrowser; -} - -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"); -} - -function getKeyboardAdaptEnvironment(ua: string | null = null) { - const platform = PlatformDetector.getPlatformInfo(ua); - const browser = BrowserDetector.getBrowserInfo(ua); - const isXiaomiFamilyDevice = PlatformDetector.isXiaomiFamilyDevice(ua); - const isFacebookInAppBrowser = BrowserDetector.isFacebookInAppBrowser(ua); - - return { - platform: platform.platform, - osName: platform.osName, - osVersion: platform.osVersion, - deviceVendor: platform.deviceVendor, - deviceModel: platform.deviceModel, - isAndroid: platform.isAndroid, - isIOS: platform.isIOS, - isMobile: platform.isMobile, - browserName: browser.name, - browserVersion: browser.version, - isInAppBrowser: browser.isInAppBrowser, - inAppBrowserName: browser.inAppBrowserName, - isXiaomiFamilyDevice, - isFacebookInAppBrowser, - hasVisualViewport: - typeof window !== "undefined" && window.visualViewport != null, - hasVirtualKeyboard: getVirtualKeyboard() != null, - usesXiaomiFacebookFallback: - platform.isAndroid && isXiaomiFamilyDevice && isFacebookInAppBrowser, - }; -} - -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), - }; -} - -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"; -} - -interface VirtualKeyboardLike extends EventTarget { - readonly boundingRect: DOMRectReadOnly; -} - -function getVirtualKeyboard(): VirtualKeyboardLike | null { - const nav = navigator as Navigator & { - virtualKeyboard?: VirtualKeyboardLike; - }; - return nav.virtualKeyboard ?? null; -} diff --git a/src/hooks/use-keyboard-height/constants.ts b/src/hooks/use-keyboard-height/constants.ts new file mode 100644 index 00000000..bb674bd0 --- /dev/null +++ b/src/hooks/use-keyboard-height/constants.ts @@ -0,0 +1,21 @@ +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 FALLBACK_WATCHDOG_INTERVAL_MS = 500; +export const FALLBACK_WATCHDOG_LOG_INTERVAL_MS = 1_500; +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"; diff --git a/src/hooks/use-keyboard-height/css-vars.ts b/src/hooks/use-keyboard-height/css-vars.ts new file mode 100644 index 00000000..a6a3d526 --- /dev/null +++ b/src/hooks/use-keyboard-height/css-vars.ts @@ -0,0 +1,19 @@ +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`); +} + +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"); + 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 new file mode 100644 index 00000000..c16277ae --- /dev/null +++ b/src/hooks/use-keyboard-height/environment.ts @@ -0,0 +1,46 @@ +import { BrowserDetector, PlatformDetector } from "@/utils"; + +import type { KeyboardAdaptEnvironment } from "./types"; +import { getVirtualKeyboard } from "./virtual-keyboard"; + +export function getKeyboardAdaptEnvironment( + ua: string | null = null, +): KeyboardAdaptEnvironment { + const platform = PlatformDetector.getPlatformInfo(ua); + const browser = BrowserDetector.getBrowserInfo(ua); + const isXiaomiFamilyDevice = PlatformDetector.isXiaomiFamilyDevice(ua); + const isFacebookInAppBrowser = BrowserDetector.isFacebookInAppBrowser(ua); + + return { + platform: platform.platform, + osName: platform.osName, + osVersion: platform.osVersion, + deviceVendor: platform.deviceVendor, + deviceModel: platform.deviceModel, + isAndroid: platform.isAndroid, + isIOS: platform.isIOS, + isMobile: platform.isMobile, + browserName: browser.name, + browserVersion: browser.version, + isInAppBrowser: browser.isInAppBrowser, + inAppBrowserName: browser.inAppBrowserName, + isXiaomiFamilyDevice, + isFacebookInAppBrowser, + hasVisualViewport: + typeof window !== "undefined" && window.visualViewport != null, + hasVirtualKeyboard: getVirtualKeyboard() != null, + usesXiaomiFacebookFallback: + platform.isAndroid && isXiaomiFamilyDevice && isFacebookInAppBrowser, + }; +} + +export function shouldEnableKeyboardAdaptation( + environment: KeyboardAdaptEnvironment, +): boolean { + return environment.isInAppBrowser; +} + +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 new file mode 100644 index 00000000..5715b1c7 --- /dev/null +++ b/src/hooks/use-keyboard-height/index.ts @@ -0,0 +1,270 @@ +"use client"; +/** + * 软键盘避让 hook。 + * + * 目标环境:小米系设备上的 Facebook IAB 等软键盘覆盖页面但 viewport 信号不稳定的浏览器。 + * + * 策略: + * 1. 持续写入 --app-viewport-height,聊天页高度不再只依赖 100dvh。 + * 2. 优先使用 visualViewport 计算键盘高度。 + * 3. 输入框聚焦后测量目标元素是否越过可见底部,计算额外抬升。 + * 4. 小米系 Android + Facebook IAB 若 viewport 未可靠变化,启用保守 fallback 抬升; + * 键盘收起后若焦点仍停留在 textarea,也要主动释放 fallback。 + */ +import { useEffect, useRef } from "react"; + +import { Logger } from "@/utils"; + +import { + RECHECK_DELAYS_MS, + RECHECK_EVENT, +} from "./constants"; +import { + resetKeyboardVars, + setInputLiftVar, + setKeyboardHeightVar, + setViewportHeightVar, +} from "./css-vars"; +import { + getKeyboardAdaptEnvironment, + shouldEnableKeyboardAdaptation, +} from "./environment"; +import { + blurKeyboardTarget, + isKeyboardTargetActive, +} from "./keyboard-target"; +import { addKeyboardRecheckListeners } from "./keyboard-event-listeners"; +import { createKeyboardWatchdog } from "./keyboard-watchdog"; +import { isPossibleKeyboardCloseEvent } from "./release-events"; +import type { UseKeyboardHeightOptions, ViewportMetrics } from "./types"; +import { + computeFallbackLift, + computeTargetOverlap, + formatMetrics, + getFocusedDurationMs, + getLiftSource, + getReleaseSkipReason, + readViewportMetrics, + shouldReleaseKeyboardState, +} from "./viewport-metrics"; + +const log = new Logger("HooksUseKeyboardHeight"); + +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(); + log.debug("[keyboard] disabled", { + reason: "regular-browser", + environment, + }); + return; + } + + const apply = () => { + rafId = null; + + const metrics = readViewportMetrics(); + let activeNow = isKeyboardTargetActive( + targetRef?.current, + activeRef.current, + ); + const shouldTryRelease = + activeNow && + lastMayBeKeyboardClose && + shouldReleaseKeyboardState(metrics, focusedAtRef.current); + + 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), + }); + } + watchdog.update("apply"); + }; + + 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), + }); + } + timeoutIds.forEach((id) => window.clearTimeout(id)); + timeoutIds = RECHECK_DELAYS_MS.map((delay) => + window.setTimeout(schedule, delay), + ); + }; + + const releaseKeyboardState = ( + reason: string, + metrics: ViewportMetrics, + ): void => { + activeRef.current = false; + fallbackAllowedRef.current = false; + focusedAtRef.current = 0; + blurKeyboardTarget(targetRef?.current); + onKeyboardDismissRef.current?.(); + log.debug("[keyboard] released", { + reason, + environment, + metrics: formatMetrics(metrics), + }); + watchdog.stop(reason); + }; + + const watchdog = createKeyboardWatchdog({ + environment, + log, + getTarget: () => targetRef?.current, + getReactActive: () => activeRef.current, + getFallbackAllowed: () => fallbackAllowedRef.current, + getFallbackWasApplied: () => fallbackWasApplied, + getLastInputLift: () => lastInputLift, + getFocusedAt: () => focusedAtRef.current, + onPossibleClose: () => { + lastEventType = "watchdog"; + lastMayBeKeyboardClose = true; + }, + schedule, + }); + const removeKeyboardRecheckListeners = + addKeyboardRecheckListeners(scheduleRechecks); + + scheduleRechecks(); + + return () => { + if (rafId != null) window.cancelAnimationFrame(rafId); + watchdog.cleanup(); + timeoutIds.forEach((id) => window.clearTimeout(id)); + removeKeyboardRecheckListeners(); + resetKeyboardVars(); + }; + }, [targetRef]); +} + +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 new file mode 100644 index 00000000..363b6b38 --- /dev/null +++ b/src/hooks/use-keyboard-height/keyboard-event-listeners.ts @@ -0,0 +1,35 @@ +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/keyboard-target.ts b/src/hooks/use-keyboard-height/keyboard-target.ts new file mode 100644 index 00000000..813368c0 --- /dev/null +++ b/src/hooks/use-keyboard-height/keyboard-target.ts @@ -0,0 +1,26 @@ +export function isKeyboardTargetActive( + target: HTMLElement | null | undefined, + reactActive: boolean, +): boolean { + return reactActive || (target != null && document.activeElement === target); +} + +export function blurKeyboardTarget( + target: HTMLElement | null | undefined, +): void { + if (!target || document.activeElement !== target) return; + target.blur(); +} + +export function getActiveElementTag(): string | null { + const activeElement = document.activeElement; + if (!activeElement) return null; + + const id = activeElement.id ? `#${activeElement.id}` : ""; + const className = + typeof activeElement.className === "string" && activeElement.className + ? `.${activeElement.className.split(/\s+/).filter(Boolean).join(".")}` + : ""; + + return `${activeElement.tagName.toLowerCase()}${id}${className}`; +} diff --git a/src/hooks/use-keyboard-height/keyboard-watchdog.ts b/src/hooks/use-keyboard-height/keyboard-watchdog.ts new file mode 100644 index 00000000..83d25139 --- /dev/null +++ b/src/hooks/use-keyboard-height/keyboard-watchdog.ts @@ -0,0 +1,176 @@ +import type { Logger } from "@/utils"; + +import { + FALLBACK_WATCHDOG_INTERVAL_MS, + FALLBACK_WATCHDOG_LOG_INTERVAL_MS, +} from "./constants"; +import type { KeyboardAdaptEnvironment, ViewportMetrics } from "./types"; +import { + didViewportMetricsChange, + formatMetrics, + getFocusedDurationMs, + hasDetectedKeyboard, + readViewportMetrics, +} from "./viewport-metrics"; +import { + getActiveElementTag, + isKeyboardTargetActive, +} from "./keyboard-target"; + +interface KeyboardWatchdogOptions { + environment: KeyboardAdaptEnvironment; + log: Logger; + getTarget: () => HTMLElement | null | undefined; + getReactActive: () => boolean; + getFallbackAllowed: () => boolean; + getFallbackWasApplied: () => boolean; + getLastInputLift: () => number; + getFocusedAt: () => number; + onPossibleClose: () => void; + schedule: () => void; +} + +export interface KeyboardWatchdog { + update: (reason: string) => void; + stop: (reason: string) => void; + cleanup: () => void; +} + +export function createKeyboardWatchdog({ + environment, + log, + getTarget, + getReactActive, + getFallbackAllowed, + getFallbackWasApplied, + getLastInputLift, + getFocusedAt, + onPossibleClose, + schedule, +}: KeyboardWatchdogOptions): KeyboardWatchdog { + let watchdogId: number | null = null; + let lastWatchdogLogAt = 0; + let lastWatchdogMetrics: ViewportMetrics | null = null; + let lastWatchdogKeyboardDetected = false; + + const shouldRun = (): boolean => { + if (!environment.usesXiaomiFacebookFallback) return false; + return isKeyboardTargetActive(getTarget(), getReactActive()); + }; + + const update = (reason: string): void => { + if (shouldRun()) { + start(reason); + return; + } + stop(reason); + }; + + const start = (reason: string): void => { + if (watchdogId != null) return; + const metrics = readViewportMetrics(); + watchdogId = window.setInterval( + run, + FALLBACK_WATCHDOG_INTERVAL_MS, + ); + lastWatchdogLogAt = performance.now(); + lastWatchdogMetrics = metrics; + lastWatchdogKeyboardDetected = hasDetectedKeyboard(metrics); + log.debug("[keyboard] watchdog started", { + reason, + environment, + active: getReactActive(), + fallbackAllowed: getFallbackAllowed(), + fallbackWasApplied: getFallbackWasApplied(), + lastInputLift: getLastInputLift(), + keyboardDetected: lastWatchdogKeyboardDetected, + metrics: formatMetrics(metrics), + }); + }; + + const stop = (reason: string): void => { + if (watchdogId == null) return; + window.clearInterval(watchdogId); + watchdogId = null; + lastWatchdogLogAt = 0; + lastWatchdogMetrics = null; + lastWatchdogKeyboardDetected = false; + log.debug("[keyboard] watchdog stopped", { + reason, + environment, + active: getReactActive(), + fallbackAllowed: getFallbackAllowed(), + fallbackWasApplied: getFallbackWasApplied(), + lastInputLift: getLastInputLift(), + }); + }; + + const cleanup = (): void => { + if (watchdogId == null) return; + window.clearInterval(watchdogId); + watchdogId = null; + }; + + const run = (): void => { + const metrics = readViewportMetrics(); + const activeNow = isKeyboardTargetActive(getTarget(), getReactActive()); + const keyboardDetected = hasDetectedKeyboard(metrics); + const metricsChanged = didViewportMetricsChange( + lastWatchdogMetrics, + metrics, + ); + const now = performance.now(); + const shouldLog = + metricsChanged || + now - lastWatchdogLogAt >= FALLBACK_WATCHDOG_LOG_INTERVAL_MS; + + if (!activeNow) { + log.debug("[keyboard] watchdog inactive target", { + environment, + reactActive: getReactActive(), + activeElement: getActiveElementTag(), + metrics: formatMetrics(metrics), + }); + stop("inactive-target"); + schedule(); + return; + } + + if (lastWatchdogKeyboardDetected && !keyboardDetected) { + onPossibleClose(); + log.debug("[keyboard] watchdog possible close detected", { + environment, + fallbackAllowed: getFallbackAllowed(), + fallbackWasApplied: getFallbackWasApplied(), + lastInputLift: getLastInputLift(), + previousMetrics: lastWatchdogMetrics + ? formatMetrics(lastWatchdogMetrics) + : null, + metrics: formatMetrics(metrics), + }); + schedule(); + } + + if (shouldLog) { + lastWatchdogLogAt = now; + log.debug("[keyboard] watchdog tick", { + environment, + active: activeNow, + reactActive: getReactActive(), + fallbackAllowed: getFallbackAllowed(), + fallbackWasApplied: getFallbackWasApplied(), + lastInputLift: getLastInputLift(), + keyboardDetected, + metricsChanged, + focusedForMs: getFocusedDurationMs(getFocusedAt()), + activeElement: getActiveElementTag(), + metrics: formatMetrics(metrics), + }); + } + + lastWatchdogMetrics = metrics; + lastWatchdogKeyboardDetected = keyboardDetected; + }; + + return { update, stop, cleanup }; +} diff --git a/src/hooks/use-keyboard-height/release-events.ts b/src/hooks/use-keyboard-height/release-events.ts new file mode 100644 index 00000000..368f2520 --- /dev/null +++ b/src/hooks/use-keyboard-height/release-events.ts @@ -0,0 +1,11 @@ +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 new file mode 100644 index 00000000..c93016f3 --- /dev/null +++ b/src/hooks/use-keyboard-height/types.ts @@ -0,0 +1,36 @@ +import type { RefObject } from "react"; + +export interface UseKeyboardHeightOptions { + targetRef?: RefObject; + active?: boolean; + onKeyboardDismiss?: () => void; +} + +export interface ViewportMetrics { + hasVisualViewport: boolean; + hasVirtualKeyboard: boolean; + layoutHeight: number; + visibleHeight: number; + visibleBottom: number; + keyboardHeight: number; +} + +export interface KeyboardAdaptEnvironment { + platform: string; + osName: string; + osVersion: string; + deviceVendor: string; + deviceModel: string; + isAndroid: boolean; + isIOS: boolean; + isMobile: boolean; + browserName: string; + browserVersion: string; + isInAppBrowser: boolean; + inAppBrowserName: string | null; + 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 new file mode 100644 index 00000000..a4bd7b84 --- /dev/null +++ b/src/hooks/use-keyboard-height/viewport-metrics.ts @@ -0,0 +1,150 @@ +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 new file mode 100644 index 00000000..0137d1d1 --- /dev/null +++ b/src/hooks/use-keyboard-height/virtual-keyboard.ts @@ -0,0 +1,11 @@ +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; +}