import React, { useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import { useNavigate } from 'react-router-dom'; import { DownloadIcon, CloseIcon, IconZoomInArea, DetailPanelCollapseIcon, WindowMaximizeIcon, } from '../ui/icons'; import PreviewZoomOverlay from '../detail/PreviewZoomOverlay'; import { buildCorrespondentOptions, sortCorrespondents, } 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'; 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 ? ( ) : null} {canZoom && onZoom ? ( ) : null} ); }; const DocumentViewerPanel = ({ 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(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' ? ( ) : null; const maximizeButton = isSidebarVariant && typeof onMaximizePanel === 'function' ? ( ) : null; const closeButton = !isSidebarVariant && onClosePanel ? ( ) : null; const headerLeadingButtons = isSidebarVariant ? [collapseButton, maximizeButton].filter(Boolean) : [sidebarToggle, closeButton].filter(Boolean); const headerLeadingContent = headerLeadingButtons.length ? ( <> {headerLeadingButtons} ) : null; const resizeHandle = isSidebarVariant ? ( ) : null; const loadingSection = (
Loading document…
Preparing preview…
); const viewerSection = document ? (
) : loadingSection; const headerTitle = breadcrumbTrailEntries.length ? ( ) : ( document?.title || 'Document preview' ); const overlay = ( ); if (isSidebarVariant) { return ( <> {overlay} ); } return ( <>
{viewerSection}
{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: ( ), supportsDetail: false, }; };