diff --git a/docker/frontend/scripts/library-check.mjs b/docker/frontend/scripts/library-check.mjs index a2dd181..819eaea 100644 --- a/docker/frontend/scripts/library-check.mjs +++ b/docker/frontend/scripts/library-check.mjs @@ -432,6 +432,37 @@ const where = await page.$eval('[data-key^="lib-node-"][aria-current="true"]', ( step('the column drags and is the same width next time', dragged >= 160 && reopened === dragged, `${dragged}px, then ${reopened}px`); step('the screen reopens on the folder it was left on', where === 'lib-node-CheckRoll/2026', where); +// The frame that was raised is remembered too: a reader who comes back finds the +// negative they were looking at, not the first one the node happens to hold. The +// JPEG is the one with a picture to show, so it is the one that gets raised. +const jpgId = indexed.thumbs.find((t) => t.id.endsWith(`/${JPG}`) && t.id.includes('/2026/04/')).id; +await page.click(`[data-key="lib-thumb-${jpgId}"]`); +await page.reload(); +await page.waitForSelector(thumbs, { timeout: 120_000 }); +const raised = await page.$eval('.adm-thumb.on', (e) => e.dataset.key); +step('the screen reopens on the frame that was raised', raised === `lib-thumb-${jpgId}`, raised); + +// A wheel tick over the frame magnifies it about the point under the pointer — +// the one gesture that makes a negative worth the whole window. The page under it +// holds still, and ticking back down fits the frame again. +const shotBox = await (await page.$('[data-key="lib-stage-img"]')).boundingBox(); +await page.mouse.move(shotBox.x + shotBox.width / 3, shotBox.y + shotBox.height / 3); +await page.mouse.wheel(0, -240); +await page.waitForTimeout(150); +const magnified = await page.$eval('[data-key="lib-stage-img"]', (el) => ({ + transform: getComputedStyle(el).transform, + origin: getComputedStyle(el).transformOrigin, +})); +await page.mouse.wheel(0, 600); +await page.waitForTimeout(150); +const fitted = await page.$eval('[data-key="lib-stage-img"]', (el) => getComputedStyle(el).transform); +const scale = Number((magnified.transform.match(/matrix\(([\d.]+)/) ?? [])[1] ?? 1); +step( + 'a wheel tick over the frame zooms it, and back down fits it again', + scale > 1 && scale <= 6 && fitted === 'none', + `${magnified.transform} about ${magnified.origin} → ${fitted}` +); + // One tap on a thumbnail raises the frame, and the button under it hands it to // the studio. The RAW is the harder half — it has to be read back off the // (stand-in) handle and developed — so it is the one that gets opened. diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index 63d587f..1cee070 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -41,13 +41,18 @@ interface Node { count: number; } -// The two things this screen remembers between visits: the folder that was open, -// and how much room its column was given. +// The three things this screen remembers between visits: the folder that was open, +// the frame that was raised in it, and how much room its column was given. const NODE_KEY = 'recipescam.library.node'; +const FRAME_KEY = 'recipescam.library.frame'; const COLUMN_KEY = 'recipescam.library.column'; const COLUMN_MIN = 88; const COLUMN_MAX = 460; const COLUMN_DEFAULT = 118; +// How far a wheel tick over the frame magnifies it, and where it stops: past a +// handful of ticks the thumbnail behind the preview runs out of pixels anyway. +const ZOOM_MAX = 6; +const ZOOM_STEP = 1.15; // Private mode throws on localStorage rather than returning null: a screen that // cannot remember is fine, a screen that cannot load is not. @@ -89,8 +94,17 @@ export function Library() { // node itself. Deep is how a roll reads — its top level holds no file of its // own — and shallow is what a folder of folders is for. const [deep, setDeep] = useState(true); - const [up, setUp] = useState(null); + // The frame that is up in the open node — raised by a click on its thumbnail, + // and picked up where the last visit left it, so a reader who leaves for the + // studio comes back to the negative they were looking at rather than to the + // first one the node happens to hold. A frame that is gone falls through to + // the first, the way a node that is gone falls back to its folder. + const [up, setUp] = useState(() => remembered(FRAME_KEY)); const [view, setView] = useState<'preview' | 'grid'>('preview'); + // How far the frame is magnified, and the point it is magnified about: the one + // under the pointer, so the picture grows around what is being looked at. + const [zoom, setZoom] = useState(1); + const [zoomAt, setZoomAt] = useState('50% 50%'); const [colWidth, setColWidth] = useState(() => { const saved = Number(remembered(COLUMN_KEY)); return saved >= COLUMN_MIN && saved <= COLUMN_MAX ? saved : COLUMN_DEFAULT; @@ -173,11 +187,14 @@ export function Library() { }; }, [reload]); - // What the screen remembers: the folder that was open, and the width its column - // was dragged to. + // What the screen remembers: the folder that was open, the frame that was up in + // it, and the width its column was dragged to. useEffect(() => { if (node) keep(NODE_KEY, node); }, [node]); + useEffect(() => { + if (up) keep(FRAME_KEY, up); + }, [up]); useEffect(() => { keep(COLUMN_KEY, String(colWidth)); }, [colWidth]); @@ -390,6 +407,41 @@ export function Library() { const frame = shown.find((p) => p.id === up) ?? shown[0] ?? null; const urls = useThumbUrls(shown); + // A frame that has just come up is fitted: the zoom belonged to the negative + // that was up before it. + useEffect(() => { + setZoom(1); + }, [frame?.id]); + + // A wheel tick over the frame is the zoom — a negative is the one thing on this + // screen worth filling the window with — and the point under the pointer is the + // point that stays put, which is how a reader aims at what to magnify. The + // listener goes on the element rather than through `onWheel`, because React's + // own wheel is passive and this one has to hold the page still while it zooms. + // It is not taken off again: it lives on the element, and the element leaves + // with it (the guard keeps a re-attach off the same node, which is what a + // double-invoked render would otherwise do). + const frameRef = useCallback((el: HTMLImageElement | null) => { + if (!el || el.dataset.zoom === 'bound') return; + el.dataset.zoom = 'bound'; + el.addEventListener( + 'wheel', + (e) => { + // A tick with no vertical axis (a wheel pushed sideways) is not a zoom. + if (!e.deltaY) return; + e.preventDefault(); + const box = el.getBoundingClientRect(); + // A tick is a notch, and a trackpad sends its notches in small pieces: the + // step is taken from how far the wheel turned rather than assumed, so both + // turn into the same travel under the pointer. + const notches = Math.max(-3, Math.min(3, -e.deltaY / 100)); + setZoomAt(`${(((e.clientX - box.left) / box.width) * 100).toFixed(2)}% ${(((e.clientY - box.top) / box.height) * 100).toFixed(2)}%`); + setZoom((z) => Math.min(ZOOM_MAX, Math.max(1, z * ZOOM_STEP ** notches))); + }, + { passive: false } + ); + }, []); + // The tile: the thumbnail the catalogue kept, or a stand-in for the RAW whose // preview the camera never wrote. A tile only carries a handler where a click // on the picture means "open it" — in the strip the click belongs to the frame @@ -637,10 +689,12 @@ export function Library() { void open(frame)} title={t('lib.open')} + style={zoom === 1 ? undefined : { transformOrigin: zoomAt, transform: `scale(${zoom})` }} /> ) : (
void open(frame)} title={t('lib.open')}>