107 lines
4.4 KiB
TypeScript
107 lines
4.4 KiB
TypeScript
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>
|
||
);
|
||
}
|