import { memo, MouseEventHandler, useCallback, useEffect, useRef, useState, } from 'react' import { debounce, throttle } from 'lodash' import PdfViewerControlsToolbar from './pdf-viewer-controls-toolbar' 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' import usePresentationMode from '../hooks/use-presentation-mode' import useMouseWheelZoom from '../hooks/use-mouse-wheel-zoom' type PdfJsViewerProps = { url: string pdfFile: Record } function PdfJsViewer({ url, pdfFile }: PdfJsViewerProps) { 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' ) // rawScale is different from scale as it is always a number. // This is relevant when scale is e.g. 'page-width'. const [rawScale, setRawScale] = useState(null) const [page, setPage] = useState(null) const [totalPages, setTotalPages] = useState(null) // local state values const [pdfJsWrapper, setPdfJsWrapper] = useState() const [initialised, setInitialised] = useState(false) const handlePageChange = useCallback( (newPage: number) => { if (!totalPages || newPage < 1 || newPage > totalPages) { return } setPage(newPage) if (pdfJsWrapper?.viewer) { pdfJsWrapper.viewer.currentPageNumber = newPage } }, [pdfJsWrapper, setPage, totalPages] ) // 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: number let timePDFRendered: number 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) } const handleRenderedInitialPageNumber = () => { setPage(pdfJsWrapper.viewer.currentPageNumber) // Only need to set the initial page number once. pdfJsWrapper.eventBus.off('pagerendered', handleRenderedInitialPageNumber) } const handleScaleChanged = (scale: { scale: number }) => { setRawScale(scale.scale) } // `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) // Once a page has been rendered we can set the initial current page number. pdfJsWrapper.eventBus.on('pagerendered', handleRenderedInitialPageNumber) pdfJsWrapper.eventBus.on('scalechanging', handleScaleChanged) return () => { pdfJsWrapper.eventBus.off('pagesinit', handlePagesinit) pdfJsWrapper.eventBus.off('pagerendered', handleRendered) pdfJsWrapper.eventBus.off('pagerendered', handleRenderedInitialPageNumber) pdfJsWrapper.eventBus.off('scalechanging', handleScaleChanged) } }, [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: Error) => { 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 }) .then(doc => { setTotalPages(doc.numPages) }) .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: number 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) setPage(pdfJsWrapper.viewer.currentPageNumber) } 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: any) => { // handle both versions for backwards-compatibility const textLayerDiv = textLayer.source.textLayerDiv ?? textLayer.source.textLayer.div if (!textLayerDiv.dataset.listeningForDoubleClick) { textLayerDiv.dataset.listeningForDoubleClick = true const doubleClickListener: MouseEventHandler = event => { const clickPosition = pdfJsWrapper.clickPosition( event, textLayerDiv.closest('.page').querySelector('canvas'), textLayer.pageNumber - 1 ) 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, }) ) } } textLayerDiv.addEventListener('dblclick', doubleClickListener) } } pdfJsWrapper.eventBus.on('textlayerrendered', handleTextlayerrendered) return () => pdfJsWrapper.eventBus.off('textlayerrendered', handleTextlayerrendered) } }, [pdfJsWrapper]) const positionRef = useRef(position) useEffect(() => { positionRef.current = position }, [position]) const scaleRef = useRef(scale) useEffect(() => { scaleRef.current = scale }, [scale]) // restore the saved scale and scroll position useEffect(() => { if (initialised && pdfJsWrapper) { if (!pdfJsWrapper.isVisible()) { return } if (positionRef.current) { // Typescript is incorrectly inferring the type of the scale argument to // scrollToPosition from its default value. We can remove this ignore once // pdfJsWrapper is converted to using tyepscript. // @ts-ignore 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: number[] = [] let intersectionObserver: 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) const element = entry.target as HTMLElement // fade the element in and out element.style.opacity = '0.5' timers.push( window.setTimeout(() => { element.style.opacity = '0' }, 1000) ) } } }, { threshold: 1.0, // the whole element must be visible } ) const elements: HTMLDivElement[] = [] 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 'zoom-in': if (pdfJsWrapper) { setScale( `${Math.min(pdfJsWrapper.viewer.currentScale * 1.25, 9.99)}` ) } break case 'zoom-out': if (pdfJsWrapper) { setScale( `${Math.max(pdfJsWrapper.viewer.currentScale / 1.25, 0.1)}` ) } break default: setScale(zoom) } }, [pdfJsWrapper, setScale] ) // adjust the scale when the container is resized useEffect(() => { if (pdfJsWrapper && 'ResizeObserver' in window) { const resizeListener = throttle(() => { pdfJsWrapper.updateOnResize() }, 250) 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 '+': case '=': event.preventDefault() setZoom('zoom-in') break case '-': event.preventDefault() setZoom('zoom-out') break case '0': event.preventDefault() setZoom('page-width') break case '9': event.preventDefault() setZoom('page-height') break } } }, [initialised, setZoom] ) useMouseWheelZoom(pdfJsWrapper, setScale) const requestPresentationMode = usePresentationMode( pdfJsWrapper, page, handlePageChange, scale, setScale ) // Don't render the toolbar until we have the necessary information const toolbarInfoLoaded = rawScale !== null && page !== null && totalPages !== null /* eslint-disable jsx-a11y/no-noninteractive-tabindex */ /* eslint-disable jsx-a11y/no-noninteractive-element-interactions */ return ( /* eslint-disable-next-line jsx-a11y/no-static-element-interactions */
{toolbarInfoLoaded && ( )}
) } export default withErrorBoundary(memo(PdfJsViewer), () => ( ))