import React, {memo, useCallback, useLayoutEffect, useRef, useState} from 'react'; import type {CSSProperties, ReactNode} from 'react'; import times from 'lodash/times.js'; import {VariableSizeList as List} from 'react-window'; import {Document} from 'react-pdf'; import 'react-pdf/dist/Page/AnnotationLayer.css'; import 'react-pdf/dist/Page/TextLayer.css'; import type {PDFDocument, PageViewport, RotationDegrees} from './types.js'; import {pdfPreviewerStyles as styles} from './styles.js'; import PDFPasswordForm, {type PDFPasswordFormProps} from './PDFPasswordForm.js'; import PageRenderer from './PageRenderer.js'; import {PAGE_BORDER, LARGE_SCREEN_SIDE_SPACING, DEFAULT_DOCUMENT_OPTIONS, DEFAULT_EXTERNAL_LINK_TARGET, PDF_PASSWORD_FORM_RESPONSES} from './constants.js'; import {setListAttributes} from './helpers.js'; type Props = { file: string; pageMaxWidth: number; isSmallScreen: boolean; maxCanvasWidth?: number; maxCanvasHeight?: number; maxCanvasArea?: number; renderPasswordForm?: ({isPasswordInvalid, onSubmit, onPasswordChange}: Omit) => ReactNode | null; LoadingComponent?: ReactNode; ErrorComponent?: ReactNode; shouldShowErrorComponent?: boolean; onLoadError?: () => void; containerStyle?: CSSProperties; contentContainerStyle?: CSSProperties; rotation?: RotationDegrees; }; type OnPasswordCallback = (password: string | null) => void; const DefaultLoadingComponent =

Loading...

; const DefaultErrorComponent =

