pdf-js-viewer.js 7.6 KB

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