From 80238bb7a1cda891c379930b2140733cce2ce3b8 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Sun, 26 Oct 2025 02:05:31 +0200 Subject: [PATCH] zoom --- frontend/src/detail/DetailPanel.jsx | 338 +++++++++++++++++++++++++--- frontend/src/styles.css | 13 +- 2 files changed, 313 insertions(+), 38 deletions(-) diff --git a/frontend/src/detail/DetailPanel.jsx b/frontend/src/detail/DetailPanel.jsx index f0fb139..11cb512 100644 --- a/frontend/src/detail/DetailPanel.jsx +++ b/frontend/src/detail/DetailPanel.jsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { DownloadIcon, @@ -351,6 +351,35 @@ const DetailPanel = ({ const [ocrLoading, setOcrLoading] = useState(false); const [ocrError, setOcrError] = useState(null); const [zoomedPreview, setZoomedPreview] = useState(null); + const [zoomNative, setZoomNative] = useState(false); + const [zoomPan, setZoomPan] = useState({ x: 0, y: 0 }); + const [isZoomDragging, setIsZoomDragging] = useState(false); + const zoomStageRef = useRef(null); + const zoomImageMetricsRef = useRef({ naturalWidth: 0, naturalHeight: 0 }); + const zoomDragRef = useRef(null); + const zoomSkipClickRef = useRef(false); + + const clampPan = useCallback( + (x, y) => { + const stage = zoomStageRef.current; + const { naturalWidth, naturalHeight } = zoomImageMetricsRef.current; + if (!stage || !naturalWidth || !naturalHeight) { + return { x: 0, y: 0 }; + } + const stageRect = stage.getBoundingClientRect(); + if (stageRect.width <= 0 || stageRect.height <= 0) { + return { x: 0, y: 0 }; + } + const imageWidth = zoomNative ? naturalWidth : Math.min(naturalWidth, stageRect.width); + const imageHeight = zoomNative ? naturalHeight : Math.min(naturalHeight, stageRect.height); + const limitX = Math.max(0, (imageWidth - stageRect.width) / 2); + const limitY = Math.max(0, (imageHeight - stageRect.height) / 2); + const clampedX = Math.min(Math.max(x, -limitX), limitX); + const clampedY = Math.min(Math.max(y, -limitY), limitY); + return { x: clampedX, y: clampedY }; + }, + [zoomNative], + ); useEffect(() => { if (!singleDoc) { @@ -384,6 +413,51 @@ const DetailPanel = ({ setZoomedPreview(null); }, [selectionKey]); + useEffect(() => { + if (!zoomedPreview) { + setZoomNative(false); + setZoomPan({ x: 0, y: 0 }); + setIsZoomDragging(false); + zoomDragRef.current = null; + zoomSkipClickRef.current = false; + } + }, [zoomedPreview]); + + useEffect(() => { + zoomSkipClickRef.current = false; + if (zoomNative) { + setZoomPan((current) => { + const clamped = clampPan(current.x, current.y); + if (clamped.x === current.x && clamped.y === current.y) { + return current; + } + return clamped; + }); + } else { + setZoomPan({ x: 0, y: 0 }); + setIsZoomDragging(false); + zoomDragRef.current = null; + } + }, [zoomNative, clampPan]); + + useEffect(() => { + if (!zoomNative) { + return undefined; + } + const handleResize = () => { + setZoomPan((current) => { + const clamped = clampPan(current.x, current.y); + if (clamped.x === current.x && clamped.y === current.y) { + return current; + } + return clamped; + }); + }; + window.addEventListener('resize', handleResize); + handleResize(); + return () => window.removeEventListener('resize', handleResize); + }, [zoomNative, clampPan]); + const startTitleEdit = useCallback(() => { if (!singleDoc) return; setTitleEditDocId(singleDoc.id); @@ -752,10 +826,22 @@ const DetailPanel = ({ mode: config.mode, docId: config.docId ?? null, }); + setZoomNative(false); + setZoomPan({ x: 0, y: 0 }); + setIsZoomDragging(false); + zoomDragRef.current = null; + zoomSkipClickRef.current = false; + zoomImageMetricsRef.current = { naturalWidth: 0, naturalHeight: 0 }; }, []); const closeZoomPreview = useCallback(() => { setZoomedPreview(null); + setZoomNative(false); + setZoomPan({ x: 0, y: 0 }); + setIsZoomDragging(false); + zoomDragRef.current = null; + zoomSkipClickRef.current = false; + zoomImageMetricsRef.current = { naturalWidth: 0, naturalHeight: 0 }; }, []); const handleSingleZoom = useCallback( @@ -777,6 +863,133 @@ const DetailPanel = ({ [openZoomPreview, stackTopDocId, topHasPreview], ); + const handleZoomImageLoad = useCallback( + (event) => { + zoomImageMetricsRef.current = { + naturalWidth: event.currentTarget.naturalWidth || 0, + naturalHeight: event.currentTarget.naturalHeight || 0, + }; + setZoomPan((current) => { + const clamped = clampPan(current.x, current.y); + return { x: clamped.x, y: clamped.y }; + }); + }, + [clampPan], + ); + + const handleZoomImageClick = useCallback( + (event) => { + event.stopPropagation(); + if (zoomSkipClickRef.current) { + zoomSkipClickRef.current = false; + return; + } + + if (!zoomNative) { + const stage = zoomStageRef.current; + const { naturalWidth, naturalHeight } = zoomImageMetricsRef.current; + if (stage && naturalWidth && naturalHeight) { + const stageRect = stage.getBoundingClientRect(); + const imageRect = event.currentTarget.getBoundingClientRect(); + const clickX = event.clientX - imageRect.left; + const clickY = event.clientY - imageRect.top; + const ratioX = imageRect.width ? clickX / imageRect.width : 0.5; + const ratioY = imageRect.height ? clickY / imageRect.height : 0.5; + const focusX = naturalWidth * ratioX; + const focusY = naturalHeight * ratioY; + const halfWidth = naturalWidth / 2; + const halfHeight = naturalHeight / 2; + const limitX = Math.max(0, (naturalWidth - stageRect.width) / 2); + const limitY = Math.max(0, (naturalHeight - stageRect.height) / 2); + let targetPanX = -(focusX - halfWidth); + let targetPanY = -(focusY - halfHeight); + targetPanX = Math.min(Math.max(targetPanX, -limitX), limitX); + targetPanY = Math.min(Math.max(targetPanY, -limitY), limitY); + setZoomPan({ x: targetPanX, y: targetPanY }); + } else { + setZoomPan({ x: 0, y: 0 }); + } + setZoomNative(true); + } else { + setZoomNative(false); + } + }, + [zoomNative], + ); + + const handleZoomImagePointerDown = useCallback( + (event) => { + if (!zoomNative || event.button !== 0) { + return; + } + event.preventDefault(); + zoomSkipClickRef.current = false; + zoomDragRef.current = { + pointerId: event.pointerId, + originX: zoomPan.x, + originY: zoomPan.y, + startX: event.clientX, + startY: event.clientY, + moved: false, + }; + event.currentTarget.setPointerCapture(event.pointerId); + }, + [zoomNative, zoomPan], + ); + + const handleZoomImagePointerMove = useCallback( + (event) => { + const drag = zoomDragRef.current; + if (!drag || drag.pointerId !== event.pointerId) { + return; + } + const deltaX = event.clientX - drag.startX; + const deltaY = event.clientY - drag.startY; + if (!drag.moved && (Math.abs(deltaX) > 2 || Math.abs(deltaY) > 2)) { + drag.moved = true; + setIsZoomDragging(true); + } + if (drag.moved) { + const clamped = clampPan(drag.originX + deltaX, drag.originY + deltaY); + setZoomPan((current) => + current.x === clamped.x && current.y === clamped.y ? current : clamped, + ); + } + }, + [clampPan], + ); + + const endZoomDrag = useCallback(() => { + zoomDragRef.current = null; + setIsZoomDragging(false); + }, []); + + const handleZoomImagePointerUp = useCallback( + (event) => { + const drag = zoomDragRef.current; + if (!drag || drag.pointerId !== event.pointerId) { + return; + } + event.currentTarget.releasePointerCapture(event.pointerId); + zoomSkipClickRef.current = Boolean(drag.moved); + endZoomDrag(); + }, + [endZoomDrag], + ); + + const handleZoomImagePointerCancel = useCallback( + (event) => { + const drag = zoomDragRef.current; + if (!drag || drag.pointerId !== event.pointerId) { + return; + } + event.currentTarget.releasePointerCapture(event.pointerId); + zoomSkipClickRef.current = true; + endZoomDrag(); + }, + [endZoomDrag], + ); + const zoomDisplay = useMemo(() => { if (!zoomedPreview) { return null; @@ -849,6 +1062,21 @@ const DetailPanel = ({ } }, [zoomedPreview, zoomDisplay]); + const zoomDisplayUrl = zoomDisplay?.url; + + useEffect(() => { + if (!zoomDisplayUrl) { + return; + } + setZoomPan((current) => { + const clamped = clampPan(current.x, current.y); + if (clamped.x === current.x && clamped.y === current.y) { + return current; + } + return clamped; + }); + }, [zoomDisplayUrl, clampPan]); + useEffect(() => { if (!zoomedPreview) { return undefined; @@ -878,6 +1106,38 @@ const DetailPanel = ({ return () => window.removeEventListener('keydown', handleKeyDown); }, [zoomedPreview, zoomDisplay]); + const zoomImageStyle = useMemo(() => { + if (!zoomNative) { + return { + cursor: 'zoom-in', + transform: 'none', + maxWidth: '95vw', + maxHeight: '95vh', + }; + } + const { naturalWidth, naturalHeight } = zoomImageMetricsRef.current; + return { + cursor: isZoomDragging ? 'grabbing' : 'grab', + width: naturalWidth ? `${naturalWidth}px` : 'auto', + height: naturalHeight ? `${naturalHeight}px` : 'auto', + maxWidth: 'none', + maxHeight: 'none', + transform: `translate3d(${zoomPan.x}px, ${zoomPan.y}px, 0)`, + }; + }, [zoomNative, zoomPan.x, zoomPan.y, isZoomDragging]); + + const zoomStageClassName = useMemo( + () => + [ + 'preview-zoom__stage', + zoomNative ? 'preview-zoom__stage--native' : '', + isZoomDragging ? 'preview-zoom__stage--dragging' : '', + ] + .filter(Boolean) + .join(' '), + [zoomNative, isZoomDragging], + ); + const renderSingle = () => { if (!singleDoc) { return

Select a document to view metadata, tags and actions.

; @@ -1321,46 +1581,52 @@ const DetailPanel = ({ aria-label="Enlarged document preview" onClick={closeZoomPreview} > -
event.stopPropagation()}> +
{zoomDisplay.alt} { - event.stopPropagation(); - closeZoomPreview(); - }} + style={zoomImageStyle} + onLoad={handleZoomImageLoad} + onClick={handleZoomImageClick} + onPointerDown={handleZoomImagePointerDown} + onPointerMove={handleZoomImagePointerMove} + onPointerUp={handleZoomImagePointerUp} + onPointerCancel={handleZoomImagePointerCancel} + draggable={false} /> -
- - -
+ {zoomDisplay.canGoPrev || zoomDisplay.canGoNext ? ( +
+ + +
+ ) : null}
, document.body, diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 9fde27c..7f0df53 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -233,9 +233,17 @@ button.danger:hover:not([disabled]) { flex-direction: column; align-items: center; justify-content: center; - gap: 1.25rem; max-width: 95vw; max-height: 95vh; + cursor: zoom-in; +} + +.preview-zoom__stage--native { + cursor: grab; +} + +.preview-zoom__stage--dragging { + cursor: grabbing; } .preview-zoom__image { @@ -259,11 +267,13 @@ button.danger:hover:not([disabled]) { gap: 0.9rem; opacity: 0; transition: opacity 0.18s ease; + pointer-events: none; } .preview-zoom__stage:hover .preview-zoom__nav, .preview-zoom__stage:focus-within .preview-zoom__nav { opacity: 1; + pointer-events: auto; } .preview-zoom__nav-button { @@ -1826,7 +1836,6 @@ button.danger:hover:not([disabled]) { filter: drop-shadow(0 2px 6px var(--shadow-soft)); transform-origin: center; border-radius: 6px; - overflow: hidden; pointer-events: none; }