Build and Deploy Verso / deploy (push) Successful in 11m53s
Per-project-type setting: Typst defaults to on, LaTeX defaults to off. Toggle appears in the compile dropdown under "Smooth PDF transition". The enableTransition flag is read via a ref so toggling does not reload the current PDF. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
353 lines
12 KiB
TypeScript
353 lines
12 KiB
TypeScript
import { captureException } from '@/infrastructure/error-reporter'
|
|
import { generatePdfCachingTransportFactory } from './pdf-caching-transport'
|
|
import { PDFJS, loadPdfDocumentFromUrl, imageResourcesPath } from './pdf-js'
|
|
import {
|
|
PDFViewer,
|
|
EventBus,
|
|
PDFLinkService,
|
|
LinkTarget,
|
|
} from 'pdfjs-dist/web/pdf_viewer.mjs'
|
|
import 'pdfjs-dist/web/pdf_viewer.css'
|
|
import browser from '@/features/source-editor/extensions/browser'
|
|
import { PDFFile } from '@ol-types/compile'
|
|
|
|
const DEFAULT_RANGE_CHUNK_SIZE = 128 * 1024 // 128K chunks
|
|
|
|
export default class PDFJSWrapper {
|
|
public readonly viewer: PDFViewer
|
|
public readonly eventBus: EventBus
|
|
private readonly linkService: PDFLinkService
|
|
private readonly pdfCachingTransportFactory: any
|
|
private url?: string
|
|
|
|
// eslint-disable-next-line no-useless-constructor
|
|
constructor(public container: HTMLDivElement) {
|
|
// create the event bus
|
|
this.eventBus = new EventBus()
|
|
|
|
// create the link service
|
|
this.linkService = new PDFLinkService({
|
|
eventBus: this.eventBus,
|
|
externalLinkTarget: LinkTarget.BLANK,
|
|
externalLinkRel: 'noopener',
|
|
})
|
|
|
|
// create the viewer
|
|
this.viewer = new PDFViewer({
|
|
container: this.container,
|
|
eventBus: this.eventBus,
|
|
imageResourcesPath,
|
|
linkService: this.linkService,
|
|
maxCanvasPixels: browser.safari ? 4096 * 4096 : 8192 * 8192, // default is 4096 * 4096, increased for better resolution at high zoom levels (but not in Safari, which struggles with large canvases)
|
|
annotationMode: PDFJS.AnnotationMode.ENABLE, // enable annotations but not forms
|
|
annotationEditorMode: PDFJS.AnnotationEditorType.DISABLE, // disable annotation editing
|
|
})
|
|
|
|
this.linkService.setViewer(this.viewer)
|
|
|
|
this.pdfCachingTransportFactory = generatePdfCachingTransportFactory()
|
|
}
|
|
|
|
// load a document from a URL
|
|
async loadDocument({
|
|
url,
|
|
pdfFile,
|
|
abortController,
|
|
handleFetchError,
|
|
enableTransition = true,
|
|
}: {
|
|
url: string
|
|
pdfFile: PDFFile
|
|
abortController: AbortController
|
|
handleFetchError: (error: any) => void
|
|
enableTransition?: boolean
|
|
}) {
|
|
this.url = url
|
|
|
|
const rangeTransport = this.pdfCachingTransportFactory({
|
|
url,
|
|
pdfFile,
|
|
abortController,
|
|
handleFetchError,
|
|
})
|
|
let rangeChunkSize = DEFAULT_RANGE_CHUNK_SIZE
|
|
if (rangeTransport && pdfFile.size < 2 * DEFAULT_RANGE_CHUNK_SIZE) {
|
|
// pdf.js disables the "bulk" download optimization when providing a
|
|
// custom range transport. Restore it by bumping the chunk size.
|
|
rangeChunkSize = pdfFile.size
|
|
}
|
|
|
|
try {
|
|
const doc = await loadPdfDocumentFromUrl(url, {
|
|
rangeChunkSize,
|
|
range: rangeTransport,
|
|
}).promise
|
|
|
|
// check that this is still the current URL
|
|
if (url !== this.url) {
|
|
return
|
|
}
|
|
|
|
// Hold the .pdfViewer element's height steady across the synchronous page-clear
|
|
// that setDocument() triggers, so the viewer doesn't visually collapse while the
|
|
// new pages are being initialised. The min-height is released on pagesinit.
|
|
const viewerEl = this.container.querySelector('.pdfViewer') as HTMLElement
|
|
const currentHeight = viewerEl.getBoundingClientRect().height
|
|
if (currentHeight > 0) {
|
|
viewerEl.style.minHeight = `${currentHeight}px`
|
|
const clearMinHeight = () => {
|
|
viewerEl.style.minHeight = ''
|
|
this.eventBus.off('pagesinit', clearMinHeight)
|
|
}
|
|
this.eventBus.on('pagesinit', clearMinHeight)
|
|
}
|
|
|
|
const setDocument = () => {
|
|
this.viewer.setDocument(doc)
|
|
this.linkService.setDocument(doc)
|
|
}
|
|
|
|
// Promise that resolves once the first page of the new document has been
|
|
// painted to canvas. Used both by the view-transition and the snapshot
|
|
// overlay so the "old content" stays visible until the new content is ready.
|
|
const firstPageRendered = new Promise<void>(resolve => {
|
|
const handler = () => {
|
|
this.eventBus.off('pagerendered', handler)
|
|
resolve()
|
|
}
|
|
this.eventBus.on('pagerendered', handler)
|
|
setTimeout(resolve, 1000) // safety valve
|
|
})
|
|
|
|
if (enableTransition) {
|
|
const container = this.container as typeof this.container & {
|
|
// element-level View Transitions (Level 2), Chrome 126+
|
|
startViewTransition?: (cb: () => void | Promise<void>) => {
|
|
ready: Promise<void>
|
|
}
|
|
}
|
|
|
|
if (
|
|
typeof container.startViewTransition === 'function' &&
|
|
document.visibilityState !== 'hidden'
|
|
) {
|
|
// Chrome 126+: element-level View Transition, scoped to this container.
|
|
// The async callback holds the captured "before" state until the first
|
|
// new page is rendered, so the crossfade goes old→new, not old→blank.
|
|
const transition = container.startViewTransition(async () => {
|
|
setDocument()
|
|
await firstPageRendered
|
|
})
|
|
transition.ready.catch(err => {
|
|
if (err?.name !== 'InvalidStateError') {
|
|
captureException(err, { tags: { handler: 'pdf-preview' } })
|
|
}
|
|
})
|
|
} else {
|
|
// All other browsers: canvas snapshot overlay.
|
|
// The snapshot covers the canvas-clear that setDocument() triggers so
|
|
// the viewer never flashes white. Once the first new page is rendered
|
|
// the overlay fades out (CSS opacity transition), giving the same smooth
|
|
// visual crossfade without ever blocking user input (pointer-events:none).
|
|
const snap = this._snapshotCanvases()
|
|
setDocument()
|
|
if (snap) {
|
|
firstPageRendered.then(() => {
|
|
snap.style.transition = 'opacity 0.25s ease'
|
|
snap.style.opacity = '0'
|
|
setTimeout(() => snap.remove(), 280)
|
|
})
|
|
}
|
|
}
|
|
} else {
|
|
setDocument()
|
|
}
|
|
|
|
return doc
|
|
} catch (error: any) {
|
|
if (
|
|
!error ||
|
|
!(error instanceof PDFJS.ResponseException && error.missing === true)
|
|
) {
|
|
captureException(error, {
|
|
tags: { handler: 'pdf-preview' },
|
|
})
|
|
}
|
|
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async fetchAllData() {
|
|
await this.viewer.pdfDocument?.getData()
|
|
}
|
|
|
|
// Snapshot all rendered canvases into a positioned overlay so the viewer
|
|
// doesn't flash white while setDocument() clears and repaints the pages.
|
|
// Returns the overlay element (caller must remove it when no longer needed),
|
|
// or null if there was nothing rendered yet.
|
|
private _snapshotCanvases(): HTMLElement | null {
|
|
const canvases = this.container.querySelectorAll<HTMLCanvasElement>(
|
|
'.pdfViewer canvas'
|
|
)
|
|
if (!canvases.length) return null
|
|
|
|
const containerRect = this.container.getBoundingClientRect()
|
|
const overlay = document.createElement('div')
|
|
overlay.style.cssText =
|
|
'position:absolute;inset:0;pointer-events:none;z-index:10;overflow:hidden;'
|
|
|
|
let hasContent = false
|
|
for (const src of canvases) {
|
|
if (src.width === 0 || src.height === 0) continue
|
|
const srcRect = src.getBoundingClientRect()
|
|
if (srcRect.height === 0) continue
|
|
|
|
const clone = document.createElement('canvas')
|
|
clone.width = src.width
|
|
clone.height = src.height
|
|
const ctx = clone.getContext('2d')
|
|
if (!ctx) continue
|
|
ctx.drawImage(src, 0, 0)
|
|
|
|
// Position relative to the scroll container, accounting for scroll offset.
|
|
const top =
|
|
srcRect.top - containerRect.top + this.container.scrollTop
|
|
const left =
|
|
srcRect.left - containerRect.left + this.container.scrollLeft
|
|
clone.style.cssText = `
|
|
position:absolute;
|
|
top:${top}px;left:${left}px;
|
|
width:${srcRect.width}px;height:${srcRect.height}px;
|
|
`
|
|
overlay.appendChild(clone)
|
|
hasContent = true
|
|
}
|
|
|
|
if (!hasContent) return null
|
|
|
|
// container must be non-static for absolute children to position correctly.
|
|
if (getComputedStyle(this.container).position === 'static') {
|
|
this.container.style.position = 'relative'
|
|
}
|
|
this.container.appendChild(overlay)
|
|
return overlay
|
|
}
|
|
|
|
// update the current scale value if the container size changes
|
|
updateOnResize() {
|
|
if (!this.isVisible()) {
|
|
return
|
|
}
|
|
|
|
// Use requestAnimationFrame to prevent errors like "ResizeObserver loop
|
|
// completed with undelivered notifications" that can occur if updating the
|
|
// viewer causes another repaint. The cost of this is that the viewer update
|
|
// lags one frame behind, but it's unlikely to matter.
|
|
// Further reading: https://github.com/WICG/resize-observer/issues/38
|
|
window.requestAnimationFrame(() => {
|
|
const currentScaleValue = this.viewer.currentScaleValue
|
|
|
|
if (
|
|
currentScaleValue === 'auto' ||
|
|
currentScaleValue === 'page-fit' ||
|
|
currentScaleValue === 'page-height' ||
|
|
currentScaleValue === 'page-width'
|
|
) {
|
|
this.viewer.currentScaleValue = currentScaleValue
|
|
}
|
|
|
|
this.viewer.update()
|
|
})
|
|
}
|
|
|
|
// get the page and offset of a click event
|
|
clickPosition(event: MouseEvent, canvas: HTMLCanvasElement, page: number) {
|
|
if (!canvas) {
|
|
return
|
|
}
|
|
|
|
const { viewport } = this.viewer.getPageView(page)
|
|
|
|
const pageRect = canvas.getBoundingClientRect()
|
|
|
|
const dx = event.clientX - pageRect.left
|
|
const dy = event.clientY - pageRect.top
|
|
|
|
const [left, top] = viewport.convertToPdfPoint(dx, dy)
|
|
|
|
return {
|
|
page,
|
|
offset: {
|
|
left,
|
|
top: viewport.viewBox[3] - top,
|
|
},
|
|
}
|
|
}
|
|
|
|
// get the current page, offset and page size
|
|
get currentPosition() {
|
|
const containerRect = this.container.getBoundingClientRect()
|
|
|
|
let pageIndex = this.viewer.currentPageNumber - 1
|
|
for (let i = pageIndex; i >= 0; i--) {
|
|
const pageView = this.viewer.getPageView(i)
|
|
if (!pageView?.div) continue
|
|
const pageRect = pageView.div.getBoundingClientRect()
|
|
if (pageRect.bottom < containerRect.top) {
|
|
pageIndex = i + 1
|
|
break
|
|
}
|
|
pageIndex = i
|
|
}
|
|
|
|
const pageView = this.viewer.getPageView(pageIndex)
|
|
const pageRect = pageView.div.getBoundingClientRect()
|
|
|
|
const dy = containerRect.top - pageRect.top
|
|
const dx = containerRect.left - pageRect.left
|
|
const [left, top] = pageView.viewport.convertToPdfPoint(dx, dy)
|
|
const [, , width, height] = pageView.viewport.viewBox
|
|
|
|
return {
|
|
page: pageIndex,
|
|
offset: { top, left },
|
|
pageSize: { height, width },
|
|
}
|
|
}
|
|
|
|
scrollToPosition(position: Record<string, any>, scale = null) {
|
|
const destArray = [
|
|
null,
|
|
{
|
|
name: 'XYZ', // 'XYZ' = scroll to the given coordinates
|
|
},
|
|
position.offset.left,
|
|
position.offset.top,
|
|
scale,
|
|
]
|
|
|
|
this.viewer.scrollPageIntoView({
|
|
pageNumber: position.page + 1,
|
|
destArray,
|
|
})
|
|
|
|
// scrollPageIntoView aligns PDF content to the container top, ignoring the page margin.
|
|
// For a top-of-document position this leaves scrollTop = marginTop (margin hidden).
|
|
// Snap back to 0 so the margin is visible, but only when we are in that margin band —
|
|
// for any real mid-document scrollTop this condition is false and we leave it untouched.
|
|
const pageIndex = this.viewer.currentPageNumber - 1
|
|
const pageView = this.viewer.getPageView(pageIndex)
|
|
if (pageView) {
|
|
const marginTop = parseFloat(getComputedStyle(pageView.div).marginTop)
|
|
if (this.viewer.container.scrollTop <= marginTop) {
|
|
this.viewer.container.scrollTop = 0
|
|
}
|
|
}
|
|
}
|
|
|
|
isVisible() {
|
|
return this.viewer.container.offsetParent !== null
|
|
}
|
|
}
|