diff --git a/frontend/src/detail/PreviewZoomOverlay.jsx b/frontend/src/detail/PreviewZoomOverlay.jsx index b579e13..eeaa901 100644 --- a/frontend/src/detail/PreviewZoomOverlay.jsx +++ b/frontend/src/detail/PreviewZoomOverlay.jsx @@ -17,6 +17,8 @@ const ensureDocumentRoot = () => { return document.body; }; +const CLICK_DELAY_MS = 240; + const PreviewZoomOverlay = ({ open = false, display = null, @@ -34,6 +36,7 @@ const PreviewZoomOverlay = ({ const previouslyFocusedRef = useRef(null); const visibilityTimerRef = useRef(null); const displayTimerRef = useRef(null); + const clickTimerRef = useRef(null); useEffect(() => { if (display?.url) { @@ -86,12 +89,20 @@ const PreviewZoomOverlay = ({ if (displayTimerRef.current) { cancelAnimationFrame(displayTimerRef.current); } + if (clickTimerRef.current) { + clearTimeout(clickTimerRef.current); + clickTimerRef.current = null; + } }, []); useEffect(() => { setIsNativeScale(false); setNaturalSize({ width: null, height: null }); focusRef.current = null; + if (clickTimerRef.current) { + clearTimeout(clickTimerRef.current); + clickTimerRef.current = null; + } const scrollEl = scrollRef.current; if (scrollEl) { scrollEl.scrollLeft = 0; @@ -198,6 +209,45 @@ const PreviewZoomOverlay = ({ const effectiveDisplay = activeDisplay; const navVisible = Boolean(effectiveDisplay?.canGoPrev || effectiveDisplay?.canGoNext); + + const toggleZoomAtPoint = (clientX, clientY) => { + const img = imageRef.current; + setIsNativeScale((current) => { + if (!current && img) { + const rect = img.getBoundingClientRect(); + const xRatio = rect.width > 0 ? (clientX - rect.left) / rect.width : 0.5; + const yRatio = rect.height > 0 ? (clientY - rect.top) / rect.height : 0.5; + focusRef.current = { + xRatio: clamp(xRatio, 0, 1), + yRatio: clamp(yRatio, 0, 1), + }; + } else { + focusRef.current = null; + } + return !current; + }); + }; + + const handleImagePointerDown = (event) => { + event.stopPropagation(); + if (event.button && event.button !== 0) { + return; + } + if (event.pointerType === 'touch' && event.isPrimary === false) { + return; + } + if (clickTimerRef.current) { + clearTimeout(clickTimerRef.current); + clickTimerRef.current = null; + toggleZoomAtPoint(event.clientX, event.clientY); + return; + } + + clickTimerRef.current = setTimeout(() => { + clickTimerRef.current = null; + onClose(); + }, CLICK_DELAY_MS); + }; const stageClassName = [ 'preview-zoom__stage', ] @@ -225,11 +275,13 @@ const PreviewZoomOverlay = ({ height: naturalSize.height ? `${naturalSize.height}px` : 'auto', maxWidth: 'none', maxHeight: 'none', + touchAction: 'manipulation', } : { cursor: 'zoom-in', maxWidth: '95vw', maxHeight: '95vh', + touchAction: 'manipulation', }; return createPortal( @@ -263,26 +315,7 @@ const PreviewZoomOverlay = ({ height: event.currentTarget.naturalHeight || null, }); }} - onClick={(event) => { - event.stopPropagation(); - if (!isNativeScale) { - const img = imageRef.current; - if (img) { - const rect = img.getBoundingClientRect(); - const xRatio = rect.width > 0 ? (event.clientX - rect.left) / rect.width : 0.5; - const yRatio = rect.height > 0 ? (event.clientY - rect.top) / rect.height : 0.5; - focusRef.current = { - xRatio: clamp(xRatio, 0, 1), - yRatio: clamp(yRatio, 0, 1), - }; - } else { - focusRef.current = null; - } - } else { - focusRef.current = null; - } - setIsNativeScale((current) => !current); - }} + onPointerDown={handleImagePointerDown} style={imageStyle} /> diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 4cfc15a..66aebf6 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -2482,7 +2482,7 @@ button.danger:hover:not([disabled]) { position: absolute; top: 0; right: 0; - width: var(--detail-panel-width); + width: min(100vw, var(--detail-panel-width)); min-height: 100vh; height: 100%; height: 100%;