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
@@ -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);
});
});
+169
View File
@@ -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
);
}