├── .babelrc ├── .gitignore ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── public ├── compressed.tracemonkey-pldi-09.pdf ├── index.html ├── pdf.worker.js └── sample-annotations.json ├── sceenshot.png ├── src ├── index.js └── pdf │ ├── AnnotationStore.js │ ├── PDFAnnotation.js │ ├── PDFViewer.css │ ├── PDFViewer.jsx │ ├── endless │ ├── AnnotatablePage.jsx │ └── EndlessViewer.jsx │ ├── index.js │ └── paginated │ ├── AnnotatablePage.jsx │ └── PaginatedViewer.jsx ├── test └── index.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/env", 4 | "@babel/react" 5 | ], 6 | "plugins": [ 7 | "@babel/transform-runtime", 8 | "@babel/proposal-class-properties" 9 | ], 10 | "env": { 11 | "production-esm": { 12 | "presets": [ 13 | ["@babel/env", { "modules": false }], 14 | "@babel/react" 15 | ], 16 | "plugins": [ 17 | ["@babel/transform-runtime", { "useESModules": true }], 18 | "@babel/proposal-class-properties" 19 | ] 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2020, Pelagios Network 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Recogito-PDF 2 | 3 | Annotate a PDF document in React. Powered by [PDF.js](https://mozilla.github.io/pdf.js/), 4 | [RecogitoJS](https://github.com/recogito/recogito-js) and [Annotorious](https://github.com/recogito/annotorious). 5 | 6 | ![A screenshot of the React PDF annotation component](https://github.com/recogito/recogito-pdf/raw/main/sceenshot.png) 7 | 8 | ## Using the Component 9 | 10 | - Import the `PDFViewer` component and provide the `url` to the PDF file 11 | - It's recommended to set a link to `pdf.worker.js` from PDF.js (copy included in folder `public`) 12 | 13 | ```js 14 | import React from 'react'; 15 | import ReactDOM from 'react-dom'; 16 | import { pdfjs, PDFViewer } from '@recogito/recogito-react-pdf'; 17 | 18 | pdfjs.GlobalWorkerOptions.workerSrc = 'pdf.worker.js'; 19 | 20 | window.onload = function() { 21 | 22 | // Recogito init config (optional) 23 | // see https://github.com/recogito/recogito-js/wiki/API-Reference 24 | const config = { /* ... */ }; 25 | 26 | // Initial annotations in W3C Web Annotation format 27 | const annotations = [ /* ... */ ]; 28 | 29 | // CRUD event handlers 30 | const onCreateAnnotation = function () { /* ... */ }; 31 | const onUpdateAnnotation = function () { /* ... */ }; 32 | const onDeleteAnnotation = function () { /* ... */ }; 33 | 34 | // Viewer mode can be "paginated" or "scrolling" 35 | const mode = "paginated"; 36 | 37 | ReactDOM.render( 38 | , 46 | document.getElementById('app') 47 | ); 48 | 49 | } 50 | ``` 51 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@recogito/recogito-react-pdf", 3 | "version": "0.8.9", 4 | "description": "A React component for annotating PDF, powered by PDF.js and RecogitoJS", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "start": "webpack serve --mode development --open" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/recogito/recogito-react-pdf.git" 12 | }, 13 | "keywords": [ 14 | "Annotation", 15 | "PDF", 16 | "RecogitoJS" 17 | ], 18 | "author": "Rainer Simon", 19 | "license": "BSD-3-Clause", 20 | "bugs": { 21 | "url": "https://github.com/recogito/recogito-react-pdf/issues" 22 | }, 23 | "homepage": "https://github.com/recogito/recogito-react-pdf#readme", 24 | "devDependencies": { 25 | "@babel/cli": "^7.8.0", 26 | "@babel/core": "^7.9.0", 27 | "@babel/plugin-proposal-class-properties": "^7.8.0", 28 | "@babel/plugin-transform-runtime": "^7.9.0", 29 | "@babel/preset-env": "^7.9.0", 30 | "@babel/preset-react": "^7.9.0", 31 | "babel-loader": "^8.2.2", 32 | "css-loader": "^5.2.7", 33 | "file-loader": "^6.2.0", 34 | "html-webpack-plugin": "^4.5.2", 35 | "react": "^17.0.2", 36 | "react-dom": "^17.0.2", 37 | "sass": "^1.35.2", 38 | "sass-loader": "^10.2.0", 39 | "style-loader": "^2.0.0" 40 | }, 41 | "dependencies": { 42 | "@recogito/annotorious": "^2.7.8", 43 | "@recogito/recogito-connections": "^0.1.11", 44 | "@recogito/recogito-js": "^1.8.1", 45 | "pdfjs-dist": "2.9.359", 46 | "react-icons": "^4.2.0", 47 | "webpack": "^5.58.1", 48 | "webpack-cli": "^4.9.0", 49 | "webpack-dev-server": "^4.3.1" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /public/compressed.tracemonkey-pldi-09.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/UltraDEV007/react-pdf-recogit/cc59889aeda30fdca7bd8655e798d4cb08359360/public/compressed.tracemonkey-pldi-09.pdf -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Recogito-PDF | Example 5 | 6 | 7 | 8 |
9 | 10 | -------------------------------------------------------------------------------- /public/sample-annotations.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "type": "Annotation", 4 | "body": [ 5 | { 6 | "type": "TextualBody", 7 | "value": "Test #1", 8 | "purpose": "commenting" 9 | } 10 | ], 11 | "target": { 12 | "selector": [ 13 | { 14 | "type": "TextQuoteSelector", 15 | "exact": "Type Specialization" 16 | }, 17 | { 18 | "type": "TextPositionSelector", 19 | "start": 25, 20 | "end": 44, 21 | "page": 1 22 | } 23 | ] 24 | }, 25 | "@context": "http://www.w3.org/ns/anno.jsonld", 26 | "id": "#b5639d48-6b1d-4341-8211-a97dcb278865" 27 | }, 28 | { 29 | "type": "Annotation", 30 | "body": [ 31 | { 32 | "type": "TextualBody", 33 | "value": "Test #2", 34 | "purpose": "commenting" 35 | } 36 | ], 37 | "target": { 38 | "selector": [ 39 | { 40 | "type": "TextQuoteSelector", 41 | "exact": "Abstract" 42 | }, 43 | { 44 | "type": "TextPositionSelector", 45 | "start": 598, 46 | "end": 606, 47 | "page": 1 48 | } 49 | ] 50 | }, 51 | "@context": "http://www.w3.org/ns/anno.jsonld", 52 | "id": "#6dc3dc53-056c-4178-9954-3e6deb6b4675" 53 | }, 54 | { 55 | "type": "Annotation", 56 | "body": [ 57 | { 58 | "type": "TextualBody", 59 | "value": "Test #3", 60 | "purpose": "commenting" 61 | } 62 | ], 63 | "target": { 64 | "selector": [ 65 | { 66 | "type": "TextQuoteSelector", 67 | "exact": "Figure 1. Sample program:" 68 | }, 69 | { 70 | "type": "TextPositionSelector", 71 | "start": 4012, 72 | "end": 4037, 73 | "page": 2 74 | } 75 | ] 76 | }, 77 | "@context": "http://www.w3.org/ns/anno.jsonld", 78 | "id": "#068333e8-2616-4a90-883d-27c8595a806a" 79 | } 80 | ] -------------------------------------------------------------------------------- /sceenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/UltraDEV007/react-pdf-recogit/cc59889aeda30fdca7bd8655e798d4cb08359360/sceenshot.png -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import * as pdfjs from 'pdfjs-dist/legacy/build/pdf'; 2 | import PDFViewer from './pdf/PDFViewer'; 3 | 4 | pdfjs.GlobalWorkerOptions.workerSrc = 'pdf.worker.js'; 5 | 6 | export { 7 | pdfjs, PDFViewer 8 | } 9 | -------------------------------------------------------------------------------- /src/pdf/AnnotationStore.js: -------------------------------------------------------------------------------- 1 | /** A minimal local annotation store **/ 2 | export default class AnnotationStore { 3 | 4 | constructor() { 5 | this._annotations = []; 6 | } 7 | 8 | setAnnotations(annotations) { 9 | this._annotations = annotations; 10 | } 11 | 12 | createAnnotation(annotation) { 13 | this._annotations.push(annotation); 14 | } 15 | 16 | updateAnnotation(updated, previous) { 17 | this._annotations = this._annotations.map(a => 18 | a.id === previous.id ? updated : a); 19 | } 20 | 21 | deleteAnnotation(annotation) { 22 | this._annotations = this._annotations.filter(a => 23 | a.id !== annotation.id); 24 | } 25 | 26 | getAnnotations(pageNumber) { 27 | // Text annotations on this page 28 | const isOnPage = annotation => { 29 | if (annotation.target.selector) { 30 | const selectors = Array.isArray(annotation.target.selector) ? 31 | annotation.target.selector : [ annotation.target.selector ]; 32 | 33 | const selectorWithPage = selectors.find(s => s.page); 34 | return selectorWithPage?.page == pageNumber; 35 | } 36 | }; 37 | 38 | const annotationsOnPage = this._annotations.filter(isOnPage); 39 | 40 | // Relations linked to the given annotations 41 | const ids = new Set(annotationsOnPage.map(a => a.id)); 42 | const linkedRelations = this._annotations 43 | .filter(a => !a.target.selector) // all relations 44 | .filter(a => { 45 | const from = a.target[0].id; 46 | const to = a.target[1].id; 47 | 48 | return ids.has(from) || ids.has(to); 49 | }); 50 | 51 | return [...annotationsOnPage, ...linkedRelations ]; 52 | } 53 | 54 | } -------------------------------------------------------------------------------- /src/pdf/PDFAnnotation.js: -------------------------------------------------------------------------------- 1 | /** Inserts the page number into the annotation target **/ 2 | export const extendTarget = (annotation, source, page) => { 3 | 4 | // Not a very robust criterion... but holds for now 5 | const isRelationAnnotation = Array.isArray(annotation.target); 6 | 7 | // Adds 'page' field to selector (unless it's a TextQuoteSelector) 8 | const extendSelector = selector => selector.type === 'TextQuoteSelector' ? 9 | selector : { ...selector, page }; 10 | 11 | if (isRelationAnnotation) { 12 | // Nothing to change, just dd source 13 | return { 14 | ...annotation, 15 | target: annotation.target.map(t => ({ 16 | id: t.id, source 17 | })) 18 | }; 19 | } else { 20 | return Array.isArray(annotation.target.selector) ? 21 | { 22 | ...annotation, 23 | target: { 24 | source, 25 | selector: annotation.target.selector.map(extendSelector) 26 | } 27 | } : { 28 | ...annotation, 29 | target: { 30 | source, 31 | selector: extendSelector(annotation.target.selector) 32 | } 33 | } 34 | } 35 | 36 | } 37 | 38 | /** Splits annotations by type, text or image **/ 39 | export const splitByType = annotations => { 40 | let text = []; 41 | let image = []; 42 | 43 | annotations.forEach(a => { 44 | if (a.target.selector) { 45 | const selectors = Array.isArray(a.target.selector) ? 46 | a.target.selector : [ a.target.selector ]; 47 | 48 | const hasImageSelector = 49 | selectors.find(s => s.type === 'FragmentSelector' || s.type === 'SvgSelector'); 50 | 51 | if (hasImageSelector) 52 | image.push(a); 53 | else 54 | text.push(a); 55 | } else { 56 | // Relationship 57 | text.push(a); 58 | } 59 | }); 60 | 61 | return { text, image }; 62 | } -------------------------------------------------------------------------------- /src/pdf/PDFViewer.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | padding:0; 3 | margin:0; 4 | } 5 | 6 | #app main { 7 | background-color:#cbcbcb; 8 | padding:110px 0 40px 0; 9 | } 10 | 11 | header { 12 | position:fixed; 13 | width:100%; 14 | padding:20px; 15 | box-shadow:0 1px 10px rgba(0, 0, 0, 0.3); 16 | background-color:#fff; 17 | z-index:3; 18 | } 19 | 20 | header button { 21 | background:none; 22 | border:none; 23 | outline:none; 24 | font-size:24px; 25 | cursor:pointer; 26 | padding:3px; 27 | border-radius:50%; 28 | width:40px; 29 | height:40px; 30 | display:inline-block; 31 | margin-right:10px; 32 | } 33 | 34 | header button span { 35 | display:flex; 36 | justify-content:center; 37 | align-items:center; 38 | } 39 | 40 | header button:hover { 41 | background:rgba(0, 0, 0, 0.08); 42 | } 43 | 44 | header button.active { 45 | background-color:orange; 46 | color:#fff; 47 | } 48 | 49 | header label { 50 | vertical-align:super; 51 | font-size:18px; 52 | padding:4px 10px; 53 | margin:0 8px; 54 | background-color:rgba(0, 0, 0, 0.08); 55 | border-radius:3px; 56 | font-family: Arial, Helvetica, sans-serif; 57 | } 58 | 59 | .pdf-viewer-container { 60 | display:flex; 61 | justify-content:center; 62 | align-items:center; 63 | flex-direction:column; 64 | } 65 | 66 | .page-container { 67 | position:relative; 68 | margin:10px 0; 69 | min-height:50vh; 70 | } 71 | 72 | .page-container .r6o-content-wrapper { 73 | position:absolute !important; 74 | top:0; 75 | left:0; 76 | right:0; 77 | bottom:0; 78 | } 79 | 80 | .page-container .textLayer { 81 | opacity:1; 82 | z-index:1; 83 | } 84 | 85 | .page-container .a9s-annotationlayer { 86 | z-index:2; 87 | pointer-events:none; 88 | } 89 | 90 | .page-container .r6o-annotation, 91 | .page-container .a9s-annotation { 92 | pointer-events:auto; 93 | } 94 | 95 | .page-container.debug .textLayer span { 96 | color:red; 97 | } 98 | 99 | .page-container .textLayer br { 100 | display:none; 101 | } 102 | 103 | .page-container .textLayer span.r6o-annotation, 104 | .page-container .textLayer span.r6o-selection { 105 | position:relative; 106 | cursor:pointer; 107 | } 108 | 109 | .page-container canvas { 110 | box-shadow:1px 1px 10px rgba(0, 0, 0, 0.1); 111 | } 112 | 113 | .r6o-connections-canvas { 114 | z-index:3; 115 | } 116 | 117 | .r6o-connections-editor { 118 | z-index:4; 119 | } -------------------------------------------------------------------------------- /src/pdf/PDFViewer.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import * as PDFJS from 'pdfjs-dist/legacy/build/pdf'; 3 | import Connections from '@recogito/recogito-connections'; 4 | 5 | import EndlessViewer from './endless/EndlessViewer'; 6 | import PaginatedViewer from './paginated/PaginatedViewer'; 7 | import Store from './AnnotationStore'; 8 | 9 | import 'pdfjs-dist/web/pdf_viewer.css'; 10 | import '@recogito/recogito-js/dist/recogito.min.css'; 11 | import '@recogito/annotorious/dist/annotorious.min.css'; 12 | import './PDFViewer.css'; 13 | 14 | const store = new Store(); 15 | 16 | const PDFViewer = props => { 17 | 18 | const [ pdf, setPdf ] = useState(); 19 | 20 | const [ connections, setConnections ] = useState(); 21 | 22 | // Load PDF on mount 23 | useEffect(() => { 24 | // Init after DOM load 25 | const conn = new Connections([], { 26 | showLabels: true, 27 | vocabulary: props.config.relationVocabulary 28 | }); 29 | 30 | setConnections(conn); 31 | 32 | PDFJS.getDocument(props.url).promise 33 | .then( 34 | pdf => setPdf(pdf), 35 | error => console.error(error) 36 | ); 37 | 38 | // Destroy connections layer on unmount 39 | return () => conn.destroy(); 40 | }, []); 41 | 42 | useEffect(() => { 43 | store.setAnnotations(props.annotations || []); 44 | }, [ props.annotations ]) 45 | 46 | const onCreateAnnotation = a => { 47 | store.createAnnotation(a); 48 | props.onCreateAnnotation && props.onCreateAnnotation(a); 49 | } 50 | 51 | const onUpdateAnnotation = (a, p) => { 52 | store.updateAnnotation(a, p); 53 | props.onUpdateAnnotation && props.onUpdateAnnotation(a, p); 54 | } 55 | 56 | const onDeleteAnnotation = a => { 57 | store.deleteAnnotation(a); 58 | props.onDeleteAnnotation && props.onDeleteAnnotation(a); 59 | } 60 | 61 | const onCancelSelected = a => { 62 | props.onCancelSelected && props.onCancelSelected(a); 63 | } 64 | 65 | return pdf ? 66 | props.mode === 'scrolling' ? 67 | : 76 | 77 | 86 | 87 | : null; 88 | 89 | } 90 | 91 | export default PDFViewer; -------------------------------------------------------------------------------- /src/pdf/endless/AnnotatablePage.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from 'react'; 2 | import * as PDFJS from 'pdfjs-dist/legacy/build/pdf'; 3 | import { Recogito } from '@recogito/recogito-js/src'; 4 | import { Annotorious } from '@recogito/annotorious/src'; 5 | 6 | import { extendTarget, splitByType } from '../PDFAnnotation'; 7 | 8 | const AnnotatablePage = props => { 9 | 10 | const containerEl = useRef(); 11 | 12 | const [ pageVisible, setPageVisible ] = useState(false) 13 | const [ isRendered, setRendered ] = useState(false); 14 | 15 | const [ anno, setAnno ] = useState(); 16 | 17 | const [ recogito, setRecogito ] = useState(); 18 | 19 | // Renders the PDF page, returning a promise 20 | const renderPage = () => { 21 | console.log('Rendering page ' + props.page); 22 | 23 | props.pdf.getPage(props.page).then(page => { 24 | const scale = props.scale || 1.8; 25 | const viewport = page.getViewport({ scale }); 26 | 27 | // Render the image layer to a CANVAS element 28 | const canvas = document.createElement('canvas'); 29 | canvas.setAttribute('class', 'imageLayer'); 30 | canvas.setAttribute('data-page', props.page); 31 | 32 | canvas.height = viewport.height; 33 | canvas.width = viewport.width; 34 | 35 | containerEl.current.appendChild(canvas); 36 | 37 | const renderContext = { 38 | canvasContext: canvas.getContext('2d'), 39 | viewport 40 | }; 41 | 42 | page.render(renderContext).promise.then(() => { 43 | page.getTextContent().then(textContent => 44 | PDFJS.renderTextLayer({ 45 | textContent: textContent, 46 | container: containerEl.current.querySelector('.textLayer'), 47 | viewport: viewport, 48 | textDivs: [] 49 | }).promise.then(() => { 50 | setRendered(true); 51 | })); 52 | }); 53 | }); 54 | } 55 | 56 | const onCreateAnnotation = a => { 57 | const extended = extendTarget(a, props.url, props.page); 58 | props.onCreateAnnotation(extended); 59 | } 60 | 61 | const onUpdateAnnotation = (a, p) => { 62 | const updated = extendTarget(a, props.url, props.page); 63 | const previous = extendTarget(p, props.url, props.page); 64 | props.onUpdateAnnotation(updated, previous); 65 | } 66 | 67 | const onDeleteAnnotation = a => { 68 | const extended = extendTarget(a, props.url, props.page); 69 | props.onDeleteAnnotation(extended) 70 | } 71 | 72 | const setMode = recogito => { 73 | if (pageVisible) { 74 | const imageLayer = containerEl.current.querySelector('svg.a9s-annotationlayer'); 75 | 76 | if (props.annotationMode === 'IMAGE') { 77 | if (imageLayer) 78 | imageLayer.style.pointerEvents = 'auto'; 79 | } else if (props.annotationMode === 'ANNOTATION') { 80 | if (imageLayer) 81 | imageLayer.style.pointerEvents = null; 82 | 83 | recogito.setMode('ANNOTATION'); 84 | } else if (props.annotationMode === 'RELATIONS') { 85 | if (imageLayer) 86 | imageLayer.style.pointerEvents = null; 87 | 88 | recogito.setMode('RELATIONS'); 89 | } 90 | } 91 | } 92 | 93 | const initAnnotationLayer = () => { 94 | console.log('Creating annotation layer on page ' + props.page); 95 | 96 | const config = props.config || {}; 97 | 98 | const { text, image } = splitByType(props.store.getAnnotations(props.page)); 99 | 100 | const r = new Recogito({ 101 | ...config, 102 | content: containerEl.current.querySelector('.textLayer'), 103 | mode: 'pre' 104 | }); 105 | 106 | // Init Recogito Connections plugin 107 | props.connections.register(r); 108 | 109 | props.connections.on('createConnection', onCreateAnnotation); 110 | props.connections.on('updateConnection', onUpdateAnnotation); 111 | props.connections.on('deleteConnection', onDeleteAnnotation); 112 | 113 | r.on('createAnnotation', onCreateAnnotation); 114 | r.on('updateAnnotation', onUpdateAnnotation); 115 | r.on('deleteAnnotation', onDeleteAnnotation); 116 | r.on('cancelSelected', a => props.onCancelSelected(a)); 117 | setRecogito(r); 118 | 119 | const anno = new Annotorious({ 120 | ...config, 121 | image: containerEl.current.querySelector('.imageLayer') 122 | }); 123 | 124 | anno.on('createAnnotation', onCreateAnnotation); 125 | anno.on('updateAnnotation', onUpdateAnnotation); 126 | anno.on('deleteAnnotation', onDeleteAnnotation); 127 | anno.on('cancelSelected', a => props.onCancelSelected(a)); 128 | 129 | setAnno(anno); 130 | 131 | r.on('selectAnnotation', () => anno.selectAnnotation()); 132 | anno.on('selectAnnotation', () => r.selectAnnotation()); 133 | 134 | // For some reason, React is not done initializing the Image-/TextAnnotators. 135 | // This remains an unsolved mystery for now. The hack is to introduce a little 136 | // wait time until Recogito/Annotorious inits are complete. 137 | const init = () => { 138 | if (r._app.current && anno._app.current) { 139 | r.setAnnotations(text); 140 | anno.setAnnotations(image); 141 | setMode(r); 142 | } else { 143 | setTimeout(() => init(), 50); 144 | } 145 | } 146 | 147 | init(); 148 | } 149 | 150 | const destroyAnnotationLayer = () => { 151 | if (recogito || anno) 152 | console.log('Destroying annotation layer on page ' + props.page); 153 | 154 | if (recogito) { 155 | props.connections.unregister(recogito); 156 | recogito.destroy(); 157 | } 158 | 159 | if (anno) { 160 | anno.destroy(); 161 | } 162 | } 163 | 164 | // Render on page change 165 | useEffect(() => { 166 | const onIntersect = entries => { 167 | const intersecting = entries[0].isIntersecting; 168 | setPageVisible(intersecting); 169 | } 170 | 171 | // Init intersection observer 172 | const observer = new IntersectionObserver(onIntersect, { 173 | rootMargin: '40px' 174 | }); 175 | 176 | const target = containerEl.current; 177 | observer.observe(target); 178 | 179 | // First page renders instantly, all others are lazy 180 | if (props.page === 1) 181 | renderPage(); 182 | 183 | return () => 184 | observer.unobserve(target); 185 | }, []); 186 | 187 | useEffect(() => { 188 | if (isRendered) { 189 | if (pageVisible) 190 | initAnnotationLayer(); 191 | else 192 | destroyAnnotationLayer(); 193 | } else if (pageVisible && props.page > 1) { 194 | renderPage(); 195 | } 196 | }, [ isRendered, pageVisible ]); 197 | 198 | useEffect(() => { 199 | setMode(recogito); 200 | }, [ props.annotationMode ]) 201 | 202 | return ( 203 |
206 |
207 |
208 | ) 209 | 210 | } 211 | 212 | export default AnnotatablePage; -------------------------------------------------------------------------------- /src/pdf/endless/EndlessViewer.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { CgDebug, CgArrowsExpandDownRight } from 'react-icons/cg'; 3 | import { RiImageEditFill } from 'react-icons/ri'; 4 | 5 | import AnnotatablePage from './AnnotatablePage'; 6 | 7 | const Range = maxValue => 8 | Array.from(Array(maxValue).keys()); 9 | 10 | const EndlessViewer = props => { 11 | 12 | const [ debug, setDebug ] = useState(false); 13 | 14 | const [ annotationMode, setAnnotationMode ] = useState('ANNOTATION'); 15 | 16 | const onToggleRelationsMode = () => { 17 | if (annotationMode === 'RELATIONS') 18 | setAnnotationMode('ANNOTATION'); 19 | else 20 | setAnnotationMode('RELATIONS'); 21 | } 22 | 23 | const onToggleImageMode = () => { 24 | if (annotationMode === 'IMAGE') 25 | setAnnotationMode('ANNOTATION'); 26 | else 27 | setAnnotationMode('IMAGE'); 28 | } 29 | 30 | return ( 31 |
32 |
33 | 38 | 39 | 46 | 47 | 54 |
55 | 56 |
57 |
58 | {Range(props.pdf.numPages).map(idx => 59 | 73 | )} 74 |
75 |
76 |
77 | ) 78 | 79 | } 80 | 81 | export default EndlessViewer; -------------------------------------------------------------------------------- /src/pdf/index.js: -------------------------------------------------------------------------------- 1 | export { default as PDFViewer } from './PDFViewer'; -------------------------------------------------------------------------------- /src/pdf/paginated/AnnotatablePage.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from 'react'; 2 | import * as PDFJS from 'pdfjs-dist/legacy/build/pdf'; 3 | import { Recogito } from '@recogito/recogito-js/src'; 4 | import { Annotorious } from '@recogito/annotorious/src'; 5 | 6 | import { splitByType } from '../PDFAnnotation'; 7 | 8 | const AnnotatablePage = props => { 9 | 10 | const containerEl = useRef(); 11 | 12 | const [ anno, setAnno ] = useState(); 13 | 14 | const [ recogito, setRecogito ] = useState(); 15 | 16 | // Cleanup previous Recogito instance, canvas + text layer 17 | const destroyPreviousPage = () => { 18 | // Clean up previous Recogito + Annotorious instance, if any 19 | if (recogito) 20 | recogito.destroy(); 21 | 22 | if (anno) 23 | anno.destroy(); 24 | 25 | const canvas = containerEl.current.querySelector('canvas'); 26 | if (canvas) 27 | containerEl.current.removeChild(canvas); 28 | 29 | const textLayer = containerEl.current.querySelector('.textLayer'); 30 | textLayer.innerHTML = ''; 31 | } 32 | 33 | // Render on page change 34 | useEffect(() => { 35 | destroyPreviousPage(); 36 | 37 | if (props.page) { 38 | const scale = props.scale || 1.8; 39 | const viewport = props.page.getViewport({ scale }); 40 | 41 | const canvas = document.createElement('canvas'); 42 | canvas.height = viewport.height; 43 | canvas.width = viewport.width; 44 | containerEl.current.appendChild(canvas); 45 | 46 | const renderContext = { 47 | canvasContext: canvas.getContext('2d'), 48 | viewport 49 | }; 50 | 51 | props.page.render(renderContext); 52 | 53 | props.page.getTextContent().then(textContent => PDFJS.renderTextLayer({ 54 | textContent: textContent, 55 | container: containerEl.current.querySelector('.textLayer'), 56 | viewport: viewport, 57 | textDivs: [] 58 | }).promise.then(() => { 59 | const config = props.config || {}; 60 | 61 | const { text, image } = splitByType(props.annotations); 62 | 63 | const r = new Recogito({ 64 | ...config, 65 | content: containerEl.current.querySelector('.textLayer'), 66 | mode: 'pre' 67 | }); 68 | 69 | r.on('createAnnotation', a => props.onCreateAnnotation(a)); 70 | r.on('updateAnnotation', (a, p) => props.onUpdateAnnotation(a, p)); 71 | r.on('deleteAnnotation', a => props.onDeleteAnnotation(a)); 72 | r.on('cancelSelected', a => props.onCancelSelected(a)); 73 | 74 | // TODO split: text annotations only 75 | r.setAnnotations(text); 76 | setRecogito(r); 77 | 78 | const anno = new Annotorious({ 79 | ...config, 80 | image: canvas 81 | }); 82 | 83 | anno.on('createAnnotation', a => props.onCreateAnnotation(a)); 84 | anno.on('updateAnnotation', (a, p) => props.onUpdateAnnotation(a, p)); 85 | anno.on('deleteAnnotation', a => props.onDeleteAnnotation(a)); 86 | anno.on('cancelSelected', a => props.onCancelSelected(a)); 87 | 88 | anno.setAnnotations(image); 89 | setAnno(anno); 90 | 91 | r.on('selectAnnotation', () => anno.selectAnnotation()); 92 | anno.on('selectAnnotation', () => r.selectAnnotation()); 93 | })); 94 | } 95 | }, [ props.page ]); 96 | 97 | useEffect(() => { 98 | // Hack 99 | if (recogito && recogito.getAnnotations() === 0) { 100 | recogito.setAnnotations(props.annotations); 101 | } 102 | }, [ props.annotations ]); 103 | 104 | useEffect(() => { 105 | if (containerEl.current) { 106 | const imageLayer = containerEl.current.querySelector('svg.a9s-annotationlayer'); 107 | 108 | if (imageLayer) { 109 | if (props.annotationMode === 'IMAGE') { 110 | imageLayer.style.pointerEvents = 'auto'; 111 | } else { 112 | imageLayer.style.pointerEvents = null; 113 | recogito.setMode(props.annotationMode); 114 | } 115 | } 116 | } 117 | }, [ props.annotationMode ]) 118 | 119 | return ( 120 |
123 |
124 |
125 | ) 126 | 127 | } 128 | 129 | export default AnnotatablePage; -------------------------------------------------------------------------------- /src/pdf/paginated/PaginatedViewer.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { CgDebug, CgChevronLeft, CgChevronRight, CgArrowsExpandDownRight } from 'react-icons/cg'; 3 | import { RiImageEditFill } from 'react-icons/ri'; 4 | 5 | import AnnotatablePage from './AnnotatablePage'; 6 | import { extendTarget } from '../PDFAnnotation'; 7 | 8 | const PaginatedViewer = props => { 9 | 10 | const [ page, setPage ] = useState(); 11 | 12 | const [ debug, setDebug ] = useState(false); 13 | 14 | const [ annotationMode, setAnnotationMode ] = useState('ANNOTATION'); 15 | 16 | // Render first page on mount 17 | useEffect(() => { 18 | props.pdf.getPage(1).then(setPage); 19 | }, []); 20 | 21 | const onPreviousPage = () => { 22 | const { pageNumber } = page; 23 | const prevNum = Math.max(0, pageNumber - 1); 24 | if (prevNum !== pageNumber) 25 | props.pdf.getPage(prevNum).then(page => setPage(page)); 26 | } 27 | 28 | const onNextPage = () => { 29 | const { numPages } = props.pdf; 30 | const { pageNumber } = page; 31 | const nextNum = Math.min(pageNumber + 1, numPages); 32 | if (nextNum !== pageNumber) 33 | props.pdf.getPage(nextNum).then(page => setPage(page)); 34 | } 35 | 36 | const onToggleRelationsMode = () => { 37 | if (annotationMode === 'RELATIONS') 38 | setAnnotationMode('ANNOTATION'); 39 | else 40 | setAnnotationMode('RELATIONS'); 41 | } 42 | 43 | const onToggleImageMode = () => { 44 | if (annotationMode === 'IMAGE') 45 | setAnnotationMode('ANNOTATION'); 46 | else 47 | setAnnotationMode('IMAGE'); 48 | } 49 | 50 | const onCreateAnnotation = a => { 51 | const extended = extendTarget(a, props.url, page.pageNumber); 52 | props.onCreateAnnotation && props.onCreateAnnotation(extended); 53 | } 54 | 55 | const onUpdateAnnotation = (a, p) => { 56 | const updated = extendTarget(a, props.url, page.pageNumber); 57 | const previous = extendTarget(p, props.url, page.pageNumber); 58 | props.onUpdateAnnotation && props.onUpdateAnnotation(updated, previous); 59 | } 60 | 61 | const onDeleteAnnotation = a => { 62 | const extended = extendTarget(a, props.url, page.pageNumber); 63 | props.onDeleteAnnotation && props.onDeleteAnnotation(extended); 64 | } 65 | 66 | return ( 67 |
68 |
69 | 74 | 75 | 80 | 81 | 82 | 83 | 88 | 89 | 96 | 97 | 104 |
105 | 106 |
107 |
108 | 118 |
119 |
120 |
121 | ) 122 | 123 | } 124 | 125 | export default PaginatedViewer; -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { pdfjs, PDFViewer } from '../src'; 4 | 5 | pdfjs.GlobalWorkerOptions.workerSrc = 'pdf.worker.js'; 6 | 7 | const App = () => { 8 | 9 | const [ annotations, setAnnotations ] = useState(); 10 | 11 | useEffect(() => { 12 | fetch('sample-annotations.json') 13 | .then(response => response.json()) 14 | .then(setAnnotations); 15 | }, []); 16 | 17 | return ( 18 | console.log(JSON.stringify(a))} 26 | onUpdateAnnotation={(a, b) => console.log(JSON.stringify(a, b))} 27 | onDeleteAnnotation={a => console.log(JSON.stringify(a))} /> 28 | ) 29 | 30 | } 31 | 32 | window.onload = function() { 33 | 34 | ReactDOM.render( 35 | , 36 | document.getElementById('app') 37 | ); 38 | 39 | } 40 | 41 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 4 | 5 | module.exports = { 6 | entry: { 7 | app: './test/index.js' 8 | }, 9 | output: { 10 | filename: '[name].bundle.js', 11 | path: path.resolve(__dirname, 'dist') 12 | }, 13 | resolve: { 14 | extensions: ['.js', '.jsx'], 15 | alias: { 16 | 'react': path.resolve('./node_modules/react'), 17 | 'react-dom': path.resolve('./node_modules/react-dom') 18 | } 19 | }, 20 | module: { 21 | rules: [ 22 | { 23 | test: /\.(js|jsx)$/, 24 | use: { 25 | loader: 'babel-loader', 26 | options: { 27 | "presets": [ 28 | "@babel/preset-env", 29 | "@babel/preset-react" 30 | ], 31 | "plugins": [ 32 | [ 33 | "@babel/plugin-proposal-class-properties" 34 | ] 35 | ] 36 | } 37 | } 38 | }, 39 | { test: /\.css$/, use: [ 'style-loader', 'css-loader'] }, 40 | { test: /\.scss$/, use: [ 'style-loader', 'css-loader', 'sass-loader' ] }, 41 | { test: /\.(png|jpg|gif)$/, 42 | use: [ 43 | { 44 | loader: 'file-loader', 45 | options: { 46 | outputPath: 'images/', 47 | name: '[name][hash].[ext]', 48 | }, 49 | }, 50 | ] 51 | } 52 | ] 53 | }, 54 | devtool: 'source-map', 55 | devServer: { 56 | static: './public', 57 | hot: true, 58 | port: 3000 59 | }, 60 | plugins: [ 61 | new HtmlWebpackPlugin({ 62 | inject: 'head', 63 | template: './public/index.html' 64 | }) 65 | ] 66 | }; 67 | --------------------------------------------------------------------------------