web: come back to the frame that was raised, and zoom it by the wheel
Two things the preview owed a reader. The frame raised in the strip outlived nothing: leaving for the studio and coming back dropped the choice and the node opened on its first negative instead. It is now remembered beside the folder and the column width — the same one-line store, read on the way in, written on the way out — and a frame that is gone from the catalogue falls through to the first, the way a node that is gone falls back to its folder. A wheel over the frame now magnifies it. 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; the origin is the point under the pointer, which is what keeps a reader's aim on the thing being looked at, and the step comes from how far the wheel turned so a notched mouse and a trackpad travel the same. Six times is where it stops — the thumbnail behind the preview has no more pixels to give — and a frame that has just come up is fitted again. library-check: 35 steps, the two new ones raising a nested frame, reloading on it, and ticking the wheel up and back down. Co-authored-by: PenguinHarness <noreply@penguin.local>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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<string | null>(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<string | null>(() => 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() {
|
||||
<img
|
||||
className="adm-stage-img"
|
||||
data-key="lib-stage-img"
|
||||
ref={frameRef}
|
||||
src={urls[frame.id]}
|
||||
alt=""
|
||||
onClick={() => void open(frame)}
|
||||
title={t('lib.open')}
|
||||
style={zoom === 1 ? undefined : { transformOrigin: zoomAt, transform: `scale(${zoom})` }}
|
||||
/>
|
||||
) : (
|
||||
<div className="lib-noimg" onClick={() => void open(frame)} title={t('lib.open')}>
|
||||
|
||||
Reference in New Issue
Block a user