unify detailpanel and documentviewer
This commit is contained in:
@@ -1,367 +0,0 @@
|
||||
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';
|
||||
import { describeDocumentSummary } from '../documents/documentSummary';
|
||||
import { createDocumentActionState } from '../documents/documentActions';
|
||||
import { sortCorrespondents, buildCorrespondentOptions } from '../documents/DocumentSummarySection';
|
||||
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,
|
||||
tags = [],
|
||||
tagLookupById = new Map(),
|
||||
onTagAdd,
|
||||
onTagRemove,
|
||||
onOpenPreview,
|
||||
onUpdateTitle = async () => false,
|
||||
onUpdateIssued = async () => false,
|
||||
ensureAssetUrl = null,
|
||||
getDocumentAsset = () => null,
|
||||
ensurePreviewData = () => Promise.resolve(),
|
||||
correspondents = [],
|
||||
onCorrespondentAdd,
|
||||
onCorrespondentRemove,
|
||||
resolveApiPath,
|
||||
onFolderNavigate = null,
|
||||
resolveFolderPath = null,
|
||||
previewEntry = null,
|
||||
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(
|
||||
() =>
|
||||
createDocumentActionState({
|
||||
document: singleDoc,
|
||||
resolveApiPath,
|
||||
ensurePreviewData,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
}),
|
||||
[singleDoc, resolveApiPath, ensurePreviewData, ensureAssetUrl, getDocumentAsset],
|
||||
);
|
||||
|
||||
const detailSummary = useMemo(() => describeDocumentSummary(singleDoc), [singleDoc]);
|
||||
|
||||
const headerTitle = singleDoc ? detailSummary.title : 'Document details';
|
||||
|
||||
const headerBreadcrumbs = useMemo(() => {
|
||||
if (!singleDoc || typeof resolveFolderPath !== 'function') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleNavigate = (folderId) => {
|
||||
if (!folderId || typeof onFolderNavigate !== 'function') {
|
||||
return;
|
||||
}
|
||||
onFolderNavigate(folderId);
|
||||
};
|
||||
|
||||
const folderSegments = resolveFolderPath(singleDoc.folder_id);
|
||||
const normalizedSegments = Array.isArray(folderSegments)
|
||||
? folderSegments
|
||||
.filter((segment) => segment && segment.id && segment.name)
|
||||
.map((segment) => ({
|
||||
id: segment.id,
|
||||
label: segment.name,
|
||||
onClick: segment.id ? () => handleNavigate(segment.id) : null,
|
||||
}))
|
||||
: [];
|
||||
|
||||
return [
|
||||
...normalizedSegments,
|
||||
{
|
||||
id: singleDoc.id || 'current-document',
|
||||
label: detailSummary.title,
|
||||
},
|
||||
];
|
||||
}, [singleDoc, resolveFolderPath, detailSummary, onFolderNavigate]);
|
||||
|
||||
const correspondentOptions = useMemo(
|
||||
() => buildCorrespondentOptions(Array.isArray(correspondents) ? correspondents : []),
|
||||
[correspondents],
|
||||
);
|
||||
|
||||
const singleCorrespondents = useMemo(() => {
|
||||
if (!singleDoc) return [];
|
||||
return sortCorrespondents(singleDoc.correspondents || []);
|
||||
}, [singleDoc]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof ensurePreviewData === 'function' && singleDoc?.id) {
|
||||
ensurePreviewData(singleDoc.id);
|
||||
}
|
||||
}, [ensurePreviewData, singleDoc?.id]);
|
||||
|
||||
const singleSummaryProps = useMemo(
|
||||
() => ({
|
||||
tagLookupById,
|
||||
tagOptions: tags,
|
||||
onTagAdd: (doc, value, extras) => onTagAdd(doc, value, extras),
|
||||
onTagRemove: (docId, tagId) => onTagRemove(docId, tagId),
|
||||
correspondents: singleCorrespondents,
|
||||
correspondentOptions,
|
||||
onCorrespondentAdd,
|
||||
onCorrespondentRemove,
|
||||
onUpdateTitle,
|
||||
onUpdateIssued,
|
||||
}),
|
||||
[
|
||||
tagLookupById,
|
||||
tags,
|
||||
onTagAdd,
|
||||
onTagRemove,
|
||||
singleCorrespondents,
|
||||
correspondentOptions,
|
||||
onCorrespondentAdd,
|
||||
onCorrespondentRemove,
|
||||
onUpdateTitle,
|
||||
onUpdateIssued,
|
||||
],
|
||||
);
|
||||
|
||||
const singleMetadataPayload = useMemo(
|
||||
() => extractDocumentMetadataPayload(singleDoc),
|
||||
[singleDoc],
|
||||
);
|
||||
|
||||
const singleHasOcr = useMemo(() => {
|
||||
if (!singleDoc || typeof getDocumentAsset !== 'function') {
|
||||
return false;
|
||||
}
|
||||
return Boolean(getDocumentAsset(singleDoc, 'ocr-text'));
|
||||
}, [singleDoc, getDocumentAsset]);
|
||||
|
||||
const loadSingleOcrContent = useCallback(async ({ signal } = {}) => {
|
||||
if (!singleDoc || !singleHasOcr || typeof getDocumentAsset !== 'function') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const updateUrl = () =>
|
||||
resolveDocumentAssetUrl(singleDoc, 'ocr-text', {
|
||||
ensureAssetUrl,
|
||||
getAsset: getDocumentAsset,
|
||||
});
|
||||
|
||||
const asset = getDocumentAsset(singleDoc, 'ocr-text');
|
||||
let url = updateUrl();
|
||||
|
||||
if (!url && singleDoc.id && asset?.id && typeof ensureAssetUrl === 'function') {
|
||||
await ensureAssetUrl(singleDoc.id, asset, { start: 1, limit: 1 });
|
||||
if (signal?.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
url = updateUrl();
|
||||
}
|
||||
|
||||
if (!url) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
mode: 'cors',
|
||||
credentials: 'omit',
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unexpected status: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.text();
|
||||
}, [singleDoc, singleHasOcr, getDocumentAsset, ensureAssetUrl]);
|
||||
|
||||
const singleContentConfig = useMemo(
|
||||
() => ({
|
||||
enabled: singleHasOcr,
|
||||
id: 'content',
|
||||
label: 'Content',
|
||||
loadContent: loadSingleOcrContent,
|
||||
loadingMessage: 'Loading OCR content…',
|
||||
emptyMessage: 'No OCR content available.',
|
||||
unavailableMessage: 'No OCR content available.',
|
||||
errorMessage: 'Failed to load OCR content.',
|
||||
}),
|
||||
[singleHasOcr, loadSingleOcrContent],
|
||||
);
|
||||
|
||||
const renderContent = () => {
|
||||
if (!singleDoc) {
|
||||
return <p className="meta">Select a document to view metadata, tags and actions.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
const headerLeading = [
|
||||
(
|
||||
<button
|
||||
key="close"
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={onClose}
|
||||
aria-label="Close detail panel"
|
||||
title="Close detail panel"
|
||||
>
|
||||
<DetailPanelCollapseIcon />
|
||||
</button>
|
||||
),
|
||||
];
|
||||
|
||||
if (singleDoc) {
|
||||
headerLeading.push(
|
||||
<button
|
||||
key="preview"
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onOpenPreview(singleDoc.id);
|
||||
}}
|
||||
aria-label="Maximize"
|
||||
title="Maximize"
|
||||
>
|
||||
<WindowMaximizeIcon className="icon--flip-y" />
|
||||
</button>,
|
||||
);
|
||||
}
|
||||
|
||||
const headerActions = [];
|
||||
|
||||
if (singleDoc && singleDownloadHref) {
|
||||
headerActions.push(
|
||||
<a
|
||||
key="download"
|
||||
className="icon-button"
|
||||
href={singleDownloadHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Download document"
|
||||
title="Download document"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<DownloadIcon />
|
||||
</a>,
|
||||
);
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
<div className="panel-body detail-panel__content">{renderContent()}</div>
|
||||
</aside>
|
||||
<PreviewZoomOverlay
|
||||
open={Boolean(zoomOverlayOpen && zoomDisplay)}
|
||||
display={zoomDisplay}
|
||||
onClose={() => setZoomOverlayOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DetailPanel;
|
||||
@@ -237,7 +237,7 @@ const useDetailWorkspace = ({
|
||||
onUpdateIssued: handleDocumentIssuedUpdate,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
ensurePreviewData,
|
||||
hydrateDocument: ensurePreviewData,
|
||||
correspondents,
|
||||
onCorrespondentAdd: handleCorrespondentAdd,
|
||||
onCorrespondentRemove: handleCorrespondentRemove,
|
||||
|
||||
Reference in New Issue
Block a user