overleaf/services/web/frontend/js/features/pdf-preview/components/pdf-js-viewer.tsx
David ca41c42288 Merge pull request #18762 from overleaf/dp-pdf-improvements
PDF Toolbar usability improvements

GitOrigin-RevId: 138e42cad0c9c97cfe45eb00fb123084e0228fdd
2024-06-10 08:03:55 +00:00

537 lines
16 KiB
TypeScript

import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { debounce, throttle } from 'lodash'
import PdfViewerControls from './pdf-viewer-controls'
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 { useFeatureFlag } from '@/shared/context/split-test-context'
type PdfJsViewerProps = {
url: string
pdfFile: Record<string, any>
}
function PdfJsViewer({ url, pdfFile }: PdfJsViewerProps) {
const { _id: projectId } = useProjectContext()
const { setError, firstRenderDone, highlights, position, setPosition } =
useCompileContext()
const { setLoadingError } = usePdfPreviewContext()
const hasNewPdfToolbar = useFeatureFlag('pdf-controls')
// 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<number | null>(null)
const [page, setPage] = useState<number | null>(null)
const [totalPages, setTotalPages] = useState<number | null>(null)
// local state values
const [pdfJsWrapper, setPdfJsWrapper] = useState<PDFJSWrapper | null>()
const [initialised, setInitialised] = useState(false)
const handlePageChange = useCallback(
newPage => {
setPage(newPage)
if (pdfJsWrapper?.viewer) {
pdfJsWrapper.viewer.currentPageNumber = newPage
}
},
[pdfJsWrapper, setPage]
)
// 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
const pageElement = textLayerDiv.closest('.page')
if (!pageElement.dataset.listeningForDoubleClick) {
pageElement.dataset.listeningForDoubleClick = true
const doubleClickListener = (event: 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])
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) {
// TODO: We can remove fit-width and fit-height once the
// pdf toolbar is fully rolled out
case 'fit-width':
setScale('page-width')
break
case 'fit-height':
setScale('page-height')
break
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 '=':
event.preventDefault()
setZoom('zoom-in')
break
case '-':
event.preventDefault()
setZoom('zoom-out')
break
case '0':
event.preventDefault()
setZoom('page-width')
break
}
}
},
[initialised, setZoom]
)
/**
* Work around an issue in Chrome 125 that causes canvas elements to become blank
* when a tab is inactive, by making the canvas redraw when the tab becomes active.
* https://github.com/mozilla/pdf.js/issues/18100
* https://issues.chromium.org/issues/339654395
* This can be removed once Chrome 127 is widely available.
*/
useEffect(() => {
const listener = () => {
if (document.visibilityState !== 'hidden' && pdfJsWrapper) {
window.setTimeout(() => {
for (const canvas of pdfJsWrapper.container.querySelectorAll(
'canvas'
)) {
canvas.style.display = 'none'
window.setTimeout(() => {
canvas.style.display = 'block'
}, 1)
}
}, 100)
}
}
document.addEventListener('visibilitychange', listener)
return () => {
document.removeEventListener('visibilitychange', listener)
}
}, [pdfJsWrapper])
// 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 */
<div
className="pdfjs-viewer pdfjs-viewer-outer"
ref={handleContainer}
onKeyDown={handleKeyDown}
tabIndex={-1}
>
<div
className="pdfjs-viewer-inner"
tabIndex={0}
onKeyDown={handleKeyDown}
role="tabpanel"
>
<div className="pdfViewer" />
</div>
<div className="pdfjs-controls" tabIndex={0}>
{hasNewPdfToolbar ? (
toolbarInfoLoaded && (
<PdfViewerControlsToolbar
setZoom={setZoom}
rawScale={rawScale}
setPage={handlePageChange}
page={page}
totalPages={totalPages}
/>
)
) : (
<PdfViewerControls setZoom={setZoom} />
)}
</div>
</div>
)
}
export default withErrorBoundary(memo(PdfJsViewer), () => (
<PdfPreviewErrorBoundaryFallback type="pdf" />
))