pdf-js-viewer.js 8.1 KB

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