diff --git a/frontend/src/detail/DetailPanel.jsx b/frontend/src/detail/DetailPanel.jsx index 2eae843..f0fb139 100644 --- a/frontend/src/detail/DetailPanel.jsx +++ b/frontend/src/detail/DetailPanel.jsx @@ -217,6 +217,7 @@ const PreviewStack = ({ emptyMessage = 'Preview unavailable', onItemActivate, onOpenPreview, + onZoomPreview, activeItemId = null, }) => { if (!items.length) { @@ -249,7 +250,11 @@ const PreviewStack = ({ zIndex: preparedItems.length - index, transform, }} - aria-hidden={hasMultiple && !onItemActivate && !onOpenPreview ? 'true' : undefined} + aria-hidden={ + hasMultiple && !onItemActivate && !onOpenPreview && !onZoomPreview + ? 'true' + : undefined + } > { event.stopPropagation(); - if (isFront && onOpenPreview) { - onOpenPreview(entry.id); + if (isFront) { + if (onZoomPreview) { + onZoomPreview(entry); + } else if (onOpenPreview) { + onOpenPreview(entry.id); + } else if (onItemActivate) { + onItemActivate(entry.id); + } } else if (onItemActivate) { onItemActivate(entry.id); } }} onKeyDown={(event) => { - if (!onItemActivate && !onOpenPreview) return; + if (!onItemActivate && !onOpenPreview && !onZoomPreview) return; if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); event.stopPropagation(); - if (isFront && onOpenPreview) { - onOpenPreview(entry.id); + if (isFront) { + if (onZoomPreview) { + onZoomPreview(entry); + } else if (onOpenPreview) { + onOpenPreview(entry.id); + } else { + onItemActivate?.(entry.id); + } } else { onItemActivate?.(entry.id); } @@ -310,6 +327,11 @@ const DetailPanel = ({ }) => { const selectedCount = selectedDocuments.length; const singleDoc = selectedCount === 1 ? selectedDocuments[0] : null; + const singleDocId = singleDoc?.id || null; + const selectionKey = useMemo( + () => selectedDocuments.map((doc) => doc?.id ?? '').join('|'), + [selectedDocuments], + ); const singleDownloadHref = useMemo(() => { if (!singleDoc) return null; @@ -328,6 +350,7 @@ const DetailPanel = ({ const [ocrUrl, setOcrUrl] = useState(null); const [ocrLoading, setOcrLoading] = useState(false); const [ocrError, setOcrError] = useState(null); + const [zoomedPreview, setZoomedPreview] = useState(null); useEffect(() => { if (!singleDoc) { @@ -357,6 +380,10 @@ const DetailPanel = ({ setOcrUrl(null); }, [singleDoc?.id]); + useEffect(() => { + setZoomedPreview(null); + }, [selectionKey]); + const startTitleEdit = useCallback(() => { if (!singleDoc) return; setTitleEditDocId(singleDoc.id); @@ -519,6 +546,7 @@ const DetailPanel = ({ }, [selectedDocuments]); const stackTopDocument = stackDocuments[0] || null; + const stackTopDocId = stackTopDocument?.id || null; const stackPreviewNavigator = useAssetNavigator({ document: stackTopDocument, assetType: 'preview', @@ -718,6 +746,138 @@ const DetailPanel = ({ [bulkCorrespondents, onBulkCorrespondentRemove, onCorrespondentRemove, selectedDocuments], ); + const openZoomPreview = useCallback((config) => { + if (!config) return; + setZoomedPreview({ + mode: config.mode, + docId: config.docId ?? null, + }); + }, []); + + const closeZoomPreview = useCallback(() => { + setZoomedPreview(null); + }, []); + + const handleSingleZoom = useCallback( + (entry) => { + if (!singleHasPreview) return; + const targetId = entry?.id ?? singleDocId; + if (!targetId) return; + openZoomPreview({ mode: 'single', docId: targetId }); + }, + [openZoomPreview, singleHasPreview, singleDocId], + ); + + const handleStackZoom = useCallback( + (entry) => { + if (!stackTopDocId || entry?.id !== stackTopDocId) return; + if (!topHasPreview) return; + openZoomPreview({ mode: 'stack', docId: stackTopDocId }); + }, + [openZoomPreview, stackTopDocId, topHasPreview], + ); + + const zoomDisplay = useMemo(() => { + if (!zoomedPreview) { + return null; + } + + if ( + zoomedPreview.mode === 'single' && + singleDocId && + singleDoc && + singleHasPreview && + zoomedPreview.docId === singleDocId + ) { + return { + url: singlePreviewNavigator.currentUrl, + alt: singleDoc.title || singleDoc.original_name || 'Document preview', + canGoPrev: + singleEffectiveCardinality > 1 && Boolean(singlePreviewNavigator.canGoPrev), + canGoNext: + singleEffectiveCardinality > 1 && Boolean(singlePreviewNavigator.canGoNext), + goPrev: singlePreviewNavigator.goPrev, + goNext: singlePreviewNavigator.goNext, + }; + } + + if ( + zoomedPreview.mode === 'stack' && + stackTopDocId && + stackTopDocument && + zoomedPreview.docId === stackTopDocId && + topHasPreview + ) { + return { + url: stackPreviewNavigator.currentUrl, + alt: stackTopDocument.title || stackTopDocument.original_name || 'Document preview', + canGoPrev: + topEffectiveCardinality > 1 && Boolean(stackPreviewNavigator.canGoPrev), + canGoNext: + topEffectiveCardinality > 1 && Boolean(stackPreviewNavigator.canGoNext), + goPrev: stackPreviewNavigator.goPrev, + goNext: stackPreviewNavigator.goNext, + }; + } + + return null; + }, [ + zoomedPreview, + singleDoc, + singleDocId, + singleHasPreview, + singlePreviewNavigator.currentUrl, + singlePreviewNavigator.canGoPrev, + singlePreviewNavigator.canGoNext, + singlePreviewNavigator.goPrev, + singlePreviewNavigator.goNext, + singleEffectiveCardinality, + stackTopDocument, + stackTopDocId, + topHasPreview, + stackPreviewNavigator.currentUrl, + stackPreviewNavigator.canGoPrev, + stackPreviewNavigator.canGoNext, + stackPreviewNavigator.goPrev, + stackPreviewNavigator.goNext, + topEffectiveCardinality, + ]); + + useEffect(() => { + if (zoomedPreview && !zoomDisplay) { + setZoomedPreview(null); + } + }, [zoomedPreview, zoomDisplay]); + + useEffect(() => { + if (!zoomedPreview) { + return undefined; + } + const handleKeyDown = (event) => { + if (event.key === 'Escape') { + setZoomedPreview(null); + return; + } + if (event.key === 'ArrowLeft') { + event.preventDefault(); + event.stopPropagation(); + if (zoomDisplay?.canGoPrev) { + zoomDisplay.goPrev?.(); + } + return; + } + if (event.key === 'ArrowRight') { + event.preventDefault(); + event.stopPropagation(); + if (zoomDisplay?.canGoNext) { + zoomDisplay.goNext?.(); + } + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [zoomedPreview, zoomDisplay]); + const renderSingle = () => { if (!singleDoc) { return

Select a document to view metadata, tags and actions.

; @@ -759,6 +919,7 @@ const DetailPanel = ({ emptyMessage="Preview loading…" onItemActivate={handlePreviewActivate} onOpenPreview={onOpenPreview} + onZoomPreview={handleSingleZoom} activeItemId={activePreviewId} /> {hasPreviewImage && (effectiveCardinality > 1 || canGoPrev || canGoNext) ? ( @@ -980,6 +1141,7 @@ const DetailPanel = ({ emptyMessage="No previews available." onItemActivate={handlePreviewActivate} onOpenPreview={onOpenPreview} + onZoomPreview={handleStackZoom} activeItemId={activePreviewId} /> {topDocIdLocal && topHasPreview && (topCardinalityLocal > 1 || topCanGoPrev || topCanGoNext) ? ( @@ -1060,13 +1222,14 @@ const DetailPanel = ({ const showOcrAction = Boolean(singleDoc && hasOcrAsset); return ( - + + {zoomDisplay?.url + ? createPortal( +
+
event.stopPropagation()}> + {zoomDisplay.alt} { + event.stopPropagation(); + closeZoomPreview(); + }} + /> +
+ + +
+
+
, + document.body, + ) + : null} + ); }; diff --git a/frontend/src/styles.css b/frontend/src/styles.css index acacaed..9fde27c 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -215,6 +215,92 @@ button.danger:hover:not([disabled]) { background: var(--sidebar-hover-bg); } +.preview-zoom-backdrop { + position: fixed; + inset: 0; + background: var(--overlay-backdrop); + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; + z-index: 3000; + cursor: zoom-out; +} + +.preview-zoom__stage { + position: relative; + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1.25rem; + max-width: 95vw; + max-height: 95vh; +} + +.preview-zoom__image { + max-width: 95vw; + max-height: 95vh; + width: auto; + height: auto; + border-radius: 0; + box-shadow: 0 32px 120px rgba(15, 23, 42, 0.55); + cursor: inherit; +} + +.preview-zoom__nav { + position: absolute; + bottom: 1em; + left: 50%; + transform: translateX(-50%); + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.9rem; + opacity: 0; + transition: opacity 0.18s ease; +} + +.preview-zoom__stage:hover .preview-zoom__nav, +.preview-zoom__stage:focus-within .preview-zoom__nav { + opacity: 1; +} + +.preview-zoom__nav-button { + width: 2.8rem; + height: 2.8rem; + border: none; + border-radius: 999px; + background: rgba(0, 0, 0, 0.62); + color: #fff; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 1; + transition: background 0.15s ease, transform 0.15s ease; + box-shadow: 0 12px 28px rgba(15, 23, 42, 0.28); +} + +.preview-zoom__nav-button:hover { + background: rgba(0, 0, 0, 0.82); +} + +.preview-zoom__nav-button:focus-visible { + outline: 2px solid var(--accent, #4f46e5); + outline-offset: 3px; +} + +.preview-zoom__nav-button svg { + width: 1.5rem; + height: 1.5rem; +} + +.preview-zoom__nav-button[disabled] { + opacity: 0.35; + cursor: default; +} + .with-icon { display: inline-flex; align-items: center;