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.
175 lines
5.6 KiB
TypeScript
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>;
|