diff --git a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx index 239535c074977..fc5a1aa7b91f9 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx @@ -2411,6 +2411,208 @@ it("keeps a workspace PDF open across URL renewals and reloads it when the file URL.revokeObjectURL(edited); } }); +function planningPdf( + titles = ["Quarterly outlook", "Delivery milestones", "Budget and risks"], + firstHeight = 140, + pageHeight = 792, +) { + const objects = [ + "<< /Type /Catalog /Pages 2 0 R >>", + `<< /Type /Pages /Count ${titles.length} /Kids [${titles.map((_, index) => `${3 + index * 2} 0 R`).join(" ")}] >>`, + ]; + 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 ? 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`, + ); + } + objects.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"); + objects.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>"); + let source = "%PDF-1.4\n"; + const offsets = [0]; + for (const [index, object] of objects.entries()) { + offsets.push(source.length); + source += `${index + 1} 0 obj\n${object}\nendobj\n`; + } + const xref = source.length; + 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 ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF`; + return new Blob([source], { type: "application/pdf" }); +} + +async function workspacePdfPreview() { + assetUrlState.mockReset(); + const { WorkspaceBrowserPreview } = await import("./FilePreviewPanel"); + 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), + ); + 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, shrunk, single].forEach(URL.revokeObjectURL), + }; + })(); + const preview = (src: string, mutation: string, path = "/repo/planning.pdf") => { + assetUrlState.mockReturnValue({ + _tag: "Success", + url: `${src}#preview`, + expiresAt: Date.now() + 3_600_000, + }); + return ( +
+ +
+ ); + }; + 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}"]`)!; + 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; + }); + 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 }); + 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 expect.element(screen.getByText("3 / 3 pages", { exact: true })).toBeVisible(); + await vi.waitFor(() => { + 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")); + await screen.getByRole("button", { name: "Fit width", exact: true }).click(); + await scrollTo({ top: region().scrollTop + 30 }); + const fitOffset = offset(2); + 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"); + expect(region().scrollLeft).toBe(0); + 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, "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(); + 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("Fit width"); + expect(region().scrollTop).toBe(0); + expect(region().scrollLeft).toBe(0); + }); +}); + +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( diff --git a/apps/web/src/components/files/BrowserDocumentFrame.tsx b/apps/web/src/components/files/BrowserDocumentFrame.tsx index e8d3139a8ae70..763e3f2fd3dc5 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"; @@ -73,7 +74,27 @@ export const isPdfPreviewFile = (path: string): boolean => const PdfViewer = lazy(() => import("./PdfViewer")); -function PdfDocument(props: { src: string; title: string }) { +// 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 ( + + ); +} + +// Keep signed URL renewals from reloading the same revision. +function PdfDocumentRevision(props: { + src: string; + title: string; + initialView: PdfViewState; + onViewChange: (view: PdfViewState) => 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 +112,12 @@ function PdfDocument(props: { src: string; title: string }) { /> ) : ( }> - + ); } @@ -108,10 +134,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 reading position. */ + 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 542f4031d48fd..502116a4b2696 100644 --- a/apps/web/src/components/files/PdfViewer.tsx +++ b/apps/web/src/components/files/PdfViewer.tsx @@ -31,6 +31,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; @@ -116,12 +123,20 @@ function PdfPage(props: { ); } -export default function PdfViewer(props: { src: string; title: string }) { +export default function PdfViewer(props: { + src: string; + title: string; + 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); @@ -133,6 +148,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 = initialView.page; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Own the PDF fetch, load timeout, and pdf.js document task across source changes, retries, and unmounts. useEffect(() => { const controller = new AbortController(); @@ -156,9 +172,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 + 1, 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 +199,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/no-unreasoned-state-sync -- Measure the PDF viewport before paint and observe resizes for fit-width sizing and visible-page selection. useLayoutEffect(() => { const node = scroll.current; @@ -195,6 +222,30 @@ export default function PdfViewer(props: { src: string; title: string }) { const jumpToPage = useEffectEvent((index: number) => { scroll.current?.scrollTo({ top: pdfPageScrollTop(offsets, index) }); }); + const saveView = (node: HTMLDivElement) => { + const page = currentPdfPage(offsets, ratios.length, 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); + 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/no-unreasoned-state-sync -- 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: pdfPageScrollTop(offsets, index) }); }; @@ -210,10 +261,14 @@ export default function PdfViewer(props: { src: string; title: string }) { 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/no-unreasoned-state-sync -- Scroll to the first asynchronous search result after its measured page offsets have committed. useLayoutEffect(() => { @@ -354,8 +409,12 @@ export default function PdfViewer(props: { src: string; title: string }) { 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" - onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)} + 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); + saveView(event.currentTarget); + }} onKeyDown={(event) => { if ((event.ctrlKey || event.metaKey) && event.key === "f") { event.preventDefault();