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 { useSidebarContext } from '../sidebar/SidebarContext'; const DETAIL_PANEL_WIDTH_STORAGE_KEY = 'detailPanelWidth'; const MIN_DETAIL_PANEL_WIDTH = 320; const MAX_DETAIL_PANEL_WIDTH = 960; const isBrowser = typeof window !== 'undefined'; const isDocumentAvailable = typeof document !== 'undefined'; const getDetailPanelBounds = () => { if (!isBrowser) { return { min: MIN_DETAIL_PANEL_WIDTH, max: MAX_DETAIL_PANEL_WIDTH, }; } const viewportWidth = Math.max(window.innerWidth, 1); const minFractionWidth = viewportWidth / 5; const maxFractionWidth = viewportWidth * 0.75; const rawMin = Math.max(MIN_DETAIL_PANEL_WIDTH, minFractionWidth); const rawMax = Math.min(MAX_DETAIL_PANEL_WIDTH, maxFractionWidth); if (rawMin >= rawMax) { const fallback = Math.min(Math.max(rawMin, viewportWidth * 0.5), MAX_DETAIL_PANEL_WIDTH); return { min: fallback, max: fallback }; } return { min: rawMin, max: rawMax, }; }; const clampDetailPanelWidth = (value) => { if (!Number.isFinite(value) || value <= 0) { return null; } const { min, max } = getDetailPanelBounds(); return Math.min(Math.max(value, min), max); }; const loadStoredDetailPanelWidth = () => { if (!isBrowser) { return null; } try { const raw = window.localStorage?.getItem(DETAIL_PANEL_WIDTH_STORAGE_KEY); if (!raw) { return null; } const parsed = parseInt(raw, 10); return Number.isFinite(parsed) ? parsed : null; } catch (error) { console.warn('Failed to read detail panel width', error); return null; } }; const applyDetailPanelWidth = (width) => { if (!isDocumentAvailable || width == null) { return; } document.documentElement.style.setProperty('--detail-panel-width', `${width}px`); }; const getSidebarWidthFromRoot = () => { if (!isBrowser || !isDocumentAvailable) { return null; } const computed = window.getComputedStyle(document.documentElement); const parsed = parseFloat(computed.getPropertyValue('--sidebar-width')); return Number.isFinite(parsed) ? parsed : null; }; const shouldSuppressSidebarForDetailWidth = (detailWidth) => { if (!isBrowser || !Number.isFinite(detailWidth)) { return false; } const sidebarWidth = getSidebarWidthFromRoot() || 0; const minMainContentWidth = window.innerWidth / 3; const occupiedWidth = detailWidth + sidebarWidth; const availableWidth = window.innerWidth - occupiedWidth; return availableWidth < minMainContentWidth; }; 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 { setSidebarSuppressed } = useSidebarContext(); 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 pendingWidthRef = useRef(null); const [isResizingPanel, setIsResizingPanel] = useState(false); const [detailPanelWidth, setDetailPanelWidth] = useState(() => { if (!isBrowser) { return null; } const stored = loadStoredDetailPanelWidth(); return stored != null ? clampDetailPanelWidth(stored) : null; }); useEffect(() => { if (detailPanelWidth != null) { const clamped = clampDetailPanelWidth(detailPanelWidth); if (clamped != null) { applyDetailPanelWidth(clamped); } } }, [detailPanelWidth]); useEffect(() => { if (!isSidebarVariant) { setSidebarSuppressed(false); return undefined; } const updateSuppression = () => { if (!isBrowser) { setSidebarSuppressed(false); return; } const panelWidth = pendingWidthRef.current ?? detailPanelWidth ?? panelRef.current?.getBoundingClientRect().width; setSidebarSuppressed(shouldSuppressSidebarForDetailWidth(panelWidth)); }; updateSuppression(); const handleWindowResize = () => updateSuppression(); window.addEventListener('resize', handleWindowResize); return () => { window.removeEventListener('resize', handleWindowResize); }; }, [isSidebarVariant, detailPanelWidth, setSidebarSuppressed]); useEffect(() => { if (!isSidebarVariant) { setSidebarSuppressed(false); } }, [isSidebarVariant, setSidebarSuppressed]); useEffect(() => { if (!isBrowser) { return undefined; } const handleResize = () => { setDetailPanelWidth((prev) => { if (prev == null) { return prev; } const clamped = clampDetailPanelWidth(prev); if (clamped != null && clamped !== prev) { applyDetailPanelWidth(clamped); try { window.localStorage?.setItem(DETAIL_PANEL_WIDTH_STORAGE_KEY, String(Math.round(clamped))); } catch (error) { console.warn('Failed to persist detail panel width', error); } return clamped; } return prev; }); }; window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, []); const handleResizePointerDown = useCallback((event) => { if (!isSidebarVariant || !panelRef.current || !isBrowser) { return; } event.preventDefault(); event.stopPropagation(); const pointerId = event.pointerId; const target = event.currentTarget; target.setPointerCapture?.(pointerId); setIsResizingPanel(true); const rect = panelRef.current.getBoundingClientRect(); const startWidth = rect.width; const startX = event.clientX; const updateWidth = (nextWidth) => { const clamped = clampDetailPanelWidth(nextWidth); if (clamped != null) { pendingWidthRef.current = clamped; applyDetailPanelWidth(clamped); if (isSidebarVariant) { setSidebarSuppressed(shouldSuppressSidebarForDetailWidth(clamped)); } } }; const handlePointerMove = (moveEvent) => { if (moveEvent.pointerId !== pointerId) { return; } const delta = startX - moveEvent.clientX; updateWidth(startWidth + delta); }; const handlePointerUp = (upEvent) => { if (upEvent.pointerId !== pointerId) { return; } target.releasePointerCapture?.(pointerId); window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener('pointerup', handlePointerUp); setIsResizingPanel(false); if (pendingWidthRef.current != null) { const finalizedWidth = pendingWidthRef.current; pendingWidthRef.current = null; setDetailPanelWidth(finalizedWidth); try { window.localStorage?.setItem(DETAIL_PANEL_WIDTH_STORAGE_KEY, String(Math.round(finalizedWidth))); } catch (error) { console.warn('Failed to persist detail panel width', error); } } }; window.addEventListener('pointermove', handlePointerMove); window.addEventListener('pointerup', handlePointerUp); }, [isSidebarVariant, setSidebarSuppressed]); const handleResizeKeyDown = useCallback((event) => { if (!isSidebarVariant || !isBrowser) { return; } if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') { return; } const baseWidth = pendingWidthRef.current ?? detailPanelWidth ?? panelRef.current?.getBoundingClientRect().width; if (!Number.isFinite(baseWidth)) { return; } event.preventDefault(); const step = event.shiftKey ? 40 : 20; const delta = event.key === 'ArrowLeft' ? step : -step; const nextWidth = clampDetailPanelWidth(baseWidth + delta); if (nextWidth == null) { return; } setDetailPanelWidth(nextWidth); try { window.localStorage?.setItem(DETAIL_PANEL_WIDTH_STORAGE_KEY, String(Math.round(nextWidth))); } catch (error) { console.warn('Failed to persist detail panel width', error); } }, [detailPanelWidth, isSidebarVariant]); 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, }; };