Files
papercrate/frontend/src/preview/DocumentViewerPanel.jsx
T
2025-11-12 02:42:37 +01:00

811 lines
22 KiB
React

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 ? (
<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 = ({
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'
? (
<button
type="button"
className="icon-button"
onClick={() => onCollapsePanel?.()}
aria-label="Close detail panel"
title="Close detail panel"
>
<DetailPanelCollapseIcon />
</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${isResizingPanel ? ' is-active' : ''}`}
aria-label="Resize detail panel"
onPointerDown={handleResizePointerDown}
onKeyDown={handleResizeKeyDown}
>
<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${isResizingPanel ? ' detail-panel--resizing' : ''}`} ref={panelRef}>
{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,
};
};