├── .editorconfig ├── .eslintrc.json ├── .gitignore ├── .npmignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── prettier.config.js ├── src ├── components │ ├── Observer.tsx │ ├── PDFViewer.tsx │ ├── PDFViewerPage.tsx │ ├── PDFViewerToolbar.tsx │ ├── PDFViewerTouchToolbar.tsx │ └── PDFWorker.tsx ├── index.ts ├── types │ ├── Page.ts │ └── pdfViewer.ts └── utils │ ├── PdfJs.ts │ └── hacks.ts ├── tsconfig.json └── webpack.config.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.md] 11 | trim_trailing_whitespace = false 12 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended", 4 | "plugin:@typescript-eslint/eslint-recommended", 5 | "plugin:@typescript-eslint/recommended", 6 | "plugin:react/recommended", 7 | "plugin:prettier/recommended" 8 | ], 9 | "parser": "@typescript-eslint/parser", 10 | "plugins": [ 11 | "no-null" 12 | ], 13 | "env": { 14 | "es6": true, 15 | "browser": true 16 | }, 17 | "parserOptions": { 18 | "ecmaFeatures": { 19 | "jsx": true 20 | }, 21 | "ecmaVersion": 2015, 22 | "sourceType": "module", 23 | "project": "./tsconfig.json" 24 | }, 25 | "rules": { 26 | "@typescript-eslint/no-use-before-define": [ 27 | 2, 28 | { 29 | "classes": false, 30 | "functions": false 31 | } 32 | ], 33 | "@typescript-eslint/no-unused-vars": [ 34 | 2, 35 | { 36 | "ignoreRestSiblings": true 37 | } 38 | ], 39 | "@typescript-eslint/ban-ts-ignore": 0, 40 | "@typescript-eslint/explicit-function-return-type": 0, 41 | "@typescript-eslint/no-explicit-any": [ 42 | 1, 43 | { 44 | "ignoreRestArgs": true 45 | } 46 | ], 47 | "@typescript-eslint/prefer-for-of": 2, 48 | "prefer-const": 2, 49 | "no-multiple-empty-lines": 2, 50 | "no-eval": 2, 51 | "no-console": 1, 52 | "eqeqeq": 2, 53 | "one-var": [ 54 | 2, 55 | "never" 56 | ], 57 | "quote-props": 0, 58 | "radix": 2, 59 | "space-before-function-paren": 0, 60 | "no-null/no-null": 2, 61 | "react/prop-types": 0, 62 | "react/display-name": 0, 63 | "prefer-arrow-callback": [ 64 | 2, 65 | { 66 | "allowNamedFunctions": true 67 | } 68 | ] 69 | }, 70 | "settings": { 71 | "react": { 72 | "version": "detect" 73 | } 74 | } 75 | } 76 | 77 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Build directory 24 | dist/ 25 | lib 26 | lib-esm 27 | _bundles 28 | 29 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (http://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | yarn.lock 45 | 46 | # Typescript v1 declaration files 47 | typings/ 48 | 49 | # Optional npm cache directory 50 | .npm 51 | 52 | # Optional eslint cache 53 | .eslintcache 54 | 55 | # Optional REPL history 56 | .node_repl_history 57 | 58 | # Output of 'npm pack' 59 | *.tgz 60 | 61 | # Yarn Integrity file 62 | .yarn-integrity 63 | 64 | # dotenv environment variables file 65 | .env 66 | 67 | # Editor files 68 | .idea 69 | .vscode 70 | *.sublime-project 71 | *.sublime-workspace 72 | 73 | # misc 74 | .DS_Store 75 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | public/ 3 | samples/ 4 | .editorconfig 5 | .eslintrc.json 6 | .gitignore 7 | prettier.config.js 8 | .travis.yml 9 | tsconfig.json 10 | **/webpack.config.js 11 | *.tgz -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | if: tag IS blank 3 | node_js: 4 | - "8" 5 | services: 6 | - docker 7 | cache: 8 | directories: 9 | - node_modules 10 | script: 11 | - npm run lint 12 | - npm run build 13 | before_deploy: 14 | - > 15 | if [ "$TRAVIS_BRANCH" != "master" ]; then 16 | sed -i -e "s/\(\"version\":\\s\+\"\([0-9]\+\.\?\)\+\)/\1-pre.$TRAVIS_BUILD_NUMBER/" package.json; 17 | fi; 18 | if ! [ "$BEFORE_DEPLOY_RUN" ]; then 19 | export BEFORE_DEPLOY_RUN=1; 20 | git config --local user.name "$git_user"; 21 | git config --local user.email "$git_email"; 22 | git tag "$(node -p 'require(`./package.json`).version')"; 23 | fi; 24 | deploy: 25 | - provider: npm 26 | email: $auth_email 27 | api_key: $auth_token 28 | edge: true 29 | skip_cleanup: true 30 | on: 31 | branch: master 32 | - provider: npm 33 | email: $auth_email 34 | api_key: $auth_token 35 | edge: true 36 | skip_cleanup: true 37 | tag: next 38 | on: 39 | branch: develop 40 | - provider: releases 41 | api_key: $git_token 42 | skip_cleanup: true 43 | on: 44 | branch: master -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # React PDF Viewer Component 2 | 3 | ## 1.0.0 4 | - Releasing version 1.0.0 as the component is fully usable and stable. 5 | 6 | ## 0.1.7 7 | - Bump dependencies 8 | 9 | ## 0.1.4 10 | - Fix scaling issues 11 | 12 | ## 0.1.3 13 | - Update dependencies 14 | 15 | ## 0.1.2 16 | - Add buttons for page navigation on mobile 17 | - Fix iOS select layout for the PDF sizing 18 | 19 | ## 0.1.1 20 | - new layout 16:9 with a maximum height of 90vh (for mobile phones in ladnscape) 21 | - new toolbar behavior which is always on screen 22 | - Fixed missalignments on the toolbar 23 | - small visual fixes 24 | 25 | ## 0.1.0 26 | - Redesign for touch devices 27 | - Support touch gestures like pan, pinch to zoom, double tap for fullscreen and more 28 | - Fix things 29 | 30 | ## 0.0.11 31 | 32 | - Fix mobile rendering for landscape PDFs 33 | - Allow the user to scroll horizontally on mobile 34 | - Change the limits for zoom 35 | 36 | ## 0.0.10 37 | 38 | - Polyfill intersection observer 39 | 40 | ## 0.0.9 41 | 42 | - Fix for IE 43 | 44 | ## 0.0.8 45 | 46 | - Fix zoom to page width / height 47 | 48 | ## 0.0.7 49 | 50 | - Even better performance with some major rewrites 51 | 52 | ## 0.0.6 53 | 54 | - Fix PDF performance for files with 100+ pages 55 | 56 | 57 | ## 0.0.5 58 | 59 | - Update PDF library to improve performance and bug fixes 60 | 61 | ## 0.0.4 62 | 63 | - Fix issue when the image rotated after scaling 64 | 65 | ## 0.0.3 66 | 67 | - Adding props to translate labels 68 | 69 | ## 0.0.2 70 | 71 | - Setting up worker to make render faster 72 | 73 | ## 0.0.1 74 | 75 | - Initial Repo 76 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 DCC Team (https://portal.zeiss.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React PDF Reader 2 | 3 | The purpose of this library is to provide a React component that works as a PDF Reader. It's basically a React wrapper from the pdf.js library from Mozilla. 4 | 5 | ## Using the Library 6 | 7 | The library can be installed via the following commands: 8 | 9 | ```sh 10 | npm i --save-dev react-view-pdf 11 | ``` 12 | 13 | Because this library uses components from `precise-ui`, it is necessary to add it as a dependency to your project: 14 | 15 | ```sh 16 | npm i precise-ui 17 | ``` 18 | 19 | Then, simply import the component like below: 20 | 21 | ```js 22 | import { PDFViewer } from 'react-view-pdf'; 23 | 24 | 25 | 26 | ``` 27 | 28 | ## Contributing 29 | 30 | Feel free to contribute to it or open issues in case of bugs. 31 | 32 | 33 | ## Roadmap 34 | 35 | 1. Remove dependency on `precise-ui`. 36 | 2. Allow selection of texts. 37 | 3. Add built-in download button. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-view-pdf", 3 | "version": "1.0.0", 4 | "description": "A simple and powerful PDF Viewer library for React.js", 5 | "homepage": "https://github.com/ZEISS/react-view-pdf", 6 | "author": { 7 | "name": "ZEISS Digital Innovation Partners" 8 | }, 9 | "main": "lib/index.js", 10 | "module": "lib/index.js", 11 | "types": "lib", 12 | "keywords": [ 13 | "react", 14 | "pdf-viewer", 15 | "pdf reader", 16 | "pdf viewer", 17 | "pdf" 18 | ], 19 | "devDependencies": { 20 | "@types/pdfjs-dist": "^2.10.377", 21 | "@typescript-eslint/eslint-plugin": "^5.4.0", 22 | "@typescript-eslint/parser": "^5.4.0", 23 | "eslint": "^8.3.0", 24 | "eslint-config-prettier": "^8.3.0", 25 | "eslint-friendly-formatter": "^4.0.1", 26 | "eslint-plugin-no-null": "^1.0.2", 27 | "eslint-plugin-prettier": "^4.0.0", 28 | "eslint-plugin-react": "^7.27.1", 29 | "husky": "^7.0.4", 30 | "prettier": "^2.4.1", 31 | "terser-webpack-plugin": "^5.2.5", 32 | "ts-loader": "^9.2.6", 33 | "typescript": "4.5.2", 34 | "webpack": "^5.64.2", 35 | "webpack-cli": "^4.9.1" 36 | }, 37 | "peerDependencies": { 38 | "precise-ui": "latest", 39 | "react": "^16.8.3", 40 | "styled-components": "^5.3.1" 41 | }, 42 | "scripts": { 43 | "start": "webpack-dev-server", 44 | "build": "webpack", 45 | "lint": "eslint -f \"node_modules/eslint-friendly-formatter\" \"src/**/*.{ts,tsx}\"", 46 | "prettier": "prettier --config prettier.config.js --write \"src/**/*.{ts,tsx}\"", 47 | "prepush": "npm run prettier" 48 | }, 49 | "license": "MIT", 50 | "dependencies": { 51 | "intersection-observer": "^0.12.0", 52 | "pdfjs-dist": "^2.10.377", 53 | "use-debounce": "^7.0.1" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 120, 3 | singleQuote: true, 4 | trailingComma: 'all', 5 | bracketSpacing: true, 6 | parser: 'typescript', 7 | semi: true, 8 | jsxBracketSameLine: true, 9 | }; 10 | -------------------------------------------------------------------------------- /src/components/Observer.tsx: -------------------------------------------------------------------------------- 1 | require('intersection-observer'); 2 | import * as React from 'react'; 3 | 4 | interface VisibilityChangedProps { 5 | isVisible: boolean; 6 | ratio: number; 7 | } 8 | 9 | interface ObserverProps { 10 | threshold?: number | number[]; 11 | onVisibilityChanged(params: VisibilityChangedProps): void; 12 | } 13 | 14 | const Observer: React.FC = ({ children, threshold, onVisibilityChanged }) => { 15 | const containerRef = React.useRef(undefined); 16 | 17 | React.useEffect(() => { 18 | const io = new IntersectionObserver( 19 | entries => { 20 | entries.forEach(entry => { 21 | const isVisible = entry.isIntersecting; 22 | const ratio = entry.intersectionRatio; 23 | onVisibilityChanged({ isVisible, ratio }); 24 | }); 25 | }, 26 | { 27 | threshold: threshold || 0, 28 | }, 29 | ); 30 | const container = containerRef.current; 31 | if (!container) { 32 | return; 33 | } 34 | io.observe(container); 35 | 36 | return (): void => { 37 | io.unobserve(container); 38 | }; 39 | }, []); 40 | 41 | return
{children}
; 42 | }; 43 | 44 | export default Observer; 45 | export type VisibilityChanged = VisibilityChangedProps; 46 | -------------------------------------------------------------------------------- /src/components/PDFViewer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { debounce, distance, styled, themed, StandardProps } from 'precise-ui'; 3 | import PdfJs from '../utils/PdfJs'; 4 | import { PageViewMode, PageType } from '../types/Page'; 5 | import { PDFViewerPage } from './PDFViewerPage'; 6 | import { dataURItoUint8Array, isDataURI } from '../utils/hacks'; 7 | import { PDFViewerToolbar, ToolbarLabelProps } from './PDFViewerToolbar'; 8 | import { PDFViewerTouchToolbar } from './PDFViewerTouchToolbar'; 9 | import { PDFWorker } from './PDFWorker'; 10 | 11 | interface FullscreenableElement { 12 | fullscreen: boolean; 13 | } 14 | 15 | const DocumentWrapper = styled.div` 16 | background-color: #fff; 17 | position: relative; 18 | width: 100%; 19 | // Thanks chrome for Android for not calculating the viewport size correctly depending 20 | // on whether you show or not the address bar. But no worries, we'll do it manually 21 | // We also set 2 times the padding-top for those browsers without var or min compatibility 22 | padding-top: 56.25%; 23 | padding-top: min(56.25%, calc(var(--vh, 1vh) * 90)); /* 16:9 Aspect Ratio */ 24 | overflow: hidden; 25 | 26 | ${({ fullscreen }: FullscreenableElement) => 27 | fullscreen && 28 | ` 29 | padding-top: 0; 30 | top: 0; 31 | left: 0; 32 | right: 0; 33 | bottom: 0; 34 | height: auto; 35 | position: fixed; 36 | z-index: 100500; 37 | `}; 38 | `; 39 | 40 | const Document = styled.div` 41 | position: absolute; 42 | top: 0; 43 | left: 0; 44 | bottom: 40px; 45 | right: 0; 46 | background-color: ${themed(({ theme = {} }: StandardProps) => theme.ui2)}; 47 | padding: ${distance.medium}; 48 | overflow: scroll; 49 | touch-action: pan-x pan-y; 50 | `; 51 | 52 | const PageWrapper = styled.div` 53 | display: flex; 54 | flex-direction: column; 55 | align-items: center; 56 | width: 100%; 57 | `; 58 | 59 | export interface PDFViewerProps { 60 | /** 61 | * URL to the file to be loaded 62 | */ 63 | url?: string; 64 | 65 | /** 66 | * Service workser URL 67 | */ 68 | workerUrl?: string; 69 | 70 | /** 71 | * Function event triggered when a document fully loads successfully 72 | * 73 | * @param document 74 | */ 75 | onLoadSuccess?(document: PdfJs.PdfDocument): void; 76 | 77 | /** 78 | * Function event triggered when a document fails to load 79 | * 80 | * @param error 81 | */ 82 | onLoadError?(error: unknown): void; 83 | 84 | /** 85 | * Function event triggered when the current page changes 86 | * 87 | * @param currentPage 88 | * @param totalPages 89 | */ 90 | onPageChanged?(currentPage: number, totalPages: number): void; 91 | 92 | /** 93 | * Optional object containing all labels used in the toolbar, in case localization is needed. 94 | */ 95 | toolbarLabels?: ToolbarLabelProps; 96 | 97 | /** 98 | * Disable text selection for rendered pages 99 | */ 100 | disableSelect?: boolean; 101 | } 102 | 103 | // const defaultWorkerUrl = 'https://unpkg.com/pdfjs-dist@2.4.456/build/pdf.worker.min.js'; 104 | const defaultWorkerUrl = 'https://unpkg.com/pdfjs-dist@2.10.377/legacy/build/pdf.worker.js'; 105 | /** 106 | * The `Document` is a wrapper to load PDFs and render all the pages 107 | */ 108 | export const PDFViewer: React.FC = props => { 109 | const { url, workerUrl = defaultWorkerUrl } = props; 110 | const documentRef = React.useRef(); 111 | const [document, setDocument] = React.useState(); 112 | const [loading, setLoading] = React.useState(true); 113 | const [pages, setPages] = React.useState>([]); 114 | const [currentPage, setCurrentPage] = React.useState(1); 115 | const [currentViewMode, setCurrentViewMode] = React.useState(PageViewMode.DEFAULT); 116 | const [currentScale, setCurrentScale] = React.useState(1); 117 | const [fullscreen, setFullscreen] = React.useState(false); 118 | 119 | const deviceAgent = navigator.userAgent.toLowerCase(); 120 | 121 | const isTouchDevice = 122 | deviceAgent.match(/(iphone|ipod|ipad)/) || 123 | deviceAgent.match(/(android)/) || 124 | deviceAgent.match(/(iemobile)/) || 125 | deviceAgent.match(/iphone/i) || 126 | deviceAgent.match(/ipad/i) || 127 | deviceAgent.match(/ipod/i) || 128 | deviceAgent.match(/blackberry/i) || 129 | deviceAgent.match(/bada/i) || 130 | (deviceAgent.match(/Mac/) && navigator.maxTouchPoints && navigator.maxTouchPoints > 2); // iPad PRO, apple thinks it should behave like a desktop Safari, and so here we are... 131 | 132 | /** 133 | * Every time a new file is set we load the new document 134 | */ 135 | React.useEffect(() => { 136 | loadDocument(); 137 | }, [url]); 138 | 139 | /** 140 | * Effect to re-calculate page size and re-render after entering / exiting fullscreen 141 | */ 142 | React.useEffect(() => { 143 | zoomToPageView(pages[currentPage - 1], currentViewMode); 144 | }, [fullscreen]); 145 | 146 | /** 147 | * Effect responsible for registering/unregistering the resize spy to determine the rendering sizes 148 | */ 149 | React.useLayoutEffect(() => { 150 | const handleResize = debounce(() => { 151 | zoomToPageView(pages[currentPage - 1], currentViewMode); 152 | 153 | // Fix chrome on Android address bar issue by setting the right viewport height with good old fashion JS 154 | // Then we set the value in the --vh custom property to the root of the document 155 | const vh = window.innerHeight * 0.01; 156 | window.document.documentElement.style.setProperty('--vh', `${vh}px`); 157 | }, 500); 158 | window.addEventListener('resize', handleResize); 159 | 160 | return () => window.removeEventListener('resize', handleResize); 161 | }); 162 | 163 | React.useEffect(() => { 164 | props.onPageChanged && props.onPageChanged(currentPage, pages.length); 165 | }, [currentPage]); 166 | 167 | /** 168 | * Finds a document source. 169 | */ 170 | async function findDocumentSource(url: string) { 171 | if (isDataURI(url)) { 172 | const fileUint8Array = dataURItoUint8Array(url); 173 | return { data: fileUint8Array }; 174 | } 175 | 176 | return { url }; 177 | } 178 | 179 | /** 180 | * Loads the PDF into the pdfjs library 181 | */ 182 | async function loadDocument() { 183 | // Reset all values for the new document 184 | setLoading(true); 185 | setPages([]); 186 | setCurrentScale(1); 187 | setCurrentViewMode(PageViewMode.DEFAULT); 188 | setCurrentPage(1); 189 | if (!url) { 190 | return; 191 | } 192 | try { 193 | const source = await findDocumentSource(url); 194 | const d = await PdfJs.getDocument(source).promise; 195 | onLoadSuccess(d); 196 | } catch (error) { 197 | onLoadError(error); 198 | } 199 | } 200 | 201 | /** 202 | * Event triggered when the document finished loading 203 | * 204 | * @param document 205 | */ 206 | function onLoadSuccess(document: PdfJs.PdfDocument) { 207 | setDocument(document); 208 | 209 | if (props.onLoadSuccess) { 210 | props.onLoadSuccess(document); 211 | } 212 | 213 | const _pages = [...new Array(document.numPages)].map(() => { 214 | return { 215 | ratio: 0, 216 | loaded: false, 217 | } as PageType; 218 | }); 219 | setPages(_pages); 220 | setLoading(false); 221 | } 222 | 223 | /** 224 | * Even triggered in case the document failed to load 225 | * 226 | * @param error 227 | */ 228 | function onLoadError(error: unknown) { 229 | setDocument(undefined); 230 | setLoading(false); 231 | 232 | if (props.onLoadError) { 233 | props.onLoadError(error); 234 | } else { 235 | throw error; 236 | } 237 | } 238 | 239 | /** 240 | * Touch events here 241 | */ 242 | window['touchInfo'] = { 243 | startX: 0, 244 | startY: 0, 245 | startDistance: 0, 246 | }; 247 | const touchInfo = window['touchInfo']; 248 | 249 | /** 250 | * Event triggered on double touch 251 | */ 252 | function onDocumentDoubleTouch() { 253 | if (isTouchDevice) { 254 | switchFullscreenMode(); 255 | } 256 | } 257 | 258 | let currentPinchScale = currentScale; 259 | 260 | /** 261 | * Event triggered when the user puts a finger on the screen 262 | * We only care here about events with 2 fingers on them so we can control pinch to zoom 263 | * 264 | * @param e 265 | */ 266 | function onDocumentTouchStart(e: React.TouchEvent) { 267 | if (e.touches.length > 1) { 268 | const startX = (e.touches[0].pageX + e.touches[1].pageX) / 2; 269 | const startY = (e.touches[0].pageY + e.touches[1].pageY) / 2; 270 | Object.assign(touchInfo, { 271 | startX, 272 | startY, 273 | startDistance: Math.hypot(e.touches[1].pageX - e.touches[0].pageX, e.touches[1].pageY - e.touches[0].pageY), 274 | }); 275 | } else { 276 | Object.assign(touchInfo, { 277 | startX: 0, 278 | startY: 0, 279 | startDistance: 0, 280 | }); 281 | } 282 | } 283 | 284 | /** 285 | * Event triggered when the user moves the finger around the screen 286 | * Since we only control pinch to zoom, we need to track how the distance between the fingers changed over time 287 | * Then we use that distance to calculate the relative scale and apply that scale using transforms 288 | * to avoid expensive re-renders, once the user let go the fingers we do a proper rendering of the PDF document 289 | * 290 | * @param e 291 | */ 292 | function onDocumentTouchMove(e: React.TouchEvent) { 293 | if (!isTouchDevice || touchInfo.startDistance <= 0 || e.touches.length < 2) { 294 | return; 295 | } 296 | const pinchDistance = Math.hypot(e.touches[1].pageX - e.touches[0].pageX, e.touches[1].pageY - e.touches[0].pageY); 297 | const originX = touchInfo.startX + documentRef.current.scrollLeft; 298 | const originY = touchInfo.startY + documentRef.current.scrollTop; 299 | currentPinchScale = pinchDistance / touchInfo.startDistance; 300 | 301 | // Adjust for min and max parameters over the absolute zoom (current zoom + pitch zoom) 302 | const absScale = currentPinchScale * currentScale; 303 | currentPinchScale = Math.min(Math.max(absScale, 0.2), 2.5) / currentScale; 304 | 305 | // Here we simulate the zooming effect with transform, not perfect, but better than a re-render 306 | documentRef.current.style.transform = `scale(${currentPinchScale})`; 307 | documentRef.current.style.transformOrigin = `${originX}px ${originY}px`; 308 | } 309 | 310 | /** 311 | * Event triggered when the user ends a touch event 312 | * If all went good and we are ending a pinch to zoom event we need to queue a rendering of the PDF pages 313 | * using the new zoom level 314 | */ 315 | function onDocumentTouchEnd() { 316 | if (!isTouchDevice || touchInfo.startDistance <= 0) return; 317 | documentRef.current.style.transform = `none`; 318 | documentRef.current.style.transformOrigin = `unset`; 319 | 320 | const rect = documentRef.current.getBoundingClientRect(); 321 | const dx = touchInfo.startX - rect.left; 322 | const dy = touchInfo.startY - rect.top; 323 | 324 | // I don't like this, but we need to make sure we change the scrolling after the re-rendering with the new zoom levels 325 | setTimeout(() => { 326 | documentRef.current.scrollLeft += dx * (currentPinchScale - 1); 327 | documentRef.current.scrollTop += dy * (currentPinchScale - 1); 328 | }, 0); 329 | 330 | Object.assign(touchInfo, { 331 | startDistance: 0, 332 | startX: 0, 333 | startY: 0, 334 | }); 335 | onScaleChange(currentPinchScale * currentScale); 336 | } 337 | 338 | /** 339 | * Event triggered when the touch event gets cancelled 340 | * In this case we need to restart our touchInfo data so other things can continue as they were 341 | */ 342 | function onTouchCancel() { 343 | if (isTouchDevice) { 344 | Object.assign(touchInfo, { 345 | startDistance: 0, 346 | startX: 0, 347 | startY: 0, 348 | }); 349 | } 350 | } 351 | 352 | /** 353 | * Event triggered when a page visibility changes 354 | * 355 | * @param pageNumber 356 | * @param ratio 357 | */ 358 | const onPageVisibilityChanged = (pageNumber: number, ratio: number): boolean => { 359 | // Ignore page change during pinch to zoom event 360 | // This needs to be done as page changes trigger a re-rendering 361 | // which conflicts with all the pinch to zoom events 362 | if (isTouchDevice && window['touchInfo'].startDistance > 0) return false; 363 | 364 | // Calculate in which page we are right now based on the scrolling position 365 | if (pages && pages.length) { 366 | pages[pageNumber - 1].ratio = ratio; 367 | const maxRatioPage = pages.reduce((maxIndex, item, index, array) => { 368 | return item.ratio > array[maxIndex].ratio ? index : maxIndex; 369 | }, 0); 370 | setCurrentPage(maxRatioPage + 1); 371 | } else { 372 | setCurrentPage(1); 373 | } 374 | return true; 375 | }; 376 | 377 | /** 378 | * Event triggered when a page loaded 379 | * 380 | * @param pageNumber 381 | * @param width 382 | * @param height 383 | */ 384 | const onPageLoaded = (pageNumber: number, width: number, height: number): void => { 385 | if (pages && pages.length) { 386 | pages[pageNumber - 1] = { 387 | ...pages[pageNumber - 1], 388 | loaded: true, 389 | width, 390 | height, 391 | }; 392 | setPages([...pages]); 393 | // On the first time we default the view to the first page 394 | if (pageNumber === 1) { 395 | zoomToPageView(pages[0], currentViewMode); 396 | } 397 | } 398 | }; 399 | /** 400 | * End of Touch events 401 | */ 402 | 403 | /** 404 | * Event triggered when the user manually changes the zoom level 405 | * @param scale 406 | */ 407 | function onScaleChange(scale: number) { 408 | setCurrentViewMode(PageViewMode.DEFAULT); 409 | zoomToScale(scale); 410 | } 411 | 412 | /** 413 | * Function used to navigate to a specific page 414 | * 415 | * @param pageNum 416 | */ 417 | function navigateToPage(pageNum: number) { 418 | pageNum = Math.min(Math.max(pageNum, 1), pages.length); 419 | const ref = pages[pageNum - 1].ref; // Convert to index from pageNumber 420 | if (ref && documentRef.current) { 421 | setCurrentPage(pageNum); 422 | documentRef.current.scrollTo 423 | ? documentRef.current.scrollTo(0, ref.offsetTop - 20) 424 | : (documentRef.current.scrollTop = ref.offsetTop - 20); 425 | } 426 | } 427 | 428 | /** 429 | * Zooms the page to the given scale 430 | * 431 | * @param scale 432 | */ 433 | function zoomToScale(scale: number) { 434 | setCurrentScale(Math.min(Math.max(scale, 0.2), 2.5)); 435 | } 436 | 437 | /** 438 | * Zooms the page according to the page view mode 439 | * 440 | * @param pageProps 441 | * @param viewMode 442 | */ 443 | function zoomToPageView(pageProps: PageType, viewMode: PageViewMode) { 444 | if (!documentRef.current || !pageProps || !pageProps.ref) { 445 | return; 446 | } 447 | 448 | const pageElement = pageProps.ref.firstChild as HTMLDivElement; 449 | const pageWidth = pageProps.width || pageElement.clientWidth; 450 | const pageHeight = pageProps.height || pageElement.clientHeight; 451 | const landscape = pageWidth > pageHeight; 452 | 453 | switch (viewMode) { 454 | case PageViewMode.DEFAULT: { 455 | if (landscape) { 456 | const desiredWidth = Math.round(documentRef.current.clientWidth - 32); 457 | zoomToScale(desiredWidth / pageWidth); 458 | } else { 459 | const desiredWidth = Math.round((documentRef.current.clientWidth - 32) * 0.7); 460 | zoomToScale(desiredWidth / pageWidth); 461 | } 462 | break; 463 | } 464 | case PageViewMode.FIT_TO_WIDTH: { 465 | const desiredWidth = Math.round(documentRef.current.clientWidth - 32); 466 | zoomToScale(desiredWidth / pageWidth); 467 | break; 468 | } 469 | case PageViewMode.FIT_TO_HEIGHT: { 470 | const desiredHeight = Math.round(documentRef.current.clientHeight - 32); 471 | zoomToScale(desiredHeight / pageHeight); 472 | break; 473 | } 474 | default: 475 | break; 476 | } 477 | } 478 | 479 | /** 480 | * Event triggered when the view mode changes 481 | */ 482 | function onViewModeChange(viewMode: PageViewMode) { 483 | setCurrentViewMode(viewMode); 484 | zoomToPageView(pages[currentPage - 1], viewMode); 485 | } 486 | 487 | /** 488 | * Enables / Disables fullscreen mode 489 | */ 490 | function switchFullscreenMode() { 491 | setFullscreen(!fullscreen); 492 | } 493 | 494 | return ( 495 | 496 | 497 | 504 | {loading ? ( 505 | 506 | 513 | 514 | ) : ( 515 | document && 516 | pages.map((_, index: number) => ( 517 | (pages[index].ref = ref)} key={index}> 518 | 527 | 528 | )) 529 | )} 530 | 531 | {!loading && !isTouchDevice && ( 532 | 544 | )} 545 | {!loading && isTouchDevice && ( 546 | 558 | )} 559 | 560 | 561 | ); 562 | }; 563 | 564 | PDFViewer.displayName = 'PDFViewer'; 565 | -------------------------------------------------------------------------------- /src/components/PDFViewerPage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { styled, themed, distance, Skeleton, StandardProps } from 'precise-ui'; 3 | import { useDebouncedCallback } from 'use-debounce'; 4 | import PdfJs from '../utils/PdfJs'; 5 | import { range } from '../utils/hacks'; 6 | import Observer, { VisibilityChanged } from './Observer'; 7 | 8 | const Page = styled.div` 9 | margin: auto auto ${distance.xlarge}; 10 | user-select: ${props => props.disableSelect && 'none'}; 11 | display: block; 12 | background-color: ${themed(({ theme = {} }: StandardProps) => theme.ui1)}; 13 | box-shadow: rgba(172, 181, 185, 0.4) 0 0 8px 0; 14 | `; 15 | 16 | export interface PDFViewerPageProps { 17 | document?: PdfJs.PdfDocument; 18 | pageNumber: number; 19 | scale: number; 20 | disableSelect?: boolean; 21 | loaded: boolean; 22 | onPageVisibilityChanged(pageIndex: number, ratio: number): boolean; 23 | onPageLoaded(pageNumber: number, width: number, height: number): void; 24 | } 25 | 26 | /** 27 | * The `Document` is a wrapper to load PDFs and render all the pages 28 | */ 29 | const PDFViewerPageInner: React.FC = props => { 30 | const { document, pageNumber, scale, onPageVisibilityChanged, onPageLoaded, loaded } = props; 31 | const [page, setPage] = React.useState(); 32 | const [isCalculated, setIsCalculated] = React.useState(false); 33 | const canvasRef = React.createRef(); 34 | const renderTask = React.useRef(); 35 | 36 | const debouncedLoad = useDebouncedCallback(() => loadPage(), 100, { leading: true }); 37 | const debouncedRender = useDebouncedCallback(() => renderPage(), 100, { leading: true }); 38 | 39 | const intersectionThreshold = [...Array(10)].map((_, i) => i / 10); 40 | 41 | React.useEffect(() => { 42 | debouncedRender(); 43 | }, [page, scale]); 44 | 45 | function loadPage() { 46 | if (document && !page && !isCalculated) { 47 | setIsCalculated(true); 48 | document.getPage(pageNumber).then(page => { 49 | const viewport = page.getViewport({ scale: 1 }); 50 | onPageLoaded(pageNumber, viewport.width, viewport.height); 51 | setPage(page); 52 | }); 53 | } 54 | } 55 | 56 | function renderPage() { 57 | if (page) { 58 | const task = renderTask.current; 59 | 60 | if (task) { 61 | task.cancel(); 62 | } 63 | 64 | const canvasEle = canvasRef.current as HTMLCanvasElement; 65 | if (!canvasEle) { 66 | return; 67 | } 68 | const viewport = page.getViewport({ scale }); 69 | canvasEle.height = viewport.height; 70 | canvasEle.width = viewport.width; 71 | 72 | const canvasContext = canvasEle.getContext('2d', { alpha: false }) as CanvasRenderingContext2D; 73 | 74 | renderTask.current = page.render({ 75 | canvasContext, 76 | viewport, 77 | }); 78 | renderTask.current.promise.then( 79 | // eslint-disable-next-line @typescript-eslint/no-empty-function 80 | () => {}, 81 | // eslint-disable-next-line @typescript-eslint/no-empty-function 82 | () => {}, 83 | ); 84 | } 85 | } 86 | 87 | function visibilityChanged(params: VisibilityChanged) { 88 | const ratio = params.isVisible ? params.ratio : 0; 89 | const changed = onPageVisibilityChanged(pageNumber, ratio); 90 | if (params.isVisible && changed) { 91 | debouncedLoad(); 92 | } 93 | } 94 | 95 | return ( 96 | 103 | 104 | {!loaded && ( 105 | <> 106 | 107 |
108 |
109 | {range(5).map(index => ( 110 | 111 | 112 |
113 | 114 |
115 | 116 |
117 | 118 |
119 | 120 |
121 | 122 |
123 | 124 |
125 | 126 |
127 | 128 |
129 |
130 | ))} 131 | 132 | )} 133 | 134 |
135 |
136 | ); 137 | }; 138 | 139 | function areEqual(prevProps: PDFViewerPageProps, nextProps: PDFViewerPageProps) { 140 | return ( 141 | prevProps.pageNumber === nextProps.pageNumber && 142 | prevProps.disableSelect === nextProps.disableSelect && 143 | prevProps.document === nextProps.document && 144 | prevProps.loaded === nextProps.loaded && 145 | (!nextProps.loaded || prevProps.scale === nextProps.scale) // if it's loading ignore the scale 146 | ); 147 | } 148 | 149 | export const PDFViewerPage = React.memo(PDFViewerPageInner, areEqual); 150 | -------------------------------------------------------------------------------- /src/components/PDFViewerToolbar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { ActionLink, distance, Flyout, Icon, styled, themed, Tooltip, StandardProps, AnchorProps } from 'precise-ui'; 3 | import { PageViewMode } from '../types/Page'; 4 | import { toCamel } from '../utils/hacks'; 5 | 6 | const Toolbar = styled.ul` 7 | background-color: ${themed(({ theme = {} }: StandardProps) => theme.ui5)}; 8 | color: ${themed(({ theme = {} }: StandardProps) => theme.text4)}; 9 | padding: 0 ${distance.medium}; 10 | display: flex; 11 | flex-direction: row; 12 | list-style: none; 13 | margin: 0; 14 | border-radius: 2px; 15 | align-items: center; 16 | 17 | position: relative; 18 | `; 19 | 20 | const ToolbarWrapper = styled.div` 21 | position: absolute; 22 | left: 0; 23 | right: 0; 24 | bottom: 0; 25 | display: flex; 26 | justify-content: center; 27 | `; 28 | 29 | const ToolbarItem = styled.li` 30 | display: list-item; 31 | padding: ${distance.medium} ${distance.xxsmall}; 32 | height: 25px; 33 | display: flex; 34 | align-items: center; 35 | 36 | & div { 37 | display: flex; 38 | } 39 | `; 40 | 41 | const ToolbarSeparator = styled.li` 42 | margin: ${distance.small} ${distance.medium}; 43 | width: 1px; 44 | overflow: hidden; 45 | background-color: ${themed(({ theme = {} }: StandardProps) => theme.ui4)}; 46 | height: 25px; 47 | `; 48 | 49 | const ToolbarTextField = styled.input` 50 | border: 0; 51 | padding: 0; 52 | height: 21px; 53 | width: 3em; 54 | `; 55 | 56 | const ToolbarDropdownListItem = styled.div` 57 | padding: ${distance.small} ${distance.medium}; 58 | background-color: ${themed(({ theme = {} }: StandardProps) => theme.ui5)}; 59 | white-space: nowrap; 60 | `; 61 | 62 | const ToolbarTooltip = styled(Tooltip)` 63 | font-size: 0.8em; 64 | white-space: nowrap; 65 | `; 66 | 67 | const ToolbarActionLink = styled(ActionLink)` 68 | color: ${themed(({ theme = {}, disabled }: StandardProps & AnchorProps) => (disabled ? theme.text3 : theme.text4))}; 69 | display: flex; 70 | align-items: center; 71 | height: 16px; 72 | 73 | :hover, 74 | :visited, 75 | :focus { 76 | color: ${themed(({ theme = {}, disabled }: StandardProps & AnchorProps) => (disabled ? theme.text3 : theme.text4))}; 77 | } 78 | `; 79 | 80 | const defaultLabels = { 81 | exitFullscreen: 'Exit Fullscreen', 82 | enterFullscreen: 'Enter Fullscreen', 83 | viewModeFitToHeight: 'Fit to Height', 84 | viewModeFitToWidth: 'Fit to Width', 85 | nextPage: 'Next', 86 | prevPage: 'Previous', 87 | zoomIn: 'Zoom In', 88 | zoomOut: 'Zoom Out', 89 | pagesOf: (current, total) => `Page ${current} of ${total}`, 90 | page: 'Page', 91 | }; 92 | 93 | export type ToolbarLabelProps = { 94 | exitFullscreen?: string; 95 | enterFullscreen?: string; 96 | viewModeFitToWidth?: string; 97 | viewModeFitToHeight?: string; 98 | viewModeDefault?: string; 99 | nextPage?: string; 100 | prevPage?: string; 101 | zoomIn?: string; 102 | zoomOut?: string; 103 | /** 104 | * Function that receives the current and total pages and returns a string with translations for number of pages 105 | * Example: 'Page 5 of 9' where 5 is the current page and 9 is the total. 106 | * 107 | * @param currentPage 108 | * @param totalPages 109 | */ 110 | pagesOf?(currentPage: number, totalPages: number): string; 111 | /** 112 | * Used as a prefix when editing the current page. 113 | * Example: 'Page ____.' 114 | * 115 | */ 116 | page?: string; 117 | }; 118 | 119 | export interface PDFViewerToolbarProps { 120 | currentPage: number; 121 | currentViewMode: PageViewMode; 122 | numPages: number; 123 | currentScale: number; 124 | fullscreen: boolean; 125 | onPageChange(pageNum: number): void; 126 | onScaleChange(pageNum: number): void; 127 | onViewModeChange(viewMode: PageViewMode): void; 128 | onFullscreenChange(): void; 129 | labels?: ToolbarLabelProps; 130 | } 131 | 132 | /** 133 | * The `Document` is a wrapper to load PDFs and render all the pages 134 | */ 135 | export const PDFViewerToolbar: React.FC = props => { 136 | const { labels = defaultLabels, fullscreen, onFullscreenChange, currentPage, currentScale } = props; 137 | 138 | const pageInputRef = React.useRef(); 139 | 140 | const [editingPageNumber, SetEditingPageNumber] = React.useState(); 141 | const [editingViewMode, SetEditingViewMode] = React.useState(false); 142 | 143 | /** 144 | * Returns the next view mode text to be used as tooltip 145 | */ 146 | function getViewModeText() { 147 | return labels[`viewMode${toCamel(PageViewMode[props.currentViewMode >= 3 ? 0 : props.currentViewMode])}`]; 148 | } 149 | 150 | /** 151 | * Returns the next view mode text to be used as tooltip 152 | */ 153 | function getViewModeIcon() { 154 | switch (props.currentViewMode) { 155 | case PageViewMode.FIT_TO_WIDTH: 156 | return 'FitToWidth'; 157 | case PageViewMode.FIT_TO_HEIGHT: 158 | return 'FitToHeight'; 159 | case PageViewMode.DEFAULT: 160 | return 'Page'; 161 | } 162 | } 163 | 164 | /** 165 | * Event triggered when the page number is clicked, thus entering page enter mode 166 | */ 167 | function onPageNumberFocused() { 168 | SetEditingPageNumber(true); 169 | } 170 | 171 | React.useEffect(() => { 172 | if (pageInputRef.current) { 173 | pageInputRef.current.focus(); 174 | } 175 | }, [pageInputRef, editingPageNumber]); 176 | 177 | /** 178 | * Event triggered when the page number field is blurred / changed 179 | */ 180 | function onPageNumberDefocused() { 181 | SetEditingPageNumber(false); 182 | 183 | // Now let's check the value 184 | if (pageInputRef.current && pageInputRef.current.value !== '') { 185 | const inputPage = Number(pageInputRef.current.value); 186 | if (!isNaN(inputPage)) { 187 | props.onPageChange(inputPage); 188 | } 189 | } 190 | } 191 | 192 | function onViewModeChange(viewMode: PageViewMode) { 193 | SetEditingViewMode(false); 194 | props.onViewModeChange(viewMode); 195 | } 196 | 197 | return ( 198 | 199 | 200 | 201 | 202 | props.onPageChange(currentPage - 1)} disabled={currentPage <= 1}> 203 | 204 | 205 | 206 | 207 | 208 | {editingPageNumber ? ( 209 | <> 210 | {labels.page}   211 | e.key === 'Enter' && onPageNumberDefocused()} 215 | /> 216 | 217 | ) : ( 218 | {labels.pagesOf(currentPage, props.numPages)} 219 | )} 220 | 221 | 222 | 223 | props.onPageChange(currentPage + 1)} 225 | disabled={currentPage >= props.numPages}> 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | { 237 | const scaleToPrev = Math.round((currentScale % 0.1) * 100) / 100; 238 | props.onScaleChange(currentScale - (scaleToPrev === 0 ? 0.1 : scaleToPrev)); 239 | }} 240 | disabled={currentScale <= 0.5}> 241 | 242 | 243 | 244 | 245 | {Math.round(currentScale * 100)}% 246 | 247 | 248 | { 250 | const scaleToPrev = Math.round((currentScale % 0.1) * 100) / 100; 251 | props.onScaleChange(currentScale + 0.1 - (scaleToPrev === 0.1 ? 0 : scaleToPrev)); 252 | }} 253 | disabled={currentScale >= 2.5}> 254 | 255 | 256 | 257 | 258 | 259 | 260 | theme.ui5) } }} 266 | content={ 267 | <> 268 | 269 | onViewModeChange(PageViewMode.FIT_TO_WIDTH)}> 270 | {labels.viewModeFitToWidth} 271 | 272 | 273 | onViewModeChange(PageViewMode.FIT_TO_HEIGHT)}> 274 | 275 | {labels.viewModeFitToHeight} 276 | 277 | 278 | 279 | }> 280 | SetEditingViewMode(!editingViewMode)}> 281 | 282 | {getViewModeText()} 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | ); 303 | }; 304 | -------------------------------------------------------------------------------- /src/components/PDFViewerTouchToolbar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { ActionLink, distance, Icon, styled, themed, StandardProps, AnchorProps } from 'precise-ui'; 3 | import { PageViewMode } from '../types/Page'; 4 | import { PDFViewerToolbarProps } from './PDFViewerToolbar'; 5 | 6 | const Toolbar = styled.ul` 7 | background-color: ${themed(({ theme = {} }: StandardProps) => theme.ui5)}; 8 | color: ${themed(({ theme = {} }: StandardProps) => theme.text4)}; 9 | padding: 0 ${distance.medium}; 10 | display: flex; 11 | flex-direction: row; 12 | list-style: none; 13 | margin: 0; 14 | position: relative; 15 | align-items: center; 16 | border-radius: 2px; 17 | `; 18 | 19 | const ToolbarWrapper = styled.div` 20 | position: absolute; 21 | left: 0; 22 | right: 0; 23 | bottom: 0; 24 | display: flex; 25 | justify-content: center; 26 | align-items: flex-end; 27 | `; 28 | 29 | const ToolbarItem = styled.li` 30 | display: list-item; 31 | padding: ${distance.medium} ${distance.xxsmall}; 32 | `; 33 | 34 | const ToolbarSeparator = styled.li` 35 | margin: ${distance.small} ${distance.medium}; 36 | width: 1px; 37 | overflow: hidden; 38 | background-color: ${themed(({ theme = {} }: StandardProps) => theme.ui4)}; 39 | height: 25px; 40 | `; 41 | 42 | const ToolbarActionLink = styled(ActionLink)` 43 | color: ${themed(({ theme = {}, disabled }: StandardProps & AnchorProps) => (disabled ? theme.text3 : theme.text4))}; 44 | display: flex; 45 | align-items: center; 46 | height: 16px; 47 | 48 | :hover, 49 | :visited, 50 | :focus { 51 | color: ${themed(({ theme = {}, disabled }: StandardProps & AnchorProps) => (disabled ? theme.text3 : theme.text4))}; 52 | } 53 | `; 54 | 55 | const ToolbarSelect = styled.select` 56 | color: ${themed(({ theme = {}, disabled }: StandardProps & AnchorProps) => (disabled ? theme.text3 : theme.text4))}; 57 | font-size: 1rem; 58 | border: none; 59 | appearance: none; 60 | -moz-appearance: none; 61 | -webkit-appearance: none; 62 | background-image: url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%23ffffff%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E'); 63 | background-color: ${themed(({ theme = {} }: StandardProps) => theme.ui5)}; 64 | background-repeat: no-repeat, repeat; 65 | background-size: 0.65em auto, 100%; 66 | background-position: right center; 67 | padding-right: 1rem; 68 | `; 69 | 70 | const defaultLabels = { 71 | exitFullscreen: 'Exit Fullscreen', 72 | enterFullscreen: 'Enter Fullscreen', 73 | viewModeFitToHeight: 'Fit to Height', 74 | viewModeFitToWidth: 'Fit to Width', 75 | viewModeDefault: 'Custom View', 76 | nextPage: 'Next', 77 | prevPage: 'Previous', 78 | zoomIn: 'Zoom In', 79 | zoomOut: 'Zoom Out', 80 | pagesOf: (current, total) => `${current} / ${total}`, 81 | page: 'Page', 82 | }; 83 | 84 | /** 85 | * The `Document` is a wrapper to load PDFs and render all the pages 86 | */ 87 | export const PDFViewerTouchToolbar: React.FC = props => { 88 | const { labels = defaultLabels, fullscreen, onFullscreenChange, currentPage } = props; 89 | 90 | function onViewModeChange(viewMode: string) { 91 | switch (viewMode) { 92 | case PageViewMode.FIT_TO_WIDTH.toString(): 93 | props.onViewModeChange(PageViewMode.FIT_TO_WIDTH); 94 | break; 95 | case PageViewMode.FIT_TO_HEIGHT.toString(): 96 | props.onViewModeChange(PageViewMode.FIT_TO_HEIGHT); 97 | break; 98 | case PageViewMode.DEFAULT.toString(): 99 | props.onViewModeChange(PageViewMode.DEFAULT); 100 | break; 101 | } 102 | } 103 | 104 | return ( 105 | 106 | 107 | 108 | props.onPageChange(currentPage - 1)} disabled={currentPage <= 1}> 109 | 110 | 111 | 112 | {labels.pagesOf(currentPage, props.numPages)} 113 | 114 | props.onPageChange(currentPage + 1)} 116 | disabled={currentPage >= props.numPages}> 117 | 118 | 119 | 120 | 121 | 122 | 123 | onViewModeChange(e.target.value)}> 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | ); 140 | }; 141 | -------------------------------------------------------------------------------- /src/components/PDFWorker.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import PdfJs from '../utils/PdfJs'; 3 | 4 | interface PDFWorkerProps { 5 | workerUrl: string; 6 | } 7 | 8 | export const PDFWorker: React.FC = ({ children, workerUrl }) => { 9 | PdfJs.GlobalWorkerOptions.workerSrc = workerUrl; 10 | return <>{children}; 11 | }; 12 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { PDFViewer } from './components/PDFViewer'; 2 | export { PDFViewerToolbar } from './components/PDFViewerToolbar'; 3 | -------------------------------------------------------------------------------- /src/types/Page.ts: -------------------------------------------------------------------------------- 1 | export enum PageViewMode { 2 | DEFAULT, 3 | FIT_TO_WIDTH, 4 | FIT_TO_HEIGHT, 5 | } 6 | 7 | export interface PageType { 8 | ref: HTMLDivElement | null; 9 | ratio: number; 10 | loaded: boolean; 11 | width?: number; 12 | height?: number; 13 | } 14 | -------------------------------------------------------------------------------- /src/types/pdfViewer.ts: -------------------------------------------------------------------------------- 1 | declare module 'pdfjs-dist' { 2 | // Worker 3 | const GlobalWorkerOptions: GlobalWorker; 4 | interface GlobalWorker { 5 | workerSrc: string; 6 | } 7 | 8 | // Loading task 9 | const PasswordResponses: PasswordResponsesValue; 10 | interface PasswordResponsesValue { 11 | NEED_PASSWORD: string; 12 | INCORRECT_PASSWORD: string; 13 | } 14 | 15 | type VerifyPassword = (password: string) => void; 16 | type FileData = string | Uint8Array; 17 | 18 | interface LoadingTask { 19 | onPassword: (verifyPassword: VerifyPassword, reason: string) => void; 20 | promise: Promise; 21 | destroy(): void; 22 | } 23 | interface PdfDocument { 24 | numPages: number; 25 | getAttachments(): Promise<{ [filename: string]: Attachment }>; 26 | getDestination(dest: string): Promise; 27 | getDownloadInfo(): Promise<{ length: number }>; 28 | getMetadata(): Promise; 29 | getOutline(): Promise; 30 | getPage(pageIndex: number): Promise; 31 | getPageIndex(ref: OutlineRef): Promise; 32 | } 33 | interface GetDocumentParams { 34 | url?: FileData; 35 | data?: FileData; 36 | cMapUrl?: string; 37 | cMapPacked?: boolean; 38 | } 39 | function getDocument(params: GetDocumentParams): LoadingTask; 40 | 41 | // Attachment 42 | interface Attachment { 43 | content: Uint8Array; 44 | filename: string; 45 | } 46 | 47 | // Metadata 48 | interface MetaData { 49 | contentDispositionFilename?: string; 50 | info: MetaDataInfo; 51 | } 52 | interface MetaDataInfo { 53 | Author: string; 54 | CreationDate: string; 55 | Creator: string; 56 | Keywords: string; 57 | ModDate: string; 58 | PDFFormatVersion: string; 59 | Producer: string; 60 | Subject: string; 61 | Title: string; 62 | } 63 | 64 | // Outline 65 | type OutlineDestinationType = string | OutlineDestination; 66 | interface Outline { 67 | bold?: boolean; 68 | color?: number[]; 69 | dest?: OutlineDestinationType; 70 | italic?: boolean; 71 | items: Outline[]; 72 | newWindow?: boolean; 73 | title: string; 74 | unsafeUrl?: string; 75 | url?: string; 76 | } 77 | type OutlineDestination = [ 78 | OutlineRef, 79 | OutlineDestinationName, 80 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 81 | ...any[], 82 | ]; 83 | interface OutlineDestinationName { 84 | name: string; // Can be 'WYZ', 'Fit', ... 85 | } 86 | interface OutlineRef { 87 | gen: number; 88 | num: number; 89 | } 90 | 91 | // View port 92 | interface ViewPortParams { 93 | rotation?: number; 94 | scale: number; 95 | } 96 | interface ViewPortCloneParams { 97 | dontFlip: boolean; 98 | } 99 | interface ViewPort { 100 | height: number; 101 | rotation: number; 102 | transform: number[]; 103 | width: number; 104 | clone(params: ViewPortCloneParams): ViewPort; 105 | } 106 | 107 | // Render task 108 | interface PageRenderTask { 109 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 110 | promise: Promise; 111 | cancel(): void; 112 | } 113 | 114 | // Render SVG 115 | interface SVGGraphics { 116 | getSVG(operatorList: PageOperatorList, viewport: ViewPort): Promise; 117 | } 118 | interface SVGGraphicsConstructor { 119 | new (commonObjs: PageCommonObjects, objs: PageObjects): SVGGraphics; 120 | } 121 | let SVGGraphics: SVGGraphicsConstructor; 122 | 123 | // Render text layer 124 | interface RenderTextLayerParams { 125 | textContent: PageTextContent; 126 | container: HTMLDivElement; 127 | viewport: ViewPort; 128 | } 129 | interface PageTextContent { 130 | items: PageTextItem[]; 131 | } 132 | interface PageTextItem { 133 | str: string; 134 | } 135 | function renderTextLayer(params: RenderTextLayerParams): PageRenderTask; 136 | 137 | // Annotations layer 138 | interface AnnotationsParams { 139 | // Can be 'display' or 'print' 140 | intent: string; 141 | } 142 | interface AnnotationPoint { 143 | x: number; 144 | y: number; 145 | } 146 | interface Annotation { 147 | annotationType: number; 148 | color?: Uint8ClampedArray; 149 | dest: string; 150 | hasAppearance: boolean; 151 | id: string; 152 | rect: number[]; 153 | subtype: string; 154 | // Border style 155 | borderStyle: { 156 | dashArray: number[]; 157 | horizontalCornerRadius: number; 158 | style: number; 159 | verticalCornerRadius: number; 160 | width: number; 161 | }; 162 | // For annotation that has a popup 163 | hasPopup?: boolean; 164 | contents?: string; 165 | modificationDate?: string; 166 | title?: string; 167 | // Parent annotation 168 | parentId?: string; 169 | parentType?: string; 170 | // File attachment annotation 171 | file?: Attachment; 172 | // Ink annotation 173 | inkLists?: AnnotationPoint[][]; 174 | // Line annotation 175 | lineCoordinates: number[]; 176 | // Link annotation 177 | // `action` can be `FirstPage`, `PrevPage`, `NextPage`, `LastPage`, `GoBack`, `GoForward` 178 | action?: string; 179 | url?: string; 180 | newWindow?: boolean; 181 | // Polyline annotation 182 | vertices?: AnnotationPoint[]; 183 | // Text annotation 184 | name?: string; 185 | } 186 | const AnnotationLayer: PdfAnnotationLayer; 187 | interface RenderAnnotationLayerParams { 188 | annotations: Annotation[]; 189 | div: HTMLDivElement | null; 190 | linkService: LinkService; 191 | page: Page; 192 | viewport: ViewPort; 193 | } 194 | interface PdfAnnotationLayer { 195 | render(params: RenderAnnotationLayerParams): void; 196 | update(params: RenderAnnotationLayerParams): void; 197 | } 198 | 199 | // Link service 200 | interface LinkService { 201 | externalLinkTarget?: number | null; 202 | getDestinationHash(dest: OutlineDestinationType): string; 203 | navigateTo(dest: OutlineDestinationType): void; 204 | } 205 | 206 | // Render page 207 | interface PageRenderParams { 208 | canvasContext: CanvasRenderingContext2D; 209 | // Should be 'print' when printing 210 | intent?: string; 211 | transform?: number[]; 212 | viewport: ViewPort; 213 | } 214 | interface Page { 215 | getAnnotations(params: AnnotationsParams): Promise; 216 | getTextContent(): Promise; 217 | getViewport(params: ViewPortParams): ViewPort; 218 | render(params: PageRenderParams): PageRenderTask; 219 | getOperatorList(): Promise; 220 | commonObjs: PageCommonObjects; 221 | objs: PageObjects; 222 | view: number[]; 223 | } 224 | 225 | /* eslint-disable @typescript-eslint/no-empty-interface */ 226 | interface PageCommonObjects {} 227 | interface PageObjects {} 228 | interface PageOperatorList {} 229 | /* eslint-enable @typescript-eslint/no-empty-interface */ 230 | } 231 | -------------------------------------------------------------------------------- /src/utils/PdfJs.ts: -------------------------------------------------------------------------------- 1 | import * as PdfJs from 'pdfjs-dist'; 2 | export default PdfJs; 3 | -------------------------------------------------------------------------------- /src/utils/hacks.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Produces an array with N items, the value of each item is n 3 | * 4 | * @param i: Length of the array to be generated 5 | */ 6 | export function range(i: number): Array { 7 | return i ? range(i - 1).concat(i) : []; 8 | } 9 | 10 | /** 11 | * Checks whether a string provided is a data URI. 12 | * 13 | * @param {String} str String to check 14 | */ 15 | export const isDataURI = (str: string) => /^data:/.test(str); 16 | 17 | export const dataURItoUint8Array = (dataURI: string) => { 18 | if (!isDataURI(dataURI)) { 19 | throw new Error('dataURItoUint8Array was provided with an argument which is not a valid data URI.'); 20 | } 21 | 22 | let byteString; 23 | if (dataURI.split(',')[0].indexOf('base64') >= 0) { 24 | byteString = atob(dataURI.split(',')[1]); 25 | } else { 26 | byteString = unescape(dataURI.split(',')[1]); 27 | } 28 | 29 | const ia = new Uint8Array(byteString.length); 30 | for (let i = 0; i < byteString.length; i += 1) { 31 | ia[i] = byteString.charCodeAt(i); 32 | } 33 | 34 | return ia; 35 | }; 36 | 37 | /** 38 | * Throttles a function call 39 | * 40 | * @param func 41 | * @param limit 42 | */ 43 | export function throttle(func: (...arg: Array) => void, limit = 1000) { 44 | let inThrottle = false; 45 | 46 | return function(...args: Array) { 47 | // eslint-disable-next-line @typescript-eslint/no-this-alias 48 | const context = this; 49 | 50 | if (!inThrottle) { 51 | func.apply(context, args); 52 | inThrottle = true; 53 | setTimeout(() => (inThrottle = false), limit); 54 | } 55 | }; 56 | } 57 | 58 | /** 59 | * Converts SnakeCase to Camelcase 60 | * @param s 61 | */ 62 | export function toCamel(s: string) { 63 | const camel = s.toLowerCase().replace(/([-_][a-z])/gi, $1 => { 64 | return $1 65 | .toUpperCase() 66 | .replace('-', '') 67 | .replace('_', ''); 68 | }); 69 | return camel.charAt(0).toUpperCase() + camel.slice(1); 70 | } 71 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "lib", 4 | "module": "commonjs", 5 | "target": "es5", 6 | "sourceMap": true, 7 | "declaration": true, 8 | "lib": [ 9 | "dom", 10 | "esnext.asynciterable", 11 | "es2015" 12 | ], 13 | "jsx": "react", 14 | "typeRoots": ["src/types", "node_modules/@types"] 15 | }, 16 | "include": ["./src/**/*"], 17 | "exclude": [ 18 | "node_modules", 19 | "**/*.test.tsx", 20 | "**/*.test.ts", 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const TerserPlugin = require('terser-webpack-plugin'); 3 | 4 | module.exports = { 5 | devtool: 'source-map', 6 | entry: { 7 | 'index': './src/index.ts', 8 | }, 9 | mode: 'production', 10 | output: { 11 | path: path.join(__dirname, 'lib'), 12 | filename: '[name].js', 13 | libraryTarget: 'umd', 14 | umdNamedDefine: true 15 | }, 16 | resolve: { 17 | extensions: ['.ts', '.js', '.tsx'], 18 | alias: { 19 | // Point to ES5 build 20 | 'pdfjs-dist': path.resolve('./node_modules/pdfjs-dist/legacy/build/pdf.js'), 21 | }, 22 | }, 23 | externals: { 24 | 'react': 'react', 25 | 'precise-ui': 'precise-ui', 26 | 'styled-components': 'styled-components', 27 | }, 28 | module: { 29 | rules: [ 30 | { 31 | test: /\.tsx?$/, 32 | use: 'ts-loader', 33 | exclude: /node_modules/, 34 | }, 35 | ], 36 | }, 37 | optimization: { 38 | minimize: true, 39 | minimizer: [new TerserPlugin()], 40 | }, 41 | }; 42 | --------------------------------------------------------------------------------