refactor(hooks): split keyboard height hook modules

This commit is contained in:
2026-06-26 15:50:45 +08:00
parent 30796ca4e7
commit 18bd9eaa47
12 changed files with 801 additions and 481 deletions
-481
View File
@@ -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<HTMLElement | null>;
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<typeof getKeyboardAdaptEnvironment>,
): 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;
}
@@ -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";
+19
View File
@@ -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");
}
@@ -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;
}
+270
View File
@@ -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 };
@@ -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);
};
}
@@ -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}`;
}
@@ -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 };
}
@@ -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);
}
+36
View File
@@ -0,0 +1,36 @@
import type { RefObject } from "react";
export interface UseKeyboardHeightOptions {
targetRef?: RefObject<HTMLElement | null>;
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;
}
@@ -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));
}
@@ -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;
}