import { h } from 'preact'; import { useEffect, useState } from 'preact/hooks'; const LS_KEY = 'uopi:ui-zoom'; const ZOOM_EVENT = 'uopi:zoomchange'; const STEPS = [0.5, 0.6, 0.75, 0.85, 1.0, 1.15, 1.3, 1.5, 1.75, 2.0, 2.5]; // serverDefaultZoom is the base scale configured server-side ([ui] default_zoom), // pushed in once /me resolves. It is the fallback when this browser has no saved // per-machine override, so a fresh client on a HiDPI screen starts enlarged // without per-browser setup while a user's local A+/A− choice still wins. let serverDefaultZoom = 1; // hasStoredZoom reports whether this browser has an explicit saved zoom override. export function hasStoredZoom(): boolean { return localStorage.getItem(LS_KEY) !== null; } // setServerDefaultZoom records the server default and, unless the user has a saved // override, applies it immediately (without persisting, so a later server change // still propagates). export function setServerDefaultZoom(z: number): void { serverDefaultZoom = isFinite(z) && z > 0 ? z : 1; if (!hasStoredZoom()) { applyZoom(serverDefaultZoom, false); } } export function getStoredZoom(): number { const raw = localStorage.getItem(LS_KEY); const v = raw === null ? serverDefaultZoom : parseFloat(raw); return isFinite(v) ? Math.max(STEPS[0], Math.min(STEPS[STEPS.length - 1], v)) : 1; } // dprFactor returns the device-pixel-ratio scaling applied on top of the manual // zoom so the UI is proportionally larger on high-DPI displays by default. // Firefox only honours CSS rem scaling (it does not auto-enlarge the root px the // way Chrome does on some setups), so the ratio must be folded into the root // font-size explicitly rather than left to the browser. export function dprFactor(): number { const dpr = window.devicePixelRatio; return isFinite(dpr) && dpr > 0 ? dpr : 1; } // applyZoom sets the root font-size to scale the whole UI. When persist is true // (a deliberate user action) the choice is saved as this browser's override; // pass false to apply the server default transiently. A ZOOM_EVENT is dispatched // so the ZoomControl's displayed percentage stays in sync regardless of source. export function applyZoom(z: number, persist = true): number { const clamped = Math.max(STEPS[0], Math.min(STEPS[STEPS.length - 1], z)); if (persist) { localStorage.setItem(LS_KEY, String(clamped)); } const dpr = dprFactor(); document.documentElement.style.setProperty('--dpr', String(dpr)); document.documentElement.style.fontSize = `${Math.round(16 * clamped * dpr)}px`; window.dispatchEvent(new CustomEvent(ZOOM_EVENT, { detail: clamped })); return clamped; } // watchDpr re-applies the current zoom whenever the device-pixel-ratio changes // (e.g. the window moves to a monitor with different scaling). matchMedia fires // once per resolution change; the listener is re-armed each time. Returns an // unsubscribe function. export function watchDpr(): () => void { let mql: MediaQueryList | null = null; const onChange = () => { // Re-apply at the new DPR; only persist if this browser actually has an // override (otherwise a monitor change would freeze the server default). applyZoom(getStoredZoom(), hasStoredZoom()); arm(); }; const arm = () => { mql?.removeEventListener('change', onChange); mql = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`); mql.addEventListener('change', onChange); }; arm(); return () => mql?.removeEventListener('change', onChange); } export default function ZoomControl() { const [zoom, setZoom] = useState(getStoredZoom); // Keep the displayed percentage in sync when zoom is applied from elsewhere // (e.g. the server default arriving via /me, or a DPR change). useEffect(() => { const onZoom = (e: Event) => setZoom((e as CustomEvent).detail); window.addEventListener(ZOOM_EVENT, onZoom); return () => window.removeEventListener(ZOOM_EVENT, onZoom); }, []); function step(dir: number) { const idx = STEPS.findIndex(s => s >= zoom - 0.01); const nextIdx = Math.max(0, Math.min(STEPS.length - 1, idx + dir)); setZoom(applyZoom(STEPS[nextIdx])); } return (
{Math.round(zoom * 100)}%
); }