Files
uopi/web/src/ZoomControl.tsx
T
2026-06-23 17:59:40 +02:00

107 lines
4.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<number>).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 (
<div class="zoom-control" title="UI zoom">
<button class="icon-btn zoom-btn" onClick={() => step(-1)} title="Zoom out (smaller UI)">A</button>
<span class="zoom-pct">{Math.round(zoom * 100)}%</span>
<button class="icon-btn zoom-btn" onClick={() => step(1)} title="Zoom in (larger UI)">A+</button>
</div>
);
}