import { useCallback, useEffect, useState } from 'react'; import { useSearchParams } from 'react-router'; import type { GenerationRow } from '../lib/api'; export const MIN_SCALE = 1; export const MAX_SCALE = 8; /** What the image sits on. Not decoration: it is how you tell transparent from white. */ export const BACKDROPS = ['checker', 'dark', 'light'] as const; export type Backdrop = (typeof BACKDROPS)[number]; export type Viewport = { scale: number; x: number; y: number }; export const RESET: Viewport = { scale: 1, x: 0, y: 0 }; export const clampScale = (scale: number) => Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale)); /** * Zoom about a point rather than about the centre. * * `point` is the cursor's offset from the centre of the frame. Keeping whatever is under the cursor * under the cursor is the difference between inspecting a detail and chasing it around the screen — * which matters here, because the reason to zoom an icon is almost always to look at one edge of it. */ export function zoomAt(viewport: Viewport, factor: number, point: { x: number; y: number }): Viewport { const scale = clampScale(viewport.scale * factor); if (scale === viewport.scale) return viewport; const ratio = scale / viewport.scale; return { scale, x: point.x - (point.x - viewport.x) * ratio, y: point.y - (point.y - viewport.y) * ratio, }; } /** * Keep the image overlapping its frame. * * Without this, one flick of a drag sends the picture off-screen and the only way back is a control * you can no longer see. The bound is half the overhang, so the image can always be pulled back. */ export function clampOffset(viewport: Viewport, frame: { width: number; height: number }): Viewport { const overhangX = Math.max(0, (frame.width * viewport.scale - frame.width) / 2); const overhangY = Math.max(0, (frame.height * viewport.scale - frame.height) / 2); return { scale: viewport.scale, x: Math.min(overhangX, Math.max(-overhangX, viewport.x)), y: Math.min(overhangY, Math.max(-overhangY, viewport.y)), }; } /** Only rows with a file can be opened; a failed generation has nothing to show. */ export const viewable = (rows: GenerationRow[]) => rows.filter((row) => row.status === 'succeeded' && row.image_path); /** * Which image is open lives in `?selected=`, not local state — a lightbox is a thing worth linking * to and worth surviving a refresh, and the URL is what `docs/navigation-audit.md` calls the source of * truth for selection. */ export const useLightboxManager = (rows: GenerationRow[]) => { const [searchParams, setSearchParams] = useSearchParams(); const openId = searchParams.get('selected'); const [viewport, setViewport] = useState(RESET); const [backdrop, setBackdrop] = useState('checker'); const images = viewable(rows); const index = images.findIndex((row) => row.id === openId); const current = index >= 0 ? images[index] : undefined; const select = useCallback( (id: string | null) => { setSearchParams( (prev) => { const next = new URLSearchParams(prev); if (id) next.set('selected', id); else next.delete('selected'); return next; }, { replace: true }, ); }, [setSearchParams], ); const close = useCallback(() => select(null), [select]); const open = useCallback( (id: string) => { select(id); setViewport(RESET); }, [select], ); // Wraps, because reaching the end of a gallery and being unable to press the same key again is a // small, repeated annoyance. const step = useCallback( (delta: number) => { if (images.length === 0 || index < 0) return; const next = images[(index + delta + images.length) % images.length]; if (next) { select(next.id); setViewport(RESET); } }, [images, index, select], ); const zoom = useCallback((factor: number, point = { x: 0, y: 0 }) => { setViewport((current) => zoomAt(current, factor, point)); }, []); const reset = useCallback(() => setViewport(RESET), []); const pan = useCallback((dx: number, dy: number, frame: { width: number; height: number }) => { setViewport((current) => current.scale <= 1 ? current : clampOffset({ ...current, x: current.x + dx, y: current.y + dy }, frame), ); }, []); // Bound to the document rather than the dialog, so the keys work before anything is focused and // keep working after a click lands on the backdrop. useEffect(() => { if (!current) return; const onKey = (event: KeyboardEvent) => { const handlers: Record void> = { Escape: close, ArrowRight: () => step(1), ArrowLeft: () => step(-1), '+': () => zoom(1.4), '=': () => zoom(1.4), '-': () => zoom(1 / 1.4), '0': reset, }; const handler = handlers[event.key]; if (handler) { event.preventDefault(); handler(); } }; document.addEventListener('keydown', onKey); // The page behind must not scroll while a full-screen overlay is up. const previousOverflow = document.body.style.overflow; document.body.style.overflow = 'hidden'; return () => { document.removeEventListener('keydown', onKey); document.body.style.overflow = previousOverflow; }; }, [current, close, step, zoom, reset]); return { images, current, index, isOpen: Boolean(current), viewport, backdrop, setBackdrop, open, close, step, zoom, reset, pan, }; }; export type LightboxManager = ReturnType;