fix(library): contain zoom in stage viewport and add drag/pan support for single view
This commit is contained in:
+163
-68
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user