diff --git a/frontend/src/detail/DetailPanel.jsx b/frontend/src/detail/DetailPanel.jsx index ea3752a..73fdee5 100644 --- a/frontend/src/detail/DetailPanel.jsx +++ b/frontend/src/detail/DetailPanel.jsx @@ -1,8 +1,9 @@ -import React, { useCallback, useEffect, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { DownloadIcon, DetailPanelCollapseIcon, WindowMaximizeIcon, + IconZoomInArea, } from '../ui/icons'; import PanelHeader from '../ui/PanelHeader'; import { resolveDocumentAssetUrl } from '../asset_manager'; @@ -12,6 +13,8 @@ import { sortCorrespondents, buildCorrespondentOptions } from '../documents/Docu import BreadcrumbTrail from '../ui/BreadcrumbTrail'; import { extractDocumentMetadataPayload } from '../documents/documentMetadata'; import DocumentViewerLayout from '../preview/DocumentViewerLayout'; +import PreviewZoomOverlay from './PreviewZoomOverlay'; +import { useAssetNavigator } from '../hooks/useAssetNavigator'; const DetailPanel = ({ document = null, @@ -35,6 +38,19 @@ const DetailPanel = ({ onClose = () => {}, }) => { const singleDoc = document || null; + const [zoomOverlayOpen, setZoomOverlayOpen] = useState(false); + const previewNavigator = useAssetNavigator({ + document: singleDoc, + assetType: 'preview', + ensureAssetUrl, + getAsset: getDocumentAsset, + prefetch: 3, + }); + const navigatorUrl = previewNavigator?.currentUrl; + const navigatorCanGoPrev = Boolean(previewNavigator?.canGoPrev); + const navigatorCanGoNext = Boolean(previewNavigator?.canGoNext); + const navigatorGoPrev = previewNavigator?.goPrev; + const navigatorGoNext = previewNavigator?.goNext; const { downloadHref: singleDownloadHref } = useMemo( () => @@ -199,15 +215,19 @@ const DetailPanel = ({ } return ( -
- +
+
+
+ +
+
); }; @@ -264,27 +284,83 @@ const DetailPanel = ({ ); } + const zoomDisplay = useMemo(() => { + if (navigatorUrl && singleDoc) { + return { + url: navigatorUrl, + alt: singleDoc.title, + canGoPrev: navigatorCanGoPrev, + canGoNext: navigatorCanGoNext, + goPrev: navigatorCanGoPrev ? navigatorGoPrev : undefined, + goNext: navigatorCanGoNext ? navigatorGoNext : undefined, + }; + } + if (singleDoc && previewEntry?.url) { + return { + url: previewEntry.url, + alt: singleDoc.title, + canGoPrev: false, + canGoNext: false, + }; + } + return null; + }, [ + navigatorUrl, + navigatorCanGoPrev, + navigatorCanGoNext, + navigatorGoPrev, + navigatorGoNext, + singleDoc, + previewEntry?.url, + ]); + + if (singleDoc && zoomDisplay) { + headerActions.push( + , + ); + } + + useEffect(() => { + setZoomOverlayOpen(false); + }, [previewEntry?.url, singleDoc?.id]); + return ( - + ); }; diff --git a/frontend/src/detail/useDetailWorkspace.js b/frontend/src/detail/useDetailWorkspace.js index 27859c6..c4c6c66 100644 --- a/frontend/src/detail/useDetailWorkspace.js +++ b/frontend/src/detail/useDetailWorkspace.js @@ -10,7 +10,6 @@ import { const useDetailWorkspace = ({ documents, searchResults, - focusedDocumentId, selectionOrder, selectedDocumentIds, documentLookup, @@ -37,14 +36,6 @@ const useDetailWorkspace = ({ tags, tagLookupById, }) => { - const selectedDocument = useMemo(() => { - if (!focusedDocumentId) { - return null; - } - const pool = searchResults ?? documents; - return pool.find((doc) => doc.id === focusedDocumentId) || null; - }, [focusedDocumentId, searchResults, documents]); - const orderedSelectedDocuments = useMemo(() => { const ordered = []; const seen = new Set(); @@ -185,12 +176,12 @@ const useDetailWorkspace = ({ [folderNodes], ); - const selectedPreviewEntry = useMemo(() => { - if (!selectedDocument) { + const detailPanelPreviewEntry = useMemo(() => { + if (!detailPanelDocument) { return null; } - return previewEntries.get(selectedDocument.id) || null; - }, [selectedDocument, previewEntries]); + return previewEntries.get(detailPanelDocument.id) || null; + }, [detailPanelDocument, previewEntries]); const previewWorkspaceEntry = useMemo(() => { if (!previewDocumentId) { @@ -239,7 +230,7 @@ const useDetailWorkspace = ({ tagLookupById, onTagAdd: handleDocumentTagAdd, onTagRemove: handleTagRemove, - previewEntry: selectedPreviewEntry, + previewEntry: detailPanelPreviewEntry, onOpenPreview: openDocumentPreview, activePreviewId, onUpdateTitle: handleDocumentTitleUpdate, @@ -273,7 +264,7 @@ const useDetailWorkspace = ({ resolveApiPath, resolveFolderPath, selectFolder, - selectedPreviewEntry, + detailPanelPreviewEntry, tags, tagLookupById, ], diff --git a/frontend/src/preview/DocumentViewerPanel.jsx b/frontend/src/preview/DocumentViewerPanel.jsx index cc41e41..f72e7ed 100644 --- a/frontend/src/preview/DocumentViewerPanel.jsx +++ b/frontend/src/preview/DocumentViewerPanel.jsx @@ -6,7 +6,9 @@ import React, { useRef, useState, } from 'react'; -import { DownloadIcon, CloseIcon } from '../ui/icons'; +import { useNavigate } from 'react-router-dom'; +import { DownloadIcon, CloseIcon, IconZoomInArea } from '../ui/icons'; +import PreviewZoomOverlay from '../detail/PreviewZoomOverlay'; import { buildCorrespondentOptions, sortCorrespondents, @@ -14,10 +16,13 @@ import { import { extractDocumentMetadataPayload } from '../documents/documentMetadata'; import { createDocumentActionState } from '../documents/documentActions'; import { resolveDocumentAssetUrl } from '../asset_manager'; +import PanelHeader from '../ui/PanelHeader'; +import BreadcrumbTrail from '../ui/BreadcrumbTrail'; +import { useAssetNavigator } from '../hooks/useAssetNavigator'; import DocumentViewerLayout from './DocumentViewerLayout'; const PORTRAIT_WIDTH_TO_HEIGHT = 1 / Math.sqrt(2); // ≈0.707 (A-series aspect ratio) -const DOCUMENT_VIEWER_PORTRAIT_HEIGHT_RATIO = 0.6; +const DOCUMENT_VIEWER_PORTRAIT_HEIGHT_RATIO = 0.4; const PORTRAIT_RATIO_STYLE_ID = 'document-viewer-portrait-ratio-style'; const ensurePortraitRatioStyle = () => { @@ -56,6 +61,51 @@ const computeStackedLayoutBreakpoint = () => { return desiredWidth; }; +export const createDocumentViewerHeaderActions = ({ + document, + actionState, + previewEntry, + onZoom, + canZoom = false, +}) => { + if (!document) { + return null; + } + + const downloadHref = actionState?.downloadHref || previewEntry?.url; + if (!downloadHref && !(canZoom && onZoom)) { + return null; + } + + return ( + <> + {downloadHref ? ( + + + + ) : null} + {canZoom && onZoom ? ( + + ) : null} + + ); +}; + const DocumentViewerPanel = ({ document, previewEntry, @@ -72,7 +122,14 @@ const DocumentViewerPanel = ({ hasOcr = false, ensureAssetUrl, getDocumentAsset, + ensurePreviewData, + resolveApiPath, + notifyApiError, + sidebarToggle = null, + onClosePanel, + resolveFolderPath, }) => { + const navigate = useNavigate(); const sortedCorrespondents = useMemo( () => sortCorrespondents(document?.correspondents || []), [document], @@ -171,6 +228,34 @@ const DocumentViewerPanel = ({ const viewerRef = useRef(null); const [isStackedLayout, setIsStackedLayout] = useState(false); + const [zoomOverlayOpen, setZoomOverlayOpen] = useState(false); + const previewNavigator = useAssetNavigator({ + document, + assetType: 'preview', + ensureAssetUrl, + getAsset: getDocumentAsset, + prefetch: 3, + }); + const navigatorUrl = previewNavigator?.currentUrl; + const navigatorCanGoPrev = Boolean(previewNavigator?.canGoPrev); + const navigatorCanGoNext = Boolean(previewNavigator?.canGoNext); + const navigatorGoPrev = previewNavigator?.goPrev; + const navigatorGoNext = previewNavigator?.goNext; + + const handleZoomOpen = useCallback(() => { + if (!previewEntry?.url) { + return; + } + setZoomOverlayOpen(true); + }, [previewEntry?.url]); + + const handleZoomClose = useCallback(() => { + setZoomOverlayOpen(false); + }, []); + + useEffect(() => { + setZoomOverlayOpen(false); + }, [previewEntry?.url, document?.id]); useEffect(() => { if (typeof hydrateDocument === 'function' && document?.id) { @@ -237,71 +322,197 @@ const DocumentViewerPanel = ({ ? 'document-viewer document-viewer--stacked' : 'document-viewer'; + const actionState = useMemo( + () => + document + ? createDocumentActionState({ + document, + resolveApiPath, + ensurePreviewData, + ensureAssetUrl, + getDocumentAsset, + notifyApiError, + ocrErrorMessage: 'Unable to open OCR text.', + }) + : null, + [ + document, + resolveApiPath, + ensurePreviewData, + ensureAssetUrl, + getDocumentAsset, + notifyApiError, + ], + ); + + const breadcrumbs = useMemo(() => { + if (!document || typeof resolveFolderPath !== 'function') { + return []; + } + const folderSegments = resolveFolderPath(document.folder_id); + const normalizedSegments = Array.isArray(folderSegments) + ? folderSegments + .filter((segment) => segment && segment.id && segment.name) + .map((segment) => ({ id: segment.id, name: segment.name })) + : []; + + return [ + ...normalizedSegments, + { id: document.id, name: document.title }, + ]; + }, [document, resolveFolderPath]); + + const handleBreadcrumbNavigate = useCallback( + (crumb) => { + if (!crumb?.id) { + return; + } + const target = crumb.id === 'root' + ? '/documents' + : `/documents/folder/${crumb.id}`; + navigate(target); + }, + [navigate], + ); + + const breadcrumbTrailEntries = useMemo(() => { + if (!breadcrumbs.length) { + return []; + } + const lastIndex = breadcrumbs.length - 1; + return breadcrumbs.map((crumb, index) => ({ + id: crumb.id, + label: crumb.name, + onClick: index < lastIndex ? () => handleBreadcrumbNavigate(crumb) : null, + })); + }, [breadcrumbs, handleBreadcrumbNavigate]); + + const zoomDisplay = useMemo(() => { + if (navigatorUrl && document) { + return { + url: navigatorUrl, + alt: document.title, + canGoPrev: navigatorCanGoPrev, + canGoNext: navigatorCanGoNext, + goPrev: navigatorCanGoPrev ? navigatorGoPrev : undefined, + goNext: navigatorCanGoNext ? navigatorGoNext : undefined, + }; + } + if (previewEntry?.url && document) { + return { + url: previewEntry.url, + alt: document.title, + canGoPrev: false, + canGoNext: false, + }; + } + return null; + }, [ + navigatorUrl, + navigatorCanGoPrev, + navigatorCanGoNext, + navigatorGoPrev, + navigatorGoNext, + previewEntry?.url, + document, + ]); + + const headerActions = createDocumentViewerHeaderActions({ + document, + actionState, + previewEntry, + onZoom: zoomDisplay ? handleZoomOpen : null, + canZoom: Boolean(zoomDisplay), + }); + + const closeButton = onClosePanel + ? ( + + ) + : null; + + const headerLeadingButtons = [sidebarToggle, closeButton].filter(Boolean); + const headerLeadingContent = headerLeadingButtons.length + ? ( + <> + {headerLeadingButtons} + + ) + : null; + if (!document) { return ( -
-
-
-
- Loading document… +
+ +
+
+
+
+
+ Loading document… +
+
-
-
-
-
Preparing preview…
+
+
Preparing preview…
+
+
); } + const headerTitle = breadcrumbTrailEntries.length ? ( + + ) : ( + document?.title || 'Document preview' + ); + return ( -
- +
+ +
+
+ +
+
+
+ -
+ ); }; export default DocumentViewerPanel; -export const createDocumentViewerHeaderActions = ({ - document, - actionState, - previewEntry, -}) => { - if (!document) { - return null; - } - - const downloadHref = actionState?.downloadHref || previewEntry?.url; - if (!downloadHref) { - return null; - } - - return ( - <> - {downloadHref ? ( - - - - ) : null} - - ); -}; - export const createDocumentViewerSurface = ({ document, previewEntry, @@ -327,70 +538,18 @@ export const createDocumentViewerSurface = ({ return null; } - const title = document.title || 'Document preview'; - const closeButton = onClose - ? ( - - ) + const hasOcr = typeof getDocumentAsset === 'function' + ? Boolean(getDocumentAsset(document, 'ocr-text')) + : false; + + const sidebarToggle = typeof renderSidebarToggle === 'function' + ? renderSidebarToggle() : null; - const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null; - const leading = closeButton || sidebarToggle - ? ( - <> - {sidebarToggle} - {closeButton} - - ) - : null; - let breadcrumbs = null; - if (typeof resolveFolderPath === 'function') { - const folderSegments = resolveFolderPath(document.folder_id); - const normalizedSegments = Array.isArray(folderSegments) - ? folderSegments - .filter((segment) => segment && segment.id && segment.name) - .map((segment) => ({ id: segment.id, name: segment.name })) - : []; - - breadcrumbs = [ - ...normalizedSegments, - { id: document.id, name: document.title }, - ]; - } - - const actionState = createDocumentActionState({ - document, - resolveApiPath, - ensurePreviewData, - ensureAssetUrl, - getDocumentAsset, - notifyApiError, - ocrErrorMessage: 'Unable to open OCR text.', - }); - - const header = { - title, - subtitle: null, - leading, - actions: createDocumentViewerHeaderActions({ - document, - actionState, - previewEntry, - }), - breadcrumbs, - }; return { key: 'preview', variant: 'preview', - header, + header: null, content: ( ), supportsDetail: false, diff --git a/frontend/src/styles/detail/detail-panels.css b/frontend/src/styles/detail/detail-panels.css index 91a4154..dd76a19 100644 --- a/frontend/src/styles/detail/detail-panels.css +++ b/frontend/src/styles/detail/detail-panels.css @@ -60,11 +60,24 @@ flex: 1; display: flex; flex-direction: column; - overflow-y: auto; min-height: 0; padding: 0; } +.detail-panel__content { + flex: 1; + min-height: 0; + overflow: auto; +} + +.detail-panel__content .document-viewer-panel { + flex: 1; +} + +.detail-panel__content .document-viewer-panel__body { + padding: 0.5rem 1rem; +} + .detail-section__header { display: flex; align-items: center; diff --git a/frontend/src/styles/documents/viewer.css b/frontend/src/styles/documents/viewer.css index 75bfe27..f415d16 100644 --- a/frontend/src/styles/documents/viewer.css +++ b/frontend/src/styles/documents/viewer.css @@ -1,3 +1,24 @@ +.document-viewer-panel { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + background: var(--bg); +} + +.document-viewer-panel__body { + flex: 1; + min-height: 0; + display: flex; + padding: 0; + overflow: auto; +} + +.document-viewer-panel__body > .document-viewer, +.document-viewer-panel__body > .document-viewer--loading { + flex: 1; +} + .document-viewer { flex: 1; display: grid; @@ -10,14 +31,8 @@ } .document-viewer--stacked { - display: grid; - grid-template-columns: minmax(0, 1fr); - grid-template-rows: - minmax(auto, 1fr) - auto; - grid-template-areas: - 'viewport' - 'details'; + display: flex; + flex-direction: column; gap: 0.5rem; padding: 0.5rem; overflow: auto; @@ -25,16 +40,19 @@ .document-viewer--stacked .document-viewer__viewport { width: 100%; - margin: 0; display: flex; flex-direction: column; - justify-content: flex-start; - align-items: stretch; + justify-content: center; + align-items: center; + flex: 2 2 auto; max-height: calc(var(--document-viewer-portrait-height-ratio) * 100vh); + order: -1; } .document-viewer--stacked .document-viewer__details-pane { - overflow: visible; + flex: 1 1 auto; + overflow: auto; + order: 0; } .document-drag-preview { @@ -322,6 +340,7 @@ overflow: hidden; align-items: stretch; justify-content: center; + max-height: 100%; grid-area: viewport; } @@ -334,9 +353,6 @@ height: 100%; } - - - .document-viewer__object--image { width: auto; height: auto; @@ -345,6 +361,15 @@ object-fit: contain; } +.document-viewer--stacked .document-viewer__object:not(.document-viewer__object--image) { + height: calc(var(--document-viewer-portrait-height-ratio) * 100vh); + max-height: 100%; +} + +.document-viewer--stacked .document-viewer__object--image { + height: auto; +} + .document-viewer__unsupported { height: 100%; width: 100%; diff --git a/frontend/src/ui/icons.js b/frontend/src/ui/icons.js index d8a8dfc..f839a7b 100644 --- a/frontend/src/ui/icons.js +++ b/frontend/src/ui/icons.js @@ -1,6 +1,7 @@ import { IconChevronRight as TablerChevronRight, IconDownload as TablerDownload, + IconZoomInArea as TablerZoomInArea, IconPencil, IconTagFilled, IconUserFilled, @@ -118,6 +119,15 @@ export const DownloadIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) /> ); +export const IconZoomInArea = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( + +); + export const ViewListIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (