252 lines
7.5 KiB
TypeScript
252 lines
7.5 KiB
TypeScript
"use client";
|
|
/**
|
|
* 软键盘避让 hook。
|
|
*
|
|
* 目标环境:小米系 Android 设备上的 Facebook IAB。
|
|
*
|
|
* 策略:
|
|
* 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 { 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: "not-xiaomi-facebook-iab",
|
|
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),
|
|
});
|
|
}
|
|
};
|
|
|
|
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 removeKeyboardRecheckListeners =
|
|
addKeyboardRecheckListeners(scheduleRechecks);
|
|
|
|
scheduleRechecks();
|
|
|
|
return () => {
|
|
if (rafId != null) window.cancelAnimationFrame(rafId);
|
|
timeoutIds.forEach((id) => window.clearTimeout(id));
|
|
removeKeyboardRecheckListeners();
|
|
resetKeyboardVars();
|
|
};
|
|
}, [targetRef]);
|
|
}
|
|
|
|
export type { UseKeyboardHeightOptions };
|