From 17dd108ce1c144eb9652583551b62ed1110be400 Mon Sep 17 00:00:00 2001 From: Alf Eaton Date: Mon, 11 May 2026 10:39:02 +0100 Subject: [PATCH] Wrap PDF setDocument in startViewTransition (#33346) * Set scale synchronously on pagesinit to prevent 1.333 DPI flash PDF.js resets its internal scale to 1.0 when setDocument() is called, causing pages to momentarily render at the default 96/72 DPI scale (1.333) before the React restore effect can apply the correct value. Setting currentScaleValue directly in the pagesinit handler eliminates this one-frame wrong-scale flash. * Override .page display to block to prevent horizontal jump on recompile Overleaf's global .loading class sets display:inline-flex, which collides with PDF.js's transient 'loading' class on .page elements. When the loading class is applied, inline-flex breaks margin:auto centering, causing the page to jump horizontally. Forcing display:block at higher specificity prevents the global rule from taking effect. * Fix scrollToPosition offset using marginTop instead of borderWidth scrollPageIntoView aligns the page content edge with the container top, leaving scrollTop equal to the page's top margin (12px) rather than 0. The previous correction used borderWidth (effectively 0) so the margin offset was never compensated. Using marginTop scrolls back the correct amount so the margin above the first page is visible. * Prevent PDF viewer collapsing during recompile by preserving height When setDocument() is called with a new PDF, _resetView() synchronously clears all page elements, briefly collapsing the .pdfViewer div to the viewport height. This produces a visible flicker before pagesinit fires and pages are re-added. Fix: record the current height and pin it as min-height on the .pdfViewer element before calling setDocument(). A one-shot pagesinit listener removes the constraint once the new pages are initialised at the correct scale, by which point the element is already at its correct final height. * Suppress PDF.js page-level loading spinner in Overleaf viewer The PDF.js loadingIcon/loading classes briefly add a ::after pseudo-element with display:block and contain:strict to each page div. Overleaf has its own loading state UI so the spinner is redundant, and its activation was the root cause of the shifts 4-5 height oscillation (the display change broke CSS margin collapse on .pdfViewer, adding 2x page margins to its computed height). The display:block rule already added to .page prevents the direct cause (Overleaf's .loading{display:inline-flex} colliding with the PDF.js class). This rule makes the intent explicit by zeroing the ::after entirely. * Wrap PDF setDocument in startViewTransition --------- Co-authored-by: Brian Gough GitOrigin-RevId: 353ab865de3c7872363a61592d86390dfc34dacc --- .../pdf-preview/components/pdf-js-viewer.tsx | 3 ++ .../pdf-preview/util/pdf-js-wrapper.ts | 46 +++++++++++++++---- .../stylesheets/pages/editor/pdf.scss | 14 ++++++ 3 files changed, 55 insertions(+), 8 deletions(-) diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-js-viewer.tsx b/services/web/frontend/js/features/pdf-preview/components/pdf-js-viewer.tsx index 63a3774d5c..4822cf794f 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-js-viewer.tsx +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-js-viewer.tsx @@ -114,6 +114,9 @@ function PdfJsViewer({ url, pdfFile }: PdfJsViewerProps) { } const handlePagesinit = () => { + // Set scale immediately to avoid a one-frame flash at PDF.js's default + // 1.333 scale (96/72 DPI) before the React restore effect can correct it. + pdfJsWrapper.viewer.currentScaleValue = scaleRef.current setInitialised(true) timePDFFetched = performance.now() if (document.hidden) { diff --git a/services/web/frontend/js/features/pdf-preview/util/pdf-js-wrapper.ts b/services/web/frontend/js/features/pdf-preview/util/pdf-js-wrapper.ts index 45e7a322db..730ac476da 100644 --- a/services/web/frontend/js/features/pdf-preview/util/pdf-js-wrapper.ts +++ b/services/web/frontend/js/features/pdf-preview/util/pdf-js-wrapper.ts @@ -86,8 +86,36 @@ export default class PDFJSWrapper { return } - this.viewer.setDocument(doc) - this.linkService.setDocument(doc) + // 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 container = this.container as typeof this.container & { + // supported since Chrome 147 + startViewTransition?: (cb: () => void | Promise) => unknown + } + if ( + container.startViewTransition && + typeof container.startViewTransition === 'function' + ) { + container.startViewTransition(() => { + this.viewer.setDocument(doc) + this.linkService.setDocument(doc) + }) + } else { + this.viewer.setDocument(doc) + this.linkService.setDocument(doc) + } return doc } catch (error: any) { @@ -206,14 +234,16 @@ export default class PDFJSWrapper { destArray, }) - // scroll the page left and down by an extra few pixels to account for the pdf.js viewer page border + // 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) - const offset = parseFloat(getComputedStyle(pageView.div).borderWidth) - this.viewer.container.scrollBy({ - top: -offset, - left: -offset, - }) + const marginTop = parseFloat(getComputedStyle(pageView.div).marginTop) + if (this.viewer.container.scrollTop <= marginTop) { + this.viewer.container.scrollTop = 0 + } } isVisible() { diff --git a/services/web/frontend/stylesheets/pages/editor/pdf.scss b/services/web/frontend/stylesheets/pages/editor/pdf.scss index f1a5e122a3..4a7a286705 100644 --- a/services/web/frontend/stylesheets/pages/editor/pdf.scss +++ b/services/web/frontend/stylesheets/pages/editor/pdf.scss @@ -217,6 +217,7 @@ .pdf-viewer { isolation: isolate; + view-transition-name: pdf-viewer; iframe { width: 100%; @@ -255,6 +256,7 @@ } .page { + display: block; // prevent Overleaf's .loading class (display:inline-flex) from affecting PDF.js page state box-sizing: content-box; margin: var(--spacing-05) auto; box-shadow: @@ -262,6 +264,13 @@ 0 3px 14px 0 #23282f08, 0 8px 10px 0 #23282f14; border: none; + + // Overleaf has its own loading state UI; suppress the PDF.js page-level loading + // spinner so the loadingIcon/loading classes have no layout or visual effect. + /* stylelint-disable-next-line selector-class-pattern */ + &.loadingIcon::after { + display: none; + } } .pdfjs-viewer-inner { @@ -316,6 +325,11 @@ } } +::view-transition-old(pdf-viewer), +::view-transition-new(pdf-viewer) { + animation-duration: 0.25s; +} + .pdfjs-viewer-controls { display: flex; align-items: center;