Files
atelier/web/Gallery/useLightboxManager.ts
pastilhas 859fb5767c web: three panels — Generate, Gallery, Collections — and a layout
ported from the standalone app's hash-tab UI. The panels do not coordinate through props (the panel
contract only ever hands a component its panelId) or usePanelChannel-as-selection:

- Gallery's lightbox is ?selected=<id>, Collections' detail is ?collection=<id> — different names
  since both panels render on the same route at once
- Generate bumps useAtelierRefresh (added to officerdev/src/channels.ts) when a job finishes;
  Gallery subscribes to it — a genuine signal, not a selection, so it stays a channel

images render through GET /files/:filename with a ?token= query param appended client-side
(lib/api.ts's fileUrl), the same escape hatch FileViewer and the music plugin use for exactly this:
a browser cannot set an Authorization header on an <img src>.

Providers is not here — see PLUGIN.md for why it became a platform settings screen instead.

assets/icon.png and assets/splash.png are [placeholder] flat swatches, not real artwork — see
assets/PLACEHOLDER.md.
2026-08-20 04:01:10 +00:00

175 lines
5.6 KiB
TypeScript

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=<id>`, 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<Viewport>(RESET);
const [backdrop, setBackdrop] = useState<Backdrop>('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<string, () => 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<typeof useLightboxManager>;