| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431 |
- import PropTypes from 'prop-types'
- import { memo, useCallback, useEffect, useRef, useState } from 'react'
- import { debounce } from 'lodash'
- import PdfViewerControls from './pdf-viewer-controls'
- import { useProjectContext } from '../../../shared/context/project-context'
- import usePersistedState from '../../../shared/hooks/use-persisted-state'
- import { buildHighlightElement } from '../util/highlights'
- import PDFJSWrapper from '../util/pdf-js-wrapper'
- import withErrorBoundary from '../../../infrastructure/error-boundary'
- import PdfPreviewErrorBoundaryFallback from './pdf-preview-error-boundary-fallback'
- import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
- import { captureException } from '../../../infrastructure/error-reporter'
- import * as eventTracking from '../../../infrastructure/event-tracking'
- import { getPdfCachingMetrics } from '../util/metrics'
- import { debugConsole } from '@/utils/debugging'
- import { usePdfPreviewContext } from '@/features/pdf-preview/components/pdf-preview-provider'
- function PdfJsViewer({ url, pdfFile }) {
- const { _id: projectId } = useProjectContext()
- const { setError, firstRenderDone, highlights, position, setPosition } =
- useCompileContext()
- const { setLoadingError } = usePdfPreviewContext()
- // state values persisted in localStorage to restore on load
- const [scale, setScale] = usePersistedState(
- `pdf-viewer-scale:${projectId}`,
- 'page-width'
- )
- // local state values
- const [pdfJsWrapper, setPdfJsWrapper] = useState()
- const [initialised, setInitialised] = useState(false)
- // create the viewer when the container is mounted
- const handleContainer = useCallback(
- parent => {
- if (parent) {
- const wrapper = new PDFJSWrapper(parent.firstChild)
- wrapper
- .init()
- .then(() => {
- setPdfJsWrapper(wrapper)
- })
- .catch(error => {
- setLoadingError(true)
- captureException(error)
- })
- return () => {
- setPdfJsWrapper(null)
- wrapper.destroy()
- }
- }
- },
- [setLoadingError]
- )
- const [startFetch, setStartFetch] = useState(0)
- // listen for events and trigger rendering.
- // Do everything in one effect to mitigate de-sync between events.
- useEffect(() => {
- if (!pdfJsWrapper || !firstRenderDone) return
- let timePDFFetched
- let timePDFRendered
- const submitLatencies = () => {
- if (!timePDFFetched) {
- // The pagerendered event was attached after pagesinit fired. :/
- return
- }
- const latencyFetch = Math.ceil(timePDFFetched - startFetch)
- let latencyRender
- if (timePDFRendered) {
- // The renderer does not yield in case the browser tab is hidden.
- // It will yield when the browser tab is visible again.
- // This will skew our performance metrics for rendering!
- // We are omitting the render time in case we detect this state.
- latencyRender = Math.ceil(timePDFRendered - timePDFFetched)
- }
- firstRenderDone({
- latencyFetch,
- latencyRender,
- // Let the pdfCachingMetrics round trip to account for pdf-detach.
- pdfCachingMetrics: getPdfCachingMetrics(),
- })
- }
- const handlePagesinit = () => {
- setInitialised(true)
- timePDFFetched = performance.now()
- if (document.hidden) {
- // Rendering does not start in case we are hidden. See comment above.
- submitLatencies()
- }
- }
- const handleRendered = () => {
- if (!document.hidden) {
- // The render time is not accurate in case we are hidden. See above.
- timePDFRendered = performance.now()
- }
- submitLatencies()
- // Only get the times for the first page.
- pdfJsWrapper.eventBus.off('pagerendered', handleRendered)
- }
- // `pagesinit` fires when the data for rendering the first page is ready.
- pdfJsWrapper.eventBus.on('pagesinit', handlePagesinit)
- // `pagerendered` fires when a page was actually rendered.
- pdfJsWrapper.eventBus.on('pagerendered', handleRendered)
- return () => {
- pdfJsWrapper.eventBus.off('pagesinit', handlePagesinit)
- pdfJsWrapper.eventBus.off('pagerendered', handleRendered)
- }
- }, [pdfJsWrapper, firstRenderDone, startFetch])
- // load the PDF document from the URL
- useEffect(() => {
- if (pdfJsWrapper && url) {
- setInitialised(false)
- setError(undefined)
- setStartFetch(performance.now())
- const abortController = new AbortController()
- const handleFetchError = err => {
- if (abortController.signal.aborted) return
- // The error is already logged at the call-site with additional context.
- if (err instanceof pdfJsWrapper.PDFJS.MissingPDFException) {
- setError('rendering-error-expected')
- } else {
- setError('rendering-error')
- }
- }
- pdfJsWrapper
- .loadDocument({ url, pdfFile, abortController, handleFetchError })
- .catch(error => {
- if (abortController.signal.aborted) return
- debugConsole.error(error)
- setError('rendering-error')
- })
- return () => {
- abortController.abort()
- pdfJsWrapper.abortDocumentLoading()
- }
- }
- }, [pdfJsWrapper, url, pdfFile, setError, setStartFetch])
- // listen for scroll events
- useEffect(() => {
- let storePositionTimer
- if (initialised && pdfJsWrapper) {
- if (!pdfJsWrapper.isVisible()) {
- return
- }
- // store the scroll position in localStorage, for the synctex button
- const storePosition = debounce(pdfViewer => {
- // set position for "sync to code" button
- try {
- setPosition(pdfViewer.currentPosition)
- } catch (error) {
- // debugConsole.error(error)
- }
- }, 500)
- storePositionTimer = window.setTimeout(() => {
- storePosition(pdfJsWrapper)
- }, 100)
- const scrollListener = () => {
- storePosition(pdfJsWrapper)
- }
- pdfJsWrapper.container.addEventListener('scroll', scrollListener)
- return () => {
- pdfJsWrapper.container.removeEventListener('scroll', scrollListener)
- if (storePositionTimer) {
- window.clearTimeout(storePositionTimer)
- }
- storePosition.cancel()
- }
- }
- }, [setPosition, pdfJsWrapper, initialised])
- // listen for double-click events
- useEffect(() => {
- if (pdfJsWrapper) {
- const handleTextlayerrendered = textLayer => {
- // handle both versions for backwards-compatibility
- const textLayerDiv =
- textLayer.source.textLayerDiv ?? textLayer.source.textLayer.div
- const pageElement = textLayerDiv.closest('.page')
- if (!pageElement.dataset.listeningForDoubleClick) {
- pageElement.dataset.listeningForDoubleClick = true
- const doubleClickListener = event => {
- const clickPosition = pdfJsWrapper.clickPosition(
- event,
- pageElement,
- textLayer
- )
- if (clickPosition) {
- eventTracking.sendMB('jump-to-location', {
- direction: 'pdf-location-in-code',
- method: 'double-click',
- })
- window.dispatchEvent(
- new CustomEvent('synctex:sync-to-position', {
- detail: clickPosition,
- })
- )
- }
- }
- pageElement.addEventListener('dblclick', doubleClickListener)
- }
- }
- pdfJsWrapper.eventBus.on('textlayerrendered', handleTextlayerrendered)
- return () =>
- pdfJsWrapper.eventBus.off('textlayerrendered', handleTextlayerrendered)
- }
- }, [pdfJsWrapper])
- // restore the saved scale and scroll position
- const positionRef = useRef(position)
- useEffect(() => {
- positionRef.current = position
- }, [position])
- const scaleRef = useRef(scale)
- useEffect(() => {
- scaleRef.current = scale
- }, [scale])
- useEffect(() => {
- if (initialised && pdfJsWrapper) {
- if (!pdfJsWrapper.isVisible()) {
- return
- }
- if (positionRef.current) {
- pdfJsWrapper.scrollToPosition(positionRef.current, scaleRef.current)
- } else {
- pdfJsWrapper.viewer.currentScaleValue = scaleRef.current
- }
- }
- }, [initialised, pdfJsWrapper, scaleRef, positionRef])
- // transmit scale value to the viewer when it changes
- useEffect(() => {
- if (pdfJsWrapper) {
- pdfJsWrapper.viewer.currentScaleValue = scale
- }
- }, [scale, pdfJsWrapper])
- // when highlights are created, build the highlight elements
- useEffect(() => {
- const timers = []
- let intersectionObserver
- if (pdfJsWrapper && highlights?.length) {
- // watch for the highlight elements to scroll into view
- intersectionObserver = new IntersectionObserver(
- entries => {
- for (const entry of entries) {
- if (entry.isIntersecting) {
- intersectionObserver.unobserve(entry.target)
- // fade the element in and out
- entry.target.style.opacity = '0.5'
- timers.push(
- window.setTimeout(() => {
- entry.target.style.opacity = '0'
- }, 1000)
- )
- }
- }
- },
- {
- threshold: 1.0, // the whole element must be visible
- }
- )
- const elements = []
- for (const highlight of highlights) {
- try {
- const element = buildHighlightElement(highlight, pdfJsWrapper)
- elements.push(element)
- intersectionObserver.observe(element)
- } catch (error) {
- // ignore invalid highlights
- }
- }
- const [firstElement] = elements
- if (firstElement) {
- // scroll to the first highlighted element
- firstElement.scrollIntoView({
- block: 'center',
- inline: 'start',
- behavior: 'smooth',
- })
- }
- return () => {
- for (const timer of timers) {
- window.clearTimeout(timer)
- }
- for (const element of elements) {
- element.remove()
- }
- intersectionObserver?.disconnect()
- }
- }
- }, [highlights, pdfJsWrapper])
- // set the scale in response to zoom option changes
- const setZoom = useCallback(
- zoom => {
- switch (zoom) {
- case 'fit-width':
- setScale('page-width')
- break
- case 'fit-height':
- setScale('page-height')
- break
- case 'zoom-in':
- if (pdfJsWrapper) {
- setScale(pdfJsWrapper.viewer.currentScale * 1.25)
- }
- break
- case 'zoom-out':
- if (pdfJsWrapper) {
- setScale(pdfJsWrapper.viewer.currentScale * 0.75)
- }
- break
- }
- },
- [pdfJsWrapper, setScale]
- )
- // adjust the scale when the container is resized
- useEffect(() => {
- if (pdfJsWrapper && 'ResizeObserver' in window) {
- const resizeListener = () => {
- pdfJsWrapper.updateOnResize()
- }
- const resizeObserver = new ResizeObserver(resizeListener)
- resizeObserver.observe(pdfJsWrapper.container)
- window.addEventListener('resize', resizeListener)
- return () => {
- resizeObserver.disconnect()
- window.removeEventListener('resize', resizeListener)
- }
- }
- }, [pdfJsWrapper])
- const handleKeyDown = useCallback(
- event => {
- if (!initialised) {
- return
- }
- if (event.metaKey || event.ctrlKey) {
- switch (event.key) {
- case '=':
- event.preventDefault()
- setZoom('zoom-in')
- break
- case '-':
- event.preventDefault()
- setZoom('zoom-out')
- break
- case '0':
- event.preventDefault()
- setZoom('fit-width')
- break
- }
- }
- },
- [initialised, setZoom]
- )
- /* eslint-disable jsx-a11y/no-noninteractive-tabindex */
- /* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
- return (
- <div className="pdfjs-viewer pdfjs-viewer-outer" ref={handleContainer}>
- <div
- className="pdfjs-viewer-inner"
- role="tabpanel"
- tabIndex="0"
- onKeyDown={handleKeyDown}
- >
- <div className="pdfViewer" />
- </div>
- <div className="pdfjs-controls" tabIndex="0">
- <PdfViewerControls setZoom={setZoom} />
- </div>
- </div>
- )
- }
- PdfJsViewer.propTypes = {
- url: PropTypes.string.isRequired,
- pdfFile: PropTypes.object,
- }
- export default withErrorBoundary(memo(PdfJsViewer), () => (
- <PdfPreviewErrorBoundaryFallback type="pdf" />
- ))
|