Files
claudeandClaude Sonnet 4.6 5a85e1b9d8
Build and Deploy Verso / deploy (push) Successful in 11m53s
Add smooth PDF transition toggle to compile settings
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>
2026-06-07 14:19:37 +00:00

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
}
}