diff --git a/package-lock.json b/package-lock.json index 7a7368316d53..5e8db3002290 100644 --- a/package-lock.json +++ b/package-lock.json @@ -73,6 +73,7 @@ "react-content-loader": "^7.0.0", "react-dom": "18.1.0", "react-error-boundary": "^4.0.11", + "react-fast-pdf": "^1.0.6", "react-map-gl": "^7.1.3", "react-native": "0.73.2", "react-native-android-location-enabler": "^2.0.1", @@ -25183,7 +25184,6 @@ }, "node_modules/dequal": { "version": "2.0.3", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -38922,6 +38922,74 @@ "react": ">=16.13.1" } }, + "node_modules/react-fast-pdf": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/react-fast-pdf/-/react-fast-pdf-1.0.6.tgz", + "integrity": "sha512-CdAnBSZaLCGLSEuiqWLzzXhV9Wvdf1VRixaXCrb3NFrXyeltahF7PY+u7eU6ynrWZGmNI6g0cMLPv0DQhJEeew==", + "dependencies": { + "react-pdf": "^7.7.0", + "react-window": "^1.8.10" + }, + "engines": { + "node": "20.10.0", + "npm": "10.2.3" + }, + "peerDependencies": { + "lodash": "4.x", + "prop-types": "15.x", + "react": "18.x", + "react-dom": "18.x" + } + }, + "node_modules/react-fast-pdf/node_modules/pdfjs-dist": { + "version": "3.11.174", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.11.174.tgz", + "integrity": "sha512-TdTZPf1trZ8/UFu5Cx/GXB7GZM30LT+wWUNfsi6Bq8ePLnb+woNKtDymI2mxZYBpMbonNFqKmiz684DIfnd8dA==", + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "canvas": "^2.11.2", + "path2d-polyfill": "^2.0.1" + } + }, + "node_modules/react-fast-pdf/node_modules/react-pdf": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-7.7.1.tgz", + "integrity": "sha512-cbbf/PuRtGcPPw+HLhMI1f6NSka8OJgg+j/yPWTe95Owf0fK6gmVY7OXpTxMeh92O3T3K3EzfE0ML0eXPGwR5g==", + "dependencies": { + "clsx": "^2.0.0", + "dequal": "^2.0.3", + "make-cancellable-promise": "^1.3.1", + "make-event-props": "^1.6.0", + "merge-refs": "^1.2.1", + "pdfjs-dist": "3.11.174", + "prop-types": "^15.6.2", + "tiny-invariant": "^1.0.0", + "warning": "^4.0.0" + }, + "funding": { + "url": "https://github.com/wojtekmaj/react-pdf?sponsor=1" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-fast-pdf/node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/react-freeze": { "version": "1.0.3", "license": "MIT", @@ -40398,8 +40466,9 @@ } }, "node_modules/react-window": { - "version": "1.8.9", - "license": "MIT", + "version": "1.8.10", + "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.10.tgz", + "integrity": "sha512-Y0Cx+dnU6NLa5/EvoHukUD0BklJ8qITCtVEPY1C/nL8wwoZ0b5aEw8Ff1dOVHw7fCzMt55XfJDd8S8W8LCaUCg==", "dependencies": { "@babel/runtime": "^7.0.0", "memoize-one": ">=3.1.1 <6" diff --git a/package.json b/package.json index 9ceda36371c2..7d91f9995ee4 100644 --- a/package.json +++ b/package.json @@ -124,6 +124,7 @@ "react-content-loader": "^7.0.0", "react-dom": "18.1.0", "react-error-boundary": "^4.0.11", + "react-fast-pdf": "^1.0.6", "react-map-gl": "^7.1.3", "react-native": "0.73.2", "react-native-android-location-enabler": "^2.0.1", diff --git a/src/CONST.ts b/src/CONST.ts index 12dbf87f79fb..3109b9ea90ca 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -1851,13 +1851,6 @@ const CONST = { MAX_INT_FOR_RANDOM_7_DIGIT_VALUE: 10000000, IOS_KEYBOARD_SPACE_OFFSET: -30, - PDF_PASSWORD_FORM: { - // Constants for password-related error responses received from react-pdf. - REACT_PDF_PASSWORD_RESPONSES: { - NEED_PASSWORD: 1, - INCORRECT_PASSWORD: 2, - }, - }, API_REQUEST_TYPE: { READ: 'read', WRITE: 'write', diff --git a/src/components/PDFView/WebPDFDocument.js b/src/components/PDFView/WebPDFDocument.js deleted file mode 100644 index dd9d1e066b19..000000000000 --- a/src/components/PDFView/WebPDFDocument.js +++ /dev/null @@ -1,132 +0,0 @@ -import 'core-js/features/array/at'; -import PropTypes from 'prop-types'; -import React, {memo, useCallback} from 'react'; -import {Document} from 'react-pdf'; -import {VariableSizeList as List} from 'react-window'; -import _ from 'underscore'; -import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; -import Text from '@components/Text'; -import stylePropTypes from '@styles/stylePropTypes'; -import CONST from '@src/CONST'; -import PageRenderer from './WebPDFPageRenderer'; - -const propTypes = { - /** Index of the PDF page to be displayed passed by VariableSizeList */ - errorLabelStyles: stylePropTypes, - /** Returns translated string for given locale and phrase */ - translate: PropTypes.func.isRequired, - /** The source URL from which to load PDF file to be displayed */ - sourceURL: PropTypes.string.isRequired, - /** Callback invoked when the PDF document is loaded successfully */ - onDocumentLoadSuccess: PropTypes.func.isRequired, - /** Viewport info of all PDF pages */ - pageViewportsLength: PropTypes.number.isRequired, - /** Sets attributes to list container */ - setListAttributes: PropTypes.func.isRequired, - /** Indicates, whether the screen is of small width */ - isSmallScreenWidth: PropTypes.bool.isRequired, - /** Height of PDF document container view */ - containerHeight: PropTypes.number.isRequired, - /** Width of PDF document container view */ - containerWidth: PropTypes.number.isRequired, - /** The number of pages of the PDF file to be rendered */ - numPages: PropTypes.number, - /** Function that calculates the height of a page of the PDF document */ - calculatePageHeight: PropTypes.func.isRequired, - /** Function that calculates the devicePixelRatio the page should be rendered with */ - getDevicePixelRatio: PropTypes.func.isRequired, - /** The estimated height of a single PDF page for virtualized rendering purposes */ - estimatedItemSize: PropTypes.number.isRequired, - /** The width of a page in the PDF file */ - pageWidth: PropTypes.number.isRequired, - /** The style applied to the list component */ - listStyle: stylePropTypes, - /** Function that should initiate that the user should be prompted for password to the PDF file */ - initiatePasswordChallenge: PropTypes.func.isRequired, - /** Either: - * - `string` - the password provided by the user to unlock the PDF file - * - `undefined` if password isn't needed to view the PDF file - * - `null` if the password is required but hasn't been provided yet */ - password: PropTypes.string, -}; - -const defaultProps = { - errorLabelStyles: [], - numPages: null, - listStyle: undefined, - password: undefined, -}; - -const WebPDFDocument = memo( - ({ - errorLabelStyles, - translate, - sourceURL, - onDocumentLoadSuccess, - pageViewportsLength, - setListAttributes, - isSmallScreenWidth, - containerHeight, - containerWidth, - numPages, - calculatePageHeight, - getDevicePixelRatio, - estimatedItemSize, - pageWidth, - listStyle, - initiatePasswordChallenge, - password, - }) => { - const onPassword = useCallback( - (callback, reason) => { - if (reason === CONST.PDF_PASSWORD_FORM.REACT_PDF_PASSWORD_RESPONSES.NEED_PASSWORD) { - if (password) { - callback(password); - } else { - initiatePasswordChallenge(reason); - } - } else if (reason === CONST.PDF_PASSWORD_FORM.REACT_PDF_PASSWORD_RESPONSES.INCORRECT_PASSWORD) { - initiatePasswordChallenge(reason); - } - }, - [password, initiatePasswordChallenge], - ); - - return ( - } - error={{translate('attachmentView.failedToLoadPDF')}} - file={sourceURL} - options={{ - cMapUrl: 'cmaps/', - cMapPacked: true, - }} - externalLinkTarget="_blank" - onLoadSuccess={onDocumentLoadSuccess} - onPassword={onPassword} - > - {!!pageViewportsLength && ( - - {PageRenderer} - - )} - - ); - }, - (prevProps, nextProps) => _.isEqual(prevProps, nextProps), -); - -WebPDFDocument.displayName = 'WebPDFDocument'; -WebPDFDocument.propTypes = propTypes; -WebPDFDocument.defaultProps = defaultProps; - -export default WebPDFDocument; diff --git a/src/components/PDFView/WebPDFPageRenderer.js b/src/components/PDFView/WebPDFPageRenderer.js deleted file mode 100644 index 15af0bb88e39..000000000000 --- a/src/components/PDFView/WebPDFPageRenderer.js +++ /dev/null @@ -1,57 +0,0 @@ -import PropTypes from 'prop-types'; -import React, {memo} from 'react'; -import {View} from 'react-native'; -import {Page} from 'react-pdf'; -import _ from 'underscore'; -import stylePropTypes from '@styles/stylePropTypes'; -import PDFViewConstants from './constants'; - -const propTypes = { - /** Index of the PDF page to be displayed passed by VariableSizeList */ - index: PropTypes.number.isRequired, - - /** Page extra data passed by VariableSizeList's data prop */ - data: PropTypes.shape({ - /** Width of a single page in the document */ - pageWidth: PropTypes.number.isRequired, - /** Function that calculates the height of a page given its index */ - calculatePageHeight: PropTypes.func.isRequired, - /** Function that calculates the pixel ratio for a page given its calculated width and height */ - getDevicePixelRatio: PropTypes.func.isRequired, - /** The estimated height of a single page in the document */ - estimatedItemSize: PropTypes.number.isRequired, - }).isRequired, - - /** Additional style props passed by VariableSizeList */ - style: stylePropTypes.isRequired, -}; - -const WebPDFPageRenderer = memo( - ({index: pageIndex, data, style}) => { - const {pageWidth, calculatePageHeight, getDevicePixelRatio, estimatedItemSize} = data; - - const pageHeight = calculatePageHeight(pageIndex); - const devicePixelRatio = getDevicePixelRatio(pageWidth, pageHeight); - - return ( - - - - ); - }, - (prevProps, nextProps) => _.isEqual(prevProps, nextProps), -); - -WebPDFPageRenderer.displayName = 'WebPDFPageRenderer'; -WebPDFPageRenderer.propTypes = propTypes; - -export default WebPDFPageRenderer; diff --git a/src/components/PDFView/constants.js b/src/components/PDFView/constants.js deleted file mode 100644 index a45beddfbb68..000000000000 --- a/src/components/PDFView/constants.js +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Each page has a default border. The app should take this size into account - * when calculates the page width and height. - */ -const PAGE_BORDER = 9; - -/** - * Pages should be more narrow than the container on large screens. The app should take this size into account - * when calculates the page width. - */ -const LARGE_SCREEN_SIDE_SPACING = 40; - -const REQUIRED_PASSWORD_MISSING = null; - -export default {PAGE_BORDER, LARGE_SCREEN_SIDE_SPACING, REQUIRED_PASSWORD_MISSING}; diff --git a/src/components/PDFView/index.js b/src/components/PDFView/index.js index 9706f8e06cc1..e69b52b74e95 100644 --- a/src/components/PDFView/index.js +++ b/src/components/PDFView/index.js @@ -1,53 +1,30 @@ import 'core-js/features/array/at'; -import pdfWorkerSource from 'pdfjs-dist/legacy/build/pdf.worker'; import React, {Component} from 'react'; +import {PDFPreviewer} from 'react-fast-pdf'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; -import {pdfjs} from 'react-pdf'; import _ from 'underscore'; +import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; +import Text from '@components/Text'; import withLocalize from '@components/withLocalize'; import withThemeStyles from '@components/withThemeStyles'; import withWindowDimensions from '@components/withWindowDimensions'; import compose from '@libs/compose'; -import Log from '@libs/Log'; import variables from '@styles/variables'; import * as CanvasSize from '@userActions/CanvasSize'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import PDFViewConstants from './constants'; import PDFPasswordForm from './PDFPasswordForm'; import * as pdfViewPropTypes from './pdfViewPropTypes'; -import PDFDocument from './WebPDFDocument'; class PDFView extends Component { constructor(props) { super(props); this.state = { - numPages: null, - pageViewports: [], - containerWidth: props.windowWidth, - containerHeight: props.windowHeight, - password: undefined, - /** used to keep the PDFPasswordForm mounted (for it to maintain state) while password is being verified */ - isCheckingPassword: false, - isPasswordInvalid: false, isKeyboardOpen: false, }; - this.onDocumentLoadSuccess = this.onDocumentLoadSuccess.bind(this); - this.initiatePasswordChallenge = this.initiatePasswordChallenge.bind(this); - this.attemptPDFLoad = this.attemptPDFLoad.bind(this); this.toggleKeyboardOnSmallScreens = this.toggleKeyboardOnSmallScreens.bind(this); - this.calculatePageHeight = this.calculatePageHeight.bind(this); - this.calculatePageWidth = this.calculatePageWidth.bind(this); - this.getDevicePixelRatio = _.memoize(this.getDevicePixelRatio.bind(this)); - this.setListAttributes = this.setListAttributes.bind(this); - - const workerURL = URL.createObjectURL(new Blob([pdfWorkerSource], {type: 'text/javascript'})); - if (pdfjs.GlobalWorkerOptions.workerSrc !== workerURL) { - pdfjs.GlobalWorkerOptions.workerSrc = workerURL; - } - this.retrieveCanvasLimits(); } @@ -69,134 +46,6 @@ class PDFView extends Component { } } - /** - * 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. - * - * @param {Object} pdf - The PDF file instance - * @param {Number} pdf.numPages - Number of pages of the PDF file - * @param {Function} pdf.getPage - A method to get page by its number. It requires to have the context. It should be the pdf itself. - * @memberof PDFView - */ - onDocumentLoadSuccess(pdf) { - const {numPages} = pdf; - - Promise.all( - _.times(numPages, (index) => { - const pageNumber = index + 1; - - return pdf.getPage(pageNumber).then((page) => page.getViewport({scale: 1})); - }), - ).then((pageViewports) => { - this.setState({ - pageViewports, - numPages, - isPasswordInvalid: false, - isCheckingPassword: false, - }); - }); - } - - /** - * Sets attributes to list container. - * It unblocks a default scroll by keyboard of browsers. - * @param {Object|undefined} ref - */ - setListAttributes(ref) { - if (!ref) { - return; - } - - // Useful for elements that should not be navigated to directly using the "Tab" key, - // but need to have keyboard focus set to them. - // eslint-disable-next-line no-param-reassign - ref.tabIndex = -1; - } - - /** - * 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 diplay 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 - */ - getDevicePixelRatio(width, height) { - const nbPixels = width * height; - const ratioHeight = this.props.maxCanvasHeight / height; - const ratioWidth = this.props.maxCanvasWidth / width; - const ratioArea = Math.sqrt(this.props.maxCanvasArea / nbPixels); - const ratio = Math.min(ratioHeight, ratioArea, ratioWidth); - - return ratio > window.devicePixelRatio ? undefined : ratio; - } - - /** - * 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. - * @param {Number} pageIndex - * @returns {Number} - */ - calculatePageHeight(pageIndex) { - if (this.state.pageViewports.length === 0 || _.some(this.state.pageViewports, (viewport) => !viewport)) { - Log.warn('Dev error: calculatePageHeight() in PDFView called too early'); - - return 0; - } - - const pageViewport = this.state.pageViewports[pageIndex]; - const pageWidth = this.calculatePageWidth(); - const scale = pageWidth / pageViewport.width; - const actualHeight = pageViewport.height * scale + PDFViewConstants.PAGE_BORDER * 2; - - return actualHeight; - } - - /** - * Calculates a proper page width. - * It depends on a screen size. Also, the app should take into account the page borders. - * @returns {Number} - */ - calculatePageWidth() { - const pdfContainerWidth = this.state.containerWidth; - const pageWidthOnLargeScreen = Math.min(pdfContainerWidth - PDFViewConstants.LARGE_SCREEN_SIDE_SPACING * 2, variables.pdfPageMaxWidth); - const pageWidth = this.props.isSmallScreenWidth ? this.state.containerWidth : pageWidthOnLargeScreen; - - return pageWidth + PDFViewConstants.PAGE_BORDER * 2; - } - - /** - * Initiate password challenge process. The WebPDFDocument - * 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. - * - * @param {Number} reason Reason code for password request - */ - initiatePasswordChallenge(reason) { - if (reason === CONST.PDF_PASSWORD_FORM.REACT_PDF_PASSWORD_RESPONSES.NEED_PASSWORD) { - this.setState({password: PDFViewConstants.REQUIRED_PASSWORD_MISSING, isCheckingPassword: false}); - } else if (reason === CONST.PDF_PASSWORD_FORM.REACT_PDF_PASSWORD_RESPONSES.INCORRECT_PASSWORD) { - this.setState({password: PDFViewConstants.REQUIRED_PASSWORD_MISSING, isPasswordInvalid: true, isCheckingPassword: false}); - } - } - - /** - * Send password to react-pdf via its callback so that it can attempt to load - * the PDF. - * - * @param {String} password Password to send via callback to react-pdf - */ - attemptPDFLoad(password) { - this.setState({password, isCheckingPassword: true}); - } - /** * On small screens notify parent that the keyboard has opened or closed. * @@ -229,59 +78,33 @@ class PDFView extends Component { renderPDFView() { const styles = this.props.themeStyles; - const pageWidth = this.calculatePageWidth(); const outerContainerStyle = [styles.w100, styles.h100, styles.justifyContentCenter, styles.alignItemsCenter]; - const pdfContainerStyle = [styles.PDFView, styles.noSelect, this.props.style]; - // If we're requesting a password then we need to hide - but still render - - // the PDF component. - if (this.state.password === PDFViewConstants.REQUIRED_PASSWORD_MISSING || this.state.isCheckingPassword) { - pdfContainerStyle.push(styles.invisible); - } - - const estimatedItemSize = this.calculatePageHeight(0); - return ( - - this.setState({containerWidth: width, containerHeight: height})} - > - - - {(this.state.password === PDFViewConstants.REQUIRED_PASSWORD_MISSING || this.state.isCheckingPassword) && ( - this.setState({isPasswordInvalid: false})} - isPasswordInvalid={this.state.isPasswordInvalid} - onPasswordFieldFocused={this.toggleKeyboardOnSmallScreens} - /> - )} + + } + ErrorComponent={{this.props.translate('attachmentView.failedToLoadPDF')}} + renderPasswordForm={({isPasswordInvalid, onSubmit, onPasswordChange}) => ( + + )} + /> ); } @@ -302,6 +125,7 @@ class PDFView extends Component { ); } } + PDFView.propTypes = pdfViewPropTypes.propTypes; PDFView.defaultProps = pdfViewPropTypes.defaultProps; diff --git a/src/styles/index.ts b/src/styles/index.ts index a56a858d1707..31e3941cc4e5 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -2373,29 +2373,12 @@ const styles = (theme: ThemeColors) => backgroundColor: theme.modalBackground, }, - PDFView: { - // `display: grid` is not supported in native platforms! - // It's being used on Web/Desktop only to vertically center short PDFs, - // while preventing the overflow of the top of long PDF files. - ...display.dGrid, - width: '100%', - height: '100%', - justifyContent: 'center', - overflow: 'hidden', - alignItems: 'center', - }, - - PDFViewList: { - overflowX: 'hidden', - // There properties disable "focus" effect on list - boxShadow: 'none', - outline: 'none', - }, - getPDFPasswordFormStyle: (isSmallScreenWidth: boolean) => ({ width: isSmallScreenWidth ? '100%' : 350, - ...(isSmallScreenWidth && flex.flex1), + flexBasis: isSmallScreenWidth ? '100%' : 350, + flexGrow: 0, + alignSelf: 'flex-start', } satisfies ViewStyle), centeredModalStyles: (isSmallScreenWidth: boolean, isFullScreenWhenSmall: boolean) =>