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 ( -