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
+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,