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();