From 57005ae1e2b4dbcc1acc78fea7e4667afd7f4f69 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Wed, 7 Oct 2026 18:34:45 +0700 Subject: [PATCH] fix(library): contain zoom in stage viewport and add drag/pan support for single view --- docker/frontend/src/Library.tsx | 231 ++++++++++++++++++++--------- docker/frontend/src/styles/app.css | 4 +- 2 files changed, 164 insertions(+), 71 deletions(-) diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index a4f63d6..7822c1d 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -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(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(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 ? ( - 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})` }} - /> - ) : ( -
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 -
- )} +
1 ? (dragStartRef.current ? 'grabbing' : 'grab') : 'pointer', + userSelect: 'none', + touchAction: 'none', + }} + > + {stageTurned ? ( + { + 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', + }} + /> + ) : ( +
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 +
+ )} +
{frame.name} {frame.dir || node} diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index 42cb421..e893aa5 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -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,