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'; interface DocumentLike { id?: string | number; title?: string; content_type?: string | null; issued_at?: string | null; folder_id?: string | null; correspondents?: Array<{ id?: string | number; name?: string }>; current_version?: { version_number?: number; download?: { url?: string | null; expires_at?: number } | null; version?: { content_type?: string | null } | null; filename?: string | null; } | null; documentLink?: { url: string; alt?: string; contentType?: string | null; } | null; [key: string]: unknown; } interface AssetLike { id?: string | number; url?: string | null; metadata?: Record | null; [key: string]: unknown; } interface DocumentViewerPanelProps extends DocumentSummarySectionProps { document: DocumentLike | null; ensureAssetUrl?: (docId: string | number, asset: AssetLike, options?: { force?: boolean }) => Promise; getDocumentAsset?: (doc: DocumentLike | null, type: string) => AssetLike | null; ensurePreviewData?: (docId: string | number, options?: { signal?: AbortSignal }) => Promise; 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?: (args: { documentIds: Array }) => void; } export const createDocumentViewerHeaderActions = ({ document, actionState, onZoom, canZoom = false, }) => { if (!document) { return null; } const downloadHref = actionState?.downloadHref; if (!downloadHref && !(canZoom && onZoom)) { return null; } return ( <> {downloadHref ? ( ) : null} {canZoom && onZoom ? ( ) : null} ); }; const DocumentViewerPanel: React.FC = ({ document, 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 || !getDocumentAsset) { return false; } return Boolean(getDocumentAsset(document, 'ocr-text')); }, [document, getDocumentAsset]); const navigateToFolder = useCallback( (folderId) => { 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 = resolveApiPath ? resolveApiPath(downloadUrl) : downloadUrl; if (!href) { return null; } const contentType = document.content_type || null; const filename = document.current_version?.filename || document.filename || document.title || null; return { url: href, contentType, filename, }; }, [document, resolveApiPath]); 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(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 || !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 normalizedContentType = document.content_type || null; return { url: resolvedDocumentLink.url, alt: document.title, contentType: normalizedContentType || undefined, }; }, [document, resolvedDocumentLink?.url]); const headerActions = createDocumentViewerHeaderActions({ document, actionState, onZoom: zoomDisplay ? handleZoomOpen : null, canZoom: Boolean(zoomDisplay), }); const collapseButton = isSidebarVariant && onCollapsePanel ? ( ) : null; const maximizeButton = isSidebarVariant && onMaximizePanel ? ( ) : null; const closeButton = !isSidebarVariant && onClosePanel ? ( ) : null; const headerLeadingButtons = isSidebarVariant ? [ collapseButton ? {collapseButton} : null, maximizeButton ? {maximizeButton} : null, ].filter(Boolean) : [ sidebarToggle ? {sidebarToggle} : null, closeButton ? {closeButton} : null, ].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 overlayDocument = useMemo(() => ( document && zoomDisplay?.url ? { ...document, documentLink: zoomDisplay } : document ), [document, zoomDisplay]); const overlay = ( ); if (isSidebarVariant) { return ( <> {overlay} ); } return ( <>
{viewerSection}
{overlay} ); }; export default DocumentViewerPanel;