pdf-js-viewer.js 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. import PropTypes from 'prop-types'
  2. import { memo, useCallback, useEffect, useState } from 'react'
  3. import { debounce } from 'lodash'
  4. import { Alert } from 'react-bootstrap'
  5. import PdfViewerControls from './pdf-viewer-controls'
  6. import { useProjectContext } from '../../../shared/context/project-context'
  7. import usePersistedState from '../../../shared/hooks/use-persisted-state'
  8. import useScopeValue from '../../../shared/hooks/use-scope-value'
  9. import { buildHighlightElement } from '../util/highlights'
  10. import PDFJSWrapper from '../util/pdf-js-wrapper'
  11. import withErrorBoundary from '../../../infrastructure/error-boundary'
  12. import ErrorBoundaryFallback from './error-boundary-fallback'
  13. function PdfJsViewer({ url }) {
  14. const { _id: projectId } = useProjectContext()
  15. // state values persisted in localStorage to restore on load
  16. const [scale, setScale] = usePersistedState(
  17. `pdf-viewer-scale:${projectId}`,
  18. 'page-width'
  19. )
  20. // state values shared with Angular scope (highlights => editor, position => synctex buttons
  21. const [highlights] = useScopeValue('pdf.highlights')
  22. const [, setPosition] = useScopeValue('pdf.position')
  23. // local state values
  24. const [pdfJsWrapper, setPdfJsWrapper] = useState()
  25. const [initialised, setInitialised] = useState(false)
  26. const [error, setError] = useState()
  27. // create the viewer when the container is mounted
  28. const handleContainer = useCallback(parent => {
  29. if (parent) {
  30. const viewer = new PDFJSWrapper(parent.firstChild)
  31. setPdfJsWrapper(viewer)
  32. return () => viewer.destroy()
  33. }
  34. }, [])
  35. // listen for initialize event
  36. useEffect(() => {
  37. if (pdfJsWrapper) {
  38. const handlePagesinit = () => setInitialised(true)
  39. pdfJsWrapper.eventBus.on('pagesinit', handlePagesinit)
  40. return () => pdfJsWrapper.eventBus.off('pagesinit', handlePagesinit)
  41. }
  42. }, [pdfJsWrapper])
  43. // load the PDF document from the URL
  44. useEffect(() => {
  45. if (pdfJsWrapper && url) {
  46. setInitialised(false)
  47. setError(undefined)
  48. // TODO: anything else to be reset?
  49. pdfJsWrapper.loadDocument(url).catch(error => setError(error))
  50. return () => pdfJsWrapper.abortDocumentLoading()
  51. }
  52. }, [pdfJsWrapper, url])
  53. // listen for scroll events
  54. useEffect(() => {
  55. let storePositionTimer
  56. if (initialised && pdfJsWrapper) {
  57. // store the scroll position in localStorage, for the synctex button
  58. const storePosition = debounce(pdfViewer => {
  59. // set position for "sync to code" button
  60. try {
  61. setPosition(pdfViewer.currentPosition)
  62. } catch (error) {
  63. // TODO: investigate handling missing offsetParent in jsdom
  64. // console.error(error)
  65. }
  66. }, 500)
  67. storePositionTimer = window.setTimeout(() => {
  68. storePosition(pdfJsWrapper)
  69. }, 100)
  70. const scrollListener = () => {
  71. storePosition(pdfJsWrapper)
  72. }
  73. pdfJsWrapper.container.addEventListener('scroll', scrollListener)
  74. return () => {
  75. pdfJsWrapper.container.removeEventListener('scroll', scrollListener)
  76. if (storePositionTimer) {
  77. window.clearTimeout(storePositionTimer)
  78. }
  79. storePosition.cancel()
  80. }
  81. }
  82. }, [setPosition, pdfJsWrapper, initialised])
  83. // listen for double-click events
  84. useEffect(() => {
  85. if (pdfJsWrapper) {
  86. const handleTextlayerrendered = textLayer => {
  87. const pageElement = textLayer.source.textLayerDiv.closest('.page')
  88. const doubleClickListener = event => {
  89. window.dispatchEvent(
  90. new CustomEvent('synctex:sync-to-position', {
  91. detail: pdfJsWrapper.clickPosition(event, pageElement, textLayer),
  92. })
  93. )
  94. }
  95. pageElement.addEventListener('dblclick', doubleClickListener)
  96. }
  97. pdfJsWrapper.eventBus.on('textlayerrendered', handleTextlayerrendered)
  98. return () =>
  99. pdfJsWrapper.eventBus.off('textlayerrendered', handleTextlayerrendered)
  100. }
  101. }, [pdfJsWrapper])
  102. // restore the saved scale and scroll position
  103. useEffect(() => {
  104. if (initialised && pdfJsWrapper) {
  105. setScale(scale => {
  106. pdfJsWrapper.viewer.currentScaleValue = scale
  107. return scale
  108. })
  109. // restore the scroll position
  110. setPosition(position => {
  111. if (position) {
  112. pdfJsWrapper.currentPosition = position
  113. }
  114. return position
  115. })
  116. }
  117. }, [initialised, setScale, setPosition, pdfJsWrapper])
  118. // transmit scale value to the viewer when it changes
  119. useEffect(() => {
  120. if (pdfJsWrapper) {
  121. pdfJsWrapper.viewer.currentScaleValue = scale
  122. }
  123. }, [scale, pdfJsWrapper])
  124. // when highlights are created, build the highlight elements
  125. useEffect(() => {
  126. if (pdfJsWrapper && highlights?.length) {
  127. const elements = []
  128. for (const highlight of highlights) {
  129. try {
  130. const element = buildHighlightElement(highlight, pdfJsWrapper.viewer)
  131. elements.push(element)
  132. } catch (error) {
  133. // ignore invalid highlights
  134. }
  135. }
  136. // scroll to the first highlighted element
  137. elements[0]?.scrollIntoView({
  138. block: 'start',
  139. inline: 'nearest',
  140. behavior: 'smooth',
  141. })
  142. return () => {
  143. for (const element of elements) {
  144. element.remove()
  145. }
  146. }
  147. }
  148. }, [highlights, pdfJsWrapper])
  149. // set the scale in response to zoom option changes
  150. const setZoom = useCallback(
  151. zoom => {
  152. switch (zoom) {
  153. case 'fit-width':
  154. setScale('page-width')
  155. break
  156. case 'fit-height':
  157. setScale('page-height')
  158. break
  159. case 'zoom-in':
  160. setScale(pdfJsWrapper.viewer.currentScale * 1.25)
  161. break
  162. case 'zoom-out':
  163. setScale(pdfJsWrapper.viewer.currentScale * 0.75)
  164. break
  165. }
  166. },
  167. [pdfJsWrapper, setScale]
  168. )
  169. // adjust the scale when the container is resized
  170. useEffect(() => {
  171. if (pdfJsWrapper) {
  172. const resizeListener = () => {
  173. pdfJsWrapper.updateOnResize()
  174. }
  175. const resizeObserver = new ResizeObserver(resizeListener)
  176. resizeObserver.observe(pdfJsWrapper.container)
  177. window.addEventListener('resize', resizeListener)
  178. return () => {
  179. resizeObserver.disconnect()
  180. window.removeEventListener('resize', resizeListener)
  181. }
  182. }
  183. }, [pdfJsWrapper])
  184. const handleKeyDown = useCallback(
  185. event => {
  186. if (!initialised) {
  187. return
  188. }
  189. if ((event.metaKey || event.ctrlKey) && event.key === '=') {
  190. event.preventDefault()
  191. setZoom('zoom-in')
  192. } else if ((event.metaKey || event.ctrlKey) && event.key === '-') {
  193. event.preventDefault()
  194. setZoom('zoom-out')
  195. } else if ((event.metaKey || event.ctrlKey) && event.key === '0') {
  196. event.preventDefault()
  197. setZoom('fit-width')
  198. }
  199. },
  200. [initialised, setZoom]
  201. )
  202. /* eslint-disable jsx-a11y/no-noninteractive-tabindex */
  203. /* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
  204. return (
  205. <div className="pdfjs-viewer" ref={handleContainer}>
  206. <div
  207. className="pdfjs-viewer-inner"
  208. role="tabpanel"
  209. tabIndex="0"
  210. onKeyDown={handleKeyDown}
  211. >
  212. <div className="pdfViewer" />
  213. </div>
  214. <div className="pdfjs-controls">
  215. <PdfViewerControls setZoom={setZoom} />
  216. </div>
  217. {error && (
  218. <div className="pdfjs-error">
  219. <Alert bsStyle="danger">{error.message}</Alert>
  220. </div>
  221. )}
  222. </div>
  223. )
  224. }
  225. PdfJsViewer.propTypes = {
  226. url: PropTypes.string.isRequired,
  227. }
  228. export default withErrorBoundary(memo(PdfJsViewer), () => (
  229. <ErrorBoundaryFallback type="pdf" />
  230. ))