Files
cozsweet-frontend-nextjs/src/hooks/use-keyboard-height/index.ts
T

202 lines
6.0 KiB
TypeScript
Raw Blame History

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