Files
papercrate/frontend/src/preview/DocumentViewerPanel.tsx
T

526 lines
14 KiB
TypeScript

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/documentSummary';
import { createDocumentActionState } from '../documents/documentActions';
import { resolveDocumentAssetUrl } from '../asset_manager';
import PanelHeader from '../ui/PanelHeader';
import BreadcrumbTrail from '../ui/BreadcrumbTrail';
import DocumentViewerLayout from './DocumentViewerLayout';
import useViewerLayoutMode from './useViewerLayoutMode';
import { usePanelResizeBindings } from '../app/PanelManagerContext';
import type { DocumentId, FolderId } from '../types/identifiers';
import type { Document } from '../types/documents';
import type { AssetLike } from '../types/assets';
type SidebarMode = 'overlay' | 'inline';
interface DocumentViewerPanelProps extends DocumentSummarySectionProps {
document: Document | null;
ensureAssetUrl?: (docId: DocumentId, asset: AssetLike, options?: { force?: boolean }) => Promise<unknown>;
getDocumentAsset?: (doc: Document | null, type: string) => AssetLike | null;
ensurePreviewData?: (docId: DocumentId, options?: { signal?: AbortSignal }) => Promise<Document | null>;
notifyApiError?: (error: unknown, fallbackMessage?: string) => void;
sidebarToggle?: ReactNode;
onClosePanel?: () => void;
resolveFolderPath?: (doc: Document | null) => Array<{ id?: string; name?: string }>;
variant?: 'viewer' | 'sidebar';
onCollapsePanel?: () => void;
onMaximizePanel?: (args: { documentIds: Array<string> }) => void;
sidebarMode?: SidebarMode;
}
export const createDocumentViewerHeaderActions = ({
document,
actionState,
onZoom,
canZoom = false,
}) => {
if (!document) {
return null;
}
const downloadHref = actionState?.downloadHref;
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,
tagLookupById,
tagOptions,
onTagAdd,
onTagRemove,
correspondents,
onCorrespondentAdd,
onCorrespondentRemove,
onUpdateTitle,
onUpdateIssued,
ensureAssetUrl,
getDocumentAsset,
ensurePreviewData,
notifyApiError,
sidebarToggle = null,
onClosePanel,
resolveFolderPath,
variant = 'viewer',
onCollapsePanel,
onMaximizePanel,
sidebarMode = 'overlay',
}) => {
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 || !getDocumentAsset) {
return false;
}
return Boolean(getDocumentAsset(document, 'ocr-text'));
}, [document, getDocumentAsset]);
const navigateToFolder = useCallback(
(folderId: FolderId | null) => {
const target = folderId == null
? '/documents'
: `/documents/folder/${folderId}`;
navigate(target);
},
[navigate],
);
const summaryProps = useMemo(
() => ({
tagLookupById,
tagOptions,
onTagAdd,
onTagRemove,
correspondents: sortedCorrespondents,
correspondentOptions,
onCorrespondentAdd,
onCorrespondentRemove,
onUpdateTitle,
onUpdateIssued,
onFolderNavigate: navigateToFolder,
}),
[
tagLookupById,
tagOptions,
onTagAdd,
onTagRemove,
sortedCorrespondents,
correspondentOptions,
onCorrespondentAdd,
onCorrespondentRemove,
onUpdateTitle,
onUpdateIssued,
navigateToFolder,
],
);
const loadOcrContent = useCallback(async ({ signal }: { signal?: AbortSignal } = {}) => {
if (!document || !hasOcr || !getDocumentAsset) {
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 && ensureAssetUrl) {
await ensureAssetUrl(document.id, asset, { force: true });
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 resolvedDocumentLink = useMemo(() => {
if (!document) {
return null;
}
const downloadUrl = document.current_version?.download?.url;
const href = downloadUrl;
if (!href) {
return null;
}
const mimeType = document.mime_type;
const filename = document.current_version?.filename || document.filename || document.title || null;
return {
url: href,
mimeType,
filename,
};
}, [document]);
const handleZoomOpen = useCallback(() => {
if (!resolvedDocumentLink?.url) {
return;
}
setZoomOverlayOpen(true);
}, [resolvedDocumentLink?.url]);
const handleZoomClose = useCallback(() => {
setZoomOverlayOpen(false);
}, []);
useEffect(() => {
setZoomOverlayOpen(false);
}, [resolvedDocumentLink?.url, 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,
ensurePreviewData,
ensureAssetUrl,
getDocumentAsset,
notifyApiError,
ocrErrorMessage: 'Unable to open OCR text.',
})
: null,
[
document,
ensurePreviewData,
ensureAssetUrl,
getDocumentAsset,
notifyApiError,
],
);
const breadcrumbs = useMemo(() => {
if (!document || !resolveFolderPath) {
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 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 ? () => navigateToFolder(crumb.id) : null,
}));
}, [breadcrumbs, navigateToFolder]);
const zoomDisplay = useMemo(() => {
if (!resolvedDocumentLink?.url || !document) {
return null;
}
const normalizedMimeType = document.mime_type;
return {
url: resolvedDocumentLink.url,
alt: document.title,
mimeType: normalizedMimeType,
};
}, [document, resolvedDocumentLink?.url]);
const headerActions = createDocumentViewerHeaderActions({
document,
actionState,
onZoom: zoomDisplay ? handleZoomOpen : null,
canZoom: Boolean(zoomDisplay),
});
const collapseButton = isSidebarVariant && onCollapsePanel
? (
<button
type="button"
className="icon-button"
onClick={() => onCollapsePanel?.()}
aria-label="Close detail panel"
title="Close detail panel"
>
<IconX />
</button>
)
: null;
const maximizeButton = isSidebarVariant && onMaximizePanel
? (
<button
type="button"
className="icon-button"
onClick={(event) => {
event.stopPropagation();
const targetId = document?.id;
if (targetId == null) {
return;
}
onMaximizePanel?.({ documentIds: [targetId] });
}}
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 ? <React.Fragment key="collapse-button">{collapseButton}</React.Fragment> : null,
maximizeButton ? <React.Fragment key="maximize-button">{maximizeButton}</React.Fragment> : null,
].filter(Boolean)
: [
sidebarToggle ? <React.Fragment key="sidebar-toggle">{sidebarToggle}</React.Fragment> : null,
closeButton ? <React.Fragment key="close-button">{closeButton}</React.Fragment> : null,
].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}
documentLink={resolvedDocumentLink}
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 overlayDocument = useMemo(() => (
document && zoomDisplay?.url
? { ...document, documentLink: zoomDisplay }
: document
), [document, zoomDisplay]);
const overlay = (
<PreviewZoomOverlay
open={Boolean(zoomOverlayOpen && zoomDisplay)}
document={overlayDocument}
onClose={handleZoomClose}
/>
);
if (isSidebarVariant) {
const sidebarClass = `detail-panel panel${sidebarMode === 'inline' ? ' detail-panel--inline' : ''}${isPanelResizing ? ' detail-panel--resizing' : ''}`;
return (
<>
<aside
className={sidebarClass}
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;