typescript
This commit is contained in:
@@ -0,0 +1,620 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
DownloadIcon,
|
||||
CloseIcon,
|
||||
IconZoomInArea,
|
||||
IconX,
|
||||
WindowMaximizeIcon,
|
||||
} from '../ui/icons';
|
||||
import PreviewZoomOverlay from '../detail/PreviewZoomOverlay';
|
||||
import {
|
||||
buildCorrespondentOptions,
|
||||
sortCorrespondents,
|
||||
} from '../documents/DocumentSummarySection';
|
||||
import type { DocumentSummarySectionProps } from '../documents/DocumentSummarySection';
|
||||
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';
|
||||
import useViewerLayoutMode from './useViewerLayoutMode';
|
||||
import { usePanelResizeBindings } from '../app/PanelManagerContext';
|
||||
|
||||
interface DocumentLike {
|
||||
id?: string | number;
|
||||
title?: string;
|
||||
issued_at?: string | null;
|
||||
correspondents?: Array<{ id?: string | number; name?: string }>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface AssetLike {
|
||||
id?: string | number;
|
||||
url?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface DocumentViewerPanelProps extends DocumentSummarySectionProps {
|
||||
document: DocumentLike | null;
|
||||
previewEntry?: {
|
||||
url?: string;
|
||||
canGoPrev?: boolean;
|
||||
canGoNext?: boolean;
|
||||
goPrev?: () => void;
|
||||
goNext?: () => void;
|
||||
} | null;
|
||||
hydrateDocument?: (doc: DocumentLike | null) => DocumentLike | null;
|
||||
ensureAssetUrl?: (docId: string | number, asset: AssetLike, options?: { start?: number; limit?: number }) => Promise<unknown>;
|
||||
getDocumentAsset?: (doc: DocumentLike | null, type: string) => AssetLike | null | undefined;
|
||||
ensurePreviewData?: (docId: string | number, options?: { signal?: AbortSignal }) => Promise<DocumentLike | null | undefined>;
|
||||
resolveApiPath?: (path: string) => string;
|
||||
notifyApiError?: (error: unknown, fallbackMessage?: string) => void;
|
||||
sidebarToggle?: ReactNode;
|
||||
onClosePanel?: () => void;
|
||||
resolveFolderPath?: (doc: DocumentLike | null) => Array<{ id?: string | number; name?: string }>;
|
||||
variant?: 'viewer' | 'sidebar';
|
||||
onCollapsePanel?: () => void;
|
||||
onMaximizePanel?: () => void;
|
||||
}
|
||||
|
||||
|
||||
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: React.FC<DocumentViewerPanelProps> = ({
|
||||
document,
|
||||
previewEntry,
|
||||
hydrateDocument,
|
||||
tagLookupById,
|
||||
tagOptions,
|
||||
onTagAdd,
|
||||
onTagRemove,
|
||||
correspondents,
|
||||
onCorrespondentAdd,
|
||||
onCorrespondentRemove,
|
||||
onUpdateTitle,
|
||||
onUpdateIssued,
|
||||
ensureAssetUrl,
|
||||
getDocumentAsset,
|
||||
ensurePreviewData,
|
||||
resolveApiPath,
|
||||
notifyApiError,
|
||||
sidebarToggle = null,
|
||||
onClosePanel,
|
||||
resolveFolderPath,
|
||||
variant = 'viewer',
|
||||
onCollapsePanel,
|
||||
onMaximizePanel,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const isSidebarVariant = variant === 'sidebar';
|
||||
const sortedCorrespondents = useMemo(
|
||||
() => sortCorrespondents(document?.correspondents || []),
|
||||
[document],
|
||||
);
|
||||
|
||||
const correspondentOptions = useMemo(
|
||||
() => buildCorrespondentOptions(Array.isArray(correspondents) ? correspondents : []),
|
||||
[correspondents],
|
||||
);
|
||||
|
||||
const metadataPayload = useMemo(
|
||||
() => extractDocumentMetadataPayload(document),
|
||||
[document],
|
||||
);
|
||||
|
||||
const hasOcr = useMemo(() => {
|
||||
if (!document || typeof getDocumentAsset !== 'function') {
|
||||
return false;
|
||||
}
|
||||
return Boolean(getDocumentAsset(document, 'ocr-text'));
|
||||
}, [document, getDocumentAsset]);
|
||||
|
||||
const summaryProps = useMemo(
|
||||
() => ({
|
||||
tagLookupById,
|
||||
tagOptions,
|
||||
onTagAdd,
|
||||
onTagRemove,
|
||||
correspondents: sortedCorrespondents,
|
||||
correspondentOptions,
|
||||
onCorrespondentAdd,
|
||||
onCorrespondentRemove,
|
||||
onUpdateTitle,
|
||||
onUpdateIssued,
|
||||
}),
|
||||
[
|
||||
tagLookupById,
|
||||
tagOptions,
|
||||
onTagAdd,
|
||||
onTagRemove,
|
||||
sortedCorrespondents,
|
||||
correspondentOptions,
|
||||
onCorrespondentAdd,
|
||||
onCorrespondentRemove,
|
||||
onUpdateTitle,
|
||||
onUpdateIssued,
|
||||
],
|
||||
);
|
||||
|
||||
const loadOcrContent = useCallback(async ({ signal } = {}) => {
|
||||
if (!document || !hasOcr || typeof getDocumentAsset !== 'function') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const updateUrl = () =>
|
||||
resolveDocumentAssetUrl(document, 'ocr-text', {
|
||||
ensureAssetUrl,
|
||||
getAsset: getDocumentAsset,
|
||||
});
|
||||
|
||||
const asset = getDocumentAsset(document, 'ocr-text');
|
||||
let url = updateUrl();
|
||||
|
||||
if (!url && document.id && asset?.id && typeof ensureAssetUrl === 'function') {
|
||||
await ensureAssetUrl(document.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();
|
||||
}, [document, hasOcr, getDocumentAsset, ensureAssetUrl]);
|
||||
|
||||
const contentTabConfig = useMemo(
|
||||
() => ({
|
||||
enabled: hasOcr,
|
||||
id: 'content',
|
||||
label: 'Content',
|
||||
loadContent: loadOcrContent,
|
||||
loadingMessage: 'Loading OCR content…',
|
||||
emptyMessage: 'No OCR content available.',
|
||||
unavailableMessage: 'No OCR content available.',
|
||||
errorMessage: 'Failed to load OCR content.',
|
||||
}),
|
||||
[hasOcr, loadOcrContent],
|
||||
);
|
||||
|
||||
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) {
|
||||
hydrateDocument(document.id);
|
||||
}
|
||||
}, [hydrateDocument, document?.id]);
|
||||
|
||||
const panelRef = useRef<HTMLDivElement | HTMLFormElement | HTMLElement | null>(null);
|
||||
const isStackedLayout = useViewerLayoutMode(panelRef, document?.id);
|
||||
|
||||
const {
|
||||
panelStyle: managedDetailPanelStyle,
|
||||
handleProps: managedResizeHandleProps,
|
||||
isPanelResizing,
|
||||
} = usePanelResizeBindings('detail', { enabled: isSidebarVariant, panelRef });
|
||||
const detailPanelStyle = isSidebarVariant ? managedDetailPanelStyle : undefined;
|
||||
const resizeHandleProps = isSidebarVariant ? managedResizeHandleProps : {};
|
||||
|
||||
const viewerClassName = isStackedLayout
|
||||
? '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 collapseButton = isSidebarVariant && typeof onCollapsePanel === 'function'
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={() => onCollapsePanel?.()}
|
||||
aria-label="Close detail panel"
|
||||
title="Close detail panel"
|
||||
>
|
||||
<IconX />
|
||||
</button>
|
||||
)
|
||||
: null;
|
||||
|
||||
const maximizeButton = isSidebarVariant && typeof onMaximizePanel === 'function'
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onMaximizePanel?.(document?.id);
|
||||
}}
|
||||
aria-label="Maximize"
|
||||
title="Maximize"
|
||||
>
|
||||
<WindowMaximizeIcon className="icon--flip-y" />
|
||||
</button>
|
||||
)
|
||||
: null;
|
||||
|
||||
const closeButton = !isSidebarVariant && onClosePanel
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={() => onClosePanel?.()}
|
||||
aria-label="Close preview"
|
||||
title="Close preview"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
)
|
||||
: null;
|
||||
|
||||
const headerLeadingButtons = isSidebarVariant
|
||||
? [collapseButton, maximizeButton].filter(Boolean)
|
||||
: [sidebarToggle, closeButton].filter(Boolean);
|
||||
const headerLeadingContent = headerLeadingButtons.length
|
||||
? (
|
||||
<>
|
||||
{headerLeadingButtons}
|
||||
</>
|
||||
)
|
||||
: null;
|
||||
|
||||
const resizeHandle = isSidebarVariant ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`detail-panel__resize-handle${isPanelResizing ? ' is-active' : ''}`}
|
||||
aria-label="Resize detail panel"
|
||||
{...resizeHandleProps}
|
||||
>
|
||||
<span aria-hidden="true" />
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
const loadingSection = (
|
||||
<div className="document-viewer-panel__body">
|
||||
<section className="document-viewer document-viewer--loading">
|
||||
<div className="document-viewer__details-pane">
|
||||
<div className="document-viewer__details">
|
||||
<div className="document-viewer__message">Loading document…</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="document-viewer__viewport">
|
||||
<div className="document-viewer__message">Preparing preview…</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
const viewerSection = document ? (
|
||||
<div
|
||||
className={isStackedLayout
|
||||
? 'document-viewer-panel__body document-viewer-panel__body--stacked'
|
||||
: 'document-viewer-panel__body'}
|
||||
>
|
||||
<section className={viewerClassName}>
|
||||
<DocumentViewerLayout
|
||||
document={document}
|
||||
previewEntry={previewEntry}
|
||||
summaryProps={summaryProps}
|
||||
metadataPayload={metadataPayload}
|
||||
contentTabConfig={contentTabConfig}
|
||||
previewLoadingMessage="Loading preview…"
|
||||
layoutMode={isStackedLayout ? 'stacked' : 'split'}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
) : loadingSection;
|
||||
|
||||
const headerTitle = breadcrumbTrailEntries.length ? (
|
||||
<BreadcrumbTrail
|
||||
entries={breadcrumbTrailEntries}
|
||||
separator="/"
|
||||
className="panel-header__breadcrumbs"
|
||||
truncateFromStart={isSidebarVariant}
|
||||
/>
|
||||
) : (
|
||||
document?.title || 'Document preview'
|
||||
);
|
||||
|
||||
const overlay = (
|
||||
<PreviewZoomOverlay
|
||||
open={Boolean(zoomOverlayOpen && zoomDisplay)}
|
||||
display={zoomDisplay}
|
||||
onClose={handleZoomClose}
|
||||
/>
|
||||
);
|
||||
|
||||
if (isSidebarVariant) {
|
||||
return (
|
||||
<>
|
||||
<aside
|
||||
className={`detail-panel panel${isPanelResizing ? ' detail-panel--resizing' : ''}`}
|
||||
ref={panelRef}
|
||||
style={detailPanelStyle}
|
||||
>
|
||||
{resizeHandle}
|
||||
<PanelHeader
|
||||
leading={headerLeadingContent}
|
||||
title={headerTitle}
|
||||
titleTag="h3"
|
||||
actions={headerActions}
|
||||
/>
|
||||
<div className="panel-body detail-panel__content">{viewerSection}</div>
|
||||
</aside>
|
||||
{overlay}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="document-viewer-panel" ref={panelRef}>
|
||||
<PanelHeader
|
||||
leading={headerLeadingContent}
|
||||
title={headerTitle}
|
||||
titleTag="h3"
|
||||
actions={headerActions}
|
||||
/>
|
||||
{viewerSection}
|
||||
</section>
|
||||
{overlay}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentViewerPanel;
|
||||
|
||||
export const createDocumentViewerSurface = ({
|
||||
document,
|
||||
previewEntry,
|
||||
ensureAssetUrl,
|
||||
ensurePreviewData,
|
||||
getDocumentAsset,
|
||||
resolveApiPath,
|
||||
notifyApiError,
|
||||
onClose,
|
||||
renderSidebarToggle,
|
||||
tagLookupById,
|
||||
tagOptions,
|
||||
onTagAdd,
|
||||
onTagRemove,
|
||||
correspondents,
|
||||
onCorrespondentAdd,
|
||||
onCorrespondentRemove,
|
||||
onUpdateTitle,
|
||||
onUpdateIssued,
|
||||
resolveFolderPath,
|
||||
}) => {
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sidebarToggle = typeof renderSidebarToggle === 'function'
|
||||
? renderSidebarToggle()
|
||||
: null;
|
||||
|
||||
return {
|
||||
key: 'preview',
|
||||
variant: 'preview',
|
||||
header: null,
|
||||
content: (
|
||||
<DocumentViewerPanel
|
||||
document={document}
|
||||
previewEntry={previewEntry}
|
||||
hydrateDocument={ensurePreviewData}
|
||||
tagLookupById={tagLookupById}
|
||||
tagOptions={tagOptions}
|
||||
onTagAdd={onTagAdd}
|
||||
onTagRemove={onTagRemove}
|
||||
correspondents={correspondents}
|
||||
onCorrespondentAdd={onCorrespondentAdd}
|
||||
onCorrespondentRemove={onCorrespondentRemove}
|
||||
onUpdateTitle={onUpdateTitle}
|
||||
onUpdateIssued={onUpdateIssued}
|
||||
ensureAssetUrl={ensureAssetUrl}
|
||||
getDocumentAsset={getDocumentAsset}
|
||||
ensurePreviewData={ensurePreviewData}
|
||||
resolveApiPath={resolveApiPath}
|
||||
notifyApiError={notifyApiError}
|
||||
sidebarToggle={sidebarToggle}
|
||||
onClosePanel={onClose}
|
||||
resolveFolderPath={resolveFolderPath}
|
||||
/>
|
||||
),
|
||||
supportsDetail: false,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user