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.
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
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>;
|
||||
Reference in New Issue
Block a user