This commit is contained in:
2025-11-11 12:01:22 +01:00
parent 2dd786ce14
commit 10f9f3be2f
6 changed files with 452 additions and 172 deletions
+105 -29
View File
@@ -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 (
<section className="document-viewer document-viewer--stacked">
<DocumentViewerLayout
document={singleDoc}
previewEntry={previewEntry}
summaryProps={singleSummaryProps}
metadataPayload={singleMetadataPayload}
contentTabConfig={singleContentConfig}
previewLoadingMessage="Loading preview…"
/>
<section className="document-viewer-panel document-viewer-panel--stacked">
<div className="document-viewer-panel__body">
<section className="document-viewer document-viewer--stacked">
<DocumentViewerLayout
document={singleDoc}
previewEntry={previewEntry}
summaryProps={singleSummaryProps}
metadataPayload={singleMetadataPayload}
contentTabConfig={singleContentConfig}
previewLoadingMessage="Loading preview…"
/>
</section>
</div>
</section>
);
};
@@ -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(
<button
key="zoom"
type="button"
className="icon-button"
onClick={() => setZoomOverlayOpen(true)}
aria-label="Open zoom preview"
title="Open zoom preview"
>
<IconZoomInArea />
</button>,
);
}
useEffect(() => {
setZoomOverlayOpen(false);
}, [previewEntry?.url, singleDoc?.id]);
return (
<aside className="detail-panel panel">
<PanelHeader
leading={headerLeading}
title={
headerBreadcrumbs ? (
<BreadcrumbTrail
entries={headerBreadcrumbs}
separator="/"
className="panel-header__breadcrumbs"
truncateFromStart
/>
) : (
headerTitle
)
}
titleTag="h3"
actions={headerActions.length ? headerActions : null}
<>
<aside className="detail-panel panel">
<PanelHeader
leading={headerLeading}
title={
headerBreadcrumbs ? (
<BreadcrumbTrail
entries={headerBreadcrumbs}
separator="/"
className="panel-header__breadcrumbs"
truncateFromStart
/>
) : (
headerTitle
)
}
titleTag="h3"
actions={headerActions.length ? headerActions : null}
/>
<div className="panel-body detail-panel__content">{renderContent()}</div>
</aside>
<PreviewZoomOverlay
open={Boolean(zoomOverlayOpen && zoomDisplay)}
display={zoomDisplay}
onClose={() => setZoomOverlayOpen(false)}
/>
<div className="panel-body">{renderContent()}</div>
</aside>
</>
);
};
+6 -15
View File
@@ -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,
],
+277 -112
View File
@@ -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 ? (
<a
className="icon-button"
href={downloadHref}
target="_blank"
rel="noopener noreferrer"
aria-label="Download document"
title="Download document"
>
<DownloadIcon />
</a>
) : null}
{canZoom && onZoom ? (
<button
type="button"
className="icon-button"
onClick={onZoom}
aria-label="Open zoom preview"
title="Open zoom preview"
>
<IconZoomInArea />
</button>
) : 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
? (
<button
type="button"
className="icon-button"
onClick={() => onClosePanel?.()}
aria-label="Close preview"
title="Close preview"
>
<CloseIcon />
</button>
)
: null;
const headerLeadingButtons = [sidebarToggle, closeButton].filter(Boolean);
const headerLeadingContent = headerLeadingButtons.length
? (
<>
{headerLeadingButtons}
</>
)
: null;
if (!document) {
return (
<section className="document-viewer document-viewer--loading" ref={viewerRef}>
<div className="document-viewer__details-pane">
<div className="document-viewer__details">
<div className="document-viewer__message">
Loading document
<section className="document-viewer-panel">
<PanelHeader title="Document preview" />
<div className="document-viewer-panel__body">
<section className="document-viewer document-viewer--loading" ref={viewerRef}>
<div className="document-viewer__details-pane">
<div className="document-viewer__details">
<div className="document-viewer__message">
Loading document
</div>
</div>
</div>
</div>
</div>
<div className="document-viewer__viewport">
<div className="document-viewer__message">Preparing preview</div>
<div className="document-viewer__viewport">
<div className="document-viewer__message">Preparing preview</div>
</div>
</section>
</div>
</section>
);
}
const headerTitle = breadcrumbTrailEntries.length ? (
<BreadcrumbTrail
entries={breadcrumbTrailEntries}
separator="/"
className="panel-header__breadcrumbs"
/>
) : (
document?.title || 'Document preview'
);
return (
<section className={viewerClassName} ref={viewerRef}>
<DocumentViewerLayout
document={document}
previewEntry={previewEntry}
summaryProps={summaryProps}
metadataPayload={metadataPayload}
contentTabConfig={contentTabConfig}
previewLoadingMessage="Loading preview…"
<>
<section className="document-viewer-panel">
<PanelHeader
leading={headerLeadingContent}
title={headerTitle}
titleTag="h3"
actions={headerActions}
/>
<div className="document-viewer-panel__body">
<section className={viewerClassName} ref={viewerRef}>
<DocumentViewerLayout
document={document}
previewEntry={previewEntry}
summaryProps={summaryProps}
metadataPayload={metadataPayload}
contentTabConfig={contentTabConfig}
previewLoadingMessage="Loading preview…"
/>
</section>
</div>
</section>
<PreviewZoomOverlay
open={Boolean(zoomOverlayOpen && zoomDisplay)}
display={zoomDisplay}
onClose={handleZoomClose}
/>
</section>
</>
);
};
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 ? (
<a
className="icon-button"
href={downloadHref}
target="_blank"
rel="noopener noreferrer"
aria-label="Download document"
title="Download document"
>
<DownloadIcon />
</a>
) : null}
</>
);
};
export const createDocumentViewerSurface = ({
document,
previewEntry,
@@ -327,70 +538,18 @@ export const createDocumentViewerSurface = ({
return null;
}
const title = document.title || 'Document preview';
const closeButton = onClose
? (
<button
type="button"
className="icon-button"
onClick={() => onClose?.()}
aria-label="Close preview"
title="Close preview"
>
<CloseIcon />
</button>
)
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: (
<DocumentViewerPanel
document={document}
@@ -405,9 +564,15 @@ export const createDocumentViewerSurface = ({
onCorrespondentRemove={onCorrespondentRemove}
onUpdateTitle={onUpdateTitle}
onUpdateIssued={onUpdateIssued}
hasOcr={Boolean(actionState?.hasOcr)}
hasOcr={hasOcr}
ensureAssetUrl={ensureAssetUrl}
getDocumentAsset={getDocumentAsset}
ensurePreviewData={ensurePreviewData}
resolveApiPath={resolveApiPath}
notifyApiError={notifyApiError}
sidebarToggle={sidebarToggle}
onClosePanel={onClose}
resolveFolderPath={resolveFolderPath}
/>
),
supportsDetail: false,
+14 -1
View File
@@ -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;
+40 -15
View File
@@ -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%;
+10
View File
@@ -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 }) => (
<TablerZoomInArea
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const ViewListIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconLayoutList
className={composeClassName('icon', className)}