refactor(viewport): centralize browser metrics
This commit is contained in:
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { observeBrowserViewport } from "../viewport_observer";
|
||||
|
||||
class MockVisualViewport extends EventTarget {
|
||||
height = 760;
|
||||
offsetLeft = 0;
|
||||
offsetTop = 0;
|
||||
scale = 1;
|
||||
width = 390;
|
||||
}
|
||||
|
||||
class MockVirtualKeyboard extends EventTarget {
|
||||
overlaysContent = false;
|
||||
boundingRect = { height: 0 };
|
||||
}
|
||||
|
||||
describe("viewport observer", () => {
|
||||
let visualViewport: MockVisualViewport;
|
||||
let virtualKeyboard: MockVirtualKeyboard;
|
||||
|
||||
beforeEach(() => {
|
||||
visualViewport = new MockVisualViewport();
|
||||
virtualKeyboard = new MockVirtualKeyboard();
|
||||
Object.defineProperty(window, "visualViewport", {
|
||||
configurable: true,
|
||||
value: visualViewport,
|
||||
});
|
||||
Object.defineProperty(navigator, "virtualKeyboard", {
|
||||
configurable: true,
|
||||
value: virtualKeyboard,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("shares one browser listener set across multiple consumers", () => {
|
||||
const addWindowListener = vi.spyOn(window, "addEventListener");
|
||||
const removeWindowListener = vi.spyOn(window, "removeEventListener");
|
||||
const first = vi.fn();
|
||||
const second = vi.fn();
|
||||
|
||||
const stopFirst = observeBrowserViewport(first);
|
||||
const stopSecond = observeBrowserViewport(second);
|
||||
|
||||
expect(
|
||||
addWindowListener.mock.calls.filter(
|
||||
([event]) => String(event) === "resize",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(first).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ reason: "initial" }),
|
||||
);
|
||||
expect(second).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ reason: "initial" }),
|
||||
);
|
||||
|
||||
visualViewport.height = 460;
|
||||
visualViewport.dispatchEvent(new Event("resize"));
|
||||
expect(first).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
reason: "visualViewport.resize",
|
||||
metrics: expect.objectContaining({ visualHeight: 460 }),
|
||||
}),
|
||||
);
|
||||
expect(second).toHaveBeenCalledTimes(2);
|
||||
|
||||
stopFirst();
|
||||
visualViewport.dispatchEvent(new Event("scroll"));
|
||||
expect(first).toHaveBeenCalledTimes(2);
|
||||
expect(second).toHaveBeenCalledTimes(3);
|
||||
|
||||
stopSecond();
|
||||
expect(
|
||||
removeWindowListener.mock.calls.filter(
|
||||
([event]) => String(event) === "resize",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
export type ViewportObservationReason =
|
||||
| "initial"
|
||||
| "window.resize"
|
||||
| "window.scroll"
|
||||
| "orientationchange"
|
||||
| "focusin"
|
||||
| "focusout"
|
||||
| "visibilitychange"
|
||||
| "visualViewport.resize"
|
||||
| "visualViewport.scroll"
|
||||
| "virtualKeyboard.geometrychange";
|
||||
|
||||
export interface BrowserViewportMetrics {
|
||||
layoutHeight: number;
|
||||
layoutWidth: number;
|
||||
documentClientHeight: number;
|
||||
scrollY: number;
|
||||
visualViewportSupported: boolean;
|
||||
visualHeight: number;
|
||||
visualWidth: number;
|
||||
visualOffsetTop: number;
|
||||
visualOffsetLeft: number;
|
||||
visualScale: number;
|
||||
virtualKeyboardSupported: boolean;
|
||||
virtualKeyboardOverlaysContent: boolean | null;
|
||||
virtualKeyboardRect: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface ViewportObservation {
|
||||
reason: ViewportObservationReason;
|
||||
metrics: BrowserViewportMetrics;
|
||||
}
|
||||
|
||||
interface VirtualKeyboardLike extends EventTarget {
|
||||
readonly overlaysContent?: boolean;
|
||||
readonly boundingRect?: Pick<
|
||||
DOMRectReadOnly,
|
||||
"height" | "width" | "x" | "y"
|
||||
>;
|
||||
}
|
||||
|
||||
interface NavigatorWithVirtualKeyboard extends Navigator {
|
||||
readonly virtualKeyboard?: VirtualKeyboardLike;
|
||||
}
|
||||
|
||||
type ViewportObserver = (observation: ViewportObservation) => void;
|
||||
|
||||
const observers = new Set<ViewportObserver>();
|
||||
let removeBrowserListeners: (() => void) | null = null;
|
||||
|
||||
export function observeBrowserViewport(
|
||||
observer: ViewportObserver,
|
||||
options: { emitInitial?: boolean } = {},
|
||||
): () => void {
|
||||
if (typeof window === "undefined") return () => undefined;
|
||||
|
||||
observers.add(observer);
|
||||
ensureBrowserListeners();
|
||||
if (options.emitInitial !== false) {
|
||||
observer({
|
||||
reason: "initial",
|
||||
metrics: readBrowserViewportMetrics(),
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
observers.delete(observer);
|
||||
if (observers.size === 0) {
|
||||
removeBrowserListeners?.();
|
||||
removeBrowserListeners = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function readBrowserViewportMetrics(): BrowserViewportMetrics {
|
||||
const visualViewport = window.visualViewport;
|
||||
const virtualKeyboard = getVirtualKeyboard();
|
||||
const rect = virtualKeyboard?.boundingRect;
|
||||
|
||||
return {
|
||||
layoutHeight: window.innerHeight,
|
||||
layoutWidth: window.innerWidth,
|
||||
documentClientHeight: document.documentElement.clientHeight,
|
||||
scrollY: window.scrollY,
|
||||
visualViewportSupported: visualViewport != null,
|
||||
visualHeight: visualViewport?.height ?? window.innerHeight,
|
||||
visualWidth: visualViewport?.width ?? window.innerWidth,
|
||||
visualOffsetTop: visualViewport?.offsetTop ?? 0,
|
||||
visualOffsetLeft: visualViewport?.offsetLeft ?? 0,
|
||||
visualScale: visualViewport?.scale ?? 1,
|
||||
virtualKeyboardSupported: virtualKeyboard != null,
|
||||
virtualKeyboardOverlaysContent:
|
||||
virtualKeyboard?.overlaysContent ?? null,
|
||||
virtualKeyboardRect: rect
|
||||
? {
|
||||
x: rect.x ?? 0,
|
||||
y: rect.y ?? 0,
|
||||
width: rect.width ?? 0,
|
||||
height: rect.height ?? 0,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function isViewportGeometryObservation(
|
||||
reason: ViewportObservationReason,
|
||||
): boolean {
|
||||
return (
|
||||
reason === "initial" ||
|
||||
reason === "window.resize" ||
|
||||
reason === "orientationchange" ||
|
||||
reason === "visualViewport.resize" ||
|
||||
reason === "visualViewport.scroll" ||
|
||||
reason === "virtualKeyboard.geometrychange"
|
||||
);
|
||||
}
|
||||
|
||||
function ensureBrowserListeners(): void {
|
||||
if (removeBrowserListeners) return;
|
||||
|
||||
const removers: Array<() => void> = [];
|
||||
const listen = (
|
||||
target: EventTarget | null | undefined,
|
||||
eventName: string,
|
||||
reason: ViewportObservationReason,
|
||||
) => {
|
||||
if (!target) return;
|
||||
const handler = () => notifyObservers(reason);
|
||||
target.addEventListener(eventName, handler);
|
||||
removers.push(() => target.removeEventListener(eventName, handler));
|
||||
};
|
||||
|
||||
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(
|
||||
getVirtualKeyboard(),
|
||||
"geometrychange",
|
||||
"virtualKeyboard.geometrychange",
|
||||
);
|
||||
|
||||
removeBrowserListeners = () => {
|
||||
removers.forEach((remove) => remove());
|
||||
};
|
||||
}
|
||||
|
||||
function notifyObservers(reason: ViewportObservationReason): void {
|
||||
const observation = {
|
||||
reason,
|
||||
metrics: readBrowserViewportMetrics(),
|
||||
};
|
||||
observers.forEach((observer) => observer(observation));
|
||||
}
|
||||
|
||||
function getVirtualKeyboard(): VirtualKeyboardLike | null {
|
||||
return (
|
||||
(navigator as NavigatorWithVirtualKeyboard).virtualKeyboard ?? null
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, type ReactNode } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ViewportCssVarsProvider } from "../viewport-css-vars-provider";
|
||||
|
||||
vi.mock("@/utils/browser-detect", () => ({
|
||||
BrowserDetector: {
|
||||
isFacebookInAppBrowser: vi.fn(() => true),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/platform-detect", () => ({
|
||||
PlatformDetector: {
|
||||
isIOS: vi.fn(() => true),
|
||||
},
|
||||
}));
|
||||
|
||||
class MockVisualViewport extends EventTarget {
|
||||
height = 760;
|
||||
offsetLeft = 0;
|
||||
offsetTop = 0;
|
||||
scale = 1;
|
||||
width = 390;
|
||||
}
|
||||
|
||||
function Harness({ children }: { children: ReactNode }) {
|
||||
return <ViewportCssVarsProvider>{children}</ViewportCssVarsProvider>;
|
||||
}
|
||||
|
||||
describe("ViewportCssVarsProvider", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let visualViewport: MockVisualViewport;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
||||
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
visualViewport = new MockVisualViewport();
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
configurable: true,
|
||||
value: 800,
|
||||
});
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
configurable: true,
|
||||
value: 390,
|
||||
});
|
||||
Object.defineProperty(window, "visualViewport", {
|
||||
configurable: true,
|
||||
value: visualViewport,
|
||||
});
|
||||
vi.stubGlobal(
|
||||
"requestAnimationFrame",
|
||||
vi.fn((callback: FrameRequestCallback) =>
|
||||
window.setTimeout(() => callback(0), 0),
|
||||
),
|
||||
);
|
||||
vi.stubGlobal(
|
||||
"cancelAnimationFrame",
|
||||
vi.fn((id: number) => window.clearTimeout(id)),
|
||||
);
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
document.documentElement.removeAttribute("style");
|
||||
delete document.documentElement.dataset.viewportHeight;
|
||||
delete document.documentElement.dataset.viewportWidth;
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("keeps stable viewport variables while the iOS Facebook keyboard is open", () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<Harness>
|
||||
<input aria-label="message" />
|
||||
</Harness>,
|
||||
);
|
||||
});
|
||||
act(() => {
|
||||
vi.runOnlyPendingTimers();
|
||||
});
|
||||
expect(getRootVar("--app-viewport-height")).toBe("800px");
|
||||
expect(getRootVar("--app-visible-height")).toBe("760px");
|
||||
|
||||
const input = container.querySelector("input");
|
||||
if (!input) throw new Error("input was not rendered");
|
||||
input.focus();
|
||||
visualViewport.height = 460;
|
||||
act(() => {
|
||||
visualViewport.dispatchEvent(new Event("resize"));
|
||||
vi.runOnlyPendingTimers();
|
||||
});
|
||||
|
||||
expect(getRootVar("--app-viewport-height")).toBe("800px");
|
||||
expect(getRootVar("--app-visible-height")).toBe("760px");
|
||||
});
|
||||
});
|
||||
|
||||
function getRootVar(name: string): string {
|
||||
return document.documentElement.style.getPropertyValue(name);
|
||||
}
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
|
||||
import {
|
||||
isViewportGeometryObservation,
|
||||
observeBrowserViewport,
|
||||
type BrowserViewportMetrics,
|
||||
} from "@/lib/browser/viewport_observer";
|
||||
import { BrowserDetector } from "@/utils/browser-detect";
|
||||
import { Logger } from "@/utils/logger";
|
||||
import { PlatformDetector } from "@/utils/platform-detect";
|
||||
@@ -35,22 +40,31 @@ export function ViewportCssVarsProvider({
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
let frameId: number | null = null;
|
||||
let stableLayoutHeight = window.innerHeight;
|
||||
let stableVisibleHeight = window.visualViewport?.height ?? window.innerHeight;
|
||||
let stableVisibleOffsetTop = window.visualViewport?.offsetTop ?? 0;
|
||||
let pendingMetrics: BrowserViewportMetrics | null = null;
|
||||
let stableLayoutHeight = 0;
|
||||
let stableVisibleHeight = 0;
|
||||
let stableVisibleOffsetTop = 0;
|
||||
let hasStableMetrics = false;
|
||||
let iosFacebookKeyboardSuppressed = false;
|
||||
const isIOSFacebookInAppBrowser =
|
||||
PlatformDetector.isIOS() && BrowserDetector.isFacebookInAppBrowser();
|
||||
|
||||
const applyVars = () => {
|
||||
frameId = null;
|
||||
const metrics = pendingMetrics;
|
||||
if (!metrics) return;
|
||||
|
||||
const root = document.documentElement;
|
||||
const viewport = window.visualViewport;
|
||||
const layoutHeight = window.innerHeight;
|
||||
const layoutWidth = window.innerWidth;
|
||||
const visibleHeight = viewport?.height ?? layoutHeight;
|
||||
const visibleWidth = viewport?.width ?? layoutWidth;
|
||||
const visibleOffsetTop = viewport?.offsetTop ?? 0;
|
||||
const layoutHeight = metrics.layoutHeight;
|
||||
const visibleHeight = metrics.visualHeight;
|
||||
const visibleWidth = metrics.visualWidth;
|
||||
const visibleOffsetTop = metrics.visualOffsetTop;
|
||||
if (!hasStableMetrics) {
|
||||
stableLayoutHeight = layoutHeight;
|
||||
stableVisibleHeight = visibleHeight;
|
||||
stableVisibleOffsetTop = visibleOffsetTop;
|
||||
hasStableMetrics = true;
|
||||
}
|
||||
const shouldSuppressIOSFacebookKeyboardViewport =
|
||||
isIOSFacebookInAppBrowser &&
|
||||
isEditableElementFocused() &&
|
||||
@@ -105,24 +119,20 @@ export function ViewportCssVarsProvider({
|
||||
root.dataset.viewportHeight = String(Math.round(nextVisibleHeight));
|
||||
};
|
||||
|
||||
const scheduleApply = () => {
|
||||
const scheduleApply = (metrics: BrowserViewportMetrics) => {
|
||||
pendingMetrics = metrics;
|
||||
if (frameId != null) return;
|
||||
frameId = window.requestAnimationFrame(applyVars);
|
||||
};
|
||||
|
||||
applyVars();
|
||||
|
||||
window.addEventListener("resize", scheduleApply);
|
||||
window.addEventListener("orientationchange", scheduleApply);
|
||||
window.visualViewport?.addEventListener("resize", scheduleApply);
|
||||
window.visualViewport?.addEventListener("scroll", scheduleApply);
|
||||
const stopObserving = observeBrowserViewport((observation) => {
|
||||
if (!isViewportGeometryObservation(observation.reason)) return;
|
||||
scheduleApply(observation.metrics);
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (frameId != null) window.cancelAnimationFrame(frameId);
|
||||
window.removeEventListener("resize", scheduleApply);
|
||||
window.removeEventListener("orientationchange", scheduleApply);
|
||||
window.visualViewport?.removeEventListener("resize", scheduleApply);
|
||||
window.visualViewport?.removeEventListener("scroll", scheduleApply);
|
||||
stopObserving();
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user