fix(gallery): stop browser zoom tripping the devtools viewport heuristic
innerHeight is in page CSS pixels and shrinks under browser zoom; outerHeight does not. At 150-200% zoom a normal window therefore shows an absolute outer/inner gap of 400-500px, past every threshold, so an accessibility zoom read as a docked DevTools panel and - at protectionLevel "maximum" - redirected the guest off the gallery on load. Pre-existing (the previous threshold was 100px), but the rewrite kept the shape. The gap is now measured relative to a baseline taken at mount, and the baseline is re-taken whenever devicePixelRatio changes, which a zoom step does and a docked panel does not. Only a gap that grows past the threshold at a constant ratio counts. The mount-time check is dropped: a panel that is already open at load is indistinguishable from a zoomed window.
This commit is contained in:
@@ -147,6 +147,66 @@ describe('useDevToolsProtection', () => {
|
|||||||
expect(onDevToolsDetected).toHaveBeenCalledTimes(1);
|
expect(onDevToolsDetected).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('viewport heuristic', () => {
|
||||||
|
const setWindowMetrics = (metrics: {
|
||||||
|
innerHeight: number; outerHeight: number; innerWidth: number; outerWidth: number; dpr: number;
|
||||||
|
}) => {
|
||||||
|
for (const [key, value] of Object.entries({
|
||||||
|
innerHeight: metrics.innerHeight,
|
||||||
|
outerHeight: metrics.outerHeight,
|
||||||
|
innerWidth: metrics.innerWidth,
|
||||||
|
outerWidth: metrics.outerWidth,
|
||||||
|
devicePixelRatio: metrics.dpr,
|
||||||
|
})) {
|
||||||
|
Object.defineProperty(window, key, { configurable: true, writable: true, value });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
it('does not fire on mount for a window that is merely zoomed', () => {
|
||||||
|
// 200% zoom on a 1000px-tall window: innerHeight halves, outerHeight
|
||||||
|
// does not, so the absolute gap is ~580px — far past every threshold.
|
||||||
|
setWindowMetrics({ innerHeight: 500, outerHeight: 1080, innerWidth: 900, outerWidth: 1000, dpr: 2 });
|
||||||
|
const onDevToolsDetected = vi.fn();
|
||||||
|
renderHook(() => useDevToolsProtection({ enabled: true, onDevToolsDetected, detectionSensitivity: 'high' }));
|
||||||
|
window.dispatchEvent(new Event('resize'));
|
||||||
|
|
||||||
|
expect(onDevToolsDetected).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fire when the gap grows because the guest zoomed in', () => {
|
||||||
|
setWindowMetrics({ innerHeight: 900, outerHeight: 1000, innerWidth: 1000, outerWidth: 1000, dpr: 1 });
|
||||||
|
const onDevToolsDetected = vi.fn();
|
||||||
|
renderHook(() => useDevToolsProtection({ enabled: true, onDevToolsDetected, detectionSensitivity: 'high' }));
|
||||||
|
|
||||||
|
setWindowMetrics({ innerHeight: 450, outerHeight: 1000, innerWidth: 500, outerWidth: 1000, dpr: 2 });
|
||||||
|
window.dispatchEvent(new Event('resize'));
|
||||||
|
|
||||||
|
expect(onDevToolsDetected).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires when the gap grows at a constant pixel ratio (a docked panel)', () => {
|
||||||
|
setWindowMetrics({ innerHeight: 900, outerHeight: 1000, innerWidth: 1000, outerWidth: 1000, dpr: 1 });
|
||||||
|
const onDevToolsDetected = vi.fn();
|
||||||
|
renderHook(() => useDevToolsProtection({ enabled: true, onDevToolsDetected, detectionSensitivity: 'medium' }));
|
||||||
|
|
||||||
|
setWindowMetrics({ innerHeight: 600, outerHeight: 1000, innerWidth: 1000, outerWidth: 1000, dpr: 1 });
|
||||||
|
window.dispatchEvent(new Event('resize'));
|
||||||
|
|
||||||
|
expect(onDevToolsDetected).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a plain window resize, where outer and inner move together', () => {
|
||||||
|
setWindowMetrics({ innerHeight: 900, outerHeight: 1000, innerWidth: 1000, outerWidth: 1000, dpr: 1 });
|
||||||
|
const onDevToolsDetected = vi.fn();
|
||||||
|
renderHook(() => useDevToolsProtection({ enabled: true, onDevToolsDetected, detectionSensitivity: 'high' }));
|
||||||
|
|
||||||
|
setWindowMetrics({ innerHeight: 500, outerHeight: 600, innerWidth: 700, outerWidth: 700, dpr: 1 });
|
||||||
|
window.dispatchEvent(new Event('resize'));
|
||||||
|
|
||||||
|
expect(onDevToolsDetected).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('lets ordinary keystrokes through', () => {
|
it('lets ordinary keystrokes through', () => {
|
||||||
renderHook(() => useDevToolsProtection({ enabled: true }));
|
renderHook(() => useDevToolsProtection({ enabled: true }));
|
||||||
|
|
||||||
|
|||||||
@@ -61,9 +61,30 @@ export const useDevToolsProtection = (options: UseDevToolsProtectionOptions) =>
|
|||||||
const threshold = detectionSensitivity === 'high' ? 160 :
|
const threshold = detectionSensitivity === 'high' ? 160 :
|
||||||
detectionSensitivity === 'low' ? 260 : 200;
|
detectionSensitivity === 'low' ? 260 : 200;
|
||||||
|
|
||||||
|
// The outer/inner gap is measured RELATIVE to a baseline taken when the
|
||||||
|
// hook mounts, not as an absolute number. innerHeight is in page CSS px
|
||||||
|
// (it shrinks under browser zoom) while outerHeight is not, so at 150-200%
|
||||||
|
// zoom the absolute gap on a normal window is 400-500px — an
|
||||||
|
// accessibility zoom looked exactly like a docked panel and, at
|
||||||
|
// protectionLevel "maximum", bounced the guest off the gallery on load.
|
||||||
|
// A zoom step changes devicePixelRatio; docking DevTools does not. So the
|
||||||
|
// baseline is re-taken whenever the ratio changes, and only a gap that
|
||||||
|
// grows past the threshold at a constant ratio counts as DevTools.
|
||||||
|
const measure = () => ({
|
||||||
|
dpr: window.devicePixelRatio || 1,
|
||||||
|
height: window.outerHeight - window.innerHeight,
|
||||||
|
width: window.outerWidth - window.innerWidth,
|
||||||
|
});
|
||||||
|
let baseline = measure();
|
||||||
|
|
||||||
const detectByWindowSize = () => {
|
const detectByWindowSize = () => {
|
||||||
if (window.outerHeight - window.innerHeight > threshold ||
|
const now = measure();
|
||||||
window.outerWidth - window.innerWidth > threshold) {
|
if (now.dpr !== baseline.dpr) {
|
||||||
|
baseline = now;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (now.height - baseline.height > threshold ||
|
||||||
|
now.width - baseline.width > threshold) {
|
||||||
handleDevToolsDetected();
|
handleDevToolsDetected();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -97,10 +118,11 @@ export const useDevToolsProtection = (options: UseDevToolsProtectionOptions) =>
|
|||||||
|
|
||||||
document.addEventListener('contextmenu', handleImageContextMenu);
|
document.addEventListener('contextmenu', handleImageContextMenu);
|
||||||
document.addEventListener('keydown', handleKeyDown, true);
|
document.addEventListener('keydown', handleKeyDown, true);
|
||||||
// Docking/undocking DevTools resizes the viewport; the initial call covers
|
// Docking/undocking DevTools resizes the viewport. There is deliberately
|
||||||
// the case where it was already open when the gallery loaded.
|
// no check at mount: a panel that was already open when the gallery
|
||||||
|
// loaded is indistinguishable from a zoomed window at that point, and
|
||||||
|
// the shortcut-key deterrent above still applies.
|
||||||
window.addEventListener('resize', detectByWindowSize);
|
window.addEventListener('resize', detectByWindowSize);
|
||||||
detectByWindowSize();
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('contextmenu', handleImageContextMenu);
|
document.removeEventListener('contextmenu', handleImageContextMenu);
|
||||||
|
|||||||
Reference in New Issue
Block a user