feat(craft): add document preview controls and PDF rendering (#15916)

This commit is contained in:
Wenxi authored and GitHub committed 2026-10-13 19:17:13 +00:00
1 parent 53f436ecb3
commit 044fcdb7a6
34 files changed
+1664 -527

No files matched your search

+1
View File
@@ -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 |
@@ -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
@@ -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,
+3 -1
View File
@@ -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
+3
View File
@@ -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/
+1
View File
@@ -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
+43 -1
View File
@@ -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=="],
+7 -5
View File
@@ -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",
@@ -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 }) => (
<div data-testid="pdf-document" data-size={blob.size} />
)),
}));
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(<BuildOutputPanel isOpen />);
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
);
});
@@ -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 (
<div
role="toolbar"
aria-label={label}
className="flex shrink-0 flex-wrap items-center justify-between gap-1 border-b border-border-02 px-2 py-1.5"
>
<div className="flex items-center gap-1">
<Button
icon={SvgSidebar}
size="xs"
prominence={showThumbnails ? "secondary" : "tertiary"}
aria-label={thumbnailsLabel}
aria-pressed={showThumbnails}
onClick={onToggleThumbnails}
/>
<Button
icon={direction === "rtl" ? SvgChevronRight : SvgChevronLeft}
size="xs"
prominence="tertiary"
aria-label={previousLabel}
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
/>
<Text font="secondary-body" color="text-03">
{counter}
</Text>
<Button
icon={direction === "rtl" ? SvgChevronLeft : SvgChevronRight}
size="xs"
prominence="tertiary"
aria-label={nextLabel}
disabled={page >= pageCount}
onClick={() => onPageChange(page + 1)}
/>
</div>
{children}
</div>
);
}
@@ -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 }) => (
<div title="report.pdf" data-size={blob.size} />
)),
}));
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<Response>();
const fetch = jest
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(new Response("original"))
.mockReturnValueOnce(replacement.promise);
const preview = (revision: string) => (
<FilePreviewContent
sessionId="pdf-feedback"
filePath="outputs/report.pdf"
revision={revision}
/>
);
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();
}
});
@@ -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 (
<SWRConfig
key={`${sessionId}:${filePath}`}
value={{ provider: () => new Map() }}
>
{/\.pptx?$/i.test(filePath) ? (
<PptxPreview
sessionId={sessionId}
filePath={filePath}
revision={revision}
refreshKey={refreshKey}
isActive={isActive}
/>
) : /\.pdf$/i.test(filePath) ? (
<PdfPreview
sessionId={sessionId}
filePath={filePath}
revision={revision}
refreshKey={refreshKey}
isActive={isActive}
/>
{/\.(pdf|pptx?)$/i.test(filePath) ? (
<div className="relative h-full">
{/\.pptx?$/i.test(filePath) ? (
<PptxPreview
sessionId={sessionId}
filePath={filePath}
revision={revision}
refreshKey={refreshKey}
isActive={isActive}
onRefreshingChange={setDocumentRefreshing}
/>
) : (
<PdfPreview
sessionId={sessionId}
filePath={filePath}
revision={revision}
refreshKey={refreshKey}
isActive={isActive}
onRefreshingChange={setDocumentRefreshing}
/>
)}
{documentRefreshing && (
<div className="absolute top-2 end-2">
<IconLoader aria-label={t("loading.label")} />
</div>
)}
</div>
) : (
<FetchedFilePreview
sessionId={sessionId}
@@ -1,13 +1,18 @@
import { fireEvent, render, screen } from "@tests/setup/test-utils";
import ImagePreview from "@/app/craft/components/output-panel/ImagePreview";
it("toggles the contrasting image background", () => {
render(<ImagePreview src="data:image/png;base64,abc" fileName="image.png" />);
const toggle = screen.getByRole("button", { name: "Contrast background" });
// Image identity
it("resets image load state when replacing a failed source", () => {
const { rerender } = render(
<ImagePreview src="first.png" fileName="first.png" />
);
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(<ImagePreview src="replacement.png" fileName="replacement.png" />);
const replacement = screen.getByRole("img");
expect(replacement).toHaveClass("opacity-0");
fireEvent.load(replacement);
expect(replacement).toHaveClass("opacity-100");
});
@@ -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 (
<div className="h-full flex flex-col overflow-hidden">
<ImageContent key={src} src={src} displayName={displayName} />
</div>
);
}
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 (
<Section
height="full"
@@ -45,44 +52,27 @@ export default function ImagePreview({ src, fileName }: ImagePreviewProps) {
</Section>
);
}
return (
<div className="h-full flex flex-col overflow-hidden">
<div className="flex justify-end px-4 pt-3">
<Button
prominence={contrastBackground ? "secondary" : "tertiary"}
size="xs"
aria-pressed={contrastBackground}
onClick={() => setContrastBackground((value) => !value)}
>
{t("background.contrast")}
</Button>
</div>
<div className="relative min-h-0 flex-1 flex items-center justify-center p-4">
{imageLoading && (
<div className="absolute">
<Text font="secondary-body" color="text-03">
{t("loading.label")}
</Text>
</div>
<div className="relative min-h-0 flex-1 flex items-center justify-center p-4">
{status === "loading" && (
<div className="absolute">
<Text font="secondary-body" color="text-03">
{t("loading.label")}
</Text>
</div>
)}
<img
src={src}
alt={displayName}
aria-label={t("preview.ariaLabel", { name: displayName })}
className={cn(
"max-w-full max-h-full object-contain rounded-md transition-opacity",
"bg-background-neutral-inverted-04",
status === "loading" ? "opacity-0" : "opacity-100"
)}
<img
src={src}
alt={displayName}
aria-label={t("preview.ariaLabel", { name: displayName })}
className={cn(
"max-w-full max-h-full object-contain rounded-md transition-opacity",
contrastBackground && "bg-background-neutral-inverted-04",
imageLoading ? "opacity-0" : "opacity-100"
)}
onLoad={() => setImageLoading(false)}
onError={() => {
setImageLoading(false);
setImageError(true);
}}
/>
</div>
onLoad={() => setStatus("loaded")}
onError={() => setStatus("failed")}
/>
</div>
);
}
@@ -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 <div>{children}</div>;
},
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 (
<div
data-testid={scale === undefined ? "thumbnail" : "pdf-page"}
data-page={pageNumber}
data-scale={scale}
/>
);
},
}));
it("navigates pages with arrows and thumbnails", async () => {
const onPageChange = jest.fn();
render(
<PdfDocumentViewer
blob={new Blob(["pdf"])}
isActive
onDownload={jest.fn()}
onPageChange={onPageChange}
/>
);
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(
<PdfDocumentViewer
blob={new Blob(["pdf"])}
isActive
onDownload={jest.fn()}
/>
);
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) => (
<PdfDocumentViewer blob={blob} isActive={isActive} onDownload={jest.fn()} />
);
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) => (
<PdfDocumentViewer
blob={blob}
isActive
onDownload={jest.fn()}
onPageChange={onPageChange}
/>
);
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);
});
@@ -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<Zoom>("width");
const [showThumbnails, setShowThumbnails] = useState(true);
const viewportRef = useRef<HTMLDivElement>(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 = (
<Text font="secondary-body" color="text-03">
{t("loading.label")}
</Text>
);
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 (
<WorkspaceHome title={t("error.title")}>
<div className="flex flex-col items-center gap-3 text-center">
<Text font="secondary-body" color="text-03">
{t("error.description")}
</Text>
<Button
icon={SvgDownloadCloud}
size="sm"
prominence="secondary"
onClick={onDownload}
>
{downloadTranslations("download.label")}
</Button>
</div>
</WorkspaceHome>
);
return (
<Document
file={blob}
options={DOCUMENT_OPTIONS}
suspense={false}
onLoadSuccess={loadDocument}
onLoadError={failDocument}
onSourceError={failDocument}
onPassword={failDocument}
onItemClick={({ pageNumber: target }) => {
if (target) selectPage(target);
}}
externalLinkTarget="_blank"
externalLinkRel="noopener noreferrer"
className="flex h-full min-h-0 flex-col"
loading={
<WorkspaceHome title={t("loading.label")}>
<></>
</WorkspaceHome>
}
error={
<WorkspaceHome title={t("error.title")}>
<Text font="secondary-body" color="text-03">
{t("error.description")}
</Text>
</WorkspaceHome>
}
>
{pageCount > 0 && (
<>
<DocumentPreviewControls
label={t("controls.label")}
previousLabel={t("page.previous")}
nextLabel={t("page.next")}
thumbnailsLabel={t("thumbnails.toggle")}
counter={t("page.counter", {
current: activePage,
total: pageCount,
})}
page={activePage}
pageCount={pageCount}
showThumbnails={showThumbnails}
onToggleThumbnails={() => setShowThumbnails((value) => !value)}
onPageChange={selectPage}
>
<div className="flex items-center gap-0.5">
<Button
icon={SvgZoomOut}
size="xs"
prominence="tertiary"
aria-label={t("zoom.out")}
disabled={scale <= 0.25}
onClick={() => zoomBy(-0.25)}
/>
<Dropdown align="end">
<Dropdown.Trigger asChild>
<Button
size="xs"
prominence="tertiary"
rightIcon={SvgChevronDown}
aria-label={t("zoom.label")}
>
{zoomLabel(zoom)}
</Button>
</Dropdown.Trigger>
<Dropdown.Data
label={t("zoom.label")}
value={String(zoom)}
items={["width", "page", ...ZOOM_LEVELS].map((value) => ({
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)
)
}
/>
</Dropdown>
<Button
icon={SvgZoomIn}
size="xs"
prominence="tertiary"
aria-label={t("zoom.in")}
disabled={scale >= 3}
onClick={() => zoomBy(0.25)}
/>
</div>
</DocumentPreviewControls>
<div className="flex min-h-0 flex-1 overflow-hidden">
<PreviewSidebar
hidden={!showThumbnails}
label={t("thumbnails.resize")}
>
{(width) => (
<div
className="h-full overflow-y-auto overscroll-contain p-2"
aria-label={t("thumbnails.label")}
>
<div className="flex flex-col gap-2">
{Array.from({ length: pageCount }, (_, index) => (
<PdfThumbnail
key={`${generation}:${index + 1}`}
page={index + 1}
active={activePage === index + 1}
width={Math.max(40, width - 24)}
isActive={isActive}
onSelect={() => selectPage(index + 1)}
/>
))}
</div>
</div>
)}
</PreviewSidebar>
<div
ref={observeViewport}
className="min-w-0 flex-1 overflow-auto bg-background-neutral-02 p-4"
role="region"
// oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- Focus lets keyboard users scroll the PDF viewport.
tabIndex={isActive ? 0 : -1}
aria-label={t("page.preview", { page: activePage })}
>
<div className="flex w-max min-w-full justify-center">
<Page
pageNumber={activePage}
scale={scale}
suspense={false}
onLoadSuccess={loadPage}
loading={loading}
error={
<Text font="secondary-body" color="text-03">
{t("error.description")}
</Text>
}
className="shrink-0 shadow-sm"
/>
</div>
</div>
</div>
</>
)}
</Document>
);
}
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<HTMLDivElement>(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 (
<SelectCard
ref={ref}
role="button"
tabIndex={0}
aria-label={t("page.select", { page })}
aria-current={active ? "page" : undefined}
state={active ? "selected" : "empty"}
padding={1}
rounding={2}
onClick={onSelect}
>
<div className="flex flex-col gap-1">
<div className="flex aspect-[3/4] items-center justify-center overflow-hidden bg-background-neutral-00">
{shouldRender && (
<Page
pageNumber={page}
width={width}
suspense={false}
renderTextLayer={false}
renderAnnotationLayer={false}
devicePixelRatio={1}
onRenderSuccess={() => setRendered(true)}
loading={null}
error={null}
/>
)}
</div>
<Text font="secondary-body" color="text-03">
{format.number(page)}
</Text>
</div>
</SelectCard>
);
}
@@ -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 }) => (
<div title="report.pdf" data-size={blob.size} />
)),
}));
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(<div />);
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<Response>();
@@ -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) => (
<PdfPreview
sessionId="unversioned-pdf"
@@ -147,30 +142,17 @@ it("keeps an unversioned PDF iframe for identical bytes and replaces changed byt
isActive={isActive}
/>
);
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");
});
@@ -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<Blob | null>(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 (
<Section
height="full"
alignItems="center"
justifyContent="center"
padding={8}
>
<SvgFileText size={48} className="stroke-text-02" />
<Text font="heading-h3" color="text-03">
{t("error.title")}
</Text>
<div className="text-center max-w-md">
<Text font="secondary-body" color="text-02">
<WorkspaceHome title={t(error ? "error.title" : "loading.label")}>
{error && (
<Text font="secondary-body" color="text-03">
{t("error.description")}
</Text>
</div>
</Section>
)}
</WorkspaceHome>
);
}
if ((!blob && isLoading) || !blobUrl) {
return (
<Section
height="full"
alignItems="center"
justifyContent="center"
padding={8}
>
<Text font="secondary-body" color="text-03">
{t("loading.label")}
</Text>
</Section>
);
}
return (
<div className="flex h-full flex-col">
@@ -140,17 +106,14 @@ export default function PdfPreview({
</Text>
</div>
)}
<div className="relative min-h-0 flex-1">
<iframe
src={blobUrl}
title={filePath.split("/").pop() || t("frame.title")}
className={cn("w-full h-full border-none")}
<div className="min-h-0 flex-1">
<PdfDocumentViewer
blob={blob}
isActive={isActive}
initialPage={initialPage}
onPageChange={onPageChange}
onDownload={() => downloadArtifactFile(sessionId, filePath)}
/>
{isLoading && (
<div className="absolute top-2 end-2">
<IconLoader aria-label={t("loading.label")} />
</div>
)}
</div>
</div>
);
@@ -13,6 +13,10 @@ import {
type PptxPreviewResponse,
} from "@/app/craft/services/apiServices";
jest.mock("@/providers/UserProvider", () => ({
useUser: () => ({ user: { id: "preview-user" } }),
}));
jest.mock("@/app/craft/services/apiServices", () => ({
...jest.requireActual("@/app/craft/services/apiServices"),
fetchPptxPreview: jest.fn(),
@@ -59,7 +63,8 @@ it("waits for an updated conversion and reloads slide images at the same paths",
/>
);
await waitFor(() => expect(fetchPptxPreview).toHaveBeenCalledTimes(2));
expect(screen.queryByRole("img")).not.toBeInTheDocument();
expect(screen.getByRole("img")).toBe(image);
expect(image).toHaveAttribute("src", originalUrl);
await act(async () => {
finishConversion(converted);
});
@@ -106,9 +111,11 @@ it("keeps the selected slide valid when an updated deck has fewer slides", async
refreshKey={2}
/>
);
expect(await screen.findByRole("img")).toHaveAttribute(
"src",
expect.stringContaining("slide-1.jpg")
await waitFor(() =>
expect(screen.getByRole("img")).toHaveAttribute(
"src",
expect.stringContaining("slide-1.jpg")
)
);
expect(screen.getByRole("img")).toHaveAttribute("alt", "Slide 1 of 1");
});
@@ -220,8 +227,10 @@ it("reloads an edited deck by revision and preserves explicit reloads", async ()
revision="456:100"
/>
);
const updatedUrl = (await screen.findByRole("img")).getAttribute("src");
expect(updatedUrl).not.toBe(originalUrl);
await waitFor(() =>
expect(screen.getByRole("img")).not.toHaveAttribute("src", originalUrl)
);
const updatedUrl = screen.getByRole("img").getAttribute("src");
expect(fetchPptxPreview).toHaveBeenCalledTimes(requests + 1);
rerender(
<PptxPreview
@@ -231,7 +240,9 @@ it("reloads an edited deck by revision and preserves explicit reloads", async ()
refreshKey={1}
/>
);
expect(await screen.findByRole("img")).not.toHaveAttribute("src", updatedUrl);
await waitFor(() =>
expect(screen.getByRole("img")).not.toHaveAttribute("src", updatedUrl)
);
expect(fetchPptxPreview).toHaveBeenCalledTimes(requests + 2);
});
@@ -394,43 +405,27 @@ it("keeps a hidden deck intact and converts only the latest revision on activati
expect(fetchPptxPreview).toHaveBeenCalledTimes(requests + 1);
});
it("resizes thumbnails with quick pointer release and keyboard limits", async () => {
it("restores the retained slide after viewer eviction", async () => {
jest.mocked(fetchPptxPreview).mockResolvedValue({
slide_count: 1,
slide_paths: ["slide-1.jpg"],
slide_count: 3,
slide_paths: ["slide-1.jpg", "slide-2.jpg", "slide-3.jpg"],
cached: false,
});
const onSlideChange = jest.fn();
render(
<PptxPreview sessionId="resizable-deck" filePath="outputs/deck.pptx" />
<PptxPreview
sessionId="restored-deck"
filePath="outputs/deck.pptx"
initialSlide={2}
onSlideChange={onSlideChange}
/>
);
const divider = await screen.findByRole("separator", {
name: "Resize slide thumbnails",
});
divider.setPointerCapture = jest.fn();
divider.hasPointerCapture = () => true;
divider.releasePointerCapture = jest.fn();
const pointer = (type: string, clientX: number) => {
const event = new MouseEvent(type, { bubbles: true, button: 0, clientX });
Object.defineProperty(event, "pointerId", { value: 1 });
fireEvent(divider, event);
};
pointer("pointerdown", 112);
pointer("pointerup", 180);
expect(divider).toHaveAttribute("aria-valuenow", "180");
expect(screen.getByRole("toolbar")).toHaveStyle({ width: "180px" });
fireEvent.keyDown(divider, { key: "ArrowRight" });
expect(divider).toHaveAttribute("aria-valuenow", "190");
fireEvent.keyDown(divider, { key: "End" });
expect(divider).toHaveAttribute("aria-valuenow", "320");
fireEvent.keyDown(divider, { key: "Home" });
expect(divider).toHaveAttribute("aria-valuenow", "88");
pointer("pointerdown", 88);
pointer("pointermove", 160);
pointer("pointercancel", 250);
expect(divider).toHaveAttribute("aria-valuenow", "160");
const image = await screen.findByRole("img");
expect(image).toHaveAttribute("alt", "Slide 3 of 3");
expect(onSlideChange).toHaveBeenLastCalledWith(2);
});
it("reattaches thumbnail sizing after a failed activation read recovers", async () => {
it("retains slides and sidebar sizing through a failed activation read", async () => {
const observe = jest.spyOn(ResizeObserver.prototype, "observe");
const preview = {
slide_count: 1,
@@ -455,35 +450,46 @@ it("reattaches thumbnail sizing after a failed activation read recovers", async
const { rerender } = render(view());
await screen.findByRole("separator");
const firstCalls = observe.mock.calls.length;
const sidebar = screen.getByRole("separator");
const image = screen.getByRole("img");
rerender(view(false));
rerender(view());
await screen.findByText("Cannot preview presentation");
await screen.findByRole("alert");
expect(screen.getByRole("img")).toBe(image);
expect(screen.getByRole("separator")).toBe(sidebar);
rerender(view(false));
rerender(view());
await screen.findByRole("separator");
expect(observe.mock.calls.length).toBeGreaterThan(firstCalls);
expect(observe.mock.calls.length).toBe(firstCalls);
} finally {
observe.mockRestore();
}
});
it("owns loading readiness by slide image URL", async () => {
// Initial position and image ownership.
it("keeps user selection when initial position changes and resets loading per slide", async () => {
jest.mocked(fetchPptxPreview).mockResolvedValue({
slide_count: 2,
slide_paths: ["slide-1.jpg", "slide-2.jpg"],
slide_count: 3,
slide_paths: ["slide-1.jpg", "slide-2.jpg", "slide-3.jpg"],
cached: true,
});
render(
<PptxPreview sessionId="image-readiness" filePath="outputs/deck.pptx" />
);
const props = {
sessionId: "initial-position",
filePath: "outputs/deck.pptx",
revision: "1",
};
const { rerender } = render(<PptxPreview {...props} initialSlide={0} />);
fireEvent.load(await screen.findByRole("img"));
expect(screen.getByRole("img")).toHaveClass("opacity-100");
fireEvent.click(screen.getByRole("button", { name: "Slide 2 of 2" }));
fireEvent.click(screen.getByRole("button", { name: "Slide 2 of 3" }));
expect(screen.getByRole("img")).toHaveClass("opacity-0");
fireEvent.load(screen.getByRole("img"));
rerender(<PptxPreview {...props} initialSlide={2} />);
expect(screen.getByRole("img")).toHaveAttribute("alt", "Slide 2 of 3");
expect(screen.getByRole("img")).toHaveClass("opacity-100");
});
// Preview lifetime and rendering.
it("shows an image error and clears it when another slide loads", async () => {
jest.mocked(fetchPptxPreview).mockResolvedValue({
slide_count: 2,
@@ -3,18 +3,18 @@
import {
useState,
useEffect,
useCallback,
useEffectEvent,
useRef,
type KeyboardEvent,
type PointerEvent,
} from "react";
import { useDirection } from "@radix-ui/react-direction";
import { useFormatter, useTranslations } from "next-intl";
import { useFilePreview } from "@/lib/build/hooks";
import { SWR_KEYS } from "@/lib/swr-keys";
import { cn } from "@opal/utils";
import { Button, SelectCard, Text } from "@opal/components";
import { SvgChevronLeft, SvgChevronRight, SvgFileText } from "@opal/icons";
import { SelectCard, Text } from "@opal/components";
import { SvgFileText } from "@opal/icons";
import DocumentPreviewControls from "@/app/craft/components/output-panel/DocumentPreviewControls";
import PreviewSidebar from "@/app/craft/components/output-panel/PreviewSidebar";
import { Section } from "@/layouts/general-layouts";
import {
fetchPptxPreview,
@@ -22,6 +22,9 @@ import {
} from "@/app/craft/services/apiServices";
interface PptxPreviewProps {
onRefreshingChange?: (isRefreshing: boolean) => void;
initialSlide?: number;
onSlideChange?: (slide: number) => void;
sessionId: string;
filePath: string;
revision?: string;
@@ -40,28 +43,15 @@ export default function PptxPreview({
revision,
refreshKey,
isActive = true,
onRefreshingChange,
initialSlide = 0,
onSlideChange,
}: PptxPreviewProps) {
const t = useTranslations("craft.pptxPreview");
const format: ReturnType<typeof useFormatter> = useFormatter();
const direction = useDirection();
const layoutRef = useRef<HTMLDivElement>(null);
const resizeGesture = useRef<{
pointerId: number;
startX: number;
width: number;
} | null>(null);
const [sidebarWidth, setSidebarWidth] = useState(112);
const [layoutWidth, setLayoutWidth] = useState(0);
const minimumWidth: number = 88;
const maximumWidth: number = layoutWidth
? Math.max(minimumWidth, Math.min(320, layoutWidth * 0.4))
: 320;
const width: number = Math.max(
minimumWidth,
Math.min(maximumWidth, sidebarWidth)
);
const selectedThumbnailRef = useRef<HTMLDivElement>(null);
const [currentSlide, setCurrentSlide] = useState(0);
const [currentSlide, setCurrentSlide] = useState(initialSlide);
const [showThumbnails, setShowThumbnails] = useState(true);
const { data, error, isLoading } = useFilePreview(
SWR_KEYS.buildSessionPptxPreview(sessionId, filePath),
@@ -69,60 +59,9 @@ export default function PptxPreview({
...(await fetchPptxPreview(sessionId, filePath)),
imageRevision: crypto.randomUUID(),
}),
{ revision, refreshKey, isActive }
{ revision, refreshKey, isActive, onRefreshingChange }
);
const layoutVisible: boolean =
!isLoading && !error && Boolean(data?.slide_count);
useEffect(() => {
const layout = layoutRef.current;
if (!layout) return;
const observer = new ResizeObserver(([entry]) => {
if (entry) setLayoutWidth(entry.contentRect.width);
});
observer.observe(layout);
return () => observer.disconnect();
}, [layoutVisible]);
function resizeAt(clientX: number) {
const gesture = resizeGesture.current;
if (!gesture) return;
const delta: number =
(clientX - gesture.startX) * (direction === "rtl" ? -1 : 1);
setSidebarWidth(
Math.max(minimumWidth, Math.min(maximumWidth, gesture.width + delta))
);
}
function finishResize(
event: PointerEvent<HTMLDivElement>,
cancelled = false
) {
if (resizeGesture.current?.pointerId !== event.pointerId) return;
if (!cancelled) resizeAt(event.clientX);
resizeGesture.current = null;
if (event.currentTarget.hasPointerCapture(event.pointerId))
event.currentTarget.releasePointerCapture(event.pointerId);
}
function resizeWithKeyboard(event: KeyboardEvent<HTMLDivElement>) {
const step = direction === "rtl" ? -10 : 10;
const next =
event.key === "Home"
? minimumWidth
: event.key === "End"
? maximumWidth
: event.key === "ArrowRight"
? width + step
: event.key === "ArrowLeft"
? width - step
: null;
if (next === null) return;
event.preventDefault();
event.stopPropagation();
setSidebarWidth(Math.max(minimumWidth, Math.min(maximumWidth, next)));
}
const slideCount = data?.slide_count ?? 0;
const activeSlide = Math.max(
0,
@@ -132,20 +71,6 @@ export default function PptxPreview({
// Keep the selection in bounds before rendering a replacement deck.
if (data && currentSlide !== activeSlide) setCurrentSlide(activeSlide);
const goToPrev = useCallback(() => {
setCurrentSlide((prev) => Math.max(0, prev - 1));
}, []);
const goToNext = useCallback(() => {
if (slideCount === 0) return;
setCurrentSlide((prev) => Math.min(slideCount - 1, prev + 1));
}, [slideCount]);
// Reset slide index when file changes
useEffect(() => {
setCurrentSlide(0);
}, [filePath]);
useEffect(() => {
if (isActive) {
selectedThumbnailRef.current?.scrollIntoView({
@@ -155,6 +80,13 @@ export default function PptxPreview({
}
}, [activeSlide, data?.imageRevision, isActive]);
const hasSlides = slideCount > 0;
const reportSlide = useEffectEvent(() => onSlideChange?.(activeSlide));
// Persist accepted selection, including bounds changes after a deck replacement.
useEffect(() => {
if (hasSlides) reportSlide();
}, [hasSlides, activeSlide]);
function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
if (!isActive || event.defaultPrevented) return;
// Horizontal arrows follow the reading direction.
@@ -164,17 +96,19 @@ export default function PptxPreview({
event.key === (isRtl ? "ArrowRight" : "ArrowLeft")
) {
event.preventDefault();
goToPrev();
setCurrentSlide((slide) => Math.max(0, slide - 1));
} else if (
event.key === "ArrowDown" ||
event.key === (isRtl ? "ArrowLeft" : "ArrowRight")
) {
event.preventDefault();
goToNext();
setCurrentSlide((slide) =>
Math.min(Math.max(0, slideCount - 1), slide + 1)
);
}
}
if (isLoading && isActive) {
if (!data && isLoading && isActive) {
return (
<Section
height="full"
@@ -189,7 +123,7 @@ export default function PptxPreview({
);
}
if (error) {
if (error && !data) {
return (
<Section
height="full"
@@ -231,119 +165,87 @@ export default function PptxPreview({
const slideUrl = `${buildArtifactUrl(sessionId, slidePath)}?revision=${data.imageRevision}`;
return (
<div
ref={layoutRef}
className="relative h-full min-h-0 flex overflow-hidden"
>
<div
role="toolbar"
aria-label={t("slides.label")}
aria-orientation="vertical"
tabIndex={0}
onKeyDown={handleKeyDown}
style={{ width }}
className="shrink-0 overflow-y-auto overscroll-contain p-2"
>
<div className="flex flex-col gap-2">
{data.slide_paths.map((path, index) => (
<SelectCard
key={path}
ref={index === activeSlide ? selectedThumbnailRef : undefined}
role="button"
tabIndex={0}
aria-label={t("slide.counter", {
current: index + 1,
total: slideCount,
})}
aria-current={index === activeSlide ? "true" : undefined}
state={index === activeSlide ? "selected" : "empty"}
padding={1}
rounding={2}
onClick={() => setCurrentSlide(index)}
>
<div className="flex flex-col gap-1">
<img
src={`${buildArtifactUrl(sessionId, path)}?revision=${data.imageRevision}`}
alt=""
loading="lazy"
decoding="async"
className="w-full aspect-video object-contain rounded-04 bg-background-neutral-02"
/>
<Text font="secondary-body" color="text-03">
{format.number(index + 1)}
</Text>
</div>
</SelectCard>
))}
<div className="relative h-full min-h-0 flex flex-col overflow-hidden">
{error && (
<div role="alert" className="shrink-0 px-3 py-2">
<Text font="secondary-body" color="text-03">
{t("error.title")}
</Text>
</div>
</div>
<div
role="separator"
tabIndex={0}
aria-label={t("resize.ariaLabel")}
aria-orientation="vertical"
aria-valuemin={minimumWidth}
aria-valuemax={Math.round(maximumWidth)}
aria-valuenow={Math.round(width)}
className="group flex w-2 shrink-0 justify-center cursor-col-resize touch-none focus-visible:outline focus-visible:outline-1 focus-visible:outline-border-03"
onPointerDown={(event) => {
if (event.button !== 0 || resizeGesture.current) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
resizeGesture.current = {
pointerId: event.pointerId,
startX: event.clientX,
width,
};
}}
onPointerMove={(event) => {
if (resizeGesture.current?.pointerId === event.pointerId)
resizeAt(event.clientX);
}}
onPointerUp={(event) => finishResize(event)}
onPointerCancel={(event) => finishResize(event, true)}
onLostPointerCapture={() => {
resizeGesture.current = null;
}}
onKeyDown={resizeWithKeyboard}
>
<div className="pointer-events-none w-1 border-e border-border-02 group-hover:bg-border-01 group-focus-visible:bg-border-01" />
</div>
<div className="min-w-0 flex-1 flex flex-col overflow-hidden">
<SlideImage
key={slideUrl}
src={slideUrl}
alt={t("slide.counter", {
current: activeSlide + 1,
total: slideCount,
})}
/>
{slideCount > 1 && (
<div className="flex items-center justify-center gap-3 p-2 border-t border-border-02">
<Button
icon={SvgChevronLeft}
prominence="tertiary"
size="sm"
aria-label={t("previous.label")}
onClick={goToPrev}
disabled={activeSlide === 0}
/>
<Text font="secondary-body" color="text-03">
{t("slide.counter", {
current: activeSlide + 1,
total: slideCount,
})}
</Text>
<Button
icon={SvgChevronRight}
prominence="tertiary"
size="sm"
aria-label={t("next.label")}
onClick={goToNext}
disabled={activeSlide === slideCount - 1}
/>
)}
<DocumentPreviewControls
label={t("controls.label")}
previousLabel={t("previous.label")}
nextLabel={t("next.label")}
thumbnailsLabel={t("thumbnails.toggle")}
counter={t("slide.counter", {
current: activeSlide + 1,
total: slideCount,
})}
page={activeSlide + 1}
pageCount={slideCount}
showThumbnails={showThumbnails}
onToggleThumbnails={() => setShowThumbnails((value) => !value)}
onPageChange={(page) => setCurrentSlide(page - 1)}
/>
<div className="flex min-h-0 flex-1 overflow-hidden">
<PreviewSidebar
hidden={!showThumbnails}
label={t("resize.ariaLabel")}
initialWidth={112}
>
<div
role="toolbar"
aria-label={t("slides.label")}
aria-orientation="vertical"
tabIndex={0}
onKeyDown={handleKeyDown}
className="h-full overflow-y-auto overscroll-contain p-2"
>
<div className="flex flex-col gap-2">
{data.slide_paths.map((path, index) => (
<SelectCard
key={path}
ref={index === activeSlide ? selectedThumbnailRef : undefined}
role="button"
tabIndex={0}
aria-label={t("slide.counter", {
current: index + 1,
total: slideCount,
})}
aria-current={index === activeSlide ? "true" : undefined}
state={index === activeSlide ? "selected" : "empty"}
padding={1}
rounding={2}
onClick={() => setCurrentSlide(index)}
>
<div className="flex flex-col gap-1">
<img
src={`${buildArtifactUrl(sessionId, path)}?revision=${data.imageRevision}`}
alt=""
loading="lazy"
decoding="async"
className="w-full aspect-video object-contain rounded-04 bg-background-neutral-02"
/>
<Text font="secondary-body" color="text-03">
{format.number(index + 1)}
</Text>
</div>
</SelectCard>
))}
</div>
</div>
)}
</PreviewSidebar>
<div className="min-w-0 flex-1 flex flex-col overflow-hidden">
<SlideImage
key={slideUrl}
src={slideUrl}
alt={t("slide.counter", {
current: activeSlide + 1,
total: slideCount,
})}
/>
</div>
</div>
</div>
);
@@ -0,0 +1,53 @@
import { render, screen, fireEvent } from "@tests/setup/test-utils";
import PreviewSidebar from "@/app/craft/components/output-panel/PreviewSidebar";
// --- Thumbnail sidebar resizing ---
it("resizes thumbnails with quick pointer release and keyboard limits", async () => {
render(
<div>
<PreviewSidebar label="Resize slide thumbnails" initialWidth={112}>
<div />
</PreviewSidebar>
</div>
);
const divider = await screen.findByRole("separator", {
name: "Resize slide thumbnails",
});
divider.setPointerCapture = jest.fn();
divider.hasPointerCapture = () => true;
divider.releasePointerCapture = jest.fn();
const pointer = (type: string, clientX: number) => {
const event = new MouseEvent(type, { bubbles: true, button: 0, clientX });
Object.defineProperty(event, "pointerId", { value: 1 });
fireEvent(divider, event);
};
pointer("pointerdown", 112);
pointer("pointerup", 180);
expect(divider).toHaveAttribute("aria-valuenow", "180");
fireEvent.keyDown(divider, { key: "ArrowRight" });
expect(divider).toHaveAttribute("aria-valuenow", "190");
fireEvent.keyDown(divider, { key: "End" });
expect(divider).toHaveAttribute("aria-valuenow", "320");
fireEvent.keyDown(divider, { key: "Home" });
expect(divider).toHaveAttribute("aria-valuenow", "88");
pointer("pointerdown", 88);
pointer("pointermove", 160);
pointer("pointercancel", 250);
expect(divider).toHaveAttribute("aria-valuenow", "160");
});
it("keeps the resized width when thumbnails are hidden and shown", () => {
const view = (hidden: boolean) => (
<div>
<PreviewSidebar label="Resize thumbnails" hidden={hidden}>
<div />
</PreviewSidebar>
</div>
);
const { rerender } = render(view(false));
fireEvent.keyDown(screen.getByRole("separator"), { key: "ArrowRight" });
rerender(view(true));
expect(screen.queryByRole("separator")).not.toBeInTheDocument();
rerender(view(false));
expect(screen.getByRole("separator")).toHaveAttribute("aria-valuenow", "154");
});
@@ -0,0 +1,136 @@
"use client";
import {
useState,
useEffect,
useRef,
type ReactNode,
type KeyboardEvent,
type PointerEvent,
} from "react";
import { cn } from "@opal/utils";
import { useDirection } from "@radix-ui/react-direction";
interface PreviewSidebarProps {
label: string;
children: ReactNode | ((width: number) => ReactNode);
initialWidth?: number;
hidden?: boolean;
}
export default function PreviewSidebar({
label,
children,
initialWidth = 144,
hidden = false,
}: PreviewSidebarProps) {
const direction = useDirection();
const sidebarRef = useRef<HTMLDivElement>(null);
const resizeGesture = useRef<{
pointerId: number;
startX: number;
width: number;
} | null>(null);
const [sidebarWidth, setSidebarWidth] = useState(initialWidth);
const [layoutWidth, setLayoutWidth] = useState(0);
const minimumWidth = 88;
const maximumWidth = layoutWidth
? Math.max(minimumWidth, Math.min(320, layoutWidth * 0.4))
: 320;
const width = Math.max(minimumWidth, Math.min(maximumWidth, sidebarWidth));
useEffect(() => {
const layout = sidebarRef.current?.parentElement;
if (!layout) return;
const observer = new ResizeObserver(([entry]) => {
if (entry) setLayoutWidth(entry.contentRect.width);
});
observer.observe(layout);
return () => observer.disconnect();
}, []);
function resizeAt(clientX: number) {
const gesture = resizeGesture.current;
if (!gesture) return;
const delta = (clientX - gesture.startX) * (direction === "rtl" ? -1 : 1);
setSidebarWidth(
Math.max(minimumWidth, Math.min(maximumWidth, gesture.width + delta))
);
}
function finishResize(
event: PointerEvent<HTMLDivElement>,
cancelled = false
) {
if (resizeGesture.current?.pointerId !== event.pointerId) return;
if (!cancelled) resizeAt(event.clientX);
resizeGesture.current = null;
if (event.currentTarget.hasPointerCapture(event.pointerId))
event.currentTarget.releasePointerCapture(event.pointerId);
}
function resizeWithKeyboard(event: KeyboardEvent<HTMLDivElement>) {
const step = direction === "rtl" ? -10 : 10;
const next =
event.key === "Home"
? minimumWidth
: event.key === "End"
? maximumWidth
: event.key === "ArrowRight"
? width + step
: event.key === "ArrowLeft"
? width - step
: null;
if (next === null) return;
event.preventDefault();
event.stopPropagation();
setSidebarWidth(Math.max(minimumWidth, Math.min(maximumWidth, next)));
}
return (
<>
<div
hidden={hidden}
ref={sidebarRef}
style={{ width }}
className="min-h-0 shrink-0 overflow-hidden"
>
{typeof children === "function" ? children(width) : children}
</div>
<div
role="separator"
hidden={hidden}
tabIndex={hidden ? -1 : 0}
aria-label={label}
aria-orientation="vertical"
aria-valuemin={minimumWidth}
aria-valuemax={Math.round(maximumWidth)}
aria-valuenow={Math.round(width)}
className={cn(
"group w-2 shrink-0 justify-center cursor-col-resize touch-none focus-visible:outline focus-visible:outline-1 focus-visible:outline-border-03",
hidden ? "hidden" : "flex"
)}
onPointerDown={(event) => {
if (event.button !== 0 || resizeGesture.current) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
resizeGesture.current = {
pointerId: event.pointerId,
startX: event.clientX,
width,
};
}}
onPointerMove={(event) => {
if (resizeGesture.current?.pointerId === event.pointerId)
resizeAt(event.clientX);
}}
onPointerUp={(event) => finishResize(event)}
onPointerCancel={(event) => finishResize(event, true)}
onLostPointerCapture={() => {
resizeGesture.current = null;
}}
onKeyDown={resizeWithKeyboard}
>
<div className="pointer-events-none w-1 border-e border-border-02 group-hover:bg-border-01 group-focus-visible:bg-border-01" />
</div>
</>
);
}
@@ -0,0 +1,34 @@
import type { ReactNode } from "react";
import { Text } from "@opal/components";
import type { RichStr } from "@opal/types";
import { cn } from "@opal/utils";
interface WorkspaceHomeProps {
title: string | RichStr;
children: ReactNode;
wide?: boolean;
}
export function WorkspaceHome({
title,
children,
wide = false,
}: WorkspaceHomeProps) {
return (
<div className="flex h-full items-start justify-center overflow-auto p-6 pt-8">
<div
className={cn(
"flex w-full flex-col gap-1.5",
wide ? "max-w-3xl @container" : "max-w-xs"
)}
>
<div className="pb-3 text-center">
<Text font="main-ui-muted" color="text-05">
{title}
</Text>
</div>
{children}
</div>
</div>
);
}
+35 -4
View File
@@ -12895,6 +12895,9 @@
"preparing": {
"description": "جارٍ إعداد بيئة التطوير الخاصة بك",
"title": "جارٍ تجهيز بيئة الاختبار..."
},
"download": {
"label": "تنزيل"
}
},
"imagePreview": {
@@ -13067,6 +13070,28 @@
}
},
"pdfPreview": {
"controls": {
"label": "عناصر التحكم في PDF"
},
"thumbnails": {
"toggle": "إظهار الصور المصغّرة للصفحات أو إخفاؤها",
"resize": "تغيير حجم الصور المصغّرة للصفحات",
"label": "الصور المصغّرة للصفحات"
},
"page": {
"previous": "الصفحة السابقة",
"next": "الصفحة التالية",
"counter": "{current} / {total}",
"preview": "معاينة الصفحة {page}",
"select": "الانتقال إلى الصفحة {page}"
},
"zoom": {
"out": "تصغير",
"in": "تكبير",
"label": "التكبير",
"fitWidth": "ملاءمة العرض",
"fitPage": "ملاءمة الصفحة"
},
"error": {
"description": "تعذّر تحميل ملف PDF.",
"title": "تعذّرت معاينة ملف PDF"
@@ -13079,6 +13104,15 @@
}
},
"pptxPreview": {
"controls": {
"label": "عناصر التحكم في العرض التقديمي"
},
"thumbnails": {
"toggle": "إظهار الصور المصغّرة للشرائح أو إخفاؤها"
},
"resize": {
"ariaLabel": "تغيير حجم الصور المصغّرة للشرائح"
},
"slides": {
"label": "الشرائح"
},
@@ -13089,7 +13123,7 @@
"label": "الشريحة التالية"
},
"converting": {
"label": "جارٍ تحويل العرض التقديمي..."
"label": "جارٍ إعداد المعاينة..."
},
"empty": {
"label": "لا توجد شرائح في هذا العرض التقديمي"
@@ -13102,9 +13136,6 @@
},
"slide": {
"counter": "الشريحة {current} من {total}"
},
"resize": {
"ariaLabel": "تغيير حجم الصور المصغّرة للشرائح"
}
},
"previewTab": {
+35 -4
View File
@@ -12895,6 +12895,9 @@
"preparing": {
"description": "Deine Entwicklungsumgebung wird eingerichtet",
"title": "Sandbox wird vorbereitet..."
},
"download": {
"label": "Herunterladen"
}
},
"imagePreview": {
@@ -13067,6 +13070,28 @@
}
},
"pdfPreview": {
"controls": {
"label": "PDF-Steuerung"
},
"thumbnails": {
"toggle": "Seitenminiaturen ein-/ausblenden",
"resize": "Seitenminiaturen skalieren",
"label": "Seitenminiaturen"
},
"page": {
"previous": "Vorherige Seite",
"next": "Nächste Seite",
"counter": "{current} / {total}",
"preview": "Vorschau von Seite {page}",
"select": "Zu Seite {page}"
},
"zoom": {
"out": "Verkleinern",
"in": "Vergrößern",
"label": "Zoom",
"fitWidth": "Breite anpassen",
"fitPage": "Seite anpassen"
},
"error": {
"description": "Die PDF-Datei konnte nicht geladen werden.",
"title": "PDF kann nicht in der Vorschau angezeigt werden"
@@ -13079,6 +13104,15 @@
}
},
"pptxPreview": {
"controls": {
"label": "Präsentationssteuerung"
},
"thumbnails": {
"toggle": "Folienminiaturen ein-/ausblenden"
},
"resize": {
"ariaLabel": "Folienminiaturen skalieren"
},
"slides": {
"label": "Folien"
},
@@ -13089,7 +13123,7 @@
"label": "Nächste Folie"
},
"converting": {
"label": "Präsentation wird konvertiert..."
"label": "Vorschau wird vorbereitet..."
},
"empty": {
"label": "Keine Folien in dieser Präsentation"
@@ -13102,9 +13136,6 @@
},
"slide": {
"counter": "Folie {current} von {total}"
},
"resize": {
"ariaLabel": "Folienminiaturen skalieren"
}
},
"previewTab": {
+35 -4
View File
@@ -12895,6 +12895,9 @@
"preparing": {
"description": "Setting up your development environment",
"title": "Preparing sandbox..."
},
"download": {
"label": "Download"
}
},
"imagePreview": {
@@ -13067,6 +13070,28 @@
}
},
"pdfPreview": {
"controls": {
"label": "PDF controls"
},
"thumbnails": {
"toggle": "Toggle page thumbnails",
"resize": "Resize page thumbnails",
"label": "Page thumbnails"
},
"page": {
"previous": "Previous page",
"next": "Next page",
"counter": "{current} / {total}",
"preview": "Page {page} preview",
"select": "Go to page {page}"
},
"zoom": {
"out": "Zoom out",
"in": "Zoom in",
"label": "Zoom",
"fitWidth": "Fit width",
"fitPage": "Fit page"
},
"error": {
"description": "The PDF file could not be loaded.",
"title": "Cannot preview PDF"
@@ -13079,6 +13104,15 @@
}
},
"pptxPreview": {
"controls": {
"label": "Presentation controls"
},
"thumbnails": {
"toggle": "Toggle slide thumbnails"
},
"resize": {
"ariaLabel": "Resize slide thumbnails"
},
"slides": {
"label": "Slides"
},
@@ -13089,7 +13123,7 @@
"label": "Next slide"
},
"converting": {
"label": "Converting presentation..."
"label": "Preparing preview..."
},
"empty": {
"label": "No slides in this presentation"
@@ -13102,9 +13136,6 @@
},
"slide": {
"counter": "Slide {current} of {total}"
},
"resize": {
"ariaLabel": "Resize slide thumbnails"
}
},
"previewTab": {
+35 -4
View File
@@ -12895,6 +12895,9 @@
"preparing": {
"description": "Configurando tu entorno de desarrollo",
"title": "Preparando sandbox..."
},
"download": {
"label": "Descargar"
}
},
"imagePreview": {
@@ -13067,6 +13070,28 @@
}
},
"pdfPreview": {
"controls": {
"label": "Controles de PDF"
},
"thumbnails": {
"toggle": "Mostrar u ocultar miniaturas",
"resize": "Cambiar el tamaño de las miniaturas",
"label": "Miniaturas de páginas"
},
"page": {
"previous": "Página anterior",
"next": "Página siguiente",
"counter": "{current} / {total}",
"preview": "Vista previa de la página {page}",
"select": "Ir a la página {page}"
},
"zoom": {
"out": "Alejar",
"in": "Acercar",
"label": "Zoom",
"fitWidth": "Ajustar al ancho",
"fitPage": "Ajustar a la página"
},
"error": {
"description": "No se pudo cargar el archivo PDF.",
"title": "No se puede previsualizar el PDF"
@@ -13079,6 +13104,15 @@
}
},
"pptxPreview": {
"controls": {
"label": "Controles de presentación"
},
"thumbnails": {
"toggle": "Mostrar u ocultar miniaturas de diapositivas"
},
"resize": {
"ariaLabel": "Cambiar el tamaño de las miniaturas"
},
"slides": {
"label": "Diapositivas"
},
@@ -13089,7 +13123,7 @@
"label": "Diapositiva siguiente"
},
"converting": {
"label": "Convirtiendo presentación..."
"label": "Preparando la vista previa..."
},
"empty": {
"label": "Esta presentación no tiene diapositivas"
@@ -13102,9 +13136,6 @@
},
"slide": {
"counter": "Diapositiva {current} de {total}"
},
"resize": {
"ariaLabel": "Cambiar el tamaño de las miniaturas"
}
},
"previewTab": {
+35 -4
View File
@@ -12895,6 +12895,9 @@
"preparing": {
"description": "Configuration de votre environnement de développement",
"title": "Préparation du bac à sable..."
},
"download": {
"label": "Télécharger"
}
},
"imagePreview": {
@@ -13067,6 +13070,28 @@
}
},
"pdfPreview": {
"controls": {
"label": "Commandes PDF"
},
"thumbnails": {
"toggle": "Afficher ou masquer les miniatures",
"resize": "Redimensionner les miniatures",
"label": "Miniatures des pages"
},
"page": {
"previous": "Page précédente",
"next": "Page suivante",
"counter": "{current} / {total}",
"preview": "Aperçu de la page {page}",
"select": "Aller à la page {page}"
},
"zoom": {
"out": "Zoom arrière",
"in": "Zoom avant",
"label": "Zoom",
"fitWidth": "Ajuster à la largeur",
"fitPage": "Ajuster à la page"
},
"error": {
"description": "Le fichier PDF n'a pas pu être chargé.",
"title": "Impossible de prévisualiser le PDF"
@@ -13079,6 +13104,15 @@
}
},
"pptxPreview": {
"controls": {
"label": "Commandes de présentation"
},
"thumbnails": {
"toggle": "Afficher ou masquer les miniatures des diapositives"
},
"resize": {
"ariaLabel": "Redimensionner les miniatures des diapositives"
},
"slides": {
"label": "Diapositives"
},
@@ -13089,7 +13123,7 @@
"label": "Diapositive suivante"
},
"converting": {
"label": "Conversion de la présentation..."
"label": "Préparation de l’aperçu..."
},
"empty": {
"label": "Aucune diapositive dans cette présentation"
@@ -13102,9 +13136,6 @@
},
"slide": {
"counter": "Diapositive {current} sur {total}"
},
"resize": {
"ariaLabel": "Redimensionner les miniatures des diapositives"
}
},
"previewTab": {
+35 -4
View File
@@ -12895,6 +12895,9 @@
"preparing": {
"description": "開発環境をセットアップしています",
"title": "サンドボックスを準備しています..."
},
"download": {
"label": "ダウンロード"
}
},
"imagePreview": {
@@ -13067,6 +13070,28 @@
}
},
"pdfPreview": {
"controls": {
"label": "PDFの操作"
},
"thumbnails": {
"toggle": "ページのサムネイルを表示・非表示",
"resize": "ページのサムネイルの幅を変更",
"label": "ページのサムネイル"
},
"page": {
"previous": "前のページ",
"next": "次のページ",
"counter": "{current} / {total}",
"preview": "ページ{page}のプレビュー",
"select": "ページ{page}に移動"
},
"zoom": {
"out": "縮小",
"in": "拡大",
"label": "ズーム",
"fitWidth": "幅に合わせる",
"fitPage": "ページに合わせる"
},
"error": {
"description": "PDF ファイルを読み込めませんでした。",
"title": "PDF をプレビューできません"
@@ -13079,6 +13104,15 @@
}
},
"pptxPreview": {
"controls": {
"label": "プレゼンテーションの操作"
},
"thumbnails": {
"toggle": "スライドのサムネイルを表示・非表示"
},
"resize": {
"ariaLabel": "スライドのサムネイルの幅を変更"
},
"slides": {
"label": "スライド"
},
@@ -13089,7 +13123,7 @@
"label": "次のスライド"
},
"converting": {
"label": "プレゼンテーションを変換しています..."
"label": "プレビューを準備中..."
},
"empty": {
"label": "このプレゼンテーションにはスライドがありません"
@@ -13102,9 +13136,6 @@
},
"slide": {
"counter": "スライド {current} / {total}"
},
"resize": {
"ariaLabel": "スライドのサムネイルの幅を変更"
}
},
"previewTab": {
+35 -4
View File
@@ -12895,6 +12895,9 @@
"preparing": {
"description": "개발 환경을 설정하는 중",
"title": "샌드박스 준비 중..."
},
"download": {
"label": "다운로드"
}
},
"imagePreview": {
@@ -13067,6 +13070,28 @@
}
},
"pdfPreview": {
"controls": {
"label": "PDF 컨트롤"
},
"thumbnails": {
"toggle": "페이지 썸네일 표시 전환",
"resize": "페이지 썸네일 크기 조정",
"label": "페이지 썸네일"
},
"page": {
"previous": "이전 페이지",
"next": "다음 페이지",
"counter": "{current} / {total}",
"preview": "{page}페이지 미리보기",
"select": "{page}페이지로 이동"
},
"zoom": {
"out": "축소",
"in": "확대",
"label": "확대/축소",
"fitWidth": "너비에 맞추기",
"fitPage": "페이지에 맞추기"
},
"error": {
"description": "PDF 파일을 불러올 수 없습니다.",
"title": "PDF를 미리 볼 수 없습니다"
@@ -13079,6 +13104,15 @@
}
},
"pptxPreview": {
"controls": {
"label": "프레젠테이션 컨트롤"
},
"thumbnails": {
"toggle": "슬라이드 썸네일 표시 전환"
},
"resize": {
"ariaLabel": "슬라이드 썸네일 크기 조정"
},
"slides": {
"label": "슬라이드"
},
@@ -13089,7 +13123,7 @@
"label": "다음 슬라이드"
},
"converting": {
"label": "프레젠테이션 변환 중..."
"label": "미리보기 준비 중..."
},
"empty": {
"label": "이 프레젠테이션에 슬라이드가 없습니다"
@@ -13102,9 +13136,6 @@
},
"slide": {
"counter": "슬라이드 {current} / {total}"
},
"resize": {
"ariaLabel": "슬라이드 썸네일 크기 조정"
}
},
"previewTab": {
+35 -4
View File
@@ -12895,6 +12895,9 @@
"preparing": {
"description": "Configurando seu ambiente de desenvolvimento",
"title": "Preparando sandbox..."
},
"download": {
"label": "Baixar"
}
},
"imagePreview": {
@@ -13067,6 +13070,28 @@
}
},
"pdfPreview": {
"controls": {
"label": "Controles de PDF"
},
"thumbnails": {
"toggle": "Mostrar ou ocultar miniaturas",
"resize": "Redimensionar miniaturas das páginas",
"label": "Miniaturas das páginas"
},
"page": {
"previous": "Página anterior",
"next": "Próxima página",
"counter": "{current} / {total}",
"preview": "Prévia da página {page}",
"select": "Ir para a página {page}"
},
"zoom": {
"out": "Diminuir zoom",
"in": "Aumentar zoom",
"label": "Zoom",
"fitWidth": "Ajustar à largura",
"fitPage": "Ajustar à página"
},
"error": {
"description": "Não foi possível carregar o arquivo PDF.",
"title": "Não é possível visualizar o PDF"
@@ -13079,6 +13104,15 @@
}
},
"pptxPreview": {
"controls": {
"label": "Controles da apresentação"
},
"thumbnails": {
"toggle": "Mostrar ou ocultar miniaturas dos slides"
},
"resize": {
"ariaLabel": "Redimensionar miniaturas dos slides"
},
"slides": {
"label": "Slides"
},
@@ -13089,7 +13123,7 @@
"label": "Próximo slide"
},
"converting": {
"label": "Convertendo apresentação..."
"label": "Preparando a prévia..."
},
"empty": {
"label": "Esta apresentação não tem slides"
@@ -13102,9 +13136,6 @@
},
"slide": {
"counter": "Slide {current} de {total}"
},
"resize": {
"ariaLabel": "Redimensionar miniaturas dos slides"
}
},
"previewTab": {
+35 -4
View File
@@ -12895,6 +12895,9 @@
"preparing": {
"description": "正在设置您的开发环境",
"title": "正在准备沙箱..."
},
"download": {
"label": "下载"
}
},
"imagePreview": {
@@ -13067,6 +13070,28 @@
}
},
"pdfPreview": {
"controls": {
"label": "PDF 控件"
},
"thumbnails": {
"toggle": "显示或隐藏页面缩略图",
"resize": "调整页面缩略图宽度",
"label": "页面缩略图"
},
"page": {
"previous": "上一页",
"next": "下一页",
"counter": "{current} / {total}",
"preview": "第 {page} 页预览",
"select": "转到第 {page} 页"
},
"zoom": {
"out": "缩小",
"in": "放大",
"label": "缩放",
"fitWidth": "适合宽度",
"fitPage": "适合页面"
},
"error": {
"description": "无法加载该 PDF 文件。",
"title": "无法预览 PDF"
@@ -13079,6 +13104,15 @@
}
},
"pptxPreview": {
"controls": {
"label": "演示文稿控件"
},
"thumbnails": {
"toggle": "显示或隐藏幻灯片缩略图"
},
"resize": {
"ariaLabel": "调整幻灯片缩略图宽度"
},
"slides": {
"label": "幻灯片"
},
@@ -13089,7 +13123,7 @@
"label": "下一张幻灯片"
},
"converting": {
"label": "正在转换演示文稿..."
"label": "正在准备预览..."
},
"empty": {
"label": "此演示文稿中没有幻灯片"
@@ -13102,9 +13136,6 @@
},
"slide": {
"counter": "幻灯片 {current} / {total}"
},
"resize": {
"ariaLabel": "调整幻灯片缩略图宽度"
}
},
"previewTab": {
+21 -1
View File
@@ -11,6 +11,7 @@ interface FilePreviewOptions {
revision?: string;
refreshKey?: number;
isActive?: boolean;
onRefreshingChange?: (refreshing: boolean) => void;
}
class FilePreviewError extends Error {
@@ -26,7 +27,12 @@ class FilePreviewError extends Error {
export function useFilePreview<T>(
key: string,
load: () => Promise<T>,
{ revision, refreshKey = 0, isActive = true }: FilePreviewOptions = {}
{
revision,
refreshKey = 0,
isActive = true,
onRefreshingChange,
}: FilePreviewOptions = {}
) {
const identity = JSON.stringify([key, revision, refreshKey]);
const lifecycle = useRef<{
@@ -126,9 +132,23 @@ export function useFilePreview<T>(
const isLoading =
(result?.identity !== identity || isValidating) && !currentError;
useOutputRefreshStatus(isActive && isLoading, onRefreshingChange);
return {
data: result?.data,
error: currentError,
isLoading,
};
}
/** Updating a listener must not look like the request finished. */
export function useOutputRefreshStatus(
refreshing: boolean,
onChange?: (refreshing: boolean) => void
) {
const notify = useEffectEvent((value: boolean) => onChange?.(value));
useEffect(() => {
notify(refreshing);
}, [refreshing, onChange]);
useEffect(() => () => notify(false), []);
}
+16
View File
@@ -0,0 +1,16 @@
// Serve PDF.js support files from the same origin, pinned to the renderer version.
const { cpSync, mkdirSync } = require("node:fs");
const path = require("node:path");
const pdfjsRoot = path.dirname(require.resolve("pdfjs-dist/package.json"));
const pdfjsVersion = require("pdfjs-dist/package.json").version;
const pdfjsAssets = path.join(__dirname, "..", "public", "pdfjs", pdfjsVersion);
mkdirSync(pdfjsAssets, { recursive: true });
for (const directory of ["cmaps", "standard_fonts", "wasm", "iccs"]) {
cpSync(path.join(pdfjsRoot, directory), path.join(pdfjsAssets, directory), {
recursive: true,
});
}
cpSync(
path.join(pdfjsRoot, "build/pdf.worker.min.mjs"),
path.join(pdfjsAssets, "pdf.worker.min.mjs")
);