hydration

This commit is contained in:
2025-11-11 10:50:57 +01:00
parent 6ae497323e
commit 2dd786ce14
4 changed files with 160 additions and 475 deletions
+46 -390
View File
@@ -1,123 +1,17 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo } from 'react';
import {
DownloadIcon,
ArrowLeftIcon,
ArrowRightIcon,
DetailPanelCollapseIcon,
WindowMaximizeIcon,
} from '../ui/icons';
import PanelHeader from '../ui/PanelHeader';
import { resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
import { useAssetNavigator } from '../hooks/useAssetNavigator';
import { resolveDocumentAssetUrl } from '../asset_manager';
import { describeDocumentSummary } from '../documents/documentSummary';
import { createDocumentActionState } from '../documents/documentActions';
import PreviewZoomOverlay from './PreviewZoomOverlay';
import DocumentInfoPanel from '../documents/DocumentInfoPanel';
import { sortCorrespondents, buildCorrespondentOptions } from '../documents/DocumentSummarySection';
import BreadcrumbTrail from '../ui/BreadcrumbTrail';
const derivePreviewOrientation = (metadata) => {
const width = Number(metadata?.width);
const height = Number(metadata?.height);
if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) {
return width >= height ? 'landscape' : 'portrait';
}
return 'landscape';
};
const PreviewImage = ({
item,
emptyMessage = 'Preview unavailable',
emptyContent = null,
onActivate,
onOpenPreview,
onZoomPreview,
showNav = false,
canGoPrev = false,
canGoNext = false,
onGoPrev = null,
onGoNext = null,
}) => {
if (!item) {
return (
<div className="preview-image preview-image--empty">
{emptyContent || <span className="meta">{emptyMessage}</span>}
</div>
);
}
const handleActivate = (event) => {
event.stopPropagation();
if (onZoomPreview) {
onZoomPreview(item);
} else if (onOpenPreview) {
onOpenPreview(item.id);
} else if (onActivate) {
onActivate(item.id);
}
};
const interceptNavPointer = (event) => {
event.preventDefault();
event.stopPropagation();
};
return (
<div className="preview-image">
<img
src={item.url}
alt={item.alt}
className={`preview-image__content orientation-${item.orientation || 'landscape'}`}
onClick={handleActivate}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
handleActivate(event);
}
}}
/>
{showNav ? (
<div className="preview-pane__nav preview-pane__nav--overlay">
<button
type="button"
className="preview-pane__nav-button preview-pane__nav-button--prev"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onGoPrev?.();
}}
onPointerDown={interceptNavPointer}
onPointerUp={interceptNavPointer}
onMouseDown={interceptNavPointer}
onMouseUp={interceptNavPointer}
disabled={!canGoPrev}
aria-label="Previous preview"
>
<ArrowLeftIcon />
</button>
<button
type="button"
className="preview-pane__nav-button preview-pane__nav-button--next"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onGoNext?.();
}}
onPointerDown={interceptNavPointer}
onPointerUp={interceptNavPointer}
onMouseDown={interceptNavPointer}
onMouseUp={interceptNavPointer}
disabled={!canGoNext}
aria-label="Next preview"
>
<ArrowRightIcon />
</button>
</div>
) : null}
</div>
);
};
import { extractDocumentMetadataPayload } from '../documents/documentMetadata';
import DocumentViewerLayout from '../preview/DocumentViewerLayout';
const DetailPanel = ({
document = null,
@@ -126,7 +20,6 @@ const DetailPanel = ({
onTagAdd,
onTagRemove,
onOpenPreview,
onPromoteSelection,
onUpdateTitle = async () => false,
onUpdateIssued = async () => false,
ensureAssetUrl = null,
@@ -138,11 +31,10 @@ const DetailPanel = ({
resolveApiPath,
onFolderNavigate = null,
resolveFolderPath = null,
previewEntry = null,
onClose = () => {},
}) => {
const singleDoc = document || null;
const singleDocId = singleDoc?.id || null;
const selectionKey = singleDocId || 'none';
const { downloadHref: singleDownloadHref } = useMemo(
() =>
@@ -192,81 +84,6 @@ const DetailPanel = ({
];
}, [singleDoc, resolveFolderPath, detailSummary, onFolderNavigate]);
const [zoomedPreview, setZoomedPreview] = useState(null);
useEffect(() => {
setZoomedPreview(null);
}, [selectionKey]);
const handlePreviewActivate = useCallback(
(docId) => {
if (!docId) return;
onPromoteSelection?.(docId);
},
[onPromoteSelection],
);
const singlePreviewNavigator = useAssetNavigator({
document: singleDoc,
assetType: 'preview',
ensureAssetUrl,
getAsset: getDocumentAsset,
prefetch: 3,
});
const makePreviewItem = useCallback(
(doc, ordinal = 1) => {
if (!doc) return null;
const asset = getDocumentAsset(doc, 'preview');
const assetView = createAssetView(asset);
const object = assetView.getObject(ordinal);
let url = object?.url || null;
if (!url) {
url = resolveDocumentAssetUrl(doc, 'preview', {
ensureAssetUrl,
getAsset: getDocumentAsset,
ensureOptions: { start: ordinal, limit: 1 },
objectOrdinal: ordinal,
});
}
if (!url) {
return null;
}
const metadata = object?.metadata || assetView.getPrimaryMetadata() || {};
const orientation = derivePreviewOrientation(metadata);
return {
id: doc.id,
url,
orientation,
alt: doc.title,
};
},
[ensureAssetUrl, getDocumentAsset],
);
const singlePreviewItem = useMemo(() => {
if (!singleDoc) return null;
const url = singlePreviewNavigator.currentUrl;
if (url) {
return {
id: singleDoc.id,
url,
orientation: derivePreviewOrientation(singlePreviewNavigator.currentMetadata),
alt: singleDoc.title,
};
}
return makePreviewItem(singleDoc, 1);
}, [
singleDoc,
singlePreviewNavigator.currentUrl,
singlePreviewNavigator.currentMetadata,
makePreviewItem,
]);
const singleCardinality = singlePreviewNavigator.cardinality;
const singleEffectiveCardinality = singleCardinality || (singlePreviewNavigator.currentUrl ? 1 : 0);
const singleHasPreview = Boolean(singlePreviewNavigator.currentUrl);
const correspondentOptions = useMemo(
() => buildCorrespondentOptions(Array.isArray(correspondents) ? correspondents : []),
[correspondents],
@@ -277,6 +94,12 @@ const DetailPanel = ({
return sortCorrespondents(singleDoc.correspondents || []);
}, [singleDoc]);
useEffect(() => {
if (typeof ensurePreviewData === 'function' && singleDoc?.id) {
ensurePreviewData(singleDoc.id);
}
}, [ensurePreviewData, singleDoc?.id]);
const singleSummaryProps = useMemo(
() => ({
tagLookupById,
@@ -304,6 +127,11 @@ const DetailPanel = ({
],
);
const singleMetadataPayload = useMemo(
() => extractDocumentMetadataPayload(singleDoc),
[singleDoc],
);
const singleHasOcr = useMemo(() => {
if (!singleDoc || typeof getDocumentAsset !== 'function') {
return false;
@@ -365,183 +193,22 @@ const DetailPanel = ({
[singleHasOcr, loadSingleOcrContent],
);
const openZoomPreview = useCallback((docId) => {
if (!docId) return;
setZoomedPreview({ docId });
}, []);
const closeZoomPreview = useCallback(() => {
setZoomedPreview(null);
}, []);
const handleSingleZoom = useCallback(
(entry) => {
if (!singleHasPreview) return;
const targetId = entry?.id ?? singleDocId;
if (!targetId) return;
openZoomPreview(targetId);
},
[openZoomPreview, singleHasPreview, singleDocId],
);
const zoomDisplay = useMemo(() => {
if (
!zoomedPreview
|| !singleDoc
|| !singleDocId
|| zoomedPreview.docId !== singleDocId
|| !singleHasPreview
) {
return null;
}
return {
url: singlePreviewNavigator.currentUrl,
alt: singleDoc.title,
canGoPrev:
singleEffectiveCardinality > 1 && Boolean(singlePreviewNavigator.canGoPrev),
canGoNext:
singleEffectiveCardinality > 1 && Boolean(singlePreviewNavigator.canGoNext),
goPrev: singlePreviewNavigator.goPrev,
goNext: singlePreviewNavigator.goNext,
};
}, [
zoomedPreview,
singleDoc,
singleDocId,
singleHasPreview,
singlePreviewNavigator.currentUrl,
singlePreviewNavigator.canGoPrev,
singlePreviewNavigator.canGoNext,
singlePreviewNavigator.goPrev,
singlePreviewNavigator.goNext,
singleEffectiveCardinality,
]);
useEffect(() => {
if (zoomedPreview && !zoomDisplay) {
setZoomedPreview(null);
}
}, [zoomedPreview, zoomDisplay]);
const {
documentId: singleNavigatorDocId,
asset: singleNavigatorAsset,
ordinal: singleNavigatorOrdinal,
canGoPrev: singleNavigatorCanGoPrev,
canGoNext: singleNavigatorCanGoNext,
cardinality: singleNavigatorCardinality,
} = singlePreviewNavigator;
useEffect(() => {
if (typeof ensureAssetUrl !== 'function') {
return;
}
if (!singleNavigatorDocId || !singleNavigatorAsset || !Number.isFinite(singleNavigatorOrdinal)) {
return;
}
const requests = [];
if (singleNavigatorCanGoPrev) {
const prevOrdinal = Math.max(1, singleNavigatorOrdinal - 1);
if (!singleNavigatorCardinality || prevOrdinal <= singleNavigatorCardinality) {
requests.push(
ensureAssetUrl(singleNavigatorDocId, singleNavigatorAsset, {
start: prevOrdinal,
limit: 1,
objectOrdinal: prevOrdinal,
}),
);
}
}
if (singleNavigatorCanGoNext) {
const nextOrdinal = singleNavigatorOrdinal + 1;
if (!singleNavigatorCardinality || nextOrdinal <= singleNavigatorCardinality) {
requests.push(
ensureAssetUrl(singleNavigatorDocId, singleNavigatorAsset, {
start: nextOrdinal,
limit: 1,
objectOrdinal: nextOrdinal,
}),
);
}
}
requests.forEach((promise) => promise?.catch?.(() => {}));
}, [
ensureAssetUrl,
singleNavigatorDocId,
singleNavigatorAsset,
singleNavigatorOrdinal,
singleNavigatorCanGoPrev,
singleNavigatorCanGoNext,
singleNavigatorCardinality,
]);
const renderContent = () => {
if (!singleDoc) {
return <p className="meta">Select a document to view metadata, tags and actions.</p>;
}
const effectiveCardinality = singleEffectiveCardinality;
const navCanGoPrev = Boolean(singlePreviewNavigator.canGoPrev);
const navCanGoNext = Boolean(singlePreviewNavigator.canGoNext);
const hasPreviewImage = Boolean(singlePreviewNavigator.currentUrl);
const previewMissingAsset = !singlePreviewNavigator.currentUrl;
const displayContentType = singleDoc.content_type || 'this file type';
const displayFilename =
singleDoc.filename || singleDoc.original_name || singleDoc.title || 'download';
const previewFallback = previewMissingAsset ? (
<div className="preview-pane__unsupported">
<div className="preview-pane__unsupported-message">
Preview not available for {displayContentType} files.
</div>
<div className="preview-pane__unsupported-filename">{displayFilename}</div>
{singleDownloadHref ? (
<a
className="button-link preview-pane__unsupported-download"
href={singleDownloadHref}
target="_blank"
rel="noopener noreferrer"
>
<DownloadIcon />
<span>Download</span>
</a>
) : null}
</div>
) : null;
const emptyMessage = previewMissingAsset ? 'Preview unavailable' : 'Preview loading…';
const showNav = hasPreviewImage && (effectiveCardinality > 1 || navCanGoPrev || navCanGoNext);
return (
<div className="preview-pane">
<div className="preview-pane__media">
<PreviewImage
item={singlePreviewItem}
emptyMessage={emptyMessage}
emptyContent={previewFallback}
onActivate={handlePreviewActivate}
onOpenPreview={onOpenPreview}
onZoomPreview={handleSingleZoom}
showNav={showNav}
canGoPrev={navCanGoPrev}
canGoNext={navCanGoNext}
onGoPrev={navCanGoPrev ? singlePreviewNavigator.goPrev : undefined}
onGoNext={navCanGoNext ? singlePreviewNavigator.goNext : undefined}
/>
</div>
<div className="document-viewer__details">
<DocumentInfoPanel
document={singleDoc}
summaryProps={singleSummaryProps}
contentConfig={singleContentConfig}
classNamePrefix="document-viewer"
defaultTabId="details"
resetKey={singleDocId}
hideTabNavWhenSingle={false}
/>
</div>
</div>
<section className="document-viewer document-viewer--stacked">
<DocumentViewerLayout
document={singleDoc}
previewEntry={previewEntry}
summaryProps={singleSummaryProps}
metadataPayload={singleMetadataPayload}
contentTabConfig={singleContentConfig}
previewLoadingMessage="Loading preview…"
/>
</section>
);
};
@@ -598,37 +265,26 @@ const DetailPanel = ({
}
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}
/>
<div className="panel-body">
{renderContent()}
</div>
</aside>
{singleDoc && (
<PreviewZoomOverlay
open={Boolean(zoomDisplay)}
display={zoomDisplay}
onClose={closeZoomPreview}
/>
)}
</>
<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">{renderContent()}</div>
</aside>
);
};
@@ -22,7 +22,6 @@ const useDetailWorkspace = ({
previewDocumentId,
activePreviewId,
openDocumentPreview,
promoteSelectionOrder,
handleDocumentTitleUpdate,
handleDocumentIssuedUpdate,
handleDocumentTagAdd,
@@ -242,7 +241,6 @@ const useDetailWorkspace = ({
onTagRemove: handleTagRemove,
previewEntry: selectedPreviewEntry,
onOpenPreview: openDocumentPreview,
onPromoteSelection: promoteSelectionOrder,
activePreviewId,
onUpdateTitle: handleDocumentTitleUpdate,
onUpdateIssued: handleDocumentIssuedUpdate,
@@ -272,7 +270,6 @@ const useDetailWorkspace = ({
handleDocumentTitleUpdate,
handleTagRemove,
openDocumentPreview,
promoteSelectionOrder,
resolveApiPath,
resolveFolderPath,
selectFolder,
@@ -0,0 +1,105 @@
import React, { useMemo } from 'react';
import DocumentInfoPanel from '../documents/DocumentInfoPanel';
import { DownloadIcon } from '../ui/icons';
const DocumentViewerLayout = ({
document,
previewEntry,
summaryProps,
metadataPayload,
contentTabConfig,
resetKey,
classNamePrefix = 'document-viewer',
defaultTabId = 'details',
infoPanelProps = {},
previewLoadingMessage = 'Preparing preview…',
}) => {
const previewContent = useMemo(() => {
if (!document || !previewEntry?.url) {
return null;
}
const normalizedContentType = (previewEntry.contentType
|| document.content_type
|| '')
.toLowerCase();
const isImage = normalizedContentType.startsWith('image/');
const isPdf = normalizedContentType === 'application/pdf'
|| normalizedContentType === 'application/x-pdf';
if (isImage) {
return (
<img
src={previewEntry.url}
alt={`Preview of ${document.title}`}
className="document-viewer__object document-viewer__object--image"
draggable={false}
/>
);
}
if (isPdf) {
return (
<iframe
src={previewEntry.url}
title={`Preview of ${document.title}`}
className="document-viewer__object"
/>
);
}
const displayContentType = document.content_type || previewEntry.contentType || 'this file type';
const displayFilename = previewEntry.filename
|| document.filename
|| document.original_name
|| 'download';
return (
<div className="document-viewer__unsupported">
<div className="document-viewer__unsupported-message">
Preview is not available for {displayContentType} files.
</div>
<div className="document-viewer__unsupported-filename">{displayFilename}</div>
<a
className="button-link document-viewer__unsupported-download"
href={previewEntry.url}
download={displayFilename}
target="_blank"
rel="noopener noreferrer"
>
<DownloadIcon />
<span>Download</span>
</a>
</div>
);
}, [previewEntry, document]);
return (
<>
<div className="document-viewer__details-pane">
<div className="document-viewer__details">
<DocumentInfoPanel
document={document}
summaryProps={summaryProps}
metadataPayload={metadataPayload}
contentConfig={contentTabConfig}
defaultTabId={defaultTabId}
classNamePrefix={classNamePrefix}
hideTabNavWhenSingle={false}
resetKey={resetKey || document?.id}
{...infoPanelProps}
/>
</div>
</div>
<div className="document-viewer__viewport">
{!previewEntry?.url ? (
<div className="document-viewer__message">{previewLoadingMessage}</div>
) : (
previewContent
)}
</div>
</>
);
};
export default DocumentViewerLayout;
+9 -82
View File
@@ -11,10 +11,10 @@ import {
buildCorrespondentOptions,
sortCorrespondents,
} from '../documents/DocumentSummarySection';
import DocumentInfoPanel from '../documents/DocumentInfoPanel';
import { extractDocumentMetadataPayload } from '../documents/documentMetadata';
import { createDocumentActionState } from '../documents/documentActions';
import { resolveDocumentAssetUrl } from '../asset_manager';
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;
@@ -83,66 +83,6 @@ const DocumentViewerPanel = ({
[correspondents],
);
const previewContent = useMemo(() => {
if (!document || !previewEntry?.url) {
return null;
}
const normalizedContentType = (previewEntry.contentType
|| document.content_type
|| '')
.toLowerCase();
const isImage = normalizedContentType.startsWith('image/');
const isPdf = normalizedContentType === 'application/pdf'
|| normalizedContentType === 'application/x-pdf';
if (isImage) {
return (
<img
src={previewEntry.url}
alt={`Preview of ${document.title}`}
className="document-viewer__object document-viewer__object--image"
draggable={false}
/>
);
}
if (isPdf) {
return (
<iframe
src={previewEntry.url}
title={`Preview of ${document.title}`}
className="document-viewer__object"
/>
);
}
const displayContentType = document.content_type || previewEntry.contentType || 'this file type';
const displayFilename = previewEntry.filename
|| document.filename
|| document.original_name
|| 'download';
return (
<div className="document-viewer__unsupported">
<div className="document-viewer__unsupported-message">
Preview is not available for {displayContentType} files.
</div>
<div className="document-viewer__unsupported-filename">{displayFilename}</div>
<a
className="button-link document-viewer__unsupported-download"
href={previewEntry.url}
download={displayFilename}
target="_blank"
rel="noopener noreferrer"
>
<DownloadIcon />
<span>Download</span>
</a>
</div>
);
}, [previewEntry, document]);
const metadataPayload = useMemo(
() => extractDocumentMetadataPayload(document),
[document],
@@ -316,27 +256,14 @@ const DocumentViewerPanel = ({
return (
<section className={viewerClassName} ref={viewerRef}>
<div className="document-viewer__details-pane">
<div className="document-viewer__details">
<DocumentInfoPanel
document={document}
summaryProps={summaryProps}
metadataPayload={metadataPayload}
contentConfig={contentTabConfig}
defaultTabId="details"
classNamePrefix="document-viewer"
hideTabNavWhenSingle={false}
resetKey={document?.id}
/>
</div>
</div>
<div className="document-viewer__viewport">
{!previewEntry?.url ? (
<div className="document-viewer__message">Loading preview</div>
) : (
previewContent
)}
</div>
<DocumentViewerLayout
document={document}
previewEntry={previewEntry}
summaryProps={summaryProps}
metadataPayload={metadataPayload}
contentTabConfig={contentTabConfig}
previewLoadingMessage="Loading preview…"
/>
</section>
);
};