refactor(viewport): centralize browser metrics
This commit is contained in:
@@ -30,10 +30,10 @@ Compressed sizes below are Turbopack module estimates. `Eager` is the route's sy
|
|||||||
|
|
||||||
| Route | Direct-import baseline | Current eager baseline | Change | Current async baseline |
|
| Route | Direct-import baseline | Current eager baseline | Change | Current async baseline |
|
||||||
| --- | ---: | ---: | ---: | ---: |
|
| --- | ---: | ---: | ---: | ---: |
|
||||||
| `/splash` | 646.2 KiB | 599.9 KiB | -46.3 KiB | 49.2 KiB |
|
| `/splash` | 646.2 KiB | 600.6 KiB | -45.6 KiB | 49.2 KiB |
|
||||||
| `/chat` | 669.8 KiB | 640.7 KiB | -29.1 KiB | 49.6 KiB |
|
| `/chat` | 669.8 KiB | 641.1 KiB | -28.7 KiB | 49.6 KiB |
|
||||||
| `/subscription` | 658.4 KiB | 617.3 KiB | -41.1 KiB | 20.6 KiB |
|
| `/subscription` | 658.4 KiB | 618.0 KiB | -40.4 KiB | 20.6 KiB |
|
||||||
| `/tip` | 651.6 KiB | 611.2 KiB | -40.4 KiB | 20.6 KiB |
|
| `/tip` | 651.6 KiB | 611.8 KiB | -39.8 KiB | 20.6 KiB |
|
||||||
|
|
||||||
The earlier direct-import pass replaced root `@/utils` and `@/data/storage`
|
The earlier direct-import pass replaced root `@/utils` and `@/data/storage`
|
||||||
barrel imports with symbol-level module paths. The current Client-boundary pass
|
barrel imports with symbol-level module paths. The current Client-boundary pass
|
||||||
|
|||||||
@@ -2,6 +2,13 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, type RefObject } from "react";
|
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 { BrowserDetector } from "@/utils/browser-detect";
|
||||||
import { Logger } from "@/utils/logger";
|
import { Logger } from "@/utils/logger";
|
||||||
import { PlatformDetector } from "@/utils/platform-detect";
|
import { PlatformDetector } from "@/utils/platform-detect";
|
||||||
@@ -19,14 +26,6 @@ const CHAT_KEYBOARD_FALLBACK_DELAY_MS = 150;
|
|||||||
|
|
||||||
const log = new Logger("UseChatKeyboardAvoidance");
|
const log = new Logger("UseChatKeyboardAvoidance");
|
||||||
|
|
||||||
interface VirtualKeyboardLike extends EventTarget {
|
|
||||||
readonly boundingRect?: Pick<DOMRectReadOnly, "height">;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NavigatorWithVirtualKeyboard extends Navigator {
|
|
||||||
readonly virtualKeyboard?: VirtualKeyboardLike;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseChatKeyboardAvoidanceOptions {
|
export interface UseChatKeyboardAvoidanceOptions {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
containerRef: RefObject<HTMLElement | null>;
|
containerRef: RefObject<HTMLElement | null>;
|
||||||
@@ -48,24 +47,15 @@ export function useChatKeyboardAvoidance({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateBaseline = () => {
|
return observeBrowserViewport((observation) => {
|
||||||
if (activeRef.current) return;
|
if (
|
||||||
baselineRef.current = readViewportBaseline();
|
activeRef.current ||
|
||||||
};
|
!isViewportGeometryObservation(observation.reason)
|
||||||
const visualViewport = window.visualViewport;
|
) {
|
||||||
|
return;
|
||||||
updateBaseline();
|
}
|
||||||
window.addEventListener("resize", updateBaseline);
|
baselineRef.current = toKeyboardBaseline(observation.metrics);
|
||||||
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);
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -75,18 +65,23 @@ export function useChatKeyboardAvoidance({
|
|||||||
const enabled = isKeyboardAvoidanceEnabled();
|
const enabled = isKeyboardAvoidanceEnabled();
|
||||||
if (!active || !enabled) {
|
if (!active || !enabled) {
|
||||||
clearBottomInset(container);
|
clearBottomInset(container);
|
||||||
if (!active && enabled) baselineRef.current = readViewportBaseline();
|
if (!active && enabled) {
|
||||||
|
baselineRef.current = toKeyboardBaseline(
|
||||||
|
readBrowserViewportMetrics(),
|
||||||
|
);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseline = baselineRef.current ?? readViewportBaseline();
|
const baseline =
|
||||||
const visualViewport = window.visualViewport;
|
baselineRef.current ??
|
||||||
const virtualKeyboard = getVirtualKeyboard();
|
toKeyboardBaseline(readBrowserViewportMetrics());
|
||||||
let frameId: number | null = null;
|
let frameId: number | null = null;
|
||||||
let fallbackTimerId: number | null = null;
|
let fallbackTimerId: number | null = null;
|
||||||
let lastInset = -1;
|
let lastInset = -1;
|
||||||
let pendingCanDismiss = false;
|
let pendingCanDismiss = false;
|
||||||
let pendingReason = "initial";
|
let pendingReason = "initial";
|
||||||
|
let pendingMetrics = readBrowserViewportMetrics();
|
||||||
let keyboardObserved = false;
|
let keyboardObserved = false;
|
||||||
let fallbackApplied = false;
|
let fallbackApplied = false;
|
||||||
let sessionDismissed = false;
|
let sessionDismissed = false;
|
||||||
@@ -115,12 +110,16 @@ export function useChatKeyboardAvoidance({
|
|||||||
fallbackTimerId = null;
|
fallbackTimerId = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const measure = (reason: string, canDismiss: boolean) => {
|
const measure = (
|
||||||
|
reason: string,
|
||||||
|
canDismiss: boolean,
|
||||||
|
metrics: BrowserViewportMetrics,
|
||||||
|
) => {
|
||||||
if (sessionDismissed) return;
|
if (sessionDismissed) return;
|
||||||
|
|
||||||
const resolution = resolveChatKeyboardInset(
|
const resolution = resolveChatKeyboardInset(
|
||||||
baseline,
|
baseline,
|
||||||
readViewportSnapshot(),
|
toKeyboardSnapshot(metrics),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (resolution.keyboardVisible) {
|
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;
|
pendingReason = reason;
|
||||||
pendingCanDismiss ||= canDismiss;
|
pendingCanDismiss ||= canDismiss;
|
||||||
|
pendingMetrics = metrics;
|
||||||
if (frameId != null) return;
|
if (frameId != null) return;
|
||||||
|
|
||||||
frameId = window.requestAnimationFrame(() => {
|
frameId = window.requestAnimationFrame(() => {
|
||||||
@@ -148,46 +152,43 @@ export function useChatKeyboardAvoidance({
|
|||||||
const nextReason = pendingReason;
|
const nextReason = pendingReason;
|
||||||
const nextCanDismiss = pendingCanDismiss;
|
const nextCanDismiss = pendingCanDismiss;
|
||||||
pendingCanDismiss = false;
|
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) => {
|
const resetSession = (reason: string) => {
|
||||||
sessionDismissed = true;
|
sessionDismissed = true;
|
||||||
clearFallbackTimer();
|
clearFallbackTimer();
|
||||||
applyBottomInset(0, "reset");
|
applyBottomInset(0, "reset");
|
||||||
log.debug("[chat-keyboard] reset", { reason });
|
log.debug("[chat-keyboard] reset", { reason });
|
||||||
};
|
};
|
||||||
const handleOrientationChange = () => resetSession("orientationchange");
|
const stopObserving = observeBrowserViewport((observation) => {
|
||||||
const handleVisibilityChange = () => {
|
if (observation.reason === "orientationchange") {
|
||||||
if (document.visibilityState === "hidden") {
|
resetSession(observation.reason);
|
||||||
resetSession("visibilitychange");
|
return;
|
||||||
}
|
}
|
||||||
};
|
if (
|
||||||
|
observation.reason === "visibilitychange" &&
|
||||||
|
document.visibilityState === "hidden"
|
||||||
|
) {
|
||||||
|
resetSession(observation.reason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isViewportGeometryObservation(observation.reason)) return;
|
||||||
|
|
||||||
visualViewport?.addEventListener("resize", handleVisualResize);
|
scheduleMeasure(
|
||||||
visualViewport?.addEventListener("scroll", handleVisualScroll);
|
observation.reason === "initial" ? "focus" : observation.reason,
|
||||||
virtualKeyboard?.addEventListener(
|
canDismissKeyboardSession(observation.reason),
|
||||||
"geometrychange",
|
observation.metrics,
|
||||||
handleVirtualKeyboardChange,
|
|
||||||
);
|
);
|
||||||
window.addEventListener("resize", handleWindowResize);
|
});
|
||||||
window.addEventListener("orientationchange", handleOrientationChange);
|
|
||||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
||||||
|
|
||||||
scheduleMeasure("focus", false);
|
|
||||||
fallbackTimerId = window.setTimeout(() => {
|
fallbackTimerId = window.setTimeout(() => {
|
||||||
fallbackTimerId = null;
|
fallbackTimerId = null;
|
||||||
if (!activeRef.current || sessionDismissed) return;
|
if (!activeRef.current || sessionDismissed) return;
|
||||||
|
|
||||||
const resolution = resolveChatKeyboardInset(
|
const resolution = resolveChatKeyboardInset(
|
||||||
baseline,
|
baseline,
|
||||||
readViewportSnapshot(),
|
toKeyboardSnapshot(readBrowserViewportMetrics()),
|
||||||
);
|
);
|
||||||
if (resolution.keyboardVisible) {
|
if (resolution.keyboardVisible) {
|
||||||
keyboardObserved = true;
|
keyboardObserved = true;
|
||||||
@@ -204,41 +205,40 @@ export function useChatKeyboardAvoidance({
|
|||||||
return () => {
|
return () => {
|
||||||
if (frameId != null) window.cancelAnimationFrame(frameId);
|
if (frameId != null) window.cancelAnimationFrame(frameId);
|
||||||
clearFallbackTimer();
|
clearFallbackTimer();
|
||||||
visualViewport?.removeEventListener("resize", handleVisualResize);
|
stopObserving();
|
||||||
visualViewport?.removeEventListener("scroll", handleVisualScroll);
|
|
||||||
virtualKeyboard?.removeEventListener(
|
|
||||||
"geometrychange",
|
|
||||||
handleVirtualKeyboardChange,
|
|
||||||
);
|
|
||||||
window.removeEventListener("resize", handleWindowResize);
|
|
||||||
window.removeEventListener("orientationchange", handleOrientationChange);
|
|
||||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
||||||
clearBottomInset(container);
|
clearBottomInset(container);
|
||||||
};
|
};
|
||||||
}, [active, containerRef]);
|
}, [active, containerRef]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function readViewportBaseline(): ChatKeyboardViewportBaseline {
|
function toKeyboardBaseline(
|
||||||
|
metrics: BrowserViewportMetrics,
|
||||||
|
): ChatKeyboardViewportBaseline {
|
||||||
return {
|
return {
|
||||||
layoutHeight: window.innerHeight,
|
layoutHeight: metrics.layoutHeight,
|
||||||
layoutWidth: window.innerWidth,
|
layoutWidth: metrics.layoutWidth,
|
||||||
visualHeight: window.visualViewport?.height ?? window.innerHeight,
|
visualHeight: metrics.visualHeight,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function readViewportSnapshot(): ChatKeyboardViewportSnapshot {
|
function toKeyboardSnapshot(
|
||||||
const visualViewport = window.visualViewport;
|
metrics: BrowserViewportMetrics,
|
||||||
|
): ChatKeyboardViewportSnapshot {
|
||||||
return {
|
return {
|
||||||
layoutHeight: window.innerHeight,
|
layoutHeight: metrics.layoutHeight,
|
||||||
visualHeight: visualViewport?.height ?? window.innerHeight,
|
visualHeight: metrics.visualHeight,
|
||||||
visualScale: visualViewport?.scale ?? 1,
|
visualScale: metrics.visualScale,
|
||||||
virtualKeyboardHeight: getVirtualKeyboard()?.boundingRect?.height ?? 0,
|
virtualKeyboardHeight: metrics.virtualKeyboardRect?.height ?? 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getVirtualKeyboard(): VirtualKeyboardLike | null {
|
function canDismissKeyboardSession(
|
||||||
|
reason: ViewportObservationReason,
|
||||||
|
): boolean {
|
||||||
return (
|
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 { useEffect, type RefObject } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
observeBrowserViewport,
|
||||||
|
readBrowserViewportMetrics,
|
||||||
|
} from "@/lib/browser/viewport_observer";
|
||||||
import { BrowserDetector } from "@/utils/browser-detect";
|
import { BrowserDetector } from "@/utils/browser-detect";
|
||||||
import { PlatformDetector } from "@/utils/platform-detect";
|
import { PlatformDetector } from "@/utils/platform-detect";
|
||||||
|
|
||||||
const CHAT_KEYBOARD_DEBUG_PARAM = "keyboard_debug";
|
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 {
|
export interface ChatKeyboardDebugSnapshot {
|
||||||
event: string;
|
event: string;
|
||||||
time: number;
|
time: number;
|
||||||
@@ -102,17 +94,6 @@ export function installChatKeyboardDiagnostics(
|
|||||||
return snapshot;
|
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 = () => {
|
const cleanup = () => {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
disposed = true;
|
disposed = true;
|
||||||
@@ -126,17 +107,12 @@ export function installChatKeyboardDiagnostics(
|
|||||||
window.console.info("[keyboard-debug] removed");
|
window.console.info("[keyboard-debug] removed");
|
||||||
};
|
};
|
||||||
const manualSnapshot = () => takeSnapshot("manual");
|
const manualSnapshot = () => takeSnapshot("manual");
|
||||||
const virtualKeyboard = getVirtualKeyboard();
|
removers.push(
|
||||||
|
observeBrowserViewport(
|
||||||
listen(window, "resize", "window.resize");
|
(observation) => takeSnapshot(observation.reason),
|
||||||
listen(window, "scroll", "window.scroll");
|
{ emitInitial: false },
|
||||||
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");
|
|
||||||
|
|
||||||
window.__keyboardDebugSnapshot = manualSnapshot;
|
window.__keyboardDebugSnapshot = manualSnapshot;
|
||||||
window.__keyboardDebugCleanup = cleanup;
|
window.__keyboardDebugCleanup = cleanup;
|
||||||
@@ -149,8 +125,7 @@ function createChatKeyboardDebugSnapshot(
|
|||||||
event: string,
|
event: string,
|
||||||
containerRef: RefObject<HTMLElement | null>,
|
containerRef: RefObject<HTMLElement | null>,
|
||||||
): ChatKeyboardDebugSnapshot {
|
): ChatKeyboardDebugSnapshot {
|
||||||
const visualViewport = window.visualViewport;
|
const metrics = readBrowserViewportMetrics();
|
||||||
const virtualKeyboardRect = getVirtualKeyboard()?.boundingRect ?? null;
|
|
||||||
const activeElement = document.activeElement;
|
const activeElement = document.activeElement;
|
||||||
const activeRect =
|
const activeRect =
|
||||||
activeElement instanceof HTMLElement
|
activeElement instanceof HTMLElement
|
||||||
@@ -166,26 +141,29 @@ function createChatKeyboardDebugSnapshot(
|
|||||||
return {
|
return {
|
||||||
event,
|
event,
|
||||||
time: Math.round(performance.now()),
|
time: Math.round(performance.now()),
|
||||||
windowInnerHeight: window.innerHeight,
|
windowInnerHeight: metrics.layoutHeight,
|
||||||
documentClientHeight: document.documentElement.clientHeight,
|
documentClientHeight: metrics.documentClientHeight,
|
||||||
scrollY: window.scrollY,
|
scrollY: metrics.scrollY,
|
||||||
visualViewportSupported: visualViewport != null,
|
visualViewportSupported: metrics.visualViewportSupported,
|
||||||
visualHeight: visualViewport?.height ?? null,
|
visualHeight: metrics.visualViewportSupported
|
||||||
visualWidth: visualViewport?.width ?? null,
|
? metrics.visualHeight
|
||||||
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,
|
|
||||||
}
|
|
||||||
: null,
|
: 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,
|
activeElement: activeElement?.tagName ?? null,
|
||||||
activeBottom: activeRect?.bottom ?? null,
|
activeBottom: activeRect?.bottom ?? null,
|
||||||
activeFocused: activeElement !== document.body,
|
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 { useEffect, type ReactNode } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
isViewportGeometryObservation,
|
||||||
|
observeBrowserViewport,
|
||||||
|
type BrowserViewportMetrics,
|
||||||
|
} from "@/lib/browser/viewport_observer";
|
||||||
import { BrowserDetector } from "@/utils/browser-detect";
|
import { BrowserDetector } from "@/utils/browser-detect";
|
||||||
import { Logger } from "@/utils/logger";
|
import { Logger } from "@/utils/logger";
|
||||||
import { PlatformDetector } from "@/utils/platform-detect";
|
import { PlatformDetector } from "@/utils/platform-detect";
|
||||||
@@ -35,22 +40,31 @@ export function ViewportCssVarsProvider({
|
|||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
let frameId: number | null = null;
|
let frameId: number | null = null;
|
||||||
let stableLayoutHeight = window.innerHeight;
|
let pendingMetrics: BrowserViewportMetrics | null = null;
|
||||||
let stableVisibleHeight = window.visualViewport?.height ?? window.innerHeight;
|
let stableLayoutHeight = 0;
|
||||||
let stableVisibleOffsetTop = window.visualViewport?.offsetTop ?? 0;
|
let stableVisibleHeight = 0;
|
||||||
|
let stableVisibleOffsetTop = 0;
|
||||||
|
let hasStableMetrics = false;
|
||||||
let iosFacebookKeyboardSuppressed = false;
|
let iosFacebookKeyboardSuppressed = false;
|
||||||
const isIOSFacebookInAppBrowser =
|
const isIOSFacebookInAppBrowser =
|
||||||
PlatformDetector.isIOS() && BrowserDetector.isFacebookInAppBrowser();
|
PlatformDetector.isIOS() && BrowserDetector.isFacebookInAppBrowser();
|
||||||
|
|
||||||
const applyVars = () => {
|
const applyVars = () => {
|
||||||
frameId = null;
|
frameId = null;
|
||||||
|
const metrics = pendingMetrics;
|
||||||
|
if (!metrics) return;
|
||||||
|
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
const viewport = window.visualViewport;
|
const layoutHeight = metrics.layoutHeight;
|
||||||
const layoutHeight = window.innerHeight;
|
const visibleHeight = metrics.visualHeight;
|
||||||
const layoutWidth = window.innerWidth;
|
const visibleWidth = metrics.visualWidth;
|
||||||
const visibleHeight = viewport?.height ?? layoutHeight;
|
const visibleOffsetTop = metrics.visualOffsetTop;
|
||||||
const visibleWidth = viewport?.width ?? layoutWidth;
|
if (!hasStableMetrics) {
|
||||||
const visibleOffsetTop = viewport?.offsetTop ?? 0;
|
stableLayoutHeight = layoutHeight;
|
||||||
|
stableVisibleHeight = visibleHeight;
|
||||||
|
stableVisibleOffsetTop = visibleOffsetTop;
|
||||||
|
hasStableMetrics = true;
|
||||||
|
}
|
||||||
const shouldSuppressIOSFacebookKeyboardViewport =
|
const shouldSuppressIOSFacebookKeyboardViewport =
|
||||||
isIOSFacebookInAppBrowser &&
|
isIOSFacebookInAppBrowser &&
|
||||||
isEditableElementFocused() &&
|
isEditableElementFocused() &&
|
||||||
@@ -105,24 +119,20 @@ export function ViewportCssVarsProvider({
|
|||||||
root.dataset.viewportHeight = String(Math.round(nextVisibleHeight));
|
root.dataset.viewportHeight = String(Math.round(nextVisibleHeight));
|
||||||
};
|
};
|
||||||
|
|
||||||
const scheduleApply = () => {
|
const scheduleApply = (metrics: BrowserViewportMetrics) => {
|
||||||
|
pendingMetrics = metrics;
|
||||||
if (frameId != null) return;
|
if (frameId != null) return;
|
||||||
frameId = window.requestAnimationFrame(applyVars);
|
frameId = window.requestAnimationFrame(applyVars);
|
||||||
};
|
};
|
||||||
|
|
||||||
applyVars();
|
const stopObserving = observeBrowserViewport((observation) => {
|
||||||
|
if (!isViewportGeometryObservation(observation.reason)) return;
|
||||||
window.addEventListener("resize", scheduleApply);
|
scheduleApply(observation.metrics);
|
||||||
window.addEventListener("orientationchange", scheduleApply);
|
});
|
||||||
window.visualViewport?.addEventListener("resize", scheduleApply);
|
|
||||||
window.visualViewport?.addEventListener("scroll", scheduleApply);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (frameId != null) window.cancelAnimationFrame(frameId);
|
if (frameId != null) window.cancelAnimationFrame(frameId);
|
||||||
window.removeEventListener("resize", scheduleApply);
|
stopObserving();
|
||||||
window.removeEventListener("orientationchange", scheduleApply);
|
|
||||||
window.visualViewport?.removeEventListener("resize", scheduleApply);
|
|
||||||
window.visualViewport?.removeEventListener("scroll", scheduleApply);
|
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user