Failed to load the PDF file :(

; function PDFPreviewer({ file, pageMaxWidth, isSmallScreen, maxCanvasWidth, maxCanvasHeight, maxCanvasArea, LoadingComponent = DefaultLoadingComponent, ErrorComponent = DefaultErrorComponent, renderPasswordForm, containerStyle, contentContainerStyle, shouldShowErrorComponent = true, onLoadError, rotation = 0, }: Props): JSX.Element { const [pageViewports, setPageViewports] = useState([]); const [numPages, setNumPages] = useState(0); const [containerWidth, setContainerWidth] = useState(0); const [containerHeight, setContainerHeight] = useState(0); const [shouldRequestPassword, setShouldRequestPassword] = useState(false); const [isPasswordInvalid, setIsPasswordInvalid] = useState(false); const containerRef = useRef(null); const onPasswordCallbackRef = useRef(null); const listRef = useRef(null); /** * Calculate the devicePixelRatio the page should be rendered with * Each platform has a different default devicePixelRatio and different canvas limits, we need to verify that * with the default devicePixelRatio it will be able to display the pdf correctly, if not we must change the devicePixelRatio. * @param {Number} width of the page * @param {Number} height of the page * @returns {Number} devicePixelRatio for this page on this platform */ const getDevicePixelRatio = (width: number, height: number): number | undefined => { if (!maxCanvasWidth || !maxCanvasHeight || !maxCanvasArea) { return undefined; } const nbPixels = width * height; const ratioHeight = maxCanvasHeight / height; const ratioWidth = maxCanvasWidth / width; const ratioArea = Math.sqrt(maxCanvasArea / nbPixels); const ratio = Math.min(ratioHeight, ratioArea, ratioWidth); if (ratio > window.devicePixelRatio) { return undefined; } return ratio; }; /** * Calculates a proper page width. * It depends on a screen size. Also, the app should take into account the page borders. */ const calculatePageWidth = useCallback(() => { const pageWidthOnLargeScreen = Math.min(containerWidth - LARGE_SCREEN_SIDE_SPACING * 2, pageMaxWidth); const pageWidth = isSmallScreen ? containerWidth : pageWidthOnLargeScreen; return pageWidth + PAGE_BORDER * 2; }, [containerWidth, pageMaxWidth, isSmallScreen]); /** * Calculates a proper page height. The method should be called only when there are page viewports. * It is based on a ratio between the specific page viewport width and provided page width. * Also, the app should take into account the page borders. * When rotation is 90 or 270 degrees, width and height are swapped. */ const calculatePageHeight = useCallback( (pageIndex: number) => { if (pageViewports.length === 0) { return 0; } const pageWidth = calculatePageWidth(); const {width: originalWidth, height: originalHeight} = pageViewports[pageIndex]; // Swap dimensions when rotated 90 or 270 degrees const isRotated90or270 = rotation === 90 || rotation === 270; const pageViewportWidth = isRotated90or270 ? originalHeight : originalWidth; const pageViewportHeight = isRotated90or270 ? originalWidth : originalHeight; const scale = pageWidth / pageViewportWidth; return pageViewportHeight * scale + PAGE_BORDER * 2; }, [pageViewports, calculatePageWidth, rotation], ); const estimatedPageHeight = calculatePageHeight(0); const pageWidth = calculatePageWidth(); /** * Upon successful document load, combine an array of page viewports, * set the number of pages on PDF, * hide/reset PDF password form, and notify parent component that * user input is no longer required. */ const onDocumentLoadSuccess = (pdf: PDFDocument) => { Promise.all( times(pdf.numPages, (index: number) => { const pageNumber = index + 1; return pdf.getPage(pageNumber).then((page) => page.getViewport({scale: 1})); }), ).then( (viewports: PageViewport[]) => { setPageViewports(viewports); setNumPages(pdf.numPages); setShouldRequestPassword(false); setIsPasswordInvalid(false); }, () => {}, ); }; /** * Initiate password challenge process. The react-pdf/Document * component calls this handler to indicate that a PDF requires a * password, or to indicate that a previously provided password was * invalid. * * The PasswordResponses constants used below were copied from react-pdf * because they're not exported in entry.webpack. */ const initiatePasswordChallenge = (callback: OnPasswordCallback, reason: number) => { onPasswordCallbackRef.current = callback; if (reason === PDF_PASSWORD_FORM_RESPONSES.NEED_PASSWORD) { setShouldRequestPassword(true); } else if (reason === PDF_PASSWORD_FORM_RESPONSES.INCORRECT_PASSWORD) { setShouldRequestPassword(true); setIsPasswordInvalid(true); } }; /** * Send password to react-pdf via its callback so that it can attempt to load * the PDF. */ const attemptPDFLoad = (password: string) => { onPasswordCallbackRef.current?.(password); }; /** * Render a form to handle password typing. * The method renders the passed or default component. */ const internalRenderPasswordForm = useCallback(() => { const onSubmit = attemptPDFLoad; const onPasswordChange = () => setIsPasswordInvalid(false); if (typeof renderPasswordForm === 'function') { return renderPasswordForm({ isPasswordInvalid, onSubmit, onPasswordChange, }); } return ( ); }, [isPasswordInvalid, attemptPDFLoad, setIsPasswordInvalid, renderPasswordForm]); /** * Reset List style cache when dimensions change */ useLayoutEffect(() => { if (containerWidth > 0 && containerHeight > 0) { listRef.current?.resetAfterIndex(0); } }, [containerWidth, containerHeight, rotation]); /** * Scroll back to the top whenever rotation changes so the list offset * is consistent regardless of how page dimensions change. */ useLayoutEffect(() => { listRef.current?.scrollTo(0); }, [rotation]); useLayoutEffect(() => { if (!containerRef.current) { return undefined; } const resizeObserver = new ResizeObserver(() => { if (!containerRef.current) { return; } setContainerWidth(containerRef.current.clientWidth); setContainerHeight(containerRef.current.clientHeight); }); resizeObserver.observe(containerRef.current); return () => resizeObserver.disconnect(); }, []); return (
{pageViewports.length > 0 && ( {PageRenderer} )}
{shouldRequestPassword && internalRenderPasswordForm()}
); } PDFPreviewer.displayName = 'PDFPreviewer'; export default memo(PDFPreviewer);