From 044fcdb7a620c1c0dfd05063cbb27472e9542c5f Mon Sep 17 00:00:00 2001 From: Wenxi Date: Tue, 13 Oct 2026 19:17:13 +0000 Subject: [PATCH] feat(craft): add document preview controls and PDF rendering (#15916) --- .agents/feature-map/PATHS.md | 1 + .../feature-map/components/craft-streaming.md | 7 +- .../components/craft-webapp-proxy.md | 13 +- web/.dockerignore | 4 +- web/.gitignore | 3 + web/Dockerfile | 1 + web/bun.lock | 44 +- web/package.json | 12 +- .../components/OutputPanel.revisions.test.tsx | 48 +- .../output-panel/DocumentPreviewControls.tsx | 73 ++++ .../output-panel/FilePreviewContent.test.tsx | 66 ++- .../output-panel/FilePreviewContent.tsx | 47 +- .../output-panel/ImagePreview.test.tsx | 21 +- .../components/output-panel/ImagePreview.tsx | 88 ++-- .../output-panel/PdfDocumentViewer.test.tsx | 168 +++++++ .../output-panel/PdfDocumentViewer.tsx | 412 ++++++++++++++++++ .../output-panel/PdfPreview.test.tsx | 52 +-- .../components/output-panel/PdfPreview.tsx | 111 ++--- .../output-panel/PptxPreview.test.tsx | 98 +++-- .../components/output-panel/PptxPreview.tsx | 310 +++++-------- .../output-panel/PreviewSidebar.test.tsx | 53 +++ .../output-panel/PreviewSidebar.tsx | 136 ++++++ .../components/output-panel/WorkspaceHome.tsx | 34 ++ web/src/i18n/messages/ar.json | 39 +- web/src/i18n/messages/de.json | 39 +- web/src/i18n/messages/en.json | 39 +- web/src/i18n/messages/es.json | 39 +- web/src/i18n/messages/fr.json | 39 +- web/src/i18n/messages/ja.json | 39 +- web/src/i18n/messages/ko.json | 39 +- web/src/i18n/messages/pt.json | 39 +- web/src/i18n/messages/zh.json | 39 +- web/src/lib/build/hooks.ts | 22 +- web/tools/prepare-pdf-assets.cjs | 16 + 34 files changed, 1664 insertions(+), 527 deletions(-) create mode 100644 web/src/app/craft/components/output-panel/DocumentPreviewControls.tsx create mode 100644 web/src/app/craft/components/output-panel/PdfDocumentViewer.test.tsx create mode 100644 web/src/app/craft/components/output-panel/PdfDocumentViewer.tsx create mode 100644 web/src/app/craft/components/output-panel/PreviewSidebar.test.tsx create mode 100644 web/src/app/craft/components/output-panel/PreviewSidebar.tsx create mode 100644 web/src/app/craft/components/output-panel/WorkspaceHome.tsx create mode 100644 web/tools/prepare-pdf-assets.cjs diff --git a/.agents/feature-map/PATHS.md b/.agents/feature-map/PATHS.md index 6e2a315ebb..b5e1d0000a 100644 --- a/.agents/feature-map/PATHS.md +++ b/.agents/feature-map/PATHS.md @@ -177,6 +177,7 @@ so verify the callers rather than a single component. | `web/src/app/craft/` | craft-sessions, craft-streaming | | `web/src/lib/csv.ts` | craft-streaming, chat-frontend | | `web/src/lib/build/hooks.ts` | craft-webapp-proxy | +| `web/tools/prepare-pdf-assets.cjs` | craft-webapp-proxy | | `web/src/app/admin/connector*/`, `admin/indexing-status/`, `admin/index-settings/`, `admin/document-processing/` | connectors, cc-pairs-and-credentials, indexing-pipeline | | `web/src/app/admin/documents/`, `admin/groups*/`, `admin/scim/`, `admin/users/`, `admin/service-accounts/` | access-control, auth-and-identity | | `web/src/app/admin/agents/` | agents-personas | diff --git a/.agents/feature-map/components/craft-streaming.md b/.agents/feature-map/components/craft-streaming.md index 99de3faf6a..879e5a671e 100644 --- a/.agents/feature-map/components/craft-streaming.md +++ b/.agents/feature-map/components/craft-streaming.md @@ -316,7 +316,7 @@ the latest revision on activation. Files without inventory revisions revalidate when their preview mounts or becomes active. Each successful PowerPoint conversion response gives slide images a fresh browser cache token; unchanged retained viewers reuse it. PDF activation reads without revisions reuse the displayed Blob when bytes match. -Changed bytes replace the PDF; previews release their object URL on replacement or unmount. Presentation +Changed bytes replace the PDF; React-PDF releases its loading tasks on replacement or unmount. Presentation keyboard navigation stays inside the active viewer. The first automatic output selection, manual tab selection, closing a tab or the panel, and @@ -336,12 +336,13 @@ Within a retained cache, preview remounts reuse matching revisions and reload ch Closing or evicting a viewer releases its cache. Reopening it reads the file again. Later transient failures cannot restore invalidated content without a successful read. Hidden viewers defer scheduled retries until activation. -Presentation viewers hide cached slides during conversion because conversion replaces slide files. +Presentation viewers retain accepted slides during conversion and show loading feedback. +Transient conversion failures retain those slides and show a warning. CSV files display a sticky-header table limited to 1,000 rows, 100 columns, and 5,000 cells. CSV viewers report malformed quoted fields and offer a raw download through the preview toolbar. The CSV preview uses the shared parser directly. Parser tests live with the shared utility. -Image previews offer a contrast background toggle. +Image previews use a fixed neutral contrast background. Presentation thumbnails support pointer and keyboard resizing. ## 5. Contracts and invariants diff --git a/.agents/feature-map/components/craft-webapp-proxy.md b/.agents/feature-map/components/craft-webapp-proxy.md index 09f87d0e58..e8ae299ef4 100644 --- a/.agents/feature-map/components/craft-webapp-proxy.md +++ b/.agents/feature-map/components/craft-webapp-proxy.md @@ -229,10 +229,15 @@ revalidate on activation while keeping their current content visible. Closing or releases its cache; reopening fetches fresh bytes. SWR retries failed requests and rejects superseded responses. Viewers show only results and errors for their accepted revision and reload counter. Cache misses and reloads bypass the -browser cache when fetching artifacts. PDF object URLs are revoked when replaced -or when their viewer unmounts. Unversioned PDF activation reads compare bytes -with the displayed PDF. Identical bytes reuse its Blob and object URL, preserving -the iframe and reading position. Changed bytes replace the displayed PDF. +browser cache when fetching artifacts. Unversioned PDF activation reads compare bytes +with the displayed PDF. Identical bytes reuse its Blob and PDF.js document. +Changed bytes replace the displayed PDF. React-PDF releases its loading tasks when the viewer unmounts. +PDF workers, fonts, character maps, and rendering support files use versioned same-origin URLs. +`web/tools/prepare-pdf-assets.cjs` copies these assets before dev and production builds, including Docker builds. +PDF and presentation viewers share page controls and a resizable thumbnail sidebar. +PDF controls provide page navigation, fit modes, zoom, and download. +Fit modes use the measured content area, which already excludes viewport padding. +The file-preview caller shows loading feedback while PDF or presentation refreshes retain their accepted content. The output panel keeps its five most recently visited tab bodies mounted, including pinned tabs and file previews. Inactive bodies keep their layout but are invisible, diff --git a/web/.dockerignore b/web/.dockerignore index cb22e743cd..6ae7c2c424 100644 --- a/web/.dockerignore +++ b/web/.dockerignore @@ -17,7 +17,9 @@ lib/shared/dist # Vendored oxlint plugin sources. They run only in pre-commit and CI lint, never # at build or run time. They are also excluded from tsconfig.json, so `next build` # does not type check them. -tools +# The PDF asset preparation script runs during the image build. +tools/* +!tools/prepare-pdf-assets.cjs # Explicitly include src/app/build (overrides .gitignore /build pattern) !src/app/build diff --git a/web/.gitignore b/web/.gitignore index 3e21252dfe..2b5240d1c4 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -50,3 +50,6 @@ next-env.d.ts # storybook storybook-static + +# Generated from the installed PDF.js package before dev and production builds. +/public/pdfjs/ diff --git a/web/Dockerfile b/web/Dockerfile index 806dd08ee3..a478be644f 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -99,6 +99,7 @@ RUN --mount=type=secret,id=sentry_auth_token \ if [ -f /run/secrets/sentry_auth_token ]; then \ export SENTRY_AUTH_TOKEN="$(cat /run/secrets/sentry_auth_token)"; \ fi && \ + node tools/prepare-pdf-assets.cjs && \ NODE_OPTIONS="${NODE_OPTIONS}" npx next build # Runtime stage. Runs as the non-root `node` user (uid 1000), which both the default slim diff --git a/web/bun.lock b/web/bun.lock index 7cd29fddb8..dee485e45e 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -49,6 +49,7 @@ "next": "16.3.8", "next-intl": "^4.13.7", "next-themes": "^0.4.4", + "pdfjs-dist": "6.3.289", "postcss": "^8.5.10", "posthog-js": "^1.421.0", "react": "19.2.8", @@ -57,6 +58,7 @@ "react-dropzone": "^20.1.0", "react-icons": "^5.7.0", "react-markdown": "^9.0.1", + "react-pdf": "11.0.0", "recharts": "^3.9.2", "rehype-highlight": "^7.0.2", "rehype-katex": "^7.0.1", @@ -494,6 +496,30 @@ "@mdx-js/react": ["@mdx-js/react@3.1.1", "", { "dependencies": { "@types/mdx": "^2.0.0" }, "peerDependencies": { "@types/react": ">=16", "react": ">=16" } }, "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw=="], + "@napi-rs/canvas": ["@napi-rs/canvas@1.0.10", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "1.0.10", "@napi-rs/canvas-darwin-arm64": "1.0.10", "@napi-rs/canvas-darwin-x64": "1.0.10", "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.10", "@napi-rs/canvas-linux-arm64-gnu": "1.0.10", "@napi-rs/canvas-linux-arm64-musl": "1.0.10", "@napi-rs/canvas-linux-riscv64-gnu": "1.0.10", "@napi-rs/canvas-linux-x64-gnu": "1.0.10", "@napi-rs/canvas-linux-x64-musl": "1.0.10", "@napi-rs/canvas-win32-arm64-msvc": "1.0.10", "@napi-rs/canvas-win32-x64-msvc": "1.0.10" } }, "sha512-V+qptzsGnPMFxEbhwc6eDNMe5eDrfiBIp8qGpuRjAVIX4Kn2zxvwQ1Xco6Fwe+3tYIe3iLW8LM9D+SvjyJhqbQ=="], + + "@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@1.0.10", "", { "os": "android", "cpu": "arm64" }, "sha512-0sDMvme+7fj6bHERUaBs5SvEyWaORc91VUkaB4RrtG7p6dBrBNGy1of8cyEKDxygd1FlHtW+Thunj8lSixzY7A=="], + + "@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@1.0.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-pdHL1TEfFMrnchRYqmPWXKN7hR4XtOYmzJqnh4XhpfWD4/eeSokOGVzrdFP/iQ3sfr2rf0939gOGq9uCp8BGmg=="], + + "@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@1.0.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-iuuk5plGxAcxurxv2ycF2yR20y5zvaBZ4heQdhPpg3EMFD21PhlY1V+YwSRmYRWzbvKL474UYdw3WIg2qfafyw=="], + + "@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@1.0.10", "", { "os": "linux", "cpu": "arm" }, "sha512-kH7GFR5Unpm77pKSEsmkqvzeQCRD7/MQ7upmeLLjg4XpQ62dX/R6THFsp6vkF1Pkco2aKUq9KhoLM4W5twdk7g=="], + + "@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@1.0.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-0/Hj7IwM9pmzdwxiqkp4YLJ8dKqASBrWQ3Y7dWUSU/4dgvCL9O6YNvX7xVUJjonQvdD3IE9ESS/nXa5srQqoVw=="], + + "@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@1.0.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-fmjKM0P2MLF9O6XzCQsRjhB+jlLzaf6tFQU36hMPMsYup+VThYvpjjhqNLOc/Ee3tHUE3r13H2BSJx/yXz4pFA=="], + + "@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@1.0.10", "", { "os": "linux", "cpu": "none" }, "sha512-O/BUrGwrs3pVP9PpD/3++ZiOpjHMJlmiB7aBgswP8rbe4egPfiFkFxmcCczoLvZa72TaQX5lJ8Udedb6rc/Hvg=="], + + "@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@1.0.10", "", { "os": "linux", "cpu": "x64" }, "sha512-48HkZPQeAN/R+9NPpY64tceoyCUW5xYYtHKZnC+BG11qiihXJCbH+xfbgGU+OdYp1Q4s84HDl9ILU0KBK6SBOQ=="], + + "@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@1.0.10", "", { "os": "linux", "cpu": "x64" }, "sha512-QYHVi7WZ9v+Wm9OQyHLMTnJ8QiBPVX94BgbCHFJC/4bCFMfvX7fRzo4FJPgkRP0ISz3ulYNow/FCbQOPjzhYjg=="], + + "@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@1.0.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-bWK+YajM/8sL0mMgLtug2s063FVBPzFV4QAn987CQomMKlmzrBY6Trz43KY+24hq7IOa3qNhUAgP/XMEF50ZEQ=="], + + "@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@1.0.10", "", { "os": "win32", "cpu": "x64" }, "sha512-9WtzW85PaIhtB27iXaR/9RrLCqHLbqD3Z8Q+kh17NjFxwQXFXU5vWbYH4vEH+drM307TTyK3hidZgpt09G5T3g=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], "@next/env": ["@next/env@16.3.8", "", {}, "sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw=="], @@ -1616,7 +1642,7 @@ "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], - "es-toolkit": ["es-toolkit@1.49.0", "", {}, "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g=="], + "es-toolkit": ["es-toolkit@1.52.0", "", {}, "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA=="], "esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], @@ -1982,6 +2008,8 @@ "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], "lowlight": ["lowlight@3.3.0", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.0.0", "highlight.js": "~11.11.0" } }, "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ=="], @@ -1994,8 +2022,12 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "make-cancellable-promise": ["make-cancellable-promise@2.0.0", "", {}, "sha512-3SEQqTpV9oqVsIWqAcmDuaNeo7yBO3tqPtqGRcKkEo0lrzD3wqbKG9mkxO65KoOgXqj+zH2phJ2LiAsdzlogSw=="], + "make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="], + "make-event-props": ["make-event-props@2.0.0", "", {}, "sha512-G/hncXrl4Qt7mauJEXSg3AcdYzmpkIITTNl5I+rH9sog5Yw0kK6vseJjCaPfOXqOqQuPUP89Rkhfz5kPS8ijtw=="], + "makeerror": ["makeerror@1.0.12", "", { "dependencies": { "tmpl": "1.0.5" } }, "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg=="], "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], @@ -2036,6 +2068,8 @@ "memfs": ["memfs@4.57.8", "", { "dependencies": { "@jsonjoy.com/fs-core": "4.57.8", "@jsonjoy.com/fs-fsa": "4.57.8", "@jsonjoy.com/fs-node": "4.57.8", "@jsonjoy.com/fs-node-builtins": "4.57.8", "@jsonjoy.com/fs-node-to-fsa": "4.57.8", "@jsonjoy.com/fs-node-utils": "4.57.8", "@jsonjoy.com/fs-print": "4.57.8", "@jsonjoy.com/fs-snapshot": "4.57.8", "@jsonjoy.com/json-pack": "^1.11.0", "@jsonjoy.com/util": "^1.9.0", "glob-to-regex.js": "^1.0.1", "thingies": "^2.5.0", "tree-dump": "^1.0.3", "tslib": "^2.0.0" } }, "sha512-bApYhn8BLpFAnAQmFfEl/NPN+8qx5Ar3V4Qt3ek23mVwBEElzV7c6XoPkb/PCG8ZFpowCEpHcPwMFTwHS7tSMA=="], + "merge-refs": ["merge-refs@2.0.0", "", { "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-3+B21mYK2IqUWnd2EivABLT7ueDhb0b8/dGK8LoFQPrU61YITeCMn14F7y7qZafWNZhUEKb24cJdiT5Wxs3prg=="], + "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], "meriyah": ["meriyah@6.1.4", "", {}, "sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ=="], @@ -2214,6 +2248,8 @@ "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], + "pdfjs-dist": ["pdfjs-dist@6.3.289", "", { "optionalDependencies": { "@napi-rs/canvas": "^1.0.0" } }, "sha512-ZHjSVpDa3D6izMq8/04lvkhkATUmL9px6ChPaXc1k6nU2Mrhlg1/7F0bdUqCwUjw3NsPTfPZsMDUU6ZIcRaeQw=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], @@ -2290,6 +2326,8 @@ "react-markdown": ["react-markdown@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw=="], + "react-pdf": ["react-pdf@11.0.0", "", { "dependencies": { "clsx": "^2.0.0", "dequal": "^2.0.3", "es-toolkit": "^1.52.0", "make-cancellable-promise": "^2.0.0", "make-event-props": "^2.0.0", "merge-refs": "^2.0.0", "pdfjs-dist": "6.3.289", "tiny-invariant": "^1.0.0", "warning": "^4.0.0" }, "peerDependencies": { "@types/react": "^19.0.0", "react": "^19.0.0", "react-dom": "^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-x4Ierg3Sl+gOoqd0pVOQndiuFKeGcXQmUNtaSH0D/cs2nUW1HBDlFBT2Z8jDsDJv0Kh//jlOJT21ERyQtlRe7A=="], + "react-redux": ["react-redux@9.3.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g=="], "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], @@ -2586,6 +2624,8 @@ "walker": ["walker@1.0.8", "", { "dependencies": { "makeerror": "1.0.12" } }, "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ=="], + "warning": ["warning@4.0.3", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w=="], + "watchpack": ["watchpack@2.5.2", "", { "dependencies": { "graceful-fs": "^4.1.2" } }, "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg=="], "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], @@ -2850,6 +2890,8 @@ "readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + "recharts/es-toolkit": ["es-toolkit@1.49.0", "", {}, "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g=="], + "redent/strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], "rehype-katex/katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="], diff --git a/web/package.json b/web/package.json index a309602b50..39f6a51da4 100644 --- a/web/package.json +++ b/web/package.json @@ -7,12 +7,12 @@ "lib/shared" ], "scripts": { - "dev": "next dev", - "dev:profile": "NEXT_PUBLIC_ENABLE_STATS=true next dev", - "dev:clean": "bun run clean && next dev", + "dev": "node tools/prepare-pdf-assets.cjs && next dev", + "dev:profile": "node tools/prepare-pdf-assets.cjs && NEXT_PUBLIC_ENABLE_STATS=true next dev", + "dev:clean": "node tools/prepare-pdf-assets.cjs && bun run clean && next dev", "clean": "rm -rf .next node_modules/.cache *.tsbuildinfo", - "build": "next build", - "build:fast": "SKIP_TYPE_CHECK=1 next build", + "build": "node tools/prepare-pdf-assets.cjs && next build", + "build:fast": "node tools/prepare-pdf-assets.cjs && SKIP_TYPE_CHECK=1 next build", "start": "next start", "lint": "oxlint", "lint:fix": "oxlint --fix", @@ -76,6 +76,7 @@ "next": "16.3.8", "next-intl": "^4.13.7", "next-themes": "^0.4.4", + "pdfjs-dist": "6.3.289", "postcss": "^8.5.10", "posthog-js": "^1.421.0", "react": "19.2.8", @@ -84,6 +85,7 @@ "react-dropzone": "^20.1.0", "react-icons": "^5.7.0", "react-markdown": "^9.0.1", + "react-pdf": "11.0.0", "recharts": "^3.9.2", "rehype-highlight": "^7.0.2", "rehype-katex": "^7.0.1", diff --git a/web/src/app/craft/components/OutputPanel.revisions.test.tsx b/web/src/app/craft/components/OutputPanel.revisions.test.tsx index 30f57388b5..b42c7968a3 100644 --- a/web/src/app/craft/components/OutputPanel.revisions.test.tsx +++ b/web/src/app/craft/components/OutputPanel.revisions.test.tsx @@ -1,3 +1,4 @@ +import PdfDocumentViewer from "@/app/craft/components/output-panel/PdfDocumentViewer"; import { act, render, screen, waitFor } from "@tests/setup/test-utils"; import BuildOutputPanel from "@/app/craft/components/OutputPanel"; import { useBuildSessionStore } from "@/app/craft/hooks/useBuildSessionStore"; @@ -30,10 +31,15 @@ jest.mock("@/app/craft/components/output-panel/ArtifactsTab", () => ({ default: () => null, })); +jest.mock("@/app/craft/components/output-panel/PdfDocumentViewer", () => ({ + __esModule: true, + default: jest.fn(({ blob }: { blob: Blob }) => ( +
+ )), +})); + const sessionId = "preview-inventory"; const store = () => useBuildSessionStore.getState(); -const originalCreateObjectURL = URL.createObjectURL; -const originalRevokeObjectURL = URL.revokeObjectURL; const originalScrollIntoView = Element.prototype.scrollIntoView; async function refreshInventory(files: OutputInventory["files"]) { @@ -46,9 +52,6 @@ async function refreshInventory(files: OutputInventory["files"]) { beforeEach(() => { jest.clearAllMocks(); - let nextObjectUrl = 0; - URL.createObjectURL = jest.fn(() => `blob:preview-${++nextObjectUrl}`); - URL.revokeObjectURL = jest.fn(); Element.prototype.scrollIntoView = jest.fn(); useBuildSessionStore.setState({ currentSessionId: null, @@ -63,8 +66,6 @@ beforeEach(() => { afterEach(() => { jest.useRealTimers(); - URL.createObjectURL = originalCreateObjectURL; - URL.revokeObjectURL = originalRevokeObjectURL; Element.prototype.scrollIntoView = originalScrollIntoView; jest.restoreAllMocks(); }); @@ -106,10 +107,6 @@ it("refreshes an open PowerPoint through inventory updates and reuses unchanged expect(fetchPptxPreview).toHaveBeenCalledTimes(3); }); -function isReportFrame(title: string, element: Element | null): boolean { - return title === "report.pdf" && element?.tagName === "IFRAME"; -} - it("refreshes an open PDF through inventory updates and reuses unchanged bytes", async () => { const path = "outputs/report.pdf"; const fetch = jest @@ -123,9 +120,10 @@ it("refreshes an open PDF through inventory updates and reuses unchanged bytes", }); render(); act(() => store().openFilePreview(sessionId, path, "report.pdf")); - const originalUrl = (await screen.findByTitle(isReportFrame)).getAttribute( - "src" - ); + await screen.findByTestId("pdf-document"); + const originalBlob = jest + .mocked(PdfDocumentViewer) + .mock.calls.at(-1)?.[0].blob; expect(fetch).toHaveBeenCalledTimes(1); expect(fetch).toHaveBeenLastCalledWith( `/api/build/sessions/${sessionId}/artifacts/outputs/report.pdf`, @@ -134,24 +132,26 @@ it("refreshes an open PDF through inventory updates and reuses unchanged bytes", await refreshInventory([{ path, revision: "200:1000", size: 1000 }]); await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); - expect(await screen.findByTitle(isReportFrame)).not.toHaveAttribute( - "src", - originalUrl + await waitFor(() => + expect(jest.mocked(PdfDocumentViewer).mock.calls.at(-1)?.[0].blob).not.toBe( + originalBlob + ) ); - expect(URL.revokeObjectURL).toHaveBeenCalledWith(originalUrl); - const updatedFrame = screen.getByTitle(isReportFrame); - const updatedBlob = jest.mocked(URL.createObjectURL).mock.calls.at(-1)?.[0]; + const updatedFrame = screen.getByTestId("pdf-document"); + const updatedBlob = jest + .mocked(PdfDocumentViewer) + .mock.calls.at(-1)?.[0].blob; act(() => store().setActiveOutputTab(sessionId, "artifacts")); act(() => store().setActivePanelTabId(sessionId, `file:${path}`)); - expect(screen.getByTitle(isReportFrame)).toBe(updatedFrame); + expect(screen.getByTestId("pdf-document")).toBe(updatedFrame); expect(fetch).toHaveBeenCalledTimes(2); act(() => store().closePanelTab(sessionId, `file:${path}`)); - expect(screen.queryByTitle(isReportFrame)).not.toBeInTheDocument(); + expect(screen.queryByTestId("pdf-document")).not.toBeInTheDocument(); act(() => store().openFilePreview(sessionId, path, "report.pdf")); - await screen.findByTitle(isReportFrame); + await screen.findByTestId("pdf-document"); expect(fetch).toHaveBeenCalledTimes(3); - expect(jest.mocked(URL.createObjectURL).mock.calls.at(-1)?.[0]).not.toBe( + expect(jest.mocked(PdfDocumentViewer).mock.calls.at(-1)?.[0].blob).not.toBe( updatedBlob ); }); diff --git a/web/src/app/craft/components/output-panel/DocumentPreviewControls.tsx b/web/src/app/craft/components/output-panel/DocumentPreviewControls.tsx new file mode 100644 index 0000000000..32ef44e7a2 --- /dev/null +++ b/web/src/app/craft/components/output-panel/DocumentPreviewControls.tsx @@ -0,0 +1,73 @@ +import type { ReactNode } from "react"; +import type { RichStr } from "@opal/types"; +import { useDirection } from "@radix-ui/react-direction"; +import { Button, Text } from "@opal/components"; +import { SvgSidebar, SvgChevronLeft, SvgChevronRight } from "@opal/icons"; + +interface DocumentPreviewControlsProps { + label: string; + previousLabel: string; + nextLabel: string; + thumbnailsLabel: string; + counter: string | RichStr; + page: number; + pageCount: number; + showThumbnails: boolean; + onToggleThumbnails: () => void; + onPageChange: (page: number) => void; + children?: ReactNode; +} + +export default function DocumentPreviewControls({ + label, + previousLabel, + nextLabel, + thumbnailsLabel, + counter, + page, + pageCount, + showThumbnails, + onToggleThumbnails, + onPageChange, + children, +}: DocumentPreviewControlsProps) { + const direction = useDirection(); + return ( +
+
+
+ {children} +
+ ); +} diff --git a/web/src/app/craft/components/output-panel/FilePreviewContent.test.tsx b/web/src/app/craft/components/output-panel/FilePreviewContent.test.tsx index 491181293d..0408d6042a 100644 --- a/web/src/app/craft/components/output-panel/FilePreviewContent.test.tsx +++ b/web/src/app/craft/components/output-panel/FilePreviewContent.test.tsx @@ -1,3 +1,4 @@ +import PdfDocumentViewer from "@/app/craft/components/output-panel/PdfDocumentViewer"; import type { State } from "swr"; import { act, @@ -35,6 +36,13 @@ jest.mock("@/app/craft/components/output-panel/PptxPreview", () => ({ ), })); +jest.mock("@/app/craft/components/output-panel/PdfDocumentViewer", () => ({ + __esModule: true, + default: jest.fn(({ blob }: { blob: Blob }) => ( +
+ )), +})); + describe("FilePreviewContent", () => { beforeEach(() => { jest.mocked(fetchFileContent).mockReset(); @@ -149,12 +157,7 @@ describe("FilePreviewContent", () => { }); }); -it("defers hidden PDF edits, retains its iframe, and releases bytes on eviction", async () => { - const originalCreateObjectURL = URL.createObjectURL; - const originalRevokeObjectURL = URL.revokeObjectURL; - let urlNumber = 0; - URL.createObjectURL = jest.fn(() => `blob:pdf-${++urlNumber}`); - URL.revokeObjectURL = jest.fn(); +it("defers hidden PDF edits and releases cached bytes on eviction", async () => { const fetch = jest .spyOn(globalThis, "fetch") .mockImplementation(async () => new Response("pdf")); @@ -172,32 +175,25 @@ it("defers hidden PDF edits, retains its iframe, and releases bytes on eviction" const { rerender, unmount } = render(view("1"), { swrConfig: { provider: () => globalCache }, }); - const frame = await screen.findByTitle("report.pdf"); + const body = await screen.findByTitle("report.pdf"); + const displayed = () => + jest.mocked(PdfDocumentViewer).mock.calls.at(-1)?.[0].blob; + const originalBlob = displayed(); rerender(view("2", false)); rerender(view("3", false, 1)); - expect(screen.getByTitle("report.pdf")).toBe(frame); + expect(screen.getByTitle("report.pdf")).toBe(body); expect(fetch).toHaveBeenCalledTimes(1); - expect(URL.revokeObjectURL).not.toHaveBeenCalled(); rerender(view("3", true, 1)); - await waitFor(() => - expect(screen.getByTitle("report.pdf")).toHaveAttribute( - "src", - "blob:pdf-2" - ) - ); + await waitFor(() => expect(displayed()).not.toBe(originalBlob)); expect(fetch).toHaveBeenCalledTimes(2); expect(globalCache.size).toBe(0); unmount(); - expect(URL.revokeObjectURL).toHaveBeenCalledTimes(2); - const reopened = render(view("3", true, 1)); await screen.findByTitle("report.pdf"); expect(fetch).toHaveBeenCalledTimes(3); reopened.unmount(); } finally { fetch.mockRestore(); - URL.createObjectURL = originalCreateObjectURL; - URL.revokeObjectURL = originalRevokeObjectURL; } }); @@ -352,3 +348,35 @@ it("renders CSV content as a table through the file preview", async () => { ).toBeInTheDocument(); expect(screen.getByRole("cell", { name: "Alice" })).toBeInTheDocument(); }); + +test("shows refresh feedback while retaining a PDF until replacement bytes arrive", async () => { + const replacement = deferred(); + const fetch = jest + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response("original")) + .mockReturnValueOnce(replacement.promise); + const preview = (revision: string) => ( + + ); + try { + const { rerender } = render(preview("1")); + const viewer = await screen.findByTitle("report.pdf"); + await waitFor(() => + expect(screen.queryByLabelText("Loading file...")).not.toBeInTheDocument() + ); + rerender(preview("2")); + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); + expect(screen.getByTitle("report.pdf")).toBe(viewer); + expect(screen.getByLabelText("Loading file...")).toBeInTheDocument(); + await act(async () => replacement.resolve(new Response("updated"))); + await waitFor(() => + expect(screen.queryByLabelText("Loading file...")).not.toBeInTheDocument() + ); + } finally { + fetch.mockRestore(); + } +}); diff --git a/web/src/app/craft/components/output-panel/FilePreviewContent.tsx b/web/src/app/craft/components/output-panel/FilePreviewContent.tsx index 60302809f5..d0526d9fcd 100644 --- a/web/src/app/craft/components/output-panel/FilePreviewContent.tsx +++ b/web/src/app/craft/components/output-panel/FilePreviewContent.tsx @@ -1,7 +1,7 @@ "use client"; import { useTranslations } from "next-intl"; -import { type ReactNode } from "react"; +import { useState, type ReactNode } from "react"; import { SWRConfig } from "swr"; import { useFilePreview } from "@/lib/build/hooks"; import { SWR_KEYS } from "@/lib/swr-keys"; @@ -38,28 +38,41 @@ export function FilePreviewContent({ revision, refreshKey, }: FilePreviewContentProps) { + const t = useTranslations("craft.filePreview"); + const [documentRefreshing, setDocumentRefreshing] = useState(false); // The retained viewer owns its bytes. Eviction releases the entire cache. return ( new Map() }} > - {/\.pptx?$/i.test(filePath) ? ( - - ) : /\.pdf$/i.test(filePath) ? ( - + {/\.(pdf|pptx?)$/i.test(filePath) ? ( +
+ {/\.pptx?$/i.test(filePath) ? ( + + ) : ( + + )} + {documentRefreshing && ( +
+ +
+ )} +
) : ( { - render(); - const toggle = screen.getByRole("button", { name: "Contrast background" }); +// Image identity +it("resets image load state when replacing a failed source", () => { + const { rerender } = render( + + ); const image = screen.getByRole("img"); - expect(toggle).toHaveAttribute("aria-pressed", "true"); - expect(image).toHaveClass("bg-background-neutral-inverted-04"); - fireEvent.click(toggle); - expect(toggle).toHaveAttribute("aria-pressed", "false"); - expect(image).not.toHaveClass("bg-background-neutral-inverted-04"); + fireEvent.load(image); + fireEvent.error(image); + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + rerender(); + const replacement = screen.getByRole("img"); + expect(replacement).toHaveClass("opacity-0"); + fireEvent.load(replacement); + expect(replacement).toHaveClass("opacity-100"); }); diff --git a/web/src/app/craft/components/output-panel/ImagePreview.tsx b/web/src/app/craft/components/output-panel/ImagePreview.tsx index 1efdca2a6e..1e37ec038b 100644 --- a/web/src/app/craft/components/output-panel/ImagePreview.tsx +++ b/web/src/app/craft/components/output-panel/ImagePreview.tsx @@ -1,9 +1,9 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState } from "react"; import { useTranslations } from "next-intl"; import { cn } from "@opal/utils"; -import { Button, Text } from "@opal/components"; +import { Text } from "@opal/components"; import { SvgImage } from "@opal/icons"; import { Section } from "@/layouts/general-layouts"; @@ -13,21 +13,28 @@ interface ImagePreviewProps { } export default function ImagePreview({ src, fileName }: ImagePreviewProps) { - const t = useTranslations("craft.imagePreview"); - const [imageLoading, setImageLoading] = useState(true); - const [imageError, setImageError] = useState(false); - const [contrastBackground, setContrastBackground] = useState(true); - // Extract just the filename from path for better alt text const displayName = fileName.split("/").pop() || fileName; - // Reset loading state when src changes - useEffect(() => { - setImageLoading(true); - setImageError(false); - }, [src]); + return ( +
+ +
+ ); +} - if (imageError) { +function ImageContent({ + src, + displayName, +}: { + src: string; + displayName: string; +}) { + const t = useTranslations("craft.imagePreview"); + const [status, setStatus] = useState<"loading" | "loaded" | "failed">( + "loading" + ); + if (status === "failed") { return (
); } - return ( -
-
- -
-
- {imageLoading && ( -
- - {t("loading.label")} - -
+
+ {status === "loading" && ( +
+ + {t("loading.label")} + +
+ )} + {displayName} setImageLoading(false)} - onError={() => { - setImageLoading(false); - setImageError(true); - }} - /> -
+ onLoad={() => setStatus("loaded")} + onError={() => setStatus("failed")} + />
); } diff --git a/web/src/app/craft/components/output-panel/PdfDocumentViewer.test.tsx b/web/src/app/craft/components/output-panel/PdfDocumentViewer.test.tsx new file mode 100644 index 0000000000..a978c7b8c7 --- /dev/null +++ b/web/src/app/craft/components/output-panel/PdfDocumentViewer.test.tsx @@ -0,0 +1,168 @@ +import { useEffect, type ReactNode } from "react"; +import { + render, + screen, + fireEvent, + waitFor, + setupUser, + act, +} from "@tests/setup/test-utils"; +import PdfDocumentViewer from "@/app/craft/components/output-panel/PdfDocumentViewer"; + +const mockDocumentCallbacks: Array<{ + onLoadSuccess: (pdf: { numPages: number }) => void; + onLoadError: () => void; +}> = []; +beforeEach(() => { + mockDocumentCallbacks.length = 0; +}); + +jest.mock("react-pdf", () => ({ + pdfjs: { version: "test", GlobalWorkerOptions: {} }, + Document: ({ + children, + file, + onLoadSuccess, + onLoadError, + }: { + children: ReactNode; + file: Blob; + onLoadSuccess: (pdf: { numPages: number }) => void; + onLoadError: () => void; + }) => { + useEffect(() => { + mockDocumentCallbacks.push({ onLoadSuccess, onLoadError }); + onLoadSuccess({ numPages: 3 }); + }, [file]); + return
{children}
; + }, + Page: ({ + pageNumber, + scale, + onLoadSuccess, + onRenderSuccess, + }: { + pageNumber: number; + scale?: number; + onRenderSuccess?: () => void; + onLoadSuccess?: (page: { + pageNumber: number; + getViewport: () => { width: number; height: number }; + }) => void; + }) => { + useEffect(() => { + onRenderSuccess?.(); + onLoadSuccess?.({ + pageNumber, + getViewport: () => ({ width: 612, height: 792 }), + }); + }, [pageNumber]); + return ( +
+ ); + }, +})); + +it("navigates pages with arrows and thumbnails", async () => { + const onPageChange = jest.fn(); + render( + + ); + const next = await screen.findByRole("button", { name: "Next page" }); + expect(screen.getByRole("button", { name: "Previous page" })).toBeDisabled(); + expect( + screen.queryByRole("button", { name: /Increase|Decrease/ }) + ).not.toBeInTheDocument(); + fireEvent.click(next); + expect(screen.getByTestId("pdf-page")).toHaveAttribute("data-page", "2"); + fireEvent.click(next); + expect(screen.getByTestId("pdf-page")).toHaveAttribute("data-page", "3"); + expect(screen.queryByRole("textbox")).not.toBeInTheDocument(); + expect(next).toBeDisabled(); + expect(onPageChange).toHaveBeenLastCalledWith(3); + fireEvent.click(screen.getByRole("button", { name: "Go to page 1" })); + expect(screen.getByTestId("pdf-page")).toHaveAttribute("data-page", "1"); +}); + +it("offers fit and zoom controls and a collapsible resizeable sidebar", async () => { + render( + + ); + const user = setupUser(); + await screen.findByRole("separator", { name: "Resize page thumbnails" }); + await user.click(screen.getByRole("button", { name: "Zoom" })); + await user.click(screen.getByRole("option", { name: "150%" })); + expect(screen.getByTestId("pdf-page")).toHaveAttribute("data-scale", "1.5"); + await user.click(screen.getByRole("button", { name: "Zoom in" })); + expect(screen.getByTestId("pdf-page")).toHaveAttribute("data-scale", "1.75"); + await user.click( + screen.getByRole("button", { name: "Toggle page thumbnails" }) + ); + expect(screen.queryByRole("separator")).not.toBeInTheDocument(); + await user.click( + screen.getByRole("button", { name: "Toggle page thumbnails" }) + ); + await waitFor(() => + expect(screen.getByRole("separator")).toBeInTheDocument() + ); +}); + +// Thumbnail retention +it("retains rendered thumbnails without starting hidden page previews", async () => { + const blob = new Blob(["pdf"]); + const view = (isActive: boolean) => ( + + ); + const { rerender } = render(view(false)); + await screen.findByTestId("pdf-page"); + expect(screen.queryAllByTestId("thumbnail")).toHaveLength(0); + rerender(view(true)); + const thumbnail = await screen.findByTestId("thumbnail"); + rerender(view(false)); + expect(screen.getByTestId("thumbnail")).toBe(thumbnail); + rerender(view(true)); + expect(screen.getByTestId("thumbnail")).toBe(thumbnail); +}); + +// Source replacement and asynchronous ownership +it("ignores retired document callbacks while retaining page and zoom preferences", async () => { + const onPageChange = jest.fn(); + const view = (blob: Blob) => ( + + ); + const { rerender } = render(view(new Blob(["first"]))); + await screen.findByRole("button", { name: "Next page" }); + fireEvent.click(screen.getByRole("button", { name: "Next page" })); + const user = setupUser(); + await user.click(screen.getByRole("button", { name: "Zoom" })); + await user.click(screen.getByRole("option", { name: "150%" })); + const retired = mockDocumentCallbacks[0]; + rerender(view(new Blob(["replacement"]))); + await screen.findByRole("button", { name: "Next page" }); + act(() => { + retired?.onLoadSuccess({ numPages: 1 }); + retired?.onLoadError(); + }); + expect(screen.getByTestId("pdf-page")).toHaveAttribute("data-page", "2"); + expect(screen.getByTestId("pdf-page")).toHaveAttribute("data-scale", "1.5"); + expect(screen.getByRole("button", { name: "Next page" })).not.toBeDisabled(); + expect(onPageChange).toHaveBeenLastCalledWith(2); +}); diff --git a/web/src/app/craft/components/output-panel/PdfDocumentViewer.tsx b/web/src/app/craft/components/output-panel/PdfDocumentViewer.tsx new file mode 100644 index 0000000000..37da2d0f24 --- /dev/null +++ b/web/src/app/craft/components/output-panel/PdfDocumentViewer.tsx @@ -0,0 +1,412 @@ +"use client"; + +import { + useState, + useRef, + useEffect, + useCallback, + useEffectEvent, +} from "react"; +import { Document, Page, pdfjs } from "react-pdf"; +import type { PDFDocumentProxy, PDFPageProxy } from "pdfjs-dist"; +import { useInView } from "motion/react"; +import { useTranslations, useFormatter } from "next-intl"; +import { Button, Dropdown, SelectCard, Text } from "@opal/components"; +import { + SvgZoomIn, + SvgZoomOut, + SvgChevronDown, + SvgDownloadCloud, +} from "@opal/icons"; +import DocumentPreviewControls from "@/app/craft/components/output-panel/DocumentPreviewControls"; +import PreviewSidebar from "@/app/craft/components/output-panel/PreviewSidebar"; +import { WorkspaceHome } from "@/app/craft/components/output-panel/WorkspaceHome"; +import "react-pdf/dist/Page/TextLayer.css"; +import "react-pdf/dist/Page/AnnotationLayer.css"; + +const assetRoot = `/pdfjs/${pdfjs.version}/`; +pdfjs.GlobalWorkerOptions.workerSrc = `${assetRoot}pdf.worker.min.mjs`; +const DOCUMENT_OPTIONS = { + cMapUrl: `${assetRoot}cmaps/`, + cMapPacked: true, + standardFontDataUrl: `${assetRoot}standard_fonts/`, + wasmUrl: `${assetRoot}wasm/`, + iccUrl: `${assetRoot}iccs/`, + isEvalSupported: false, +}; +const ZOOM_LEVELS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3]; +interface PdfDocumentViewerProps { + blob: Blob; + isActive: boolean; + initialPage?: number; + onDownload: () => void; + onPageChange?: (page: number) => void; +} +type Zoom = "width" | "page" | number; + +export default function PdfDocumentViewer({ + blob, + isActive, + initialPage = 1, + onPageChange, + onDownload, +}: PdfDocumentViewerProps) { + const t = useTranslations("craft.pdfPreview"); + const format = useFormatter(); + const downloadTranslations = useTranslations("craft.filesTab"); + const [documentState, setDocumentState] = useState<{ + blob: Blob; + pdf: PDFDocumentProxy | null; + failed: boolean; + generation: number; + pageNumber: number; + pageSize: { width: number; height: number }; + }>({ + blob, + pdf: null, + failed: false, + generation: 0, + pageNumber: initialPage, + pageSize: { width: 612, height: 792 }, + }); + if (documentState.blob !== blob) { + setDocumentState({ + ...documentState, + blob, + pdf: null, + failed: false, + generation: documentState.generation + 1, + }); + } + const { pdf, failed, generation, pageNumber, pageSize } = documentState; + const [zoom, setZoom] = useState("width"); + const [showThumbnails, setShowThumbnails] = useState(true); + const viewportRef = useRef(null); + const [viewportSize, setViewportSize] = useState({ width: 0, height: 0 }); + const pageCount = pdf?.numPages ?? 0; + const activePage = Math.max(1, Math.min(pageNumber, pageCount || 1)); + const availableWidth = Math.max(1, viewportSize.width); + const availableHeight = Math.max(1, viewportSize.height); + const fitWidth = availableWidth / pageSize.width; + const scale = + typeof zoom === "number" + ? zoom + : zoom === "width" + ? fitWidth + : Math.min(fitWidth, availableHeight / pageSize.height); + const loading = ( + + {t("loading.label")} + + ); + + const observeViewport = useCallback((viewport: HTMLDivElement | null) => { + viewportRef.current = viewport; + if (!viewport) return; + const observer = new ResizeObserver(([entry]) => { + if (entry && entry.contentRect.width > 0 && entry.contentRect.height > 0) + setViewportSize({ + width: entry.contentRect.width, + height: entry.contentRect.height, + }); + }); + // oxlint-disable-next-line react-doctor/effect-needs-cleanup -- React calls this ref's returned disposer on detachment. + observer.observe(viewport); + return () => { + observer.disconnect(); + viewportRef.current = null; + }; + }, []); + const reportPage = useEffectEvent(() => onPageChange?.(activePage)); + useEffect(() => { + if (pageCount) reportPage(); + }, [activePage, pageCount]); + useEffect(() => { + viewportRef.current?.scrollTo?.({ top: 0, left: 0 }); + }, [activePage]); + + function loadDocument(document: PDFDocumentProxy) { + setDocumentState((current) => + current.blob === blob + ? { + ...current, + pdf: document, + failed: false, + pageNumber: Math.max( + 1, + Math.min(current.pageNumber, document.numPages) + ), + } + : current + ); + } + function selectPage(page: number) { + setDocumentState((current) => + current.blob === blob + ? { + ...current, + pageNumber: Math.max(1, Math.min(page, current.pdf?.numPages ?? 1)), + } + : current + ); + } + function failDocument() { + setDocumentState((current) => + current.blob === blob ? { ...current, failed: true } : current + ); + } + function loadPage(page: PDFPageProxy) { + const viewport = page.getViewport({ scale: 1 }); + setDocumentState((current) => + current.blob !== blob || + Math.max(1, Math.min(current.pageNumber, current.pdf?.numPages ?? 1)) !== + page.pageNumber || + (current.pageSize.width === viewport.width && + current.pageSize.height === viewport.height) + ? current + : { + ...current, + pageSize: { width: viewport.width, height: viewport.height }, + } + ); + } + function zoomBy(delta: number) { + setZoom( + Math.max(0.25, Math.min(3, Math.round((scale + delta) * 100) / 100)) + ); + } + const zoomLabel = (value: Zoom) => + typeof value === "number" + ? format.number(value, { style: "percent", maximumFractionDigits: 0 }) + : t(value === "width" ? "zoom.fitWidth" : "zoom.fitPage"); + + if (failed) + return ( + +
+ + {t("error.description")} + + +
+
+ ); + + return ( + { + if (target) selectPage(target); + }} + externalLinkTarget="_blank" + externalLinkRel="noopener noreferrer" + className="flex h-full min-h-0 flex-col" + loading={ + + <> + + } + error={ + + + {t("error.description")} + + + } + > + {pageCount > 0 && ( + <> + setShowThumbnails((value) => !value)} + onPageChange={selectPage} + > +
+ + + ({ + kind: "option", + value: String(value), + title: zoomLabel( + typeof value === "number" + ? value + : value === "width" + ? "width" + : "page" + ), + }))} + onSelect={(option) => + setZoom( + option.value === "width" || option.value === "page" + ? option.value + : Number(option.value) + ) + } + /> + +
+
+
+ +
+
+ + {t("error.description")} + + } + className="shrink-0 shadow-sm" + /> +
+
+
+ + )} +
+ ); +} + +function PdfThumbnail({ + page, + active, + width, + isActive, + onSelect, +}: { + page: number; + active: boolean; + width: number; + isActive: boolean; + onSelect: () => void; +}) { + const t = useTranslations("craft.pdfPreview"); + const format = useFormatter(); + const ref = useRef(null); + const visible = useInView(ref, { margin: "100px" }); + const [rendered, setRendered] = useState(false); + const shouldRender = rendered || (isActive && (visible || active)); + useEffect(() => { + if (active && isActive) ref.current?.scrollIntoView?.({ block: "nearest" }); + }, [active, isActive]); + return ( + +
+
+ {shouldRender && ( + setRendered(true)} + loading={null} + error={null} + /> + )} +
+ + {format.number(page)} + +
+
+ ); +} diff --git a/web/src/app/craft/components/output-panel/PdfPreview.test.tsx b/web/src/app/craft/components/output-panel/PdfPreview.test.tsx index 9d2655d1f2..49c1267b48 100644 --- a/web/src/app/craft/components/output-panel/PdfPreview.test.tsx +++ b/web/src/app/craft/components/output-panel/PdfPreview.test.tsx @@ -10,8 +10,13 @@ import { skipRetryOnAuthError } from "@/lib/fetcher"; import PdfPreview from "@/app/craft/components/output-panel/PdfPreview"; const originalBlob = globalThis.Blob; -const originalCreateObjectURL = URL.createObjectURL; -const originalRevokeObjectURL = URL.revokeObjectURL; +jest.mock("@/app/craft/components/output-panel/PdfDocumentViewer", () => ({ + __esModule: true, + default: jest.fn(({ blob }: { blob: Blob }) => ( +
+ )), +})); +import PdfDocumentViewer from "@/app/craft/components/output-panel/PdfDocumentViewer"; beforeEach(() => { // JSDOM lacks Blob.arrayBuffer(); use Node's binary implementation. @@ -20,18 +25,15 @@ beforeEach(() => { writable: true, value: NodeBlob, }); - URL.createObjectURL = jest.fn().mockReturnValue("blob:preview"); - URL.revokeObjectURL = jest.fn(); + jest.mocked(PdfDocumentViewer).mockClear(); }); afterEach(() => { globalThis.Blob = originalBlob; - URL.createObjectURL = originalCreateObjectURL; - URL.revokeObjectURL = originalRevokeObjectURL; jest.restoreAllMocks(); }); -it("reuses PDF bytes across tab switches and releases viewer URLs", async () => { +it("reuses PDF bytes across tab switches", async () => { const fetch = jest .spyOn(globalThis, "fetch") .mockImplementation(async () => new Response("pdf bytes")); @@ -48,16 +50,13 @@ it("reuses PDF bytes across tab switches and releases viewer URLs", async () => jest.useFakeTimers(); try { rerender(
); - expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:preview"); await act(async () => { await jest.advanceTimersByTimeAsync(11000); }); rerender(preview); await screen.findByTitle("report.pdf"); expect(fetch).toHaveBeenCalledTimes(1); - expect(URL.createObjectURL).toHaveBeenCalledTimes(2); unmount(); - expect(URL.revokeObjectURL).toHaveBeenCalledTimes(2); } finally { jest.useRealTimers(); } @@ -127,7 +126,7 @@ it.each([401, 402, 403])( } ); -it("keeps an unversioned PDF iframe for identical bytes and replaces changed bytes", async () => { +it("reuses the displayed Blob for identical bytes and replaces edited bytes", async () => { const originalBytes = new Uint8Array([37, 80, 68, 70, 0, 255]); const changedBytes = new Uint8Array([37, 80, 68, 70, 0, 254]); const unchangedResponse = deferred(); @@ -136,10 +135,6 @@ it("keeps an unversioned PDF iframe for identical bytes and replaces changed byt .mockResolvedValueOnce(new Response(originalBytes)) .mockReturnValueOnce(unchangedResponse.promise) .mockResolvedValueOnce(new Response(changedBytes)); - URL.createObjectURL = jest - .fn() - .mockReturnValueOnce("blob:original") - .mockReturnValueOnce("blob:changed"); const view = (isActive: boolean) => ( ); - const { rerender, unmount } = render(view(true)); - const frame = await screen.findByTitle("report.pdf"); + const { rerender } = render(view(true)); + await screen.findByTitle("report.pdf"); + const displayed = () => + jest.mocked(PdfDocumentViewer).mock.calls.at(-1)?.[0].blob; + const originalBlob = displayed(); rerender(view(false)); rerender(view(true)); - expect(fetch).toHaveBeenCalledTimes(2); - expect(screen.getByTitle("report.pdf")).toBe(frame); await act(async () => unchangedResponse.resolve(new Response(originalBytes))); - expect(screen.getByTitle("report.pdf")).toBe(frame); - expect(frame).toHaveAttribute("src", "blob:original"); - expect(URL.createObjectURL).toHaveBeenCalledTimes(1); - expect(URL.revokeObjectURL).not.toHaveBeenCalled(); - - // Equal byte lengths must not hide an actual edit. + expect(displayed()).toBe(originalBlob); rerender(view(false)); rerender(view(true)); - await waitFor(() => - expect(screen.getByTitle("report.pdf")).toHaveAttribute( - "src", - "blob:changed" - ) - ); + await waitFor(() => expect(displayed()).not.toBe(originalBlob)); expect(fetch).toHaveBeenCalledTimes(3); - expect(URL.createObjectURL).toHaveBeenCalledTimes(2); - expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:original"); - unmount(); - expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:changed"); }); diff --git a/web/src/app/craft/components/output-panel/PdfPreview.tsx b/web/src/app/craft/components/output-panel/PdfPreview.tsx index 9a174de94a..7d5426cab8 100644 --- a/web/src/app/craft/components/output-panel/PdfPreview.tsx +++ b/web/src/app/craft/components/output-panel/PdfPreview.tsx @@ -1,18 +1,27 @@ "use client"; -import { useState, useEffect } from "react"; +import { useRef, useEffect } from "react"; import { useFilePreview } from "@/lib/build/hooks"; import { FetchError } from "@/lib/fetcher"; import { SWR_KEYS } from "@/lib/swr-keys"; import { useTranslations } from "next-intl"; -import { cn } from "@opal/utils"; import { Text } from "@opal/components"; -import { SvgFileText } from "@opal/icons"; -import { IconLoader } from "@opal/loaders"; -import { Section } from "@/layouts/general-layouts"; -import { buildArtifactUrl } from "@/app/craft/services/apiServices"; +import { WorkspaceHome } from "@/app/craft/components/output-panel/WorkspaceHome"; +import dynamic from "next/dynamic"; +import { + buildArtifactUrl, + downloadArtifactFile, +} from "@/app/craft/services/apiServices"; + +const PdfDocumentViewer = dynamic( + () => import("@/app/craft/components/output-panel/PdfDocumentViewer"), + { ssr: false } +); interface PdfPreviewProps { + initialPage?: number; + onPageChange?: (page: number) => void; + onRefreshingChange?: (isRefreshing: boolean) => void; sessionId: string; filePath: string; revision?: string; @@ -20,29 +29,19 @@ interface PdfPreviewProps { isActive?: boolean; } -/** - * PdfPreview - Renders PDF files using the browser's built-in PDF viewer. - * Fetches the PDF as a blob and creates an object URL so the iframe renders - * it inline (the backend serves artifacts with Content-Disposition: attachment, - * which would otherwise force a download). - */ export default function PdfPreview({ sessionId, filePath, revision, refreshKey, isActive = true, + onRefreshingChange, + initialPage, + onPageChange, }: PdfPreviewProps) { const t = useTranslations("craft.pdfPreview"); - const [objectUrl, setObjectUrl] = useState<{ - blob: Blob; - url: string; - } | null>(null); - const { - data: blob, - error, - isLoading, - } = useFilePreview( + const displayedBlobRef = useRef(null); + const { data: blob, error } = useFilePreview( SWR_KEYS.buildSessionArtifactFile(sessionId, filePath), async () => { const response = await fetch(buildArtifactUrl(sessionId, filePath), { @@ -55,7 +54,7 @@ export default function PdfPreview({ null ); const nextBlob = await response.blob(); - const displayedBlob = objectUrl?.blob; + const displayedBlob = displayedBlobRef.current; if ( revision === undefined && displayedBlob && @@ -67,7 +66,7 @@ export default function PdfPreview({ nextBlob.arrayBuffer(), ]); const next = new Uint8Array(nextBytes); - // Keep the iframe's URL when an activation read returns identical bytes. + // Reuse the loaded document when an activation read returns identical bytes. if ( new Uint8Array(displayedBytes).every( (byte, index) => byte === next[index] @@ -77,56 +76,23 @@ export default function PdfPreview({ } return nextBlob; }, - { revision, refreshKey, isActive } + { revision, refreshKey, isActive, onRefreshingChange } ); - // Object URLs belong only to the mounted viewer. useEffect(() => { - if (!blob) { - setObjectUrl(null); - return; - } - const url = URL.createObjectURL(blob); - setObjectUrl({ blob, url }); - return () => URL.revokeObjectURL(url); + displayedBlobRef.current = blob ?? null; }, [blob]); - const blobUrl = objectUrl?.blob === blob ? objectUrl?.url : undefined; - if (error && !blob) { + if (!blob) return ( -
- - - {t("error.title")} - -
- + + {error && ( + {t("error.description")} -
-
+ )} + ); - } - - if ((!blob && isLoading) || !blobUrl) { - return ( -
- - {t("loading.label")} - -
- ); - } return (
@@ -140,17 +106,14 @@ export default function PdfPreview({
)} -
-