refactor(viewport): centralize browser metrics

This commit is contained in:
2026-07-16 19:43:03 +08:00
parent 990add97f8
commit b5990bca8b
7 changed files with 508 additions and 162 deletions
@@ -2,6 +2,13 @@
import { useEffect, useRef, type RefObject } from "react";
import {
isViewportGeometryObservation,
observeBrowserViewport,
readBrowserViewportMetrics,
type BrowserViewportMetrics,
type ViewportObservationReason,
} from "@/lib/browser/viewport_observer";
import { BrowserDetector } from "@/utils/browser-detect";
import { Logger } from "@/utils/logger";
import { PlatformDetector } from "@/utils/platform-detect";
@@ -19,14 +26,6 @@ const CHAT_KEYBOARD_FALLBACK_DELAY_MS = 150;
const log = new Logger("UseChatKeyboardAvoidance");
interface VirtualKeyboardLike extends EventTarget {
readonly boundingRect?: Pick<DOMRectReadOnly, "height">;
}
interface NavigatorWithVirtualKeyboard extends Navigator {
readonly virtualKeyboard?: VirtualKeyboardLike;
}
export interface UseChatKeyboardAvoidanceOptions {
active: boolean;
containerRef: RefObject<HTMLElement | null>;
@@ -48,24 +47,15 @@ export function useChatKeyboardAvoidance({
return;
}
const updateBaseline = () => {
if (activeRef.current) return;
baselineRef.current = readViewportBaseline();
};
const visualViewport = window.visualViewport;
updateBaseline();
window.addEventListener("resize", updateBaseline);
window.addEventListener("orientationchange", updateBaseline);
visualViewport?.addEventListener("resize", updateBaseline);
visualViewport?.addEventListener("scroll", updateBaseline);
return () => {
window.removeEventListener("resize", updateBaseline);
window.removeEventListener("orientationchange", updateBaseline);
visualViewport?.removeEventListener("resize", updateBaseline);
visualViewport?.removeEventListener("scroll", updateBaseline);
};
return observeBrowserViewport((observation) => {
if (
activeRef.current ||
!isViewportGeometryObservation(observation.reason)
) {
return;
}
baselineRef.current = toKeyboardBaseline(observation.metrics);
});
}, []);
useEffect(() => {
@@ -75,18 +65,23 @@ export function useChatKeyboardAvoidance({
const enabled = isKeyboardAvoidanceEnabled();
if (!active || !enabled) {
clearBottomInset(container);
if (!active && enabled) baselineRef.current = readViewportBaseline();
if (!active && enabled) {
baselineRef.current = toKeyboardBaseline(
readBrowserViewportMetrics(),
);
}
return;
}
const baseline = baselineRef.current ?? readViewportBaseline();
const visualViewport = window.visualViewport;
const virtualKeyboard = getVirtualKeyboard();
const baseline =
baselineRef.current ??
toKeyboardBaseline(readBrowserViewportMetrics());
let frameId: number | null = null;
let fallbackTimerId: number | null = null;
let lastInset = -1;
let pendingCanDismiss = false;
let pendingReason = "initial";
let pendingMetrics = readBrowserViewportMetrics();
let keyboardObserved = false;
let fallbackApplied = false;
let sessionDismissed = false;
@@ -115,12 +110,16 @@ export function useChatKeyboardAvoidance({
fallbackTimerId = null;
};
const measure = (reason: string, canDismiss: boolean) => {
const measure = (
reason: string,
canDismiss: boolean,
metrics: BrowserViewportMetrics,
) => {
if (sessionDismissed) return;
const resolution = resolveChatKeyboardInset(
baseline,
readViewportSnapshot(),
toKeyboardSnapshot(metrics),
);
if (resolution.keyboardVisible) {
@@ -138,9 +137,14 @@ export function useChatKeyboardAvoidance({
}
};
const scheduleMeasure = (reason: string, canDismiss: boolean) => {
const scheduleMeasure = (
reason: string,
canDismiss: boolean,
metrics: BrowserViewportMetrics,
) => {
pendingReason = reason;
pendingCanDismiss ||= canDismiss;
pendingMetrics = metrics;
if (frameId != null) return;
frameId = window.requestAnimationFrame(() => {
@@ -148,46 +152,43 @@ export function useChatKeyboardAvoidance({
const nextReason = pendingReason;
const nextCanDismiss = pendingCanDismiss;
pendingCanDismiss = false;
measure(nextReason, nextCanDismiss);
measure(nextReason, nextCanDismiss, pendingMetrics);
});
};
const handleVisualResize = () => scheduleMeasure("visual-resize", true);
const handleVisualScroll = () => scheduleMeasure("visual-scroll", false);
const handleWindowResize = () => scheduleMeasure("window-resize", true);
const handleVirtualKeyboardChange = () =>
scheduleMeasure("virtual-keyboard", true);
const resetSession = (reason: string) => {
sessionDismissed = true;
clearFallbackTimer();
applyBottomInset(0, "reset");
log.debug("[chat-keyboard] reset", { reason });
};
const handleOrientationChange = () => resetSession("orientationchange");
const handleVisibilityChange = () => {
if (document.visibilityState === "hidden") {
resetSession("visibilitychange");
const stopObserving = observeBrowserViewport((observation) => {
if (observation.reason === "orientationchange") {
resetSession(observation.reason);
return;
}
};
if (
observation.reason === "visibilitychange" &&
document.visibilityState === "hidden"
) {
resetSession(observation.reason);
return;
}
if (!isViewportGeometryObservation(observation.reason)) return;
visualViewport?.addEventListener("resize", handleVisualResize);
visualViewport?.addEventListener("scroll", handleVisualScroll);
virtualKeyboard?.addEventListener(
"geometrychange",
handleVirtualKeyboardChange,
);
window.addEventListener("resize", handleWindowResize);
window.addEventListener("orientationchange", handleOrientationChange);
document.addEventListener("visibilitychange", handleVisibilityChange);
scheduleMeasure("focus", false);
scheduleMeasure(
observation.reason === "initial" ? "focus" : observation.reason,
canDismissKeyboardSession(observation.reason),
observation.metrics,
);
});
fallbackTimerId = window.setTimeout(() => {
fallbackTimerId = null;
if (!activeRef.current || sessionDismissed) return;
const resolution = resolveChatKeyboardInset(
baseline,
readViewportSnapshot(),
toKeyboardSnapshot(readBrowserViewportMetrics()),
);
if (resolution.keyboardVisible) {
keyboardObserved = true;
@@ -204,41 +205,40 @@ export function useChatKeyboardAvoidance({
return () => {
if (frameId != null) window.cancelAnimationFrame(frameId);
clearFallbackTimer();
visualViewport?.removeEventListener("resize", handleVisualResize);
visualViewport?.removeEventListener("scroll", handleVisualScroll);
virtualKeyboard?.removeEventListener(
"geometrychange",
handleVirtualKeyboardChange,
);
window.removeEventListener("resize", handleWindowResize);
window.removeEventListener("orientationchange", handleOrientationChange);
document.removeEventListener("visibilitychange", handleVisibilityChange);
stopObserving();
clearBottomInset(container);
};
}, [active, containerRef]);
}
function readViewportBaseline(): ChatKeyboardViewportBaseline {
function toKeyboardBaseline(
metrics: BrowserViewportMetrics,
): ChatKeyboardViewportBaseline {
return {
layoutHeight: window.innerHeight,
layoutWidth: window.innerWidth,
visualHeight: window.visualViewport?.height ?? window.innerHeight,
layoutHeight: metrics.layoutHeight,
layoutWidth: metrics.layoutWidth,
visualHeight: metrics.visualHeight,
};
}
function readViewportSnapshot(): ChatKeyboardViewportSnapshot {
const visualViewport = window.visualViewport;
function toKeyboardSnapshot(
metrics: BrowserViewportMetrics,
): ChatKeyboardViewportSnapshot {
return {
layoutHeight: window.innerHeight,
visualHeight: visualViewport?.height ?? window.innerHeight,
visualScale: visualViewport?.scale ?? 1,
virtualKeyboardHeight: getVirtualKeyboard()?.boundingRect?.height ?? 0,
layoutHeight: metrics.layoutHeight,
visualHeight: metrics.visualHeight,
visualScale: metrics.visualScale,
virtualKeyboardHeight: metrics.virtualKeyboardRect?.height ?? 0,
};
}
function getVirtualKeyboard(): VirtualKeyboardLike | null {
function canDismissKeyboardSession(
reason: ViewportObservationReason,
): boolean {
return (
(navigator as NavigatorWithVirtualKeyboard).virtualKeyboard ?? null
reason === "window.resize" ||
reason === "visualViewport.resize" ||
reason === "virtualKeyboard.geometrychange"
);
}
@@ -2,23 +2,15 @@
import { useEffect, type RefObject } from "react";
import {
observeBrowserViewport,
readBrowserViewportMetrics,
} from "@/lib/browser/viewport_observer";
import { BrowserDetector } from "@/utils/browser-detect";
import { PlatformDetector } from "@/utils/platform-detect";
const CHAT_KEYBOARD_DEBUG_PARAM = "keyboard_debug";
interface VirtualKeyboardLike extends EventTarget {
readonly overlaysContent?: boolean;
readonly boundingRect?: Pick<
DOMRectReadOnly,
"height" | "width" | "x" | "y"
>;
}
interface NavigatorWithVirtualKeyboard extends Navigator {
readonly virtualKeyboard?: VirtualKeyboardLike;
}
export interface ChatKeyboardDebugSnapshot {
event: string;
time: number;
@@ -102,17 +94,6 @@ export function installChatKeyboardDiagnostics(
return snapshot;
};
const listen = (
target: EventTarget | null | undefined,
eventName: string,
label = eventName,
) => {
if (!target) return;
const handler = () => takeSnapshot(label);
target.addEventListener(eventName, handler);
removers.push(() => target.removeEventListener(eventName, handler));
};
const cleanup = () => {
if (disposed) return;
disposed = true;
@@ -126,17 +107,12 @@ export function installChatKeyboardDiagnostics(
window.console.info("[keyboard-debug] removed");
};
const manualSnapshot = () => takeSnapshot("manual");
const virtualKeyboard = getVirtualKeyboard();
listen(window, "resize", "window.resize");
listen(window, "scroll", "window.scroll");
listen(window, "orientationchange", "orientationchange");
listen(document, "focusin", "focusin");
listen(document, "focusout", "focusout");
listen(document, "visibilitychange", "visibilitychange");
listen(window.visualViewport, "resize", "visualViewport.resize");
listen(window.visualViewport, "scroll", "visualViewport.scroll");
listen(virtualKeyboard, "geometrychange", "virtualKeyboard.geometrychange");
removers.push(
observeBrowserViewport(
(observation) => takeSnapshot(observation.reason),
{ emitInitial: false },
),
);
window.__keyboardDebugSnapshot = manualSnapshot;
window.__keyboardDebugCleanup = cleanup;
@@ -149,8 +125,7 @@ function createChatKeyboardDebugSnapshot(
event: string,
containerRef: RefObject<HTMLElement | null>,
): ChatKeyboardDebugSnapshot {
const visualViewport = window.visualViewport;
const virtualKeyboardRect = getVirtualKeyboard()?.boundingRect ?? null;
const metrics = readBrowserViewportMetrics();
const activeElement = document.activeElement;
const activeRect =
activeElement instanceof HTMLElement
@@ -166,26 +141,29 @@ function createChatKeyboardDebugSnapshot(
return {
event,
time: Math.round(performance.now()),
windowInnerHeight: window.innerHeight,
documentClientHeight: document.documentElement.clientHeight,
scrollY: window.scrollY,
visualViewportSupported: visualViewport != null,
visualHeight: visualViewport?.height ?? null,
visualWidth: visualViewport?.width ?? null,
visualOffsetTop: visualViewport?.offsetTop ?? null,
visualOffsetLeft: visualViewport?.offsetLeft ?? null,
visualScale: visualViewport?.scale ?? null,
virtualKeyboardSupported: getVirtualKeyboard() != null,
virtualKeyboardOverlaysContent:
getVirtualKeyboard()?.overlaysContent ?? null,
virtualKeyboardRect: virtualKeyboardRect
? {
x: virtualKeyboardRect.x,
y: virtualKeyboardRect.y,
width: virtualKeyboardRect.width,
height: virtualKeyboardRect.height,
}
windowInnerHeight: metrics.layoutHeight,
documentClientHeight: metrics.documentClientHeight,
scrollY: metrics.scrollY,
visualViewportSupported: metrics.visualViewportSupported,
visualHeight: metrics.visualViewportSupported
? metrics.visualHeight
: null,
visualWidth: metrics.visualViewportSupported
? metrics.visualWidth
: null,
visualOffsetTop: metrics.visualViewportSupported
? metrics.visualOffsetTop
: null,
visualOffsetLeft: metrics.visualViewportSupported
? metrics.visualOffsetLeft
: null,
visualScale: metrics.visualViewportSupported
? metrics.visualScale
: null,
virtualKeyboardSupported: metrics.virtualKeyboardSupported,
virtualKeyboardOverlaysContent:
metrics.virtualKeyboardOverlaysContent,
virtualKeyboardRect: metrics.virtualKeyboardRect,
activeElement: activeElement?.tagName ?? null,
activeBottom: activeRect?.bottom ?? null,
activeFocused: activeElement !== document.body,
@@ -217,9 +195,3 @@ function createChatKeyboardDebugSnapshot(
},
};
}
function getVirtualKeyboard(): VirtualKeyboardLike | null {
return (
(navigator as NavigatorWithVirtualKeyboard).virtualKeyboard ?? null
);
}