pdfjs
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user