fix(pdf): replace document.startViewTransition with non-blocking canvas fade
Build and Deploy Verso / deploy (push) Successful in 14m22s

document.startViewTransition with an async callback places a ::view-transition
overlay on top of the entire page, intercepting pointer events for the duration
of the callback (up to the 1s safety timeout + 250ms animation).  With rapid
auto-compiles this created interface freezes and overlapping transitions that
could leave the visual lock in a broken state, causing 'stuck on compiling'.

Replace with a canvas snapshot overlay + CSS opacity fade-out:
- pointer-events:none so the overlay never blocks input
- snapshot covers the canvas-clear from setDocument() (no white flash)
- on pagerendered: opacity transitions to 0 over 250ms, then overlay removed
- gives the same smooth visual crossfade, reliably, in all browsers

Chrome 126+ retains the element-level startViewTransition path which is
scoped to the PDF container and does not affect the rest of the page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
claude
2026-06-07 13:09:18 +00:00
parent 453439e611
commit 71755e5cee
@@ -121,22 +121,6 @@ export default class PDFJSWrapper {
// element-level View Transitions (Level 2), Chrome 126+
startViewTransition?: (cb: () => void | Promise<void>) => {
ready: Promise<void>
finished: Promise<void>
}
}
const doc_ = document as typeof document & {
// document-level View Transitions (Level 1), Chrome 111+
startViewTransition?: (cb: () => void | Promise<void>) => {
ready: Promise<void>
finished: Promise<void>
}
}
// Shared error handler for transition.ready rejections.
const onTransitionError = (err: any) => {
// InvalidStateError just means the document was hidden during the transition.
if (err?.name !== 'InvalidStateError') {
captureException(err, { tags: { handler: 'pdf-preview' } })
}
}
@@ -145,44 +129,31 @@ export default class PDFJSWrapper {
document.visibilityState !== 'hidden'
) {
// Chrome 126+: element-level View Transition, scoped to this container.
// Async callback holds the "before" snapshot until pagerendered fires.
// 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(onTransitionError)
} else if (
typeof doc_.startViewTransition === 'function' &&
document.visibilityState !== 'hidden'
) {
// Chrome 111+ / Edge 111+: document-level View Transition.
// Give the PDF container a unique view-transition-name so only it
// crossfades. We also suppress the auto-generated root-level animation
// (which would otherwise fade the entire page including the editor) by
// injecting a temporary <style> that sets animation:none on the root
// pseudo-elements. The style is removed after the transition finishes.
const name = 'ol-pdf-viewer'
this.container.style.viewTransitionName = name
const noRootAnim = document.createElement('style')
noRootAnim.textContent =
'::view-transition-old(root),::view-transition-new(root){animation:none}'
document.head.appendChild(noRootAnim)
const transition = doc_.startViewTransition(async () => {
setDocument()
await firstPageRendered
transition.ready.catch(err => {
if (err?.name !== 'InvalidStateError') {
captureException(err, { tags: { handler: 'pdf-preview' } })
}
})
transition.finished.finally(() => {
this.container.style.viewTransitionName = ''
noRootAnim.remove()
})
transition.ready.catch(onTransitionError)
} else {
// Firefox / Safari / very old Chromium: canvas snapshot overlay keeps
// the old content visible while setDocument() clears and repaints.
// 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.remove())
firstPageRendered.then(() => {
snap.style.transition = 'opacity 0.25s ease'
snap.style.opacity = '0'
setTimeout(() => snap.remove(), 280)
})
}
}