mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-10-22 03:14:06 -05:00
feat(craft): add document preview controls and PDF rendering (#15916)
This commit is contained in:
1 parent
53f436ecb3
commit
044fcdb7a6
34 files changed
+1664
-527
No files matched your search
@@ -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
@@ -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
|
||||
@@ -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/
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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), []);
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
Reference in new issue
Block a user