'use client'; import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react'; import clsx from 'clsx'; import { dequal } from 'dequal'; import makeEventProps from 'make-event-props'; import * as pdfjs from 'pdfjs-dist'; import invariant from 'tiny-invariant'; import warning from 'warning'; import DocumentContext from './DocumentContext.js'; import LinkService from './LinkService.js'; import Message from './Message.js'; import PasswordResponses from './PasswordResponses.js'; import useResource from './shared/hooks/useResource.js'; import areDocumentInputsEqual from './shared/areDocumentInputsEqual.js'; import ResourceCache from './shared/ResourceCache.js'; import { dataURItoByteString, displayCORSWarning, isArrayBuffer, isBlob, isBrowser, isDataURI, loadFromFile, } from './shared/utils.js'; import type { EventProps } from 'make-event-props'; import type { PDFDocumentLoadingTask, PDFDocumentProxy } from 'pdfjs-dist'; import type { DocumentInitParameters } from 'pdfjs-dist/types/src/display/api.js'; import type { ClassName, DocumentCallback, DocumentContextType, DocumentRenderProps, ExternalLinkRel, ExternalLinkTarget, File, ImageResourcesPath, NodeOrRenderer, OnDocumentLoadError, OnDocumentLoadProgress, OnDocumentLoadSuccess, OnError, OnItemClickArgs, OnPasswordCallback, Options, PasswordResponse, RenderMode, ScrollPageIntoViewArgs, Source, } from './shared/types.js'; const { PDFDataRangeTransport } = pdfjs; type OnItemClick = (args: OnItemClickArgs) => void; type OnPassword = (callback: OnPasswordCallback, reason: PasswordResponse) => void; type OnSourceError = OnError; type OnSourceSuccess = () => void; export type DocumentProps = { children?: React.ReactNode | ((props: DocumentRenderProps) => React.ReactNode); /** * Class name(s) that will be added to rendered element along with the default `react-pdf__Document`. * * @example 'custom-class-name-1 custom-class-name-2' * @example ['custom-class-name-1', 'custom-class-name-2'] */ className?: ClassName; /** * What the component should display in case of an error. * * @default 'Failed to load PDF file.' * @example 'An error occurred!' * @example

An error occurred!

* @example {this.renderError} */ error?: NodeOrRenderer; /** * Link rel for links rendered in annotations. * * @default 'noopener noreferrer nofollow' */ externalLinkRel?: ExternalLinkRel; /** * Link target for external links rendered in annotations. */ externalLinkTarget?: ExternalLinkTarget; /** * What PDF should be displayed. * * Its value can be an URL, a file (imported using `import … from …` or from file input form element), or an object with parameters (`url` - URL; `data` - data, preferably Uint8Array; `range` - PDFDataRangeTransport. * * Suspense compares plain parameter objects by value. Keep binary inputs and range transports outside the suspended subtree. With `suspense={false}`, memoize object props to avoid unnecessary reloads. * * @example 'https://example.com/sample.pdf' * @example importedPdf * @example { url: 'https://example.com/sample.pdf' } */ file?: File; /** * The path used to prefix the src attributes of annotation SVGs. * * @default '' * @example '/public/images/' */ imageResourcesPath?: ImageResourcesPath; /** * A prop that behaves like [ref](https://reactjs.org/docs/refs-and-the-dom.html), but it's passed to main `
` rendered by `` component. * * @example (ref) => { this.myDocument = ref; } * @example this.ref * @example ref */ inputRef?: React.Ref; /** * What the component should display while loading. * * @default 'Loading PDF…' * @example 'Please wait!' * @example

Please wait!

* @example {this.renderLoader} */ loading?: NodeOrRenderer; /** * What the component should display in case of no data. * * @default 'No PDF file specified.' * @example 'Please select a file.' * @example

Please select a file.

* @example {this.renderNoData} */ noData?: NodeOrRenderer; /** * Function called when an outline item or a thumbnail has been clicked. Usually, you would like to use this callback to move the user wherever they requested to. * * @example ({ dest, pageIndex, pageNumber }) => alert('Clicked an item from page ' + pageNumber + '!') */ onItemClick?: OnItemClick; /** * Function called in case of an error while loading a document. * * @example (error) => alert('Error while loading document! ' + error.message) */ onLoadError?: OnDocumentLoadError; /** * Function called, potentially multiple times, as the loading progresses. * * @example ({ loaded, total }) => alert('Loading a document: ' + (loaded / total) * 100 + '%') */ onLoadProgress?: OnDocumentLoadProgress; /** * Function called when the document is successfully loaded. * * @example (pdf) => alert('Loaded a file with ' + pdf.numPages + ' pages!') */ onLoadSuccess?: OnDocumentLoadSuccess; /** * Function called when a password-protected PDF is loaded. * * @example (callback) => callback('s3cr3t_p4ssw0rd') */ onPassword?: OnPassword; /** * Function called in case of an error while retrieving document source from `file` prop. * * @example (error) => alert('Error while retrieving document source! ' + error.message) */ onSourceError?: OnSourceError; /** * Function called when document source is successfully retrieved from `file` prop. * * @example () => alert('Document source retrieved!') */ onSourceSuccess?: OnSourceSuccess; /** * An object in which additional parameters to be passed to PDF.js can be defined. Most notably: * - `cMapUrl`; * - `httpHeaders` - custom request headers, e.g. for authorization); * - `wasmUrl`; * - `withCredentials` - a boolean to indicate whether or not to include cookies in the request (defaults to `false`) * * For a full list of possible parameters, check [PDF.js documentation on DocumentInitParameters](https://mozilla.github.io/pdf.js/api/draft/module-pdfjsLib.html#~DocumentInitParameters). * * Suspense compares plain options by value. Keep workers and other non-plain values outside the suspended subtree. With `suspense={false}`, memoize options to avoid unnecessary reloads. * * @example { cMapUrl: '/cmaps/', wasmUrl: '/wasm/' } */ options?: Options; /** * Rendering mode of the document. Can be `"canvas"`, `"custom"` or `"none"``. If set to `"custom"`, `customRenderer` must also be provided. * * @default 'canvas' * @example 'custom' */ renderMode?: RenderMode; /** * Rotation of the document in degrees. If provided, will change rotation globally, even for the pages which were given `rotate` prop of their own. `90` = rotated to the right, `180` = upside down, `270` = rotated to the left. * * @example 90 */ rotate?: number | null; /** * Document scale. * * @default 1 * @example 0.5 */ scale?: number; /** * Whether loading suspends and errors propagate to the nearest Error Boundary. * Set to `false` to use the component's loading and error behavior instead. * * @default true */ suspense?: boolean; } & EventProps; const defaultOnPassword: OnPassword = (callback, reason) => { switch (reason) { case PasswordResponses.NEED_PASSWORD: { const password = prompt('Enter the password to open this PDF file.'); callback(password); break; } case PasswordResponses.INCORRECT_PASSWORD: { const password = prompt('Invalid password. Please try again.'); callback(password); break; } default: } }; function isParameterObject(file: File): file is Source { return ( typeof file === 'object' && file !== null && ('data' in file || 'range' in file || 'url' in file) ); } async function findDocumentSource(file: File | undefined): Promise { if (!file) { return null; } // File is a string if (typeof file === 'string') { if (isDataURI(file)) { const fileByteString = dataURItoByteString(file); return { data: fileByteString }; } displayCORSWarning(); return { url: file }; } // File is PDFDataRangeTransport if (file instanceof PDFDataRangeTransport) { return { range: file }; } // File is an ArrayBuffer if (isArrayBuffer(file)) { return { data: file }; } /** * The cases below are browser-only. * If you're running on a non-browser environment, these cases will be of no use. */ if (isBrowser) { // File is a Blob if (isBlob(file)) { const data = await loadFromFile(file); return { data }; } } // At this point, file must be an object invariant( typeof file === 'object', 'Invalid parameter in file, need either Uint8Array, string or a parameter object', ); invariant(isParameterObject(file), 'Invalid parameter object: need either .data, .range or .url'); // File .url is a string if ('url' in file && typeof file.url === 'string') { if (isDataURI(file.url)) { const { url, ...otherParams } = file; const fileByteString = dataURItoByteString(url); return { data: fileByteString, ...otherParams }; } displayCORSWarning(); } return file; } const sourceCache = new ResourceCache<[File | undefined], Source | null>({ areKeysEqual: areDocumentInputsEqual, }); const documentCache = new ResourceCache<[File | undefined, Options | undefined], PDFDocumentProxy>({ areKeysEqual: areDocumentInputsEqual, }); function createDocumentTask( source: Source, options: Options | undefined, onPassword: OnPassword, onLoadProgress: OnDocumentLoadProgress | undefined, ): PDFDocumentLoadingTask { const initParameters: DocumentInitParameters = options ? { ...source, ...options } : source; const parameters = { ...initParameters }; // PDF.js transfers input buffers to its worker. Keep the cached source reusable // after eviction, and when another document uses the same input. if (parameters.data instanceof ArrayBuffer) { parameters.data = parameters.data.slice(0); } else if (ArrayBuffer.isView(parameters.data)) { const data = parameters.data; parameters.data = new Uint8Array(data.buffer, data.byteOffset, data.byteLength).slice(); } const task = pdfjs.getDocument(parameters); task.onPassword = onPassword; if (onLoadProgress) { task.onProgress = onLoadProgress; } return task; } /** * Loads a document passed using `file` prop. */ const Document: React.ForwardRefExoticComponent< DocumentProps & React.RefAttributes<{ linkService: React.RefObject; pages: React.RefObject; viewer: React.RefObject<{ scrollPageIntoView: (args: ScrollPageIntoViewArgs) => void }>; }> > = forwardRef(function Document( { children, className, error = 'Failed to load PDF file.', externalLinkRel, externalLinkTarget, file, inputRef, imageResourcesPath, loading = 'Loading PDF…', noData = 'No PDF file specified.', onItemClick, onLoadError: onLoadErrorProps, onLoadProgress, onLoadSuccess: onLoadSuccessProps, onPassword = defaultOnPassword, onSourceError: onSourceErrorProps, onSourceSuccess: onSourceSuccessProps, options, renderMode, rotate, scale, suspense = true, ...otherProps }, ref, ) { const loadSource = useCallback(() => ({ promise: findDocumentSource(file) }), [file]); const { value: source, error: sourceError } = useResource( !suspense || file ? { cache: sourceCache, key: [file], load: loadSource } : undefined, suspense, onSourceErrorProps, ); // biome-ignore lint/correctness/useExhaustiveDependencies: Callback changes should not restart the document load const loadPdf = useMemo(() => { if (!source) { return; } return () => { const task = createDocumentTask(source, options, onPassword, onLoadProgress); return { promise: task.promise, dispose: () => task.destroy() }; }; }, [options, source, suspense]); const { value: pdf, error: pdfError } = useResource( loadPdf ? { cache: documentCache, key: [file, options], load: loadPdf } : undefined, suspense, onLoadErrorProps, ); const linkService = useRef(new LinkService()); const pages = useRef([]); const prevFile = useRef(undefined); const prevOptions = useRef(undefined); if (!suspense && file && file !== prevFile.current && isParameterObject(file)) { warning( !dequal(file, prevFile.current), `File prop passed to changed, but it's equal to previous one. This might result in unnecessary reloads. Consider memoizing the value passed to "file" prop.`, ); prevFile.current = file; } // Detect non-memoized changes in options prop if (!suspense && options && options !== prevOptions.current) { warning( !dequal(options, prevOptions.current), `Options prop passed to changed, but it's equal to previous one. This might result in unnecessary reloads. Consider memoizing the value passed to "options" prop.`, ); prevOptions.current = options; } const viewer = useRef({ // Handling jumping to internal links target scrollPageIntoView: (args: ScrollPageIntoViewArgs) => { const { dest, pageNumber, pageIndex = pageNumber - 1 } = args; // First, check if custom handling of onItemClick was provided if (onItemClick) { onItemClick({ dest, pageIndex, pageNumber }); return; } // If not, try to look for target page within the . const page = pages.current[pageIndex]; if (page) { // Scroll to the page automatically page.scrollIntoView(); return; } warning( false, `An internal link leading to page ${pageNumber} was clicked, but neither was provided with onItemClick nor it was able to find the page within itself. Either provide onItemClick to and handle navigating by yourself or ensure that all pages are rendered within .`, ); }, }); useImperativeHandle( ref, () => ({ linkService, pages, viewer, }), [], ); /** * Called when a document source is resolved correctly */ function onSourceSuccess() { if (onSourceSuccessProps) { onSourceSuccessProps(); } } /** * Called when a document source failed to be resolved correctly */ function onSourceError() { if (!sourceError) { // Impossible, but TypeScript doesn't know that return; } warning(false, sourceError.toString()); if (onSourceErrorProps) { onSourceErrorProps(sourceError); } } // biome-ignore lint/correctness/useExhaustiveDependencies: Omitted callbacks so they are not called every time they change useEffect(() => { if (typeof source === 'undefined') { return; } if (source === false) { onSourceError(); return; } onSourceSuccess(); }, [source]); /** * Called when a document is read successfully */ function onLoadSuccess() { if (!pdf) { // Impossible, but TypeScript doesn't know that return; } if (onLoadSuccessProps) { onLoadSuccessProps(pdf); } linkService.current.setDocument(pdf); } /** * Called when a document failed to read successfully */ function onLoadError() { if (!pdfError) { // Impossible, but TypeScript doesn't know that return; } warning(false, pdfError.toString()); if (onLoadErrorProps) { onLoadErrorProps(pdfError); } } // biome-ignore lint/correctness/useExhaustiveDependencies: Omitted callbacks so they are not called every time they change useEffect(() => { if (typeof pdf === 'undefined') { return; } if (pdf === false) { onLoadError(); return; } onLoadSuccess(); }, [pdf]); useEffect( function setupLinkService() { linkService.current.setViewer(viewer.current); linkService.current.setExternalLinkRel(externalLinkRel); linkService.current.setExternalLinkTarget(externalLinkTarget); }, [externalLinkRel, externalLinkTarget], ); const registerPage = useCallback((pageIndex: number, ref: HTMLDivElement) => { pages.current[pageIndex] = ref; }, []); const unregisterPage = useCallback((pageIndex: number) => { delete pages.current[pageIndex]; }, []); const childContext = useMemo( () => ({ imageResourcesPath, linkService: linkService.current, onItemClick, pdf, registerPage, renderMode, rotate, scale, suspense, unregisterPage, }), [ imageResourcesPath, onItemClick, pdf, registerPage, renderMode, rotate, scale, suspense, unregisterPage, ], ); const eventProps = useMemo( () => makeEventProps(otherProps, () => pdf), // biome-ignore lint/correctness/useExhaustiveDependencies: FIXME [otherProps, pdf], ); function renderChildren() { function isFulfilledContext(context: DocumentContextType): context is DocumentRenderProps { return Boolean(context?.pdf); } if (!isFulfilledContext(childContext)) { // Impossible, but TypeScript doesn't know that throw new Error('pdf is undefined'); } const resolvedChildren = typeof children === 'function' ? children(childContext) : children; return ( {resolvedChildren} ); } function renderContent() { if (!file) { return {typeof noData === 'function' ? noData() : noData}; } if (pdf === undefined || pdf === null) { return ( {typeof loading === 'function' ? loading() : loading} ); } if (pdf === false) { return {typeof error === 'function' ? error() : error}; } return renderChildren(); } return (
{renderContent()}
); }); export default Document;