From 0a6c5eec88e2d2e3cf7834f7cabed3705bb0e442 Mon Sep 17 00:00:00 2001 From: Erik Demaine Date: Mon, 26 Oct 2026 12:02:29 -0400 Subject: [PATCH 1/7] fix(web): preserve PDF page across autoreloads --- .../AttachmentFilePreview.browser.test.tsx | 87 ++++++++++++++++--- .../components/files/BrowserDocumentFrame.tsx | 44 +++++++++- .../src/components/files/FilePreviewPanel.tsx | 1 + apps/web/src/components/files/PdfViewer.tsx | 47 ++++++++-- 4 files changed, 157 insertions(+), 22 deletions(-) diff --git a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx index ab9503aed9ed1..c182518a8de91 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx @@ -2334,19 +2334,19 @@ it("toggles a Mermaid attachment between diagram and source through the lazy pre expect(screen.container.querySelector('[role="alert"]')).toBeNull(); }); -it("navigates, searches, zooms, and selects mixed-height PDF pages", async () => { +function planningPdf( + titles = ["Quarterly outlook", "Delivery milestones", "Budget and risks"], + firstHeight = 140, +) { const objects = [ "<< /Type /Catalog /Pages 2 0 R >>", - "<< /Type /Pages /Count 3 /Kids [3 0 R 5 0 R 7 0 R] >>", + `<< /Type /Pages /Count ${titles.length} /Kids [${titles.map((_, index) => `${3 + index * 2} 0 R`).join(" ")}] >>`, ]; - for (const [index, title] of [ - "Quarterly outlook", - "Delivery milestones", - "Budget and risks", - ].entries()) { + const font = 3 + titles.length * 2; + for (const [index, title] of titles.entries()) { const text = `/GS1 gs BT /F1 24 Tf 30 ${index === 0 ? 70 : 700} Td ${index === 0 ? "(Quarter) Tj /F2 24 Tf (ly outlook) Tj" : `(${title}) Tj`} ET`; objects.push( - `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 ${index === 0 ? 140 : 792}] /Rotate ${index === 2 ? 90 : 0} /Resources << /Font << /F1 9 0 R /F2 10 0 R >> /ExtGState << /GS1 << /TR << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 2 >> >> >> >> /Contents ${4 + index * 2} 0 R >>`, + `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 ${index === 0 ? firstHeight : 792}] /Rotate ${index === 2 ? 90 : 0} /Resources << /Font << /F1 ${font} 0 R /F2 ${font + 1} 0 R >> /ExtGState << /GS1 << /TR << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 2 >> >> >> >> /Contents ${4 + index * 2} 0 R >>`, `<< /Length ${text.length} >>\nstream\n${text}\nendstream`, ); } @@ -2359,11 +2359,76 @@ it("navigates, searches, zooms, and selects mixed-height PDF pages", async () => source += `${index + 1} 0 obj\n${object}\nendobj\n`; } const xref = source.length; - source += `xref\n0 11\n0000000000 65535 f \n${offsets + source += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n${offsets .slice(1) .map((offset) => `${String(offset).padStart(10, "0")} 00000 n \n`) - .join("")}trailer\n<< /Size 11 /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF`; - const file = new Blob([source], { type: "application/pdf" }); + .join("")}trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF`; + return new Blob([source], { type: "application/pdf" }); +} + +it("keeps the PDF page after reloads and clamps it when the document gets shorter", async () => { + const { BrowserDocumentFrame } = await import("./BrowserDocumentFrame"); + using _native = vi.spyOn(navigator, "pdfViewerEnabled", "get").mockReturnValue(false); + using urls = (() => { + const original = URL.createObjectURL(planningPdf()); + const updated = URL.createObjectURL( + planningPdf(["Quarterly outlook", "Delivery milestones", "Updated budget"], 500), + ); + const shorter = URL.createObjectURL(planningPdf(["Quarterly outlook", "Updated milestones"])); + const single = URL.createObjectURL(planningPdf(["Quarterly outlook"])); + return { + original, + updated, + shorter, + single, + [Symbol.dispose]: () => [original, updated, shorter, single].forEach(URL.revokeObjectURL), + }; + })(); + const preview = (src: string, revision: string, documentId = "planning") => ( +
+ +
+ ); + await using screen = await render(preview(urls.original, "one")); + await vi.waitFor(() => expect(screen.container.textContent).toContain("1 / 3 pages"), { + timeout: 15_000, + }); + await screen.getByRole("button", { name: "Next page", exact: true }).click(); + await vi.waitFor(() => expect(screen.container.textContent).toContain("2 / 3 pages")); + await screen.getByRole("button", { name: "Next page", exact: true }).click(); + await vi.waitFor(() => expect(screen.container.textContent).toContain("3 / 3 pages")); + await screen.rerender(preview(urls.updated, "two")); + await vi.waitFor(() => { + expect(screen.container.textContent).toContain("3 / 3 pages"); + expect(screen.container.querySelector('[role="region"]')?.textContent).toContain( + "Updated budget", + ); + }); + await screen.rerender(preview(urls.shorter, "three")); + await vi.waitFor(() => { + expect(screen.container.textContent).toContain("2 / 2 pages"); + expect(screen.container.querySelector('[role="region"]')?.textContent).toContain( + "Updated milestones", + ); + }); + await screen.rerender(preview(urls.single, "four")); + await vi.waitFor(() => expect(screen.container.textContent).toContain("1 / 1 pages")); + await screen.rerender(preview(urls.original, "five")); + await vi.waitFor(() => expect(screen.container.textContent).toContain("1 / 3 pages")); + await screen.getByRole("button", { name: "Next page", exact: true }).click(); + await vi.waitFor(() => expect(screen.container.textContent).toContain("2 / 3 pages")); + await screen.rerender(preview(urls.original, "one", "another-document")); + await vi.waitFor(() => expect(screen.container.textContent).toContain("1 / 3 pages")); +}); + +it("navigates, searches, zooms, and selects mixed-height PDF pages", async () => { + const file = planningPdf(); const { default: PdfViewer } = await import("./PdfViewer"); const url = URL.createObjectURL(file); try { diff --git a/apps/web/src/components/files/BrowserDocumentFrame.tsx b/apps/web/src/components/files/BrowserDocumentFrame.tsx index 757d37a783f01..bc09e7f746c9e 100644 --- a/apps/web/src/components/files/BrowserDocumentFrame.tsx +++ b/apps/web/src/components/files/BrowserDocumentFrame.tsx @@ -73,7 +73,33 @@ export const isPdfPreviewFile = (path: string): boolean => const PdfViewer = lazy(() => import("./PdfViewer")); -function PdfDocument(props: { src: string; title: string }) { +/** + * documentKey includes the file's revision and remounts the viewer when it changes. + * A stable documentId keeps this component mounted to track the current page number across reloads. + */ +function PdfDocument(props: { src: string; title: string; documentKey: string }) { + const [page, setPage] = useState(0); + return ( + + ); +} + +/** + * File URLs expire and are refreshed even when the PDF hasn't changed. + * Keep the initial URL so those refreshes don't reload the viewer. + */ +function PdfDocumentRevision(props: { + src: string; + title: string; + initialPage: number; + onPageChange: (page: number) => void; +}) { const [src] = useState(props.src); // The Electron shell does not enable PDF plugins. WebKit embeds on iOS // expose only the first page, even when pdfViewerEnabled reports support. @@ -91,7 +117,12 @@ function PdfDocument(props: { src: string; title: string }) { /> ) : ( }> - + ); } @@ -108,10 +139,17 @@ export function BrowserDocumentFrame(props: { readonly threadRef?: ScopedThreadRef | undefined; /** Stable resource identity and revision; defaults to src for callers that reload on URL changes. */ readonly documentKey?: string | undefined; + /** Resource identity without its revision, so PDF reloads can retain the page. */ + readonly documentId?: string | undefined; }) { const key = props.documentKey ?? props.src; return props.pdf ? ( - + ) : props.htmlRender ? ( diff --git a/apps/web/src/components/files/PdfViewer.tsx b/apps/web/src/components/files/PdfViewer.tsx index 70f005a532ed5..234f71ae45f0f 100644 --- a/apps/web/src/components/files/PdfViewer.tsx +++ b/apps/web/src/components/files/PdfViewer.tsx @@ -116,7 +116,12 @@ function PdfPage(props: { ); } -export default function PdfViewer(props: { src: string; title: string }) { +export default function PdfViewer(props: { + src: string; + title: string; + initialPage?: number; + onPageChange?: (page: number) => void; +}) { const [pdf, setPdf] = useState(null); const [error, setError] = useState(null); const [revision, setRevision] = useState(0); @@ -133,6 +138,7 @@ export default function PdfViewer(props: { src: string; title: string }) { const scroll = useRef(null); const find = useRef(null); const zoomAnchor = useRef<{ page: number; offset: number; width: number } | null>(null); + const [initialPage] = useState(props.initialPage ?? 0); // oxlint-disable-next-line t3code/react-effect-budget -- Own the PDF fetch, load timeout, and pdf.js document task across source changes, retries, and unmounts. useEffect(() => { const controller = new AbortController(); @@ -156,9 +162,20 @@ export default function PdfViewer(props: { src: string; title: string }) { if (document.numPages > MAX_PAGES) throw new Error("Preview limited to 2,000 pages. Save this PDF to read it in full."); const first = (await document.getPage(1)).getViewport({ scale: 1 }); + const sizes = await Promise.all( + Array.from({ length: Math.min(initialPage, document.numPages) }, async (_, index) => { + const size = (await document.getPage(index + 1)).getViewport({ scale: 1 }); + return size.height / size.width; + }), + ); if (controller.signal.aborted) return; setPageWidth(first.width); - setRatios(Array.from({ length: document.numPages }, () => first.height / first.width)); + setRatios( + Array.from( + { length: document.numPages }, + (_, index) => sizes[index] ?? first.height / first.width, + ), + ); setPdf(document); }) .catch((cause: unknown) => { @@ -172,7 +189,7 @@ export default function PdfViewer(props: { src: string; title: string }) { clearTimeout(timeout); void task?.destroy(); }; - }, [props.src, revision]); + }, [props.src, revision, initialPage]); // oxlint-disable-next-line t3code/react-effect-budget -- Measure the PDF viewport before paint and observe resizes for fit-width sizing and visible-page selection. useLayoutEffect(() => { const node = scroll.current; @@ -189,10 +206,12 @@ export default function PdfViewer(props: { src: string; title: string }) { for (const ratio of ratios) result.push(result[result.length - 1]! + width * ratio + 16); return result; })(); - const current = Math.max( - 0, - ratios.findIndex((_, index) => offsets[index + 1]! > scrollTop + 17), - ); + const pageAtOffset = (top: number) => + Math.max( + 0, + ratios.findIndex((_, index) => offsets[index + 1]! > top + 17), + ); + const current = pageAtOffset(scrollTop); const visible = ratios.flatMap((ratio, index) => offsets[index + 1]! >= scrollTop - viewport.height && offsets[index]! <= scrollTop + viewport.height * 2 @@ -209,6 +228,14 @@ export default function PdfViewer(props: { src: string; title: string }) { const jumpToPage = useEffectEvent((index: number) => { scroll.current?.scrollTo({ top: Math.max(0, (offsets[index] ?? 16) - 16) }); }); + const { onPageChange } = props; + // oxlint-disable-next-line t3code/react-effect-budget -- Restore the saved PDF page after the loaded document's page layout commits. + useLayoutEffect(() => { + if (!pdf) return; + const page = Math.min(initialPage, pdf.numPages - 1); + jumpToPage(page); + onPageChange?.(page); + }, [pdf, initialPage, onPageChange]); const showPage = (index: number) => { scroll.current?.scrollTo({ top: Math.max(0, (offsets[index] ?? 16) - 16) }); }; @@ -371,7 +398,11 @@ export default function PdfViewer(props: { src: string; title: string }) { aria-label={props.title} tabIndex={0} className="relative min-h-0 min-w-0 w-full flex-1 overflow-auto bg-muted/40" - onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)} + onScroll={(event) => { + const top = event.currentTarget.scrollTop; + setScrollTop(top); + props.onPageChange?.(pageAtOffset(top)); + }} onKeyDown={(event) => { if ((event.ctrlKey || event.metaKey) && event.key === "f") { event.preventDefault(); From 884c699d7b7c976785c38c626511f89c70d4f1d2 Mon Sep 17 00:00:00 2001 From: Erik Demaine Date: Mon, 26 Oct 2026 12:59:49 -0400 Subject: [PATCH 2/7] test(web): cover workspace PDF autoreload path --- .../AttachmentFilePreview.browser.test.tsx | 38 ++++++++++++------- .../src/components/files/FilePreviewPanel.tsx | 2 +- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx index c182518a8de91..f30aa6f4e537c 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx @@ -2366,8 +2366,9 @@ function planningPdf( return new Blob([source], { type: "application/pdf" }); } -it("keeps the PDF page after reloads and clamps it when the document gets shorter", async () => { - const { BrowserDocumentFrame } = await import("./BrowserDocumentFrame"); +it("keeps the workspace PDF page after reloads and clamps it when the document gets shorter", async () => { + assetUrlState.mockReset(); + const { WorkspaceBrowserPreview } = await import("./FilePreviewPanel"); using _native = vi.spyOn(navigator, "pdfViewerEnabled", "get").mockReturnValue(false); using urls = (() => { const original = URL.createObjectURL(planningPdf()); @@ -2384,17 +2385,26 @@ it("keeps the PDF page after reloads and clamps it when the document gets shorte [Symbol.dispose]: () => [original, updated, shorter, single].forEach(URL.revokeObjectURL), }; })(); - const preview = (src: string, revision: string, documentId = "planning") => ( -
- -
- ); + const preview = (src: string, mutation: string, path = "/repo/planning.pdf") => { + assetUrlState.mockReturnValue({ + _tag: "Success", + url: `${src}#preview`, + expiresAt: Date.now() + 3_600_000, + }); + return ( +
+ +
+ ); + }; await using screen = await render(preview(urls.original, "one")); await vi.waitFor(() => expect(screen.container.textContent).toContain("1 / 3 pages"), { timeout: 15_000, @@ -2423,7 +2433,7 @@ it("keeps the PDF page after reloads and clamps it when the document gets shorte await vi.waitFor(() => expect(screen.container.textContent).toContain("1 / 3 pages")); await screen.getByRole("button", { name: "Next page", exact: true }).click(); await vi.waitFor(() => expect(screen.container.textContent).toContain("2 / 3 pages")); - await screen.rerender(preview(urls.original, "one", "another-document")); + await screen.rerender(preview(urls.original, "one", "/repo/another.pdf")); await vi.waitFor(() => expect(screen.container.textContent).toContain("1 / 3 pages")); }); diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 0489058ea2bb1..ff63cff528446 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -217,7 +217,7 @@ function WorkspaceImagePreview(props: { ); } -function WorkspaceBrowserPreview(props: { +export function WorkspaceBrowserPreview(props: { readonly environmentId: EnvironmentId; readonly threadRef: ScopedThreadRef; readonly draft: boolean; From 4c1ada4cf15422443981de15543f0b76129e79da Mon Sep 17 00:00:00 2001 From: Erik Demaine Date: Mon, 26 Oct 2026 13:09:02 -0400 Subject: [PATCH 3/7] fix(web): preserve PDF scroll and zoom across autoreloads --- .../AttachmentFilePreview.browser.test.tsx | 70 +++++++++++++++++-- .../components/files/BrowserDocumentFrame.tsx | 19 ++--- apps/web/src/components/files/PdfViewer.tsx | 64 ++++++++++++----- 3 files changed, 123 insertions(+), 30 deletions(-) diff --git a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx index f30aa6f4e537c..93d45dd6bf904 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx @@ -2337,6 +2337,7 @@ it("toggles a Mermaid attachment between diagram and source through the lazy pre function planningPdf( titles = ["Quarterly outlook", "Delivery milestones", "Budget and risks"], firstHeight = 140, + pageHeight = 792, ) { const objects = [ "<< /Type /Catalog /Pages 2 0 R >>", @@ -2346,7 +2347,7 @@ function planningPdf( for (const [index, title] of titles.entries()) { const text = `/GS1 gs BT /F1 24 Tf 30 ${index === 0 ? 70 : 700} Td ${index === 0 ? "(Quarter) Tj /F2 24 Tf (ly outlook) Tj" : `(${title}) Tj`} ET`; objects.push( - `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 ${index === 0 ? firstHeight : 792}] /Rotate ${index === 2 ? 90 : 0} /Resources << /Font << /F1 ${font} 0 R /F2 ${font + 1} 0 R >> /ExtGState << /GS1 << /TR << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 2 >> >> >> >> /Contents ${4 + index * 2} 0 R >>`, + `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 ${index === 0 ? firstHeight : pageHeight}] /Rotate ${index === 2 ? 90 : 0} /Resources << /Font << /F1 ${font} 0 R /F2 ${font + 1} 0 R >> /ExtGState << /GS1 << /TR << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 2 >> >> >> >> /Contents ${4 + index * 2} 0 R >>`, `<< /Length ${text.length} >>\nstream\n${text}\nendstream`, ); } @@ -2366,7 +2367,7 @@ function planningPdf( return new Blob([source], { type: "application/pdf" }); } -it("keeps the workspace PDF page after reloads and clamps it when the document gets shorter", async () => { +it("keeps workspace PDF scroll and zoom after reloads and clamps them when the document shrinks", async () => { assetUrlState.mockReset(); const { WorkspaceBrowserPreview } = await import("./FilePreviewPanel"); using _native = vi.spyOn(navigator, "pdfViewerEnabled", "get").mockReturnValue(false); @@ -2376,13 +2377,18 @@ it("keeps the workspace PDF page after reloads and clamps it when the document g planningPdf(["Quarterly outlook", "Delivery milestones", "Updated budget"], 500), ); const shorter = URL.createObjectURL(planningPdf(["Quarterly outlook", "Updated milestones"])); + const shrunk = URL.createObjectURL( + planningPdf(["Quarterly outlook", "Small page", "Budget and risks"], 500, 200), + ); const single = URL.createObjectURL(planningPdf(["Quarterly outlook"])); return { original, updated, shorter, + shrunk, single, - [Symbol.dispose]: () => [original, updated, shorter, single].forEach(URL.revokeObjectURL), + [Symbol.dispose]: () => + [original, updated, shorter, shrunk, single].forEach(URL.revokeObjectURL), }; })(); const preview = (src: string, mutation: string, path = "/repo/planning.pdf") => { @@ -2413,12 +2419,53 @@ it("keeps the workspace PDF page after reloads and clamps it when the document g await vi.waitFor(() => expect(screen.container.textContent).toContain("2 / 3 pages")); await screen.getByRole("button", { name: "Next page", exact: true }).click(); await vi.waitFor(() => expect(screen.container.textContent).toContain("3 / 3 pages")); + for (let index = 0; index < 4; index++) { + await screen.getByRole("button", { name: "Zoom in", exact: true }).click(); + } + const region = () => screen.container.querySelector('[role="region"]')!; + const canvas = (page: number) => + region().querySelector(`canvas[aria-label="Page ${page}"]`)!; + const offset = (page: number) => + region().getBoundingClientRect().top - canvas(page).getBoundingClientRect().top; + const scrollTo = (options: ScrollToOptions) => + act(async () => { + const node = region(); + const scrolled = new Promise((resolve) => + node.addEventListener("scroll", () => resolve(), { once: true }), + ); + node.scrollTo(options); + await scrolled; + }); + const zoom = screen.container.textContent!.match(/\d+%/)![0]; + const top = region().scrollTop; + await scrollTo({ top: top + 100, left: 80 }); + await vi.waitFor(() => { + expect(region().scrollTop).toBeCloseTo(top + 100, 0); + expect(region().scrollLeft).toBe(80); + }); + const savedOffset = offset(3); await screen.rerender(preview(urls.updated, "two")); await vi.waitFor(() => { expect(screen.container.textContent).toContain("3 / 3 pages"); expect(screen.container.querySelector('[role="region"]')?.textContent).toContain( "Updated budget", ); + expect(screen.container.textContent).toContain(zoom); + expect(region().scrollLeft).toBe(80); + expect(offset(3)).toBeCloseTo(savedOffset, 0); + }); + await screen.getByRole("button", { name: "Previous page", exact: true }).click(); + await vi.waitFor(() => expect(screen.container.textContent).toContain("2 / 3 pages")); + const secondPageTop = region().scrollTop; + await scrollTo({ top: secondPageTop + 600 }); + await vi.waitFor(() => expect(region().scrollTop).toBeCloseTo(secondPageTop + 600, 0)); + await screen.rerender(preview(urls.shrunk, "smaller-page")); + await vi.waitFor(() => { + expect(screen.container.textContent).toContain("2 / 3 pages"); + expect(screen.container.textContent).toContain(zoom); + expect(region().scrollLeft).toBe(80); + expect(offset(2)).toBeGreaterThan(0); + expect(offset(2)).toBeLessThan(canvas(2).getBoundingClientRect().height); }); await screen.rerender(preview(urls.shorter, "three")); await vi.waitFor(() => { @@ -2431,10 +2478,25 @@ it("keeps the workspace PDF page after reloads and clamps it when the document g await vi.waitFor(() => expect(screen.container.textContent).toContain("1 / 1 pages")); await screen.rerender(preview(urls.original, "five")); await vi.waitFor(() => expect(screen.container.textContent).toContain("1 / 3 pages")); + await screen.getByRole("button", { name: "Fit width", exact: true }).click(); + await screen.rerender(preview(urls.original, "six")); + await vi.waitFor(() => { + expect(screen.container.textContent).toContain("Fit width"); + expect(region().scrollLeft).toBe(0); + }); + await screen.getByRole("button", { name: "Zoom in", exact: true }).click(); + const firstPageZoom = screen.container.textContent!.match(/\d+%/)![0]; + await screen.rerender(preview(urls.original, "seven")); + await vi.waitFor(() => expect(screen.container.textContent).toContain(firstPageZoom)); await screen.getByRole("button", { name: "Next page", exact: true }).click(); await vi.waitFor(() => expect(screen.container.textContent).toContain("2 / 3 pages")); await screen.rerender(preview(urls.original, "one", "/repo/another.pdf")); - await vi.waitFor(() => expect(screen.container.textContent).toContain("1 / 3 pages")); + await vi.waitFor(() => { + expect(screen.container.textContent).toContain("1 / 3 pages"); + expect(screen.container.textContent).toContain("Fit width"); + expect(region().scrollTop).toBe(0); + expect(region().scrollLeft).toBe(0); + }); }); it("navigates, searches, zooms, and selects mixed-height PDF pages", async () => { diff --git a/apps/web/src/components/files/BrowserDocumentFrame.tsx b/apps/web/src/components/files/BrowserDocumentFrame.tsx index bc09e7f746c9e..0a7d12b650a84 100644 --- a/apps/web/src/components/files/BrowserDocumentFrame.tsx +++ b/apps/web/src/components/files/BrowserDocumentFrame.tsx @@ -33,6 +33,7 @@ import { } from "react"; import { isElectron } from "~/env"; import { FileSurfaceLoading } from "./fileSurfaceChrome"; +import type { PdfViewState } from "./PdfViewer"; import { useOpenLink } from "~/browser/useOpenLink"; import { ExpandedImageDialog } from "~/components/chat/ExpandedImageDialog"; @@ -75,17 +76,17 @@ const PdfViewer = lazy(() => import("./PdfViewer")); /** * documentKey includes the file's revision and remounts the viewer when it changes. - * A stable documentId keeps this component mounted to track the current page number across reloads. + * A stable documentId keeps this component mounted to track the page, scroll, and zoom across reloads. */ function PdfDocument(props: { src: string; title: string; documentKey: string }) { - const [page, setPage] = useState(0); + const [view, setView] = useState({ page: 0, offset: 0, zoom: null, left: 0 }); return ( ); } @@ -97,8 +98,8 @@ function PdfDocument(props: { src: string; title: string; documentKey: string }) function PdfDocumentRevision(props: { src: string; title: string; - initialPage: number; - onPageChange: (page: number) => void; + initialView: PdfViewState; + onViewChange: (view: PdfViewState) => void; }) { const [src] = useState(props.src); // The Electron shell does not enable PDF plugins. WebKit embeds on iOS @@ -120,8 +121,8 @@ function PdfDocumentRevision(props: { ); @@ -139,7 +140,7 @@ export function BrowserDocumentFrame(props: { readonly threadRef?: ScopedThreadRef | undefined; /** Stable resource identity and revision; defaults to src for callers that reload on URL changes. */ readonly documentKey?: string | undefined; - /** Resource identity without its revision, so PDF reloads can retain the page. */ + /** Resource identity without its revision, so PDF reloads can retain the reading position. */ readonly documentId?: string | undefined; }) { const key = props.documentKey ?? props.src; diff --git a/apps/web/src/components/files/PdfViewer.tsx b/apps/web/src/components/files/PdfViewer.tsx index 234f71ae45f0f..d561e6f527ee6 100644 --- a/apps/web/src/components/files/PdfViewer.tsx +++ b/apps/web/src/components/files/PdfViewer.tsx @@ -18,6 +18,13 @@ import "./pdfViewer.css"; const MAX_PAGES = 2000; const MAX_FIND_TEXT = 4 * 1024 * 1024; +export type PdfViewState = { + page: number; + offset: number; + zoom: number | null; + left: number; +}; + function PdfPage(props: { document: PDFDocumentProxy; index: number; @@ -119,14 +126,17 @@ function PdfPage(props: { export default function PdfViewer(props: { src: string; title: string; - initialPage?: number; - onPageChange?: (page: number) => void; + initialView?: PdfViewState; + onViewChange?: (view: PdfViewState) => void; }) { + const [initialView] = useState( + () => props.initialView ?? { page: 0, offset: 0, zoom: null, left: 0 }, + ); const [pdf, setPdf] = useState(null); const [error, setError] = useState(null); const [revision, setRevision] = useState(0); const [ratios, setRatios] = useState([]); - const [zoom, setZoom] = useState(null); + const [zoom, setZoom] = useState(initialView.zoom); const [pageWidth, setPageWidth] = useState(612); const [viewport, setViewport] = useState({ width: 600, height: 800 }); const [scrollTop, setScrollTop] = useState(0); @@ -138,7 +148,7 @@ export default function PdfViewer(props: { const scroll = useRef(null); const find = useRef(null); const zoomAnchor = useRef<{ page: number; offset: number; width: number } | null>(null); - const [initialPage] = useState(props.initialPage ?? 0); + const initialPage = initialView.page; // oxlint-disable-next-line t3code/react-effect-budget -- Own the PDF fetch, load timeout, and pdf.js document task across source changes, retries, and unmounts. useEffect(() => { const controller = new AbortController(); @@ -163,7 +173,7 @@ export default function PdfViewer(props: { throw new Error("Preview limited to 2,000 pages. Save this PDF to read it in full."); const first = (await document.getPage(1)).getViewport({ scale: 1 }); const sizes = await Promise.all( - Array.from({ length: Math.min(initialPage, document.numPages) }, async (_, index) => { + Array.from({ length: Math.min(initialPage + 1, document.numPages) }, async (_, index) => { const size = (await document.getPage(index + 1)).getViewport({ scale: 1 }); return size.height / size.width; }), @@ -228,14 +238,30 @@ export default function PdfViewer(props: { const jumpToPage = useEffectEvent((index: number) => { scroll.current?.scrollTo({ top: Math.max(0, (offsets[index] ?? 16) - 16) }); }); - const { onPageChange } = props; - // oxlint-disable-next-line t3code/react-effect-budget -- Restore the saved PDF page after the loaded document's page layout commits. - useLayoutEffect(() => { - if (!pdf) return; + const saveView = (node: HTMLDivElement) => { + const page = pageAtOffset(node.scrollTop); + props.onViewChange?.({ + page, + offset: Math.max(0, node.scrollTop - (offsets[page]! - 16)), + zoom, + left: node.scrollLeft, + }); + }; + const restoreView = useEffectEvent(() => { + const node = scroll.current; + if (!pdf || !node) return; const page = Math.min(initialPage, pdf.numPages - 1); - jumpToPage(page); - onPageChange?.(page); - }, [pdf, initialPage, onPageChange]); + node.scrollTo({ + top: + offsets[page]! - 16 + Math.min(initialView.offset, Math.max(0, width * ratios[page]! - 1)), + left: initialView.left, + }); + saveView(node); + }); + // oxlint-disable-next-line t3code/react-effect-budget -- Restore the saved PDF scroll position after the loaded document's page layout commits. + useLayoutEffect(() => { + if (pdf) restoreView(); + }, [pdf]); const showPage = (index: number) => { scroll.current?.scrollTo({ top: Math.max(0, (offsets[index] ?? 16) - 16) }); }; @@ -251,10 +277,14 @@ export default function PdfViewer(props: { useLayoutEffect(() => { const anchor = zoomAnchor.current; zoomAnchor.current = null; - if (!anchor || anchor.width === width) return; - scroll.current?.scrollTo({ - top: offsets[anchor.page]! - 16 + (anchor.offset * width) / anchor.width, - }); + const node = scroll.current; + if (!anchor || !node) return; + if (anchor.width !== width) { + node.scrollTo({ + top: offsets[anchor.page]! - 16 + (anchor.offset * width) / anchor.width, + }); + } + saveView(node); }); // oxlint-disable-next-line t3code/react-effect-budget -- Scroll to the first asynchronous search result after its measured page offsets have committed. useLayoutEffect(() => { @@ -401,7 +431,7 @@ export default function PdfViewer(props: { onScroll={(event) => { const top = event.currentTarget.scrollTop; setScrollTop(top); - props.onPageChange?.(pageAtOffset(top)); + saveView(event.currentTarget); }} onKeyDown={(event) => { if ((event.ctrlKey || event.metaKey) && event.key === "f") { From 57488dd65aaceb50aaea73013663cab2cf8be622 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 27 Oct 2026 02:29:22 +0000 Subject: [PATCH 4/7] refactor(web): keep PDF reload coverage focused --- .../files/AttachmentFilePreview.browser.test.tsx | 4 +--- apps/web/src/components/files/BrowserDocumentFrame.tsx | 10 ++-------- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx index 882ed9b168140..aa24241ea28c3 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx @@ -2399,9 +2399,7 @@ it("keeps workspace PDF scroll and zoom after reloads and clamps them when the d ); }; await using screen = await render(preview(urls.original, "one")); - await vi.waitFor(() => expect(screen.container.textContent).toContain("1 / 3 pages"), { - timeout: 15_000, - }); + await expect.element(screen.getByText("1 / 3 pages", { exact: true })).toBeVisible(); await screen.getByRole("button", { name: "Next page", exact: true }).click(); await vi.waitFor(() => expect(screen.container.textContent).toContain("2 / 3 pages")); await screen.getByRole("button", { name: "Next page", exact: true }).click(); diff --git a/apps/web/src/components/files/BrowserDocumentFrame.tsx b/apps/web/src/components/files/BrowserDocumentFrame.tsx index 0a7d12b650a84..efe29c78e343e 100644 --- a/apps/web/src/components/files/BrowserDocumentFrame.tsx +++ b/apps/web/src/components/files/BrowserDocumentFrame.tsx @@ -74,10 +74,7 @@ export const isPdfPreviewFile = (path: string): boolean => const PdfViewer = lazy(() => import("./PdfViewer")); -/** - * documentKey includes the file's revision and remounts the viewer when it changes. - * A stable documentId keeps this component mounted to track the page, scroll, and zoom across reloads. - */ +// The document stays mounted while each revision gets a fresh PDF.js document. function PdfDocument(props: { src: string; title: string; documentKey: string }) { const [view, setView] = useState({ page: 0, offset: 0, zoom: null, left: 0 }); return ( @@ -91,10 +88,7 @@ function PdfDocument(props: { src: string; title: string; documentKey: string }) ); } -/** - * File URLs expire and are refreshed even when the PDF hasn't changed. - * Keep the initial URL so those refreshes don't reload the viewer. - */ +// Keep signed URL renewals from reloading the same revision. function PdfDocumentRevision(props: { src: string; title: string; From 57d3363583a6c0b933f95579d9eef4ff69efa469 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 27 Oct 2026 02:40:05 +0000 Subject: [PATCH 5/7] fix(web): keep fit-width PDF sizing stable during reloads --- .../files/AttachmentFilePreview.browser.test.tsx | 8 ++++++++ apps/web/src/components/files/PdfViewer.tsx | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx index aa24241ea28c3..899921f679fc8 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx @@ -2464,11 +2464,19 @@ it("keeps workspace PDF scroll and zoom after reloads and clamps them when the d await screen.rerender(preview(urls.original, "five")); await vi.waitFor(() => expect(screen.container.textContent).toContain("1 / 3 pages")); await screen.getByRole("button", { name: "Fit width", exact: true }).click(); + await screen.getByRole("button", { name: "Next page", exact: true }).click(); + await vi.waitFor(() => expect(screen.container.textContent).toContain("2 / 3 pages")); + await scrollTo({ top: region().scrollTop + 30 }); + const fitOffset = offset(2); await screen.rerender(preview(urls.original, "six")); await vi.waitFor(() => { expect(screen.container.textContent).toContain("Fit width"); expect(region().scrollLeft).toBe(0); + expect(screen.container.textContent).toContain("2 / 3 pages"); + expect(offset(2)).toBeCloseTo(fitOffset, 0); }); + await screen.getByRole("button", { name: "Previous page", exact: true }).click(); + await vi.waitFor(() => expect(screen.container.textContent).toContain("1 / 3 pages")); await screen.getByRole("button", { name: "Zoom in", exact: true }).click(); const firstPageZoom = screen.container.textContent!.match(/\d+%/)![0]; await screen.rerender(preview(urls.original, "seven")); diff --git a/apps/web/src/components/files/PdfViewer.tsx b/apps/web/src/components/files/PdfViewer.tsx index ec49807f2baa2..ca089047c837b 100644 --- a/apps/web/src/components/files/PdfViewer.tsx +++ b/apps/web/src/components/files/PdfViewer.tsx @@ -409,7 +409,7 @@ export default function PdfViewer(props: { role="region" aria-label={props.title} tabIndex={0} - className="relative min-h-0 min-w-0 w-full flex-1 overflow-auto bg-muted/40" + className="relative min-h-0 min-w-0 w-full flex-1 overflow-auto scrollbar-gutter-stable bg-muted/40" onScroll={(event) => { const top = event.currentTarget.scrollTop; setScrollTop(top); From f67ddee448a0ecb80211c9e2620ab2a741ef0cf1 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Fri, 30 Oct 2026 22:30:31 +0000 Subject: [PATCH 6/7] fix(web): await PDF reloads in preview browser test --- .../files/AttachmentFilePreview.browser.test.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx index a7451502cd4b3..6c941e3ece58b 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx @@ -2520,8 +2520,8 @@ it("keeps workspace PDF scroll and zoom after reloads and clamps them when the d }); const savedOffset = offset(3); await screen.rerender(preview(urls.updated, "two")); + await expect.element(screen.getByText("3 / 3 pages", { exact: true })).toBeVisible(); await vi.waitFor(() => { - expect(screen.container.textContent).toContain("3 / 3 pages"); expect(screen.container.querySelector('[role="region"]')?.textContent).toContain( "Updated budget", ); @@ -2535,34 +2535,34 @@ it("keeps workspace PDF scroll and zoom after reloads and clamps them when the d await scrollTo({ top: secondPageTop + 600 }); await vi.waitFor(() => expect(region().scrollTop).toBeCloseTo(secondPageTop + 600, 0)); await screen.rerender(preview(urls.shrunk, "smaller-page")); + await expect.element(screen.getByText("2 / 3 pages", { exact: true })).toBeVisible(); await vi.waitFor(() => { - expect(screen.container.textContent).toContain("2 / 3 pages"); expect(screen.container.textContent).toContain(zoom); expect(region().scrollLeft).toBe(80); expect(offset(2)).toBeGreaterThan(0); expect(offset(2)).toBeLessThan(canvas(2).getBoundingClientRect().height); }); await screen.rerender(preview(urls.shorter, "three")); + await expect.element(screen.getByText("2 / 2 pages", { exact: true })).toBeVisible(); await vi.waitFor(() => { - expect(screen.container.textContent).toContain("2 / 2 pages"); expect(screen.container.querySelector('[role="region"]')?.textContent).toContain( "Updated milestones", ); }); await screen.rerender(preview(urls.single, "four")); - await vi.waitFor(() => expect(screen.container.textContent).toContain("1 / 1 pages")); + await expect.element(screen.getByText("1 / 1 pages", { exact: true })).toBeVisible(); await screen.rerender(preview(urls.original, "five")); - await vi.waitFor(() => expect(screen.container.textContent).toContain("1 / 3 pages")); + await expect.element(screen.getByText("1 / 3 pages", { exact: true })).toBeVisible(); await screen.getByRole("button", { name: "Fit width", exact: true }).click(); await screen.getByRole("button", { name: "Next page", exact: true }).click(); await vi.waitFor(() => expect(screen.container.textContent).toContain("2 / 3 pages")); await scrollTo({ top: region().scrollTop + 30 }); const fitOffset = offset(2); await screen.rerender(preview(urls.original, "six")); + await expect.element(screen.getByText("2 / 3 pages", { exact: true })).toBeVisible(); await vi.waitFor(() => { expect(screen.container.textContent).toContain("Fit width"); expect(region().scrollLeft).toBe(0); - expect(screen.container.textContent).toContain("2 / 3 pages"); expect(offset(2)).toBeCloseTo(fitOffset, 0); }); await screen.getByRole("button", { name: "Previous page", exact: true }).click(); @@ -2570,12 +2570,13 @@ it("keeps workspace PDF scroll and zoom after reloads and clamps them when the d await screen.getByRole("button", { name: "Zoom in", exact: true }).click(); const firstPageZoom = screen.container.textContent!.match(/\d+%/)![0]; await screen.rerender(preview(urls.original, "seven")); + await expect.element(screen.getByText("1 / 3 pages", { exact: true })).toBeVisible(); await vi.waitFor(() => expect(screen.container.textContent).toContain(firstPageZoom)); await screen.getByRole("button", { name: "Next page", exact: true }).click(); await vi.waitFor(() => expect(screen.container.textContent).toContain("2 / 3 pages")); await screen.rerender(preview(urls.original, "one", "/repo/another.pdf")); + await expect.element(screen.getByText("1 / 3 pages", { exact: true })).toBeVisible(); await vi.waitFor(() => { - expect(screen.container.textContent).toContain("1 / 3 pages"); expect(screen.container.textContent).toContain("Fit width"); expect(region().scrollTop).toBe(0); expect(region().scrollLeft).toBe(0); From 759aa5b7c0d3af7835ccf9f969dbbf67a0a2288f Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Fri, 30 Oct 2026 22:57:03 +0000 Subject: [PATCH 7/7] test(web): split PDF reload and shrink browser checks --- .../AttachmentFilePreview.browser.test.tsx | 106 +++++++++++------- 1 file changed, 68 insertions(+), 38 deletions(-) diff --git a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx index 6c941e3ece58b..fc5a1aa7b91f9 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx @@ -2444,11 +2444,11 @@ function planningPdf( return new Blob([source], { type: "application/pdf" }); } -it("keeps workspace PDF scroll and zoom after reloads and clamps them when the document shrinks", async () => { +async function workspacePdfPreview() { assetUrlState.mockReset(); const { WorkspaceBrowserPreview } = await import("./FilePreviewPanel"); - using _native = vi.spyOn(navigator, "pdfViewerEnabled", "get").mockReturnValue(false); - using urls = (() => { + const native = vi.spyOn(navigator, "pdfViewerEnabled", "get").mockReturnValue(false); + const urls = (() => { const original = URL.createObjectURL(planningPdf()); const updated = URL.createObjectURL( planningPdf(["Quarterly outlook", "Delivery milestones", "Updated budget"], 500), @@ -2488,15 +2488,17 @@ it("keeps workspace PDF scroll and zoom after reloads and clamps them when the d ); }; - await using screen = await render(preview(urls.original, "one")); - await expect.element(screen.getByText("1 / 3 pages", { exact: true })).toBeVisible(); - await screen.getByRole("button", { name: "Next page", exact: true }).click(); - await vi.waitFor(() => expect(screen.container.textContent).toContain("2 / 3 pages")); - await screen.getByRole("button", { name: "Next page", exact: true }).click(); - await vi.waitFor(() => expect(screen.container.textContent).toContain("3 / 3 pages")); - for (let index = 0; index < 4; index++) { - await screen.getByRole("button", { name: "Zoom in", exact: true }).click(); - } + return { + preview, + urls, + [Symbol.dispose]() { + native.mockRestore(); + urls[Symbol.dispose](); + }, + }; +} + +function pdfScroll(screen: Screen) { const region = () => screen.container.querySelector('[role="region"]')!; const canvas = (page: number) => region().querySelector(`canvas[aria-label="Page ${page}"]`)!; @@ -2511,6 +2513,22 @@ it("keeps workspace PDF scroll and zoom after reloads and clamps them when the d node.scrollTo(options); await scrolled; }); + return { region, canvas, offset, scrollTo }; +} + +it("keeps workspace PDF scroll and zoom after reloads", async () => { + using fixture = await workspacePdfPreview(); + const { preview, urls } = fixture; + await using screen = await render(preview(urls.original, "one")); + await expect.element(screen.getByText("1 / 3 pages", { exact: true })).toBeVisible(); + await screen.getByRole("button", { name: "Next page", exact: true }).click(); + await vi.waitFor(() => expect(screen.container.textContent).toContain("2 / 3 pages")); + await screen.getByRole("button", { name: "Next page", exact: true }).click(); + await vi.waitFor(() => expect(screen.container.textContent).toContain("3 / 3 pages")); + for (let index = 0; index < 4; index++) { + await screen.getByRole("button", { name: "Zoom in", exact: true }).click(); + } + const { region, offset, scrollTo } = pdfScroll(screen); const zoom = screen.container.textContent!.match(/\d+%/)![0]; const top = region().scrollTop; await scrollTo({ top: top + 100, left: 80 }); @@ -2531,34 +2549,10 @@ it("keeps workspace PDF scroll and zoom after reloads and clamps them when the d }); await screen.getByRole("button", { name: "Previous page", exact: true }).click(); await vi.waitFor(() => expect(screen.container.textContent).toContain("2 / 3 pages")); - const secondPageTop = region().scrollTop; - await scrollTo({ top: secondPageTop + 600 }); - await vi.waitFor(() => expect(region().scrollTop).toBeCloseTo(secondPageTop + 600, 0)); - await screen.rerender(preview(urls.shrunk, "smaller-page")); - await expect.element(screen.getByText("2 / 3 pages", { exact: true })).toBeVisible(); - await vi.waitFor(() => { - expect(screen.container.textContent).toContain(zoom); - expect(region().scrollLeft).toBe(80); - expect(offset(2)).toBeGreaterThan(0); - expect(offset(2)).toBeLessThan(canvas(2).getBoundingClientRect().height); - }); - await screen.rerender(preview(urls.shorter, "three")); - await expect.element(screen.getByText("2 / 2 pages", { exact: true })).toBeVisible(); - await vi.waitFor(() => { - expect(screen.container.querySelector('[role="region"]')?.textContent).toContain( - "Updated milestones", - ); - }); - await screen.rerender(preview(urls.single, "four")); - await expect.element(screen.getByText("1 / 1 pages", { exact: true })).toBeVisible(); - await screen.rerender(preview(urls.original, "five")); - await expect.element(screen.getByText("1 / 3 pages", { exact: true })).toBeVisible(); await screen.getByRole("button", { name: "Fit width", exact: true }).click(); - await screen.getByRole("button", { name: "Next page", exact: true }).click(); - await vi.waitFor(() => expect(screen.container.textContent).toContain("2 / 3 pages")); await scrollTo({ top: region().scrollTop + 30 }); const fitOffset = offset(2); - await screen.rerender(preview(urls.original, "six")); + await screen.rerender(preview(urls.original, "fit-width")); await expect.element(screen.getByText("2 / 3 pages", { exact: true })).toBeVisible(); await vi.waitFor(() => { expect(screen.container.textContent).toContain("Fit width"); @@ -2569,7 +2563,7 @@ it("keeps workspace PDF scroll and zoom after reloads and clamps them when the d await vi.waitFor(() => expect(screen.container.textContent).toContain("1 / 3 pages")); await screen.getByRole("button", { name: "Zoom in", exact: true }).click(); const firstPageZoom = screen.container.textContent!.match(/\d+%/)![0]; - await screen.rerender(preview(urls.original, "seven")); + await screen.rerender(preview(urls.original, "first-page")); await expect.element(screen.getByText("1 / 3 pages", { exact: true })).toBeVisible(); await vi.waitFor(() => expect(screen.container.textContent).toContain(firstPageZoom)); await screen.getByRole("button", { name: "Next page", exact: true }).click(); @@ -2583,6 +2577,42 @@ it("keeps workspace PDF scroll and zoom after reloads and clamps them when the d }); }); +it("clamps workspace PDF scroll when pages shrink or disappear", async () => { + using fixture = await workspacePdfPreview(); + const { preview, urls } = fixture; + await using screen = await render(preview(urls.original, "one")); + await expect.element(screen.getByText("1 / 3 pages", { exact: true })).toBeVisible(); + await screen.getByRole("button", { name: "Next page", exact: true }).click(); + await expect.element(screen.getByText("2 / 3 pages", { exact: true })).toBeVisible(); + for (let index = 0; index < 4; index++) { + await screen.getByRole("button", { name: "Zoom in", exact: true }).click(); + } + const { region, canvas, offset, scrollTo } = pdfScroll(screen); + const zoom = screen.container.textContent!.match(/\d+%/)![0]; + const secondPageTop = region().scrollTop; + await scrollTo({ top: secondPageTop + 600, left: 80 }); + await vi.waitFor(() => expect(region().scrollTop).toBeCloseTo(secondPageTop + 600, 0)); + await screen.rerender(preview(urls.shrunk, "smaller-page")); + await expect.element(screen.getByText("2 / 3 pages", { exact: true })).toBeVisible(); + await vi.waitFor(() => { + expect(screen.container.textContent).toContain(zoom); + expect(region().scrollLeft).toBe(80); + expect(offset(2)).toBeGreaterThan(0); + expect(offset(2)).toBeLessThan(canvas(2).getBoundingClientRect().height); + }); + await screen.rerender(preview(urls.shorter, "three")); + await expect.element(screen.getByText("2 / 2 pages", { exact: true })).toBeVisible(); + await vi.waitFor(() => { + expect(screen.container.querySelector('[role="region"]')?.textContent).toContain( + "Updated milestones", + ); + }); + await screen.rerender(preview(urls.single, "four")); + await expect.element(screen.getByText("1 / 1 pages", { exact: true })).toBeVisible(); + await screen.rerender(preview(urls.original, "five")); + await expect.element(screen.getByText("1 / 3 pages", { exact: true })).toBeVisible(); +}); + it("unwraps one text attachment without turning off the word wrap setting", async () => { const file = new Blob(["a long line"], { type: "text/plain" }); await using screen = await render(