fix(library): contain zoom in stage viewport and add drag/pan support for single view

This commit is contained in:
2026-10-07 18:34:45 +07:00
parent 5dbcdd36e7
commit 57005ae1e2
2 changed files with 164 additions and 71 deletions
+163 -68
View File
@@ -340,10 +340,12 @@ function Catalogue() {
useEffect(() => { keep(YEAR_KEY, year); }, [year]);
useEffect(() => { keep(FROM_KEY, from); }, [from]);
useEffect(() => { keep(TO_KEY, to); }, [to]);
// 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.
// How far the frame is magnified, and the pan offset relative to viewport center.
const [zoom, setZoom] = useState(1);
const [zoomAt, setZoomAt] = useState('50% 50%');
const [pan, setPan] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
const isDraggingRef = useRef(false);
const dragStartRef = useRef<{ x: number; y: number; panX: number; panY: number } | null>(null);
const viewportRef = useRef<HTMLDivElement | null>(null);
const [colWidth, setColWidth] = useState(() => {
const saved = Number(recall(COLUMN_KEY));
return saved >= COLUMN_MIN && saved <= COLUMN_MAX ? saved : COLUMN_DEFAULT;
@@ -1268,12 +1270,96 @@ function Catalogue() {
};
}, [frame?.id]);
// A frame that has just come up is fitted: the zoom belonged to the negative
// A frame that has just come up is fitted: the zoom and pan belong to the negative
// that was up before it.
useEffect(() => {
setZoom(1);
setPan({ x: 0, y: 0 });
}, [frame?.id]);
// Wheel zoom over the single view stage: zooms in/out relative to cursor position
useEffect(() => {
const el = viewportRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
if (!e.deltaY) return;
e.preventDefault();
const rect = el.getBoundingClientRect();
const mouseX = e.clientX - (rect.left + rect.width / 2);
const mouseY = e.clientY - (rect.top + rect.height / 2);
const notches = Math.max(-3, Math.min(3, -e.deltaY / 100));
setZoom((zPrev) => {
const zNext = Math.min(ZOOM_MAX, Math.max(1, zPrev * ZOOM_STEP ** notches));
if (zNext === 1) {
setPan({ x: 0, y: 0 });
return 1;
}
const scaleFactor = zNext / zPrev;
setPan((pPrev) => ({
x: mouseX - (mouseX - pPrev.x) * scaleFactor,
y: mouseY - (mouseY - pPrev.y) * scaleFactor,
}));
return zNext;
});
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => {
el.removeEventListener('wheel', onWheel);
};
}, [view, frame?.id]);
const handlePointerDown = (e: React.PointerEvent) => {
if (e.button !== 0) return;
if (zoom <= 1) return;
isDraggingRef.current = false;
dragStartRef.current = {
x: e.clientX,
y: e.clientY,
panX: pan.x,
panY: pan.y,
};
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
};
const handlePointerMove = (e: React.PointerEvent) => {
if (!dragStartRef.current) return;
const dx = e.clientX - dragStartRef.current.x;
const dy = e.clientY - dragStartRef.current.y;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) {
isDraggingRef.current = true;
}
setPan({
x: dragStartRef.current.panX + dx,
y: dragStartRef.current.panY + dy,
});
};
const handlePointerUp = (e: React.PointerEvent) => {
if (dragStartRef.current) {
dragStartRef.current = null;
}
};
const handleStageDoubleClick = (e: React.MouseEvent) => {
if (zoom > 1) {
setZoom(1);
setPan({ x: 0, y: 0 });
} else {
const el = viewportRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const mouseX = e.clientX - (rect.left + rect.width / 2);
const mouseY = e.clientY - (rect.top + rect.height / 2);
const zNext = 2.5;
setZoom(zNext);
setPan({
x: mouseX - mouseX * zNext,
y: mouseY - mouseY * zNext,
});
}
};
const [stageUrl, setStageUrl] = useState<string | null>(null);
// The frame that URL was read for. The raise moves on a render before the URL
// is dropped, so without this the frame that has just come up is handed the
@@ -1346,34 +1432,7 @@ function Catalogue() {
const stageSrc = stageUrl && stageFor === frame?.id ? stageUrl : frame ? urls[frame.id] ?? null : null;
const stageTurned = useTurned(stageSrc, frame?.rot ?? 0);
// 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 frame
// whose own small copy the file never carried — a RAW whose preview the camera
@@ -1804,42 +1863,78 @@ function Catalogue() {
</>
) : frame ? (
<>
{stageTurned ? (
<img
className="adm-stage-img"
data-key="lib-stage-img"
ref={frameRef}
src={stageTurned}
alt=""
onClick={() => void open(frame)}
onContextMenu={(e) => {
e.preventDefault();
setPhotoMenu({
photo: frame,
x: Math.min(e.clientX, window.innerWidth - 220),
y: Math.min(e.clientY, window.innerHeight - 160),
});
}}
title={t('lib.open')}
style={zoom === 1 ? undefined : { transformOrigin: zoomAt, transform: `scale(${zoom})` }}
/>
) : (
<div
className="lib-noimg"
onClick={() => void open(frame)}
onContextMenu={(e) => {
e.preventDefault();
setPhotoMenu({
photo: frame,
x: Math.min(e.clientX, window.innerWidth - 220),
y: Math.min(e.clientY, window.innerHeight - 160),
});
}}
title={t('lib.open')}
>
RAW
</div>
)}
<div
className="adm-stage-viewport"
ref={viewportRef}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onDoubleClick={handleStageDoubleClick}
style={{
flex: '1 1 auto',
minHeight: 0,
width: '100%',
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
cursor: zoom > 1 ? (dragStartRef.current ? 'grabbing' : 'grab') : 'pointer',
userSelect: 'none',
touchAction: 'none',
}}
>
{stageTurned ? (
<img
className="adm-stage-img"
data-key="lib-stage-img"
src={stageTurned}
alt=""
draggable={false}
onClick={() => {
if (isDraggingRef.current) {
isDraggingRef.current = false;
return;
}
void open(frame);
}}
onContextMenu={(e) => {
e.preventDefault();
setPhotoMenu({
photo: frame,
x: Math.min(e.clientX, window.innerWidth - 220),
y: Math.min(e.clientY, window.innerHeight - 160),
});
}}
title={t('lib.open')}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
transform: zoom === 1 && pan.x === 0 && pan.y === 0 ? undefined : `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
transformOrigin: 'center center',
transition: dragStartRef.current ? 'none' : 'transform 0.05s ease-out',
}}
/>
) : (
<div
className="lib-noimg"
onClick={() => void open(frame)}
onContextMenu={(e) => {
e.preventDefault();
setPhotoMenu({
photo: frame,
x: Math.min(e.clientX, window.innerWidth - 220),
y: Math.min(e.clientY, window.innerHeight - 160),
});
}}
title={t('lib.open')}
>
RAW
</div>
)}
</div>
<div className="adm-meta" data-key="lib-stage-meta">
<b>{frame.name}</b>
<span>{frame.dir || node}</span>
+1 -3
View File
@@ -1240,9 +1240,7 @@ button.hint:disabled { cursor: default; }
.lib-root .adm-col > .adm-filmstrip { flex: 0 0 auto; }
.lib-root .adm-album-list { max-height: none; flex: 1 1 auto; min-height: 0; }
.lib-root .adm-stage { min-height: 0; overflow: hidden; }
/* The frame fills the stage and the stage fits the window: `object-fit` letterboxes
it inside that box, so a 3:2 or a 4:3 frame stays whole and never pushes the
strip off screen. */
.lib-root .adm-stage-viewport { flex: 1 1 auto; min-height: 0; width: 100%; overflow: hidden; display: flex; align-items: center; justify-content: center; position: relative; }
.lib-root .adm-stage-img { flex: 1 1 auto; min-height: 0; width: 100%; aspect-ratio: auto; object-fit: contain; }
.lib-root .adm-stage > .lib-noimg { flex: 1 1 auto; min-height: 0; aspect-ratio: auto; }
.lib-root .adm-stage > .adm-meta,