refactor(hooks): split keyboard height hook modules
This commit is contained in:
@@ -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";
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user