This commit is contained in:
2025-11-16 11:36:11 +01:00
parent 7e0768a09a
commit dae66703f2
17 changed files with 1110 additions and 160 deletions
@@ -2,6 +2,7 @@ import { useCallback, useMemo } from 'react';
import type { JSX } from 'react';
import DocumentInfoPanel from '../documents/DocumentInfoPanel';
import { DownloadIcon } from '../ui/icons';
import PdfViewer from './PdfViewer';
interface DocumentLike {
id?: string | number;
@@ -87,11 +88,13 @@ const DocumentViewerLayout = ({
}
if (isPdf) {
const documentTitle = document.title
|| document.filename
|| document.original_name;
return (
<iframe
<PdfViewer
src={previewEntry.url}
title={`Preview of ${document.title}`}
className="document-viewer__object"
title={documentTitle ? `Preview of ${documentTitle}` : undefined}
/>
);
}
+16 -40
View File
@@ -25,7 +25,6 @@ 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';
@@ -33,8 +32,13 @@ import { usePanelResizeBindings } from '../app/PanelManagerContext';
interface DocumentLike {
id?: string | number;
title?: string;
content_type?: string | null;
issued_at?: string | null;
correspondents?: Array<{ id?: string | number; name?: string }>;
current_version?: {
version_number?: number;
version?: { content_type?: string | null } | null;
} | null;
[key: string]: unknown;
}
@@ -245,18 +249,6 @@ const DocumentViewerPanel: React.FC<DocumentViewerPanelProps> = ({
);
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) {
@@ -360,34 +352,18 @@ const panelRef = useRef<HTMLDivElement | HTMLFormElement | HTMLElement | null>(n
}, [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 null;
}
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 docContentType = document.content_type;
const versionContentType = document.current_version?.version?.content_type;
const normalizedContentType = previewEntry.contentType || docContentType || versionContentType || null;
return {
url: previewEntry.url,
alt: document.title,
contentType: normalizedContentType || undefined,
};
}, [previewEntry?.url, previewEntry?.contentType, document]);
const headerActions = createDocumentViewerHeaderActions({
document,
+378
View File
@@ -0,0 +1,378 @@
import React, {
Fragment,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { JSX, MutableRefObject, RefObject } from 'react';
import {
GlobalWorkerOptions,
getDocument,
} from 'pdfjs-dist';
import type {
PDFDocumentLoadingTask,
PDFDocumentProxy,
RenderTask,
} from 'pdfjs-dist/types/src/display/api';
import { TextLayerBuilder } from 'pdfjs-dist/web/pdf_viewer.mjs';
GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url,
).toString();
interface PdfViewerProps {
src: string;
title?: string;
className?: string;
}
type RenderStatus = 'idle' | 'loading' | 'ready' | 'error';
interface PageDescriptor {
number: number;
width: number;
height: number;
scale: number;
}
const MIN_RENDER_WIDTH = 1200;
const MAX_RENDER_WIDTH = 2200;
const RERENDER_DELTA = 48;
const resolvePdfWasmBaseUrl = (): string => {
if (typeof document !== 'undefined') {
const base = document.baseURI || (typeof window !== 'undefined' ? window.location.href : '/');
return new URL('./pdfjs/wasm/', base).toString();
}
if (typeof window !== 'undefined' && window.location) {
return new URL('./pdfjs/wasm/', window.location.href).toString();
}
return '/pdfjs/wasm/';
};
const PdfViewer = ({ src, title, className }: PdfViewerProps): JSX.Element => {
const containerRef = useRef<HTMLDivElement>(null);
const [viewportWidth, setViewportWidth] = useState(0);
const [renderWidth, setRenderWidth] = useState(0);
const [status, setStatus] = useState<RenderStatus>('idle');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [pages, setPages] = useState<PageDescriptor[]>([]);
const [pixelRatio, setPixelRatio] = useState(1);
const pdfRef = useRef<PDFDocumentProxy | null>(null);
const wasmUrlRef = useRef<string | null>(null);
const ensureWasmUrl = useCallback(() => {
if (!wasmUrlRef.current) {
wasmUrlRef.current = resolvePdfWasmBaseUrl();
}
return wasmUrlRef.current;
}, []);
useLayoutEffect(() => {
const container = containerRef.current;
if (!container) {
return undefined;
}
const updateWidth = () => {
const measurementTarget = (
container.closest('.document-viewer__viewport') as HTMLElement | null
) || container.parentElement || container;
const nextWidth = Math.max(1, Math.round(measurementTarget.clientWidth));
setViewportWidth((prev) => (prev === nextWidth ? prev : nextWidth));
};
updateWidth();
const observer = new ResizeObserver(() => {
updateWidth();
});
observer.observe(container);
const parent = container.parentElement;
if (parent) {
observer.observe(parent);
}
const viewportNode = container.closest('.document-viewer__viewport') as HTMLElement | null;
if (viewportNode) {
observer.observe(viewportNode);
}
return () => observer.disconnect();
}, []);
useEffect(() => {
if (viewportWidth <= 0) {
return;
}
const desiredWidth = Math.min(
MAX_RENDER_WIDTH,
Math.max(viewportWidth, MIN_RENDER_WIDTH),
);
setRenderWidth((current) => {
if (current === 0 || desiredWidth > current + RERENDER_DELTA) {
return desiredWidth;
}
return current;
});
}, [viewportWidth]);
useEffect(() => {
if (!src || renderWidth <= 0) {
return undefined;
}
let cancelled = false;
let loadingTask: PDFDocumentLoadingTask | null = null;
let activePdf: PDFDocumentProxy | null = null;
const wasmUrl = ensureWasmUrl();
const disposeActivePdf = () => {
if (activePdf) {
void activePdf.destroy();
if (pdfRef.current === activePdf) {
pdfRef.current = null;
}
activePdf = null;
}
};
if (pdfRef.current) {
void pdfRef.current.destroy();
pdfRef.current = null;
}
setPages([]);
setStatus('loading');
setErrorMessage(null);
const renderDocument = async () => {
try {
loadingTask = getDocument({
url: src,
withCredentials: true,
wasmUrl,
});
const pdf = await loadingTask.promise;
if (cancelled) {
await pdf.destroy();
return;
}
const ratio = window.devicePixelRatio || 1;
const descriptors: PageDescriptor[] = [];
for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) {
if (cancelled) {
break;
}
const page = await pdf.getPage(pageNumber);
if (cancelled) {
break;
}
const baseViewport = page.getViewport({ scale: 1 });
const desiredScale = Math.max(0.5, renderWidth / baseViewport.width);
const viewport = page.getViewport({ scale: desiredScale });
descriptors.push({
number: pageNumber,
width: viewport.width,
height: viewport.height,
scale: desiredScale,
});
}
if (cancelled) {
await pdf.destroy();
return;
}
activePdf = pdf;
pdfRef.current = pdf;
setPixelRatio(ratio);
setPages(descriptors);
setStatus('ready');
} catch (error) {
if (!cancelled) {
console.error('Failed to prepare PDF preview', error);
setStatus('error');
setErrorMessage('Unable to render PDF preview.');
}
}
};
renderDocument().catch((error) => {
console.error('Unhandled PDF render error', error);
setStatus('error');
setErrorMessage('Unable to render PDF preview.');
});
return () => {
cancelled = true;
loadingTask?.destroy();
disposeActivePdf();
};
}, [ensureWasmUrl, renderWidth, src]);
const viewerClasses = ['document-viewer__object', 'document-viewer__object--pdf', className]
.filter(Boolean)
.join(' ');
const statusMessage = status === 'error'
? (errorMessage || 'Unable to render PDF preview.')
: 'Preparing preview…';
const statusClasses = ['pdf-viewer__status', status === 'error' ? 'pdf-viewer__status--error' : null]
.filter(Boolean)
.join(' ');
const showStatus = status !== 'ready' || pages.length === 0;
const pageElements = useMemo(() => (
pages.map((page) => (
<Fragment key={`${page.number}-${Math.round(page.scale * 100)}-${Math.round(pixelRatio * 100)}`}>
<PdfPageCanvas
descriptor={page}
pdfRef={pdfRef}
pixelRatio={pixelRatio}
rootRef={containerRef}
/>
</Fragment>
))
), [pages, pixelRatio]);
const handleCanvasStackClick = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
const target = event.target as HTMLElement;
if (target.closest('.pdf-viewer__page-wrapper')) {
event.stopPropagation();
}
}, []);
return (
<div className={viewerClasses}>
<div
ref={containerRef}
className="pdf-viewer__canvas-stack"
role="document"
aria-label={title || 'PDF document'}
onClick={handleCanvasStackClick}
>
{pageElements}
</div>
{showStatus ? (
<div className={statusClasses}>
<div className="document-viewer__message">
{statusMessage}
</div>
</div>
) : null}
</div>
);
};
interface PdfPageCanvasProps {
descriptor: PageDescriptor;
pdfRef: MutableRefObject<PDFDocumentProxy | null>;
pixelRatio: number;
rootRef: RefObject<HTMLDivElement>;
}
function PdfPageCanvas({
descriptor,
pdfRef,
pixelRatio,
rootRef,
}: PdfPageCanvasProps): JSX.Element {
const wrapperRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const renderTaskRef = useRef<RenderTask | null>(null);
const hasRenderedRef = useRef(false);
const textLayerBuilderRef = useRef<TextLayerBuilder | null>(null);
const renderPage = useCallback(async () => {
if (renderTaskRef.current || hasRenderedRef.current) {
return;
}
const pdf = pdfRef.current;
const canvas = canvasRef.current;
if (!pdf || !canvas) {
return;
}
try {
const page = await pdf.getPage(descriptor.number);
const viewport = page.getViewport({ scale: descriptor.scale });
const context = canvas.getContext('2d');
if (!context) {
return;
}
canvas.width = viewport.width * pixelRatio;
canvas.height = viewport.height * pixelRatio;
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
const renderTask = page.render({ canvasContext: context, viewport, canvas });
renderTaskRef.current = renderTask;
await renderTask.promise;
hasRenderedRef.current = true;
const wrapper = wrapperRef.current;
if (wrapper) {
textLayerBuilderRef.current?.cancel();
const textLayerBuilder = new TextLayerBuilder({
pdfPage: page,
});
textLayerBuilderRef.current = textLayerBuilder;
wrapper.querySelector('.textLayer')?.remove();
wrapper.appendChild(textLayerBuilder.div);
await textLayerBuilder.render({ viewport });
}
} catch (error) {
console.error('Failed to render PDF page', descriptor.number, error);
} finally {
renderTaskRef.current = null;
}
}, [descriptor.number, descriptor.scale, pdfRef, pixelRatio]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) {
return undefined;
}
canvas.style.width = '100%';
canvas.style.height = 'auto';
canvas.style.setProperty('aspect-ratio', `${descriptor.width} / ${descriptor.height}`);
const wrapperNode = wrapperRef.current;
return () => {
renderTaskRef.current?.cancel();
textLayerBuilderRef.current?.cancel();
wrapperNode?.querySelector('.textLayer')?.remove();
};
}, [descriptor.height, descriptor.width]);
useEffect(() => {
const node = canvasRef.current;
if (!node) {
return undefined;
}
const root = rootRef.current || undefined;
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
void renderPage();
}
});
}, {
root,
rootMargin: '200px 0px',
threshold: 0.1,
});
observer.observe(node);
return () => observer.disconnect();
}, [renderPage, rootRef]);
return (
<div ref={wrapperRef} className="pdf-viewer__page-wrapper">
<canvas
ref={canvasRef}
className="pdf-viewer__page"
aria-label={`Page ${descriptor.number}`}
/>
</div>
);
}
export default PdfViewer;