mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 03:43:11 +08:00
feat(desktop): preview documents in the workspace and images in chat
Documents the agent writes open in the workspace panel instead of another application, and local images the agent mentions show up in the conversation. Workspace preview - PDF (pdf.js with its own layout and text layer), Word (docx-preview inside a scripts-disabled sandboxed iframe) and Excel (SheetJS; .xlsx, .xlsm, .xls) open in the side panel with zoom and fit, per-file scroll/zoom/sheet memory, and a refresh when the agent rewrites the file. The engines load lazily. - Bytes come from a new GET /api/sessions/:id/workspace/raw route, with an extension allowlist, size caps, the workspace boundary and canonical-path checks. The file endpoint returns metadata and a version for documents. The client fetches with the bearer credential, so it works in Electron, LAN H5 and remote access alike. - Chat links, output cards and the change card open pdf/docx/xlsx in the workspace; documents outside the workdir still go to the system application. - Image viewer with fit, zoom and pan, and "open in system app". Chat images - Markdown images outside the workdir, at ~/, C:\ and file:// paths render, open in the viewer, and offer "open original" (pictures only). - Images returned by tools such as Read appear as thumbnails under the call. Hardening found in review - previewFsUrl escapes each path segment; a double-escaped %2e%2e used to leave /preview-fs/<session>/. - The CORS, API timing and remote-access header decorators set headers in place. Rebuilding the response buffered whole files in memory and dropped Content-Length. - The engine owns the pdf.js worker, so closing one document no longer fails the next open. - Office archives are inflated in steps to check their real sizes, not the sizes they declare. - A viewer that fails to load stays in its panel instead of taking the window down. Adds pdfjs-dist, docx-preview, xlsx (SheetJS 0.20.3 tarball) and fflate as renderer dev dependencies; Vite bundles them. Refs #1397
This commit is contained in:
@@ -10,6 +10,19 @@ This project includes code and binaries from the following open source projects.
|
||||
- License: dual-licensed under MIT or the Unlicense
|
||||
- License texts: bundled beside the executable under `ripgrep-licenses/`
|
||||
|
||||
## pdf.js
|
||||
|
||||
- Project: pdf.js (https://github.com/mozilla/pdf.js), distributed as `pdfjs-dist`
|
||||
- Included as: the PDF engine and its worker, loaded when a PDF is opened in the workspace, and the run-time data files it reads, emitted beside the app under `assets/pdfjs-<version>/`
|
||||
- Adapted in: `desktop/src/components/workspace/surfaces/document/pdfPage.css` (the text-layer rules of `web/pdf_viewer.css`)
|
||||
- Version: 6.3.289
|
||||
- License: Apache-2.0
|
||||
- Bundled data files, each folder shipped unmodified with its own license files:
|
||||
- `cmaps/`: character maps, Copyright 1990-2009 Adobe Systems Incorporated (BSD-style license, `cmaps/LICENSE`)
|
||||
- `standard_fonts/`: Foxit fonts (Copyright PDFium Authors, BSD-3-Clause, `LICENSE_FOXIT`) and Liberation Sans fonts (Liberation Font License, GNU GPL v2 with a font exception, `LICENSE_LIBERATION`), kept as separate data files
|
||||
- `wasm/`: image decoders OpenJPEG (BSD-2-Clause), JBIG2 from PDFium (BSD-3-Clause) and qcms (MIT), with pdf.js' wrappers for them; each license is in a `LICENSE_*` file beside the decoder
|
||||
- `iccs/`: an ICC colour profile (CC0 1.0, `iccs/LICENSE`)
|
||||
|
||||
## claude-tap
|
||||
|
||||
- Project: claude-tap (https://github.com/liaohch3/claude-tap)
|
||||
|
||||
@@ -49,15 +49,19 @@
|
||||
"@typescript-eslint/parser": "^8",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/coverage-v8": "3.2.4",
|
||||
"docx-preview": "0.4.1",
|
||||
"electron": "^42.4.0",
|
||||
"electron-builder": "^26.8.1",
|
||||
"eslint": "^9",
|
||||
"eslint-plugin-react-hooks": "^5",
|
||||
"fflate": "^0.8.3",
|
||||
"jsdom": "^25.0.1",
|
||||
"pdfjs-dist": "6.3.289",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^8.0.10",
|
||||
"vitest": "^3.0.4",
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -332,6 +336,30 @@
|
||||
|
||||
"@mermaid-js/parser": ["@mermaid-js/parser@1.1.0", "https://registry.npmmirror.com/@mermaid-js/parser/-/parser-1.1.0.tgz", { "dependencies": { "langium": "^4.0.0" } }, "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw=="],
|
||||
|
||||
"@napi-rs/canvas": ["@napi-rs/canvas@1.0.9", "https://registry.npmmirror.com/@napi-rs/canvas/-/canvas-1.0.9.tgz", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "1.0.9", "@napi-rs/canvas-darwin-arm64": "1.0.9", "@napi-rs/canvas-darwin-x64": "1.0.9", "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.9", "@napi-rs/canvas-linux-arm64-gnu": "1.0.9", "@napi-rs/canvas-linux-arm64-musl": "1.0.9", "@napi-rs/canvas-linux-riscv64-gnu": "1.0.9", "@napi-rs/canvas-linux-x64-gnu": "1.0.9", "@napi-rs/canvas-linux-x64-musl": "1.0.9", "@napi-rs/canvas-win32-arm64-msvc": "1.0.9", "@napi-rs/canvas-win32-x64-msvc": "1.0.9" } }, "sha512-QviPdJImDi/jMAvBfqaw+19BndMd/sizXVW3NnpMd3VJGz++QXkOHcP9kWR/smHG0hNjHeyuHFyrx/5lD0oNcQ=="],
|
||||
|
||||
"@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@1.0.9", "https://registry.npmmirror.com/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.9.tgz", { "os": "android", "cpu": "arm64" }, "sha512-4LGXk2/0HVzE29K8SzML5WubgCp++B1FH3qgl35XmSZE+lLdr6P9VRQEnZ0MCLZMTSuJP41yyhtoiVNEuvrTIA=="],
|
||||
|
||||
"@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@1.0.9", "https://registry.npmmirror.com/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.9.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-YNdfLBzY0W/Pep9fo2L6RmoNlNksnn05LRnX66W63R3ij58S25QOTcjdtEt2v8+PnCESzqZsYzUo+QPeIR44NA=="],
|
||||
|
||||
"@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@1.0.9", "https://registry.npmmirror.com/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.9.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-ceZQSknTEcy3dOXoekv59LTCkXjvnLsq+VW5PeNNDHEPQbRS5Ervkm1EaDa7WLAjiYWMLuSQTRTHao1dEX4prg=="],
|
||||
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@1.0.9", "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.9.tgz", { "os": "linux", "cpu": "arm" }, "sha512-XhfI0Wwv4llhd6nnWDtY3kQKjq0r+y1i91PlJlJI24ag2U9WrnwbG1qS3+fDLEyouwEVFchiKkTEHozK+5iUNA=="],
|
||||
|
||||
"@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@1.0.9", "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.9.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-012oiYtKaE7i9oxc8q7nraT7kDOpLcaCmFLzVe9Ty34RHDdoDzbWLrVh827CNxYh/EADX1eSikA3ymLjo/nNuw=="],
|
||||
|
||||
"@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@1.0.9", "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.9.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-Ls5UWYFFn63casTZEczbeyEg3vRDRkv9lscuGwfchtY5yLLQhgOB8SN4YGxmfJ5vTaBwZ2YUxBS3NtmjJmFXdA=="],
|
||||
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@1.0.9", "https://registry.npmmirror.com/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.9.tgz", { "os": "linux", "cpu": "none" }, "sha512-hLKEGxV7ZiRHqndePTokgDMdBlo/rDfzg7P4p4QIv9pUhuYobnu3R2NIFLCRghG0nwfo+s2sw+c1xZFeCmEAsw=="],
|
||||
|
||||
"@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@1.0.9", "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.9.tgz", { "os": "linux", "cpu": "x64" }, "sha512-6kaz3w0QMy77PDWk6rJ1ksIihdad3qzEyX2o2oGT8GwCaypfT5mhjr8buOO5hstyLxcWXDScuz56RsINLtBPIQ=="],
|
||||
|
||||
"@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@1.0.9", "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.9.tgz", { "os": "linux", "cpu": "x64" }, "sha512-xrGvmS3v55hmZ86ls/kBLVNMUTYio3f6Ik0DireemG994VfPAwiA3ZXA0Uf1bByctkB3NQ1Sfb+H5bkdUnnzfQ=="],
|
||||
|
||||
"@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@1.0.9", "https://registry.npmmirror.com/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.9.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-yjmVS3ArZeRVCP7jqbPq4rpZa/BhTeI7ELE2XqJg3snICQBDevLZyArxswHkiTnT34KRic33/4fLirrHI+SY8A=="],
|
||||
|
||||
"@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@1.0.9", "https://registry.npmmirror.com/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.9.tgz", { "os": "win32", "cpu": "x64" }, "sha512-QlSYQdMQslB81nlABo9wNfQ6npFhE7/O+saCZdqVGueGanyRk4jCogD5EwQenfP3kIq9e+mm6GreQBjX5MrA8g=="],
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
|
||||
|
||||
"@ngrok/ngrok": ["@ngrok/ngrok@1.7.0", "https://registry.npmmirror.com/@ngrok/ngrok/-/ngrok-1.7.0.tgz", { "optionalDependencies": { "@ngrok/ngrok-android-arm64": "1.7.0", "@ngrok/ngrok-darwin-arm64": "1.7.0", "@ngrok/ngrok-darwin-universal": "1.7.0", "@ngrok/ngrok-darwin-x64": "1.7.0", "@ngrok/ngrok-freebsd-x64": "1.7.0", "@ngrok/ngrok-linux-arm-gnueabihf": "1.7.0", "@ngrok/ngrok-linux-arm64-gnu": "1.7.0", "@ngrok/ngrok-linux-arm64-musl": "1.7.0", "@ngrok/ngrok-linux-x64-gnu": "1.7.0", "@ngrok/ngrok-linux-x64-musl": "1.7.0", "@ngrok/ngrok-win32-arm64-msvc": "1.7.0", "@ngrok/ngrok-win32-ia32-msvc": "1.7.0", "@ngrok/ngrok-win32-x64-msvc": "1.7.0" } }, "sha512-P06o9TpxrJbiRbHQkiwy/rUrlXRupc+Z8KT4MiJfmcdWxvIdzjCaJOdnNkcOTs6DMyzIOefG5tvk/HLdtjqr0g=="],
|
||||
@@ -934,6 +962,8 @@
|
||||
|
||||
"dmg-license": ["dmg-license@1.0.11", "https://registry.npmmirror.com/dmg-license/-/dmg-license-1.0.11.tgz", { "dependencies": { "@types/plist": "^3.0.1", "@types/verror": "^1.10.3", "ajv": "^6.10.0", "crc": "^3.8.0", "iconv-corefoundation": "^1.1.7", "plist": "^3.0.4", "smart-buffer": "^4.0.2", "verror": "^1.10.0" }, "os": "darwin", "bin": { "dmg-license": "bin/dmg-license.js" } }, "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q=="],
|
||||
|
||||
"docx-preview": ["docx-preview@0.4.1", "https://registry.npmmirror.com/docx-preview/-/docx-preview-0.4.1.tgz", { "dependencies": { "jszip": ">=3.0.0" } }, "sha512-Dv4g+LeE0swHpPvNC0lAT0YsR5Zq5pAxw7d3vHQ8EChBLgIrqqe9VPL5nVt2HBJap8bEZbXUI0TJob7kBeNU9w=="],
|
||||
|
||||
"dom-accessibility-api": ["dom-accessibility-api@0.5.16", "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
|
||||
|
||||
"dompurify": ["dompurify@3.3.3", "https://registry.npmmirror.com/dompurify/-/dompurify-3.3.3.tgz", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA=="],
|
||||
@@ -1028,6 +1058,8 @@
|
||||
|
||||
"fdir": ["fdir@6.5.0", "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", { "peerDependencies": { "picomatch": "^3 || ^4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fflate": ["fflate@0.8.3", "https://registry.npmmirror.com/fflate/-/fflate-0.8.3.tgz", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="],
|
||||
|
||||
"file-entry-cache": ["file-entry-cache@8.0.0", "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
|
||||
|
||||
"filelist": ["filelist@1.0.6", "https://registry.npmmirror.com/filelist/-/filelist-1.0.6.tgz", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="],
|
||||
@@ -1122,6 +1154,8 @@
|
||||
|
||||
"ignore": ["ignore@5.3.2", "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"immediate": ["immediate@3.0.6", "https://registry.npmmirror.com/immediate/-/immediate-3.0.6.tgz", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
|
||||
|
||||
"import-fresh": ["import-fresh@3.3.1", "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
||||
|
||||
"imurmurhash": ["imurmurhash@0.1.4", "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
||||
@@ -1156,6 +1190,8 @@
|
||||
|
||||
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "https://registry.npmmirror.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
|
||||
|
||||
"isarray": ["isarray@1.0.0", "https://registry.npmmirror.com/isarray/-/isarray-1.0.0.tgz", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
|
||||
|
||||
"isbinaryfile": ["isbinaryfile@5.0.7", "https://registry.npmmirror.com/isbinaryfile/-/isbinaryfile-5.0.7.tgz", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="],
|
||||
|
||||
"isexe": ["isexe@3.1.5", "https://registry.npmmirror.com/isexe/-/isexe-3.1.5.tgz", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
|
||||
@@ -1196,6 +1232,8 @@
|
||||
|
||||
"jsonfile": ["jsonfile@6.2.1", "https://registry.npmmirror.com/jsonfile/-/jsonfile-6.2.1.tgz", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
|
||||
"jszip": ["jszip@3.10.2", "https://registry.npmmirror.com/jszip/-/jszip-3.10.2.tgz", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ=="],
|
||||
|
||||
"katex": ["katex@0.16.45", "https://registry.npmmirror.com/katex/-/katex-0.16.45.tgz", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA=="],
|
||||
|
||||
"keyv": ["keyv@4.5.4", "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||
@@ -1210,6 +1248,8 @@
|
||||
|
||||
"levn": ["levn@0.4.1", "https://registry.npmmirror.com/levn/-/levn-0.4.1.tgz", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||
|
||||
"lie": ["lie@3.3.0", "https://registry.npmmirror.com/lie/-/lie-3.3.0.tgz", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.32.0", "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||
@@ -1404,6 +1444,8 @@
|
||||
|
||||
"package-manager-detector": ["package-manager-detector@1.6.0", "https://registry.npmmirror.com/package-manager-detector/-/package-manager-detector-1.6.0.tgz", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="],
|
||||
|
||||
"pako": ["pako@1.0.11", "https://registry.npmmirror.com/pako/-/pako-1.0.11.tgz", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
|
||||
|
||||
"parent-module": ["parent-module@1.0.1", "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
||||
|
||||
"parse-entities": ["parse-entities@4.0.2", "https://registry.npmmirror.com/parse-entities/-/parse-entities-4.0.2.tgz", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
|
||||
@@ -1430,6 +1472,8 @@
|
||||
|
||||
"pathval": ["pathval@2.0.1", "https://registry.npmmirror.com/pathval/-/pathval-2.0.1.tgz", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
|
||||
|
||||
"pdfjs-dist": ["pdfjs-dist@6.3.289", "https://registry.npmmirror.com/pdfjs-dist/-/pdfjs-dist-6.3.289.tgz", { "optionalDependencies": { "@napi-rs/canvas": "^1.0.0" } }, "sha512-ZHjSVpDa3D6izMq8/04lvkhkATUmL9px6ChPaXc1k6nU2Mrhlg1/7F0bdUqCwUjw3NsPTfPZsMDUU6ZIcRaeQw=="],
|
||||
|
||||
"pe-library": ["pe-library@0.4.1", "https://registry.npmmirror.com/pe-library/-/pe-library-0.4.1.tgz", {}, "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
@@ -1460,6 +1504,8 @@
|
||||
|
||||
"proc-log": ["proc-log@6.1.0", "https://registry.npmmirror.com/proc-log/-/proc-log-6.1.0.tgz", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="],
|
||||
|
||||
"process-nextick-args": ["process-nextick-args@2.0.1", "https://registry.npmmirror.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
|
||||
|
||||
"progress": ["progress@2.0.3", "https://registry.npmmirror.com/progress/-/progress-2.0.3.tgz", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="],
|
||||
|
||||
"promise-retry": ["promise-retry@2.0.1", "https://registry.npmmirror.com/promise-retry/-/promise-retry-2.0.1.tgz", { "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" } }, "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g=="],
|
||||
@@ -1502,6 +1548,8 @@
|
||||
|
||||
"read-binary-file-arch": ["read-binary-file-arch@1.0.6", "https://registry.npmmirror.com/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", { "dependencies": { "debug": "^4.3.4" }, "bin": { "read-binary-file-arch": "cli.js" } }, "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg=="],
|
||||
|
||||
"readable-stream": ["readable-stream@2.3.8", "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
||||
|
||||
"redent": ["redent@3.0.0", "https://registry.npmmirror.com/redent/-/redent-3.0.0.tgz", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
|
||||
|
||||
"regex": ["regex@6.1.0", "https://registry.npmmirror.com/regex/-/regex-6.1.0.tgz", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
|
||||
@@ -1544,6 +1592,8 @@
|
||||
|
||||
"rw": ["rw@1.3.3", "https://registry.npmmirror.com/rw/-/rw-1.3.3.tgz", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.1.2", "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"sanitize-filename": ["sanitize-filename@1.6.4", "https://registry.npmmirror.com/sanitize-filename/-/sanitize-filename-1.6.4.tgz", { "dependencies": { "truncate-utf8-bytes": "^1.0.0" } }, "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg=="],
|
||||
@@ -1562,6 +1612,8 @@
|
||||
|
||||
"set-blocking": ["set-blocking@2.0.0", "https://registry.npmmirror.com/set-blocking/-/set-blocking-2.0.0.tgz", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
|
||||
|
||||
"setimmediate": ["setimmediate@1.0.5", "https://registry.npmmirror.com/setimmediate/-/setimmediate-1.0.5.tgz", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="],
|
||||
|
||||
"sharp": ["sharp@0.34.5", "https://registry.npmmirror.com/sharp/-/sharp-0.34.5.tgz", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
@@ -1600,6 +1652,8 @@
|
||||
|
||||
"string-width-cjs": ["string-width@4.2.3", "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"string_decoder": ["string_decoder@1.1.1", "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
|
||||
"stringify-entities": ["stringify-entities@4.0.4", "https://registry.npmmirror.com/stringify-entities/-/stringify-entities-4.0.4.tgz", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@6.0.1", "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
@@ -1764,6 +1818,8 @@
|
||||
|
||||
"ws": ["ws@8.20.0", "https://registry.npmmirror.com/ws/-/ws-8.20.0.tgz", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
|
||||
|
||||
"xlsx": ["xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", { "bin": { "xlsx": "./bin/xlsx.njs" } }, "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA=="],
|
||||
|
||||
"xml-name-validator": ["xml-name-validator@5.0.0", "https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="],
|
||||
|
||||
"xmlbuilder": ["xmlbuilder@15.1.1", "https://registry.npmmirror.com/xmlbuilder/-/xmlbuilder-15.1.1.tgz", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
|
||||
|
||||
@@ -154,14 +154,18 @@
|
||||
"@typescript-eslint/parser": "^8",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/coverage-v8": "3.2.4",
|
||||
"docx-preview": "0.4.1",
|
||||
"electron": "^42.4.0",
|
||||
"electron-builder": "^26.8.1",
|
||||
"eslint": "^9",
|
||||
"eslint-plugin-react-hooks": "^5",
|
||||
"fflate": "^0.8.3",
|
||||
"jsdom": "^25.0.1",
|
||||
"pdfjs-dist": "6.3.289",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^8.0.10",
|
||||
"vitest": "^3.0.4"
|
||||
"vitest": "^3.0.4",
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
// @vitest-environment node
|
||||
import fs from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Plugin } from 'vite'
|
||||
import { createPdfEngine } from '../src/components/workspace/surfaces/document/pdfEngine'
|
||||
import {
|
||||
PDFJS_ASSET_DIRS,
|
||||
PDFJS_ASSET_FOLDERS,
|
||||
pdfjsAssetsPrefix,
|
||||
} from '../src/components/workspace/surfaces/document/pdfAssets'
|
||||
import { listPdfjsAssets, pdfjsAssets } from './vite-pdfjs-assets'
|
||||
|
||||
const realPackageDir = path.dirname(createRequire(import.meta.url).resolve('pdfjs-dist/package.json'))
|
||||
const realVersion = (JSON.parse(fs.readFileSync(path.join(realPackageDir, 'package.json'), 'utf8')) as { version: string }).version
|
||||
|
||||
const tempDirs: string[] = []
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** A stand-in pdfjs-dist with a file or two in each data folder. */
|
||||
function fakePackage(options: { omit?: string } = {}): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pdfjs-assets-'))
|
||||
tempDirs.push(dir)
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'pdfjs-dist', version: '9.8.7' }))
|
||||
fs.writeFileSync(path.join(dir, 'secret.txt'), 'not a data file')
|
||||
for (const folder of PDFJS_ASSET_DIRS) {
|
||||
if (folder === options.omit) continue
|
||||
fs.mkdirSync(path.join(dir, folder))
|
||||
fs.writeFileSync(path.join(dir, folder, `${folder}-one.bin`), `${folder} one`)
|
||||
fs.writeFileSync(path.join(dir, folder, 'LICENSE'), `${folder} licence`)
|
||||
}
|
||||
fs.writeFileSync(path.join(dir, 'wasm', 'quickjs-eval.wasm'), 'scripting sandbox')
|
||||
fs.writeFileSync(path.join(dir, 'wasm', 'quickjs-eval.js'), 'scripting sandbox')
|
||||
fs.writeFileSync(path.join(dir, 'wasm', 'decoder.wasm'), 'a decoder')
|
||||
return dir
|
||||
}
|
||||
|
||||
type Emitted = { type: string; fileName: string; source: Buffer }
|
||||
|
||||
function build(plugin: Plugin): Emitted[] {
|
||||
const emitted: Emitted[] = []
|
||||
const generateBundle = plugin.generateBundle as (this: unknown, ...args: unknown[]) => void
|
||||
generateBundle.call({ emitFile: (file: Emitted) => emitted.push(file) }, {}, {}, false)
|
||||
return emitted
|
||||
}
|
||||
|
||||
type Middleware = (request: { url?: string }, response: unknown, next: () => void) => void
|
||||
|
||||
function devServer(plugin: Plugin) {
|
||||
const mounted: Array<{ at: string; handler: Middleware }> = []
|
||||
const configureServer = plugin.configureServer as (server: unknown) => void
|
||||
configureServer({ middlewares: { use: (at: string, handler: Middleware) => mounted.push({ at, handler }) } })
|
||||
return mounted
|
||||
}
|
||||
|
||||
/** Ask the dev middleware for `url`; resolves with what it sent, or `null` if it passed the request on. */
|
||||
async function request(plugin: Plugin, url: string) {
|
||||
const [middleware] = devServer(plugin)
|
||||
const chunks: Buffer[] = []
|
||||
const headers: Record<string, string> = {}
|
||||
const response = Object.assign(new PassThrough(), {
|
||||
setHeader: (name: string, value: string) => { headers[name.toLowerCase()] = value },
|
||||
})
|
||||
response.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||
return new Promise<{ body: Buffer; headers: Record<string, string> } | null>((resolve) => {
|
||||
response.on('end', () => resolve({ body: Buffer.concat(chunks), headers }))
|
||||
middleware!.handler({ url }, response, () => resolve(null))
|
||||
})
|
||||
}
|
||||
|
||||
describe('listPdfjsAssets', () => {
|
||||
it('lists the four data folders, licences included', () => {
|
||||
const files = listPdfjsAssets(fakePackage()).sort()
|
||||
|
||||
expect(files).toEqual([
|
||||
'cmaps/LICENSE',
|
||||
'cmaps/cmaps-one.bin',
|
||||
'iccs/LICENSE',
|
||||
'iccs/iccs-one.bin',
|
||||
'standard_fonts/LICENSE',
|
||||
'standard_fonts/standard_fonts-one.bin',
|
||||
'wasm/LICENSE',
|
||||
'wasm/decoder.wasm',
|
||||
'wasm/wasm-one.bin',
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves out the scripting sandbox, which a read-only preview never runs', () => {
|
||||
const files = listPdfjsAssets(fakePackage())
|
||||
|
||||
expect(files.filter((file) => file.includes('quickjs'))).toEqual([])
|
||||
expect(files).toContain('wasm/decoder.wasm')
|
||||
})
|
||||
|
||||
it('does not ship anything else in the package', () => {
|
||||
expect(listPdfjsAssets(fakePackage()).some((file) => file.includes('secret') || file.includes('package.json'))).toBe(false)
|
||||
})
|
||||
|
||||
it('fails the build by naming the missing folder, rather than shipping a viewer that cannot read Chinese', () => {
|
||||
expect(() => listPdfjsAssets(fakePackage({ omit: 'cmaps' }))).toThrow(/no "cmaps" folder/)
|
||||
})
|
||||
|
||||
it('finds what a real pdfjs-dist ships: CJK CMaps, the standard fonts, the JPEG 2000 decoder, and their licences', () => {
|
||||
const files = listPdfjsAssets(realPackageDir)
|
||||
|
||||
expect(files).toEqual(expect.arrayContaining([
|
||||
'cmaps/UniGB-UCS2-H.bcmap',
|
||||
'cmaps/Adobe-GB1-UCS2.bcmap',
|
||||
'standard_fonts/LiberationSans-Regular.ttf',
|
||||
'standard_fonts/FoxitSerif.pfb',
|
||||
'standard_fonts/LICENSE_FOXIT',
|
||||
'standard_fonts/LICENSE_LIBERATION',
|
||||
'wasm/openjpeg.wasm',
|
||||
'wasm/qcms_bg.wasm',
|
||||
'wasm/LICENSE_OPENJPEG',
|
||||
'iccs/CGATS001Compat-v2-micro.icc',
|
||||
]))
|
||||
expect(files.some((file) => file.includes('quickjs-eval'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the build', () => {
|
||||
it('emits every file under a folder named for the installed version, with fixed names', () => {
|
||||
const dir = fakePackage()
|
||||
|
||||
const emitted = build(pdfjsAssets({ packageDir: dir }))
|
||||
|
||||
expect(emitted.every((file) => file.type === 'asset')).toBe(true)
|
||||
expect(emitted.map((file) => file.fileName).sort()).toEqual(
|
||||
listPdfjsAssets(dir).map((file) => `assets/pdfjs-9.8.7/${file}`).sort(),
|
||||
)
|
||||
})
|
||||
|
||||
it('ships the bytes of the files, unchanged', () => {
|
||||
const dir = fakePackage()
|
||||
|
||||
const emitted = build(pdfjsAssets({ packageDir: dir }))
|
||||
|
||||
expect(emitted.find((file) => file.fileName === 'assets/pdfjs-9.8.7/wasm/decoder.wasm')!.source.toString()).toBe('a decoder')
|
||||
})
|
||||
|
||||
it('names the folder for the version that is installed, so an upgrade cannot be served stale data', () => {
|
||||
const emitted = build(pdfjsAssets())
|
||||
|
||||
expect(emitted.length).toBeGreaterThan(100)
|
||||
expect(emitted.every((file) => file.fileName.startsWith(`assets/pdfjs-${realVersion}/`))).toBe(true)
|
||||
})
|
||||
|
||||
it('uses the same version string the engine builds its URLs from at run time', async () => {
|
||||
const { version } = await import('pdfjs-dist/legacy/build/pdf.mjs')
|
||||
|
||||
expect(version).toBe(realVersion)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the dev server', () => {
|
||||
it('serves the data at the URL the app will use once built', () => {
|
||||
const [middleware] = devServer(pdfjsAssets({ packageDir: fakePackage() }))
|
||||
|
||||
expect(middleware!.at).toBe(`/${pdfjsAssetsPrefix('9.8.7')}`)
|
||||
})
|
||||
|
||||
it('serves a data file, uncached', async () => {
|
||||
const plugin = pdfjsAssets({ packageDir: fakePackage() })
|
||||
|
||||
const served = await request(plugin, '/cmaps/cmaps-one.bin')
|
||||
|
||||
expect(served!.body.toString()).toBe('cmaps one')
|
||||
expect(served!.headers['content-type']).toBe('application/octet-stream')
|
||||
expect(served!.headers['cache-control']).toBe('no-cache')
|
||||
})
|
||||
|
||||
it('serves wasm as wasm, which streaming compilation insists on', async () => {
|
||||
const served = await request(pdfjsAssets({ packageDir: fakePackage() }), '/wasm/decoder.wasm')
|
||||
|
||||
expect(served!.headers['content-type']).toBe('application/wasm')
|
||||
})
|
||||
|
||||
it('ignores a query string', async () => {
|
||||
const served = await request(pdfjsAssets({ packageDir: fakePackage() }), '/iccs/iccs-one.bin?v=1')
|
||||
|
||||
expect(served!.body.toString()).toBe('iccs one')
|
||||
})
|
||||
|
||||
// Every way out here leads to a file that exists, so that it is the guard that stops
|
||||
// it, not the file's absence.
|
||||
it.each([
|
||||
['a file outside the data folders', () => '/package.json'],
|
||||
['a sibling file, by way of the data folder', () => '/cmaps/../secret.txt'],
|
||||
['an encoded slash way out', () => '/cmaps/..%2fsecret.txt'],
|
||||
['an encoded way out and back in again', (dir: string) => `/%2e%2e/${path.basename(dir)}/secret.txt`],
|
||||
['a way out and back in again', (dir: string) => `/../${path.basename(dir)}/secret.txt`],
|
||||
['a file that does not exist', () => '/cmaps/nothing.bin'],
|
||||
['the scripting sandbox', () => '/wasm/quickjs-eval.wasm'],
|
||||
['a folder', () => '/cmaps'],
|
||||
['the root', () => '/'],
|
||||
['a malformed escape', () => '/cmaps/%E0%A4%A'],
|
||||
])('passes on %s', async (_label, urlFor) => {
|
||||
const dir = fakePackage()
|
||||
|
||||
const served = await request(pdfjsAssets({ packageDir: dir }), urlFor(dir))
|
||||
|
||||
expect(served).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('the engine and the plugin agree on where the data is', () => {
|
||||
it('asks for exactly the folders that are shipped, under the prefix that is served', async () => {
|
||||
const getDocument = vi.fn(() => ({
|
||||
promise: Promise.resolve({ numPages: 1 }),
|
||||
destroy: async () => undefined,
|
||||
}))
|
||||
const pdfjs = { version: '9.8.7', getDocument } as never
|
||||
const base = `http://app.test/${pdfjsAssetsPrefix('9.8.7')}`
|
||||
const engine = createPdfEngine({ loadPdfjs: async () => pdfjs, assetBaseUrl: () => base })
|
||||
|
||||
await engine.open(new Uint8Array([1]))
|
||||
|
||||
const options = getDocument.mock.calls[0]![0] as unknown as Record<string, string>
|
||||
const requested = Object.keys(PDFJS_ASSET_FOLDERS).map((option) => options[option]!)
|
||||
expect(requested.sort()).toEqual([...PDFJS_ASSET_DIRS].map((dir) => `${base}${dir}/`).sort())
|
||||
// pdf.js ships CMaps compressed; without this it fetches names that do not exist.
|
||||
expect(options.cMapPacked).toBe(true)
|
||||
})
|
||||
|
||||
it('ships every folder that the engine can ask for', () => {
|
||||
const shipped = new Set(listPdfjsAssets(realPackageDir).map((file) => file.split('/')[0]))
|
||||
|
||||
expect([...shipped].sort()).toEqual([...PDFJS_ASSET_DIRS].sort())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import fs from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import path from 'node:path'
|
||||
import type { Plugin } from 'vite'
|
||||
import { PDFJS_ASSET_DIRS, pdfjsAssetsPrefix } from '../src/components/workspace/surfaces/document/pdfAssets'
|
||||
|
||||
/**
|
||||
* Not shipped: pdf.js' scripting sandbox (JavaScript embedded in PDF forms). A
|
||||
* read-only preview never runs it, and it is a sizeable wasm the app would carry
|
||||
* for nothing.
|
||||
*/
|
||||
const EXCLUDED = /^wasm\/quickjs-eval\./
|
||||
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
'.wasm': 'application/wasm',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.ttf': 'font/ttf',
|
||||
}
|
||||
|
||||
/** Every file to ship, as `dir/name` paths relative to the pdfjs-dist package. */
|
||||
export function listPdfjsAssets(packageDir: string): string[] {
|
||||
return PDFJS_ASSET_DIRS.flatMap((dir) => {
|
||||
let names: string[]
|
||||
try {
|
||||
names = fs.readdirSync(path.join(packageDir, dir))
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`pdfjs-dist has no "${dir}" folder at ${packageDir}. This version lays its data out differently; `
|
||||
+ 'update PDFJS_ASSET_FOLDERS in pdfAssets.ts to match.',
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
return names.map((name) => `${dir}/${name}`).filter((relative) => !EXCLUDED.test(relative))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Ships pdf.js' run-time data with the app, and serves it from the dev server at
|
||||
* the same URL, so the renderer finds it identically in `vite dev`, in the
|
||||
* packaged Electron app (`file://` inside the asar) and on the H5 static host.
|
||||
*
|
||||
* `packageDir` is for tests; by default the installed pdfjs-dist is used.
|
||||
*/
|
||||
export function pdfjsAssets(options: { packageDir?: string } = {}): Plugin {
|
||||
const packageDir = options.packageDir
|
||||
?? path.dirname(createRequire(import.meta.url).resolve('pdfjs-dist/package.json'))
|
||||
const { version } = JSON.parse(fs.readFileSync(path.join(packageDir, 'package.json'), 'utf8')) as { version: string }
|
||||
const prefix = pdfjsAssetsPrefix(version)
|
||||
|
||||
return {
|
||||
name: 'cc-haha:pdfjs-assets',
|
||||
|
||||
configureServer(server) {
|
||||
server.middlewares.use(`/${prefix}`, (request, response, next) => {
|
||||
let relative: string
|
||||
try {
|
||||
relative = decodeURIComponent((request.url ?? '/').split('?')[0]!).replace(/^\/+/, '')
|
||||
} catch {
|
||||
next() // a malformed escape is not one of our files
|
||||
return
|
||||
}
|
||||
const file = path.resolve(packageDir, relative)
|
||||
// Only the data folders: nothing else in the package, and no way out of it.
|
||||
const inDataDir = PDFJS_ASSET_DIRS.some((dir) => file.startsWith(path.join(packageDir, dir) + path.sep))
|
||||
if (!inDataDir || EXCLUDED.test(path.relative(packageDir, file).split(path.sep).join('/')) || !fs.existsSync(file)) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
response.setHeader('Content-Type', MIME_TYPES[path.extname(file)] ?? 'application/octet-stream')
|
||||
response.setHeader('Cache-Control', 'no-cache')
|
||||
fs.createReadStream(file).pipe(response)
|
||||
})
|
||||
},
|
||||
|
||||
generateBundle() {
|
||||
for (const relative of listPdfjsAssets(packageDir)) {
|
||||
this.emitFile({
|
||||
type: 'asset',
|
||||
// A fixed name, not a content hash: the folder already carries the version.
|
||||
fileName: prefix + relative,
|
||||
source: fs.readFileSync(path.join(packageDir, relative)),
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// @vitest-environment node
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* pdf.js, docx-preview and SheetJS are hundreds of kilobytes apiece. The build does
|
||||
* not warn when one of them slips into the main bundle (`INEFFECTIVE_DYNAMIC_IMPORT`
|
||||
* is silenced in vite.config.ts), and every user would then pay for it on startup
|
||||
* whether or not they ever open a document.
|
||||
*
|
||||
* So the rule is checked here, on the source: a document engine is reached by a
|
||||
* dynamic `import()` from inside a function, never by an `import` at the top of a
|
||||
* module. The one exception is the worker entry, which exists to be loaded on its
|
||||
* own by `?worker` and is not part of any bundle that runs on the page.
|
||||
*/
|
||||
|
||||
const srcRoot = path.resolve(import.meta.dirname, '..')
|
||||
|
||||
const HEAVY_PACKAGE = /^(pdfjs-dist|docx-preview|xlsx)(\/|$)/
|
||||
|
||||
/** Modules whose whole job is to be the far end of a dynamic import or a worker. */
|
||||
const ENTRY_POINTS = new Set(['components/workspace/surfaces/document/pdf.worker.ts'])
|
||||
|
||||
/** `src/test`: fixtures and fakes that only tests import. They build documents with these very libraries. */
|
||||
const TEST_SUPPORT_DIRECTORY = path.join(srcRoot, 'test')
|
||||
|
||||
function sourceFiles(directory: string): string[] {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const full = path.join(directory, entry.name)
|
||||
if (entry.isDirectory()) return entry.name === 'node_modules' || full === TEST_SUPPORT_DIRECTORY ? [] : sourceFiles(full)
|
||||
return /\.(ts|tsx)$/.test(entry.name) && !/\.(test|spec)\.tsx?$/.test(entry.name) && !entry.name.endsWith('.d.ts') ? [full] : []
|
||||
})
|
||||
}
|
||||
|
||||
type StaticImport = { file: string; specifier: string; typeOnly: boolean }
|
||||
|
||||
function staticImports(file: string): StaticImport[] {
|
||||
const source = ts.createSourceFile(file, readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true)
|
||||
const found: StaticImport[] = []
|
||||
for (const node of source.statements) {
|
||||
const isImport = ts.isImportDeclaration(node)
|
||||
const isReExport = ts.isExportDeclaration(node)
|
||||
if (!(isImport || isReExport) || !node.moduleSpecifier || !ts.isStringLiteral(node.moduleSpecifier)) continue
|
||||
const typeOnly = isImport ? node.importClause?.isTypeOnly === true : node.isTypeOnly
|
||||
found.push({ file: path.relative(srcRoot, file), specifier: node.moduleSpecifier.text, typeOnly })
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
const files = sourceFiles(srcRoot)
|
||||
const imports = files.flatMap(staticImports)
|
||||
|
||||
describe('document engines stay out of the main bundle', () => {
|
||||
it('reads the source tree it is guarding', () => {
|
||||
// A scan that found nothing would pass every rule below.
|
||||
expect(files.length).toBeGreaterThan(500)
|
||||
expect(files.some((file) => file.endsWith('pdfEngine.ts'))).toBe(true)
|
||||
expect(imports.some((statement) => statement.specifier === 'react')).toBe(true)
|
||||
})
|
||||
|
||||
it('imports a document engine only dynamically, or as a type', () => {
|
||||
const eager = imports.filter(
|
||||
(statement) => HEAVY_PACKAGE.test(statement.specifier) && !statement.typeOnly && !ENTRY_POINTS.has(statement.file),
|
||||
)
|
||||
|
||||
expect(eager.map((statement) => `${statement.file} imports ${statement.specifier}`)).toEqual([])
|
||||
})
|
||||
|
||||
it('loads pdf.js through the engine, which fetches it on first use', () => {
|
||||
const engine = readFileSync(path.join(srcRoot, 'components/workspace/surfaces/document/pdfEngine.ts'), 'utf8')
|
||||
|
||||
expect(engine).toMatch(/import\('pdfjs-dist\/legacy\/build\/pdf\.mjs'\)/)
|
||||
})
|
||||
|
||||
it('never imports the worker entry from the page, only through ?worker', () => {
|
||||
const direct = imports.filter((statement) => /(^|\/)pdf\.worker(\.ts)?$/.test(statement.specifier))
|
||||
|
||||
expect(direct.map((statement) => `${statement.file} imports ${statement.specifier}`)).toEqual([])
|
||||
})
|
||||
|
||||
it('reaches every document viewer through a lazy import, so opening a workspace loads none of them', () => {
|
||||
const viewers = imports.filter((statement) => statement.file.endsWith('document/documentViewers.ts'))
|
||||
|
||||
// Only React (for `lazy`) and types: a viewer named here would be in the main bundle.
|
||||
expect(viewers.filter((statement) => !statement.typeOnly).map((statement) => statement.specifier)).toEqual(['react'])
|
||||
})
|
||||
|
||||
it('does not let the panel that hosts documents import a viewer or engine directly', () => {
|
||||
const host = imports.filter(
|
||||
(statement) => statement.file.endsWith('workbench/WorkspaceFileTab.tsx') && !statement.typeOnly,
|
||||
)
|
||||
|
||||
expect(host.filter((statement) => /(Pdf|Docx|Spreadsheet)(Surface|Viewer|Engine)/.test(statement.specifier))).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('document libraries are declared as what they are', () => {
|
||||
const manifest = JSON.parse(readFileSync(path.resolve(srcRoot, '../package.json'), 'utf8')) as {
|
||||
dependencies: Record<string, string>
|
||||
devDependencies: Record<string, string>
|
||||
}
|
||||
|
||||
it.each(['pdfjs-dist', 'docx-preview', 'xlsx', 'fflate'])(
|
||||
'%s is a devDependency: the renderer bundles it, and electron-builder packs every "dependencies" entry into app.asar',
|
||||
(name) => {
|
||||
// Listed under `dependencies` these four shipped a second, unused copy of about 45 MB
|
||||
// in every installer, beside the copy Vite had already put in dist/.
|
||||
expect(manifest.devDependencies[name]).toBeDefined()
|
||||
expect(manifest.dependencies[name]).toBeUndefined()
|
||||
},
|
||||
)
|
||||
|
||||
it('takes SheetJS from its own CDN release, because the npm package stops at 0.18.5 and has unfixed advisories', () => {
|
||||
expect(manifest.devDependencies.xlsx).toBe('https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz')
|
||||
})
|
||||
|
||||
it.each(['pdfjs-dist', 'docx-preview'])('pins %s to one version: the viewers are written against its internals', (name) => {
|
||||
expect(manifest.devDependencies[name]).toMatch(/^\d+\.\d+\.\d+$/)
|
||||
})
|
||||
})
|
||||
@@ -246,6 +246,73 @@ describe('sessionsApi', () => {
|
||||
expect(init).toMatchObject({ method: 'GET' })
|
||||
})
|
||||
|
||||
it('downloads a workspace document as a blob from the raw route with an encoded path', async () => {
|
||||
const bytes = new Uint8Array([0x25, 0x50, 0x44, 0x46])
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(bytes, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/pdf' },
|
||||
}))
|
||||
|
||||
const blob = await sessionsApi.getWorkspaceRaw('session-1', 'docs/论文 final.pdf')
|
||||
|
||||
expect(blob.size).toBe(bytes.byteLength)
|
||||
expect(blob.type).toBe('application/pdf')
|
||||
const [url, init] = fetchMock.mock.calls[0]!
|
||||
expect(url).toBe('http://127.0.0.1:3456/api/sessions/session-1/workspace/raw?path=docs%2F%E8%AE%BA%E6%96%87+final.pdf')
|
||||
expect(init).toMatchObject({ method: 'GET' })
|
||||
})
|
||||
|
||||
it('surfaces a refused document download as an API error instead of a blob', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response('too large', { status: 413 }))
|
||||
|
||||
await expect(sessionsApi.getWorkspaceRaw('session-1', 'big.docx')).rejects.toMatchObject({ status: 413 })
|
||||
})
|
||||
|
||||
describe('document download timing', () => {
|
||||
// A hung transfer must be cut off, but a whole file over a remote link is
|
||||
// far slower than the JSON the default two-minute limit is sized for.
|
||||
function hangingFetch() {
|
||||
const seen: { signal?: AbortSignal } = {}
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementationOnce((_url, init) => new Promise((_resolve, reject) => {
|
||||
seen.signal = init?.signal ?? undefined
|
||||
seen.signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')))
|
||||
}))
|
||||
return seen
|
||||
}
|
||||
|
||||
// What the promise rejected with, without leaving an unawaited assertion behind.
|
||||
const settle = (promise: Promise<unknown>) => promise.then(() => null, (error: unknown) => error)
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('allows ten minutes rather than the default request timeout', async () => {
|
||||
vi.useFakeTimers()
|
||||
const seen = hangingFetch()
|
||||
|
||||
const settled = settle(sessionsApi.getWorkspaceRaw('session-1', 'a.pdf'))
|
||||
await vi.advanceTimersByTimeAsync(10 * 60_000 - 1)
|
||||
expect(seen.signal?.aborted).toBe(false)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(seen.signal?.aborted).toBe(true)
|
||||
expect(await settled).toMatchObject({ name: 'AbortError' })
|
||||
})
|
||||
|
||||
it('stops as soon as the caller cancels', async () => {
|
||||
const seen = hangingFetch()
|
||||
const caller = new AbortController()
|
||||
|
||||
const settled = settle(sessionsApi.getWorkspaceRaw('session-1', 'a.pdf', caller.signal))
|
||||
expect(seen.signal?.aborted).toBe(false)
|
||||
|
||||
caller.abort()
|
||||
expect(seen.signal?.aborted).toBe(true)
|
||||
expect(await settled).toMatchObject({ name: 'AbortError' })
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves optional local index progress from session list responses', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { api, type ApiRequestOptions } from './client'
|
||||
import { api, apiGetBlob, type ApiRequestOptions } from './client'
|
||||
import type { SlashCommandOption } from '../types/slashCommand'
|
||||
import type { AgentTaskNotification } from '../types/chat'
|
||||
import type { LocalIndexStatus, SessionListItem, MessageEntry } from '../types/session'
|
||||
@@ -325,10 +325,18 @@ export type WorkspaceStatusResult = {
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** Formats the workspace renders itself from bytes fetched with {@link sessionsApi.getWorkspaceRaw}. */
|
||||
export type WorkspaceDocumentPreviewType = 'pdf' | 'docx' | 'xlsx'
|
||||
|
||||
export type WorkspaceReadFileResult = {
|
||||
state: 'ok' | 'binary' | 'too_large' | 'missing' | 'error'
|
||||
path: string
|
||||
previewType?: 'text' | 'image'
|
||||
/**
|
||||
* `text` and `image` carry their payload in this response. A document type is
|
||||
* metadata only — its bytes are fetched separately, so a watcher reload never
|
||||
* re-downloads a large file as JSON.
|
||||
*/
|
||||
previewType?: 'text' | 'image' | WorkspaceDocumentPreviewType
|
||||
content?: string
|
||||
dataUrl?: string
|
||||
mimeType?: string
|
||||
@@ -336,6 +344,8 @@ export type WorkspaceReadFileResult = {
|
||||
size: number
|
||||
truncated?: boolean
|
||||
readBytes?: number
|
||||
/** Document types only: changes whenever the bytes may have changed. */
|
||||
version?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
@@ -400,9 +410,15 @@ function getSessionGitInfo(sessionId: string) {
|
||||
return trackedRequest
|
||||
}
|
||||
|
||||
/**
|
||||
* A whole document is fetched in one request and can be large and slow to
|
||||
* cross a remote link; the default request timeout is sized for JSON.
|
||||
*/
|
||||
const WORKSPACE_RAW_TIMEOUT_MS = 10 * 60_000
|
||||
|
||||
function buildWorkspacePath(
|
||||
sessionId: string,
|
||||
resource: 'status' | 'tree' | 'file' | 'diff',
|
||||
resource: 'status' | 'tree' | 'file' | 'diff' | 'raw',
|
||||
workspacePath?: string,
|
||||
) {
|
||||
const query = new URLSearchParams()
|
||||
@@ -606,6 +622,18 @@ export const sessionsApi = {
|
||||
return api.get<WorkspaceReadFileResult>(buildWorkspacePath(sessionId, 'file', workspacePath), { signal })
|
||||
},
|
||||
|
||||
/**
|
||||
* The bytes of a document the workspace previews, as a Blob. Fetched with the
|
||||
* bearer credential, the one form that works in the desktop shell, a LAN
|
||||
* browser and remote access alike — an `<img>`/`<iframe>` cannot send it.
|
||||
*/
|
||||
getWorkspaceRaw(sessionId: string, workspacePath: string, signal?: AbortSignal) {
|
||||
return apiGetBlob(buildWorkspacePath(sessionId, 'raw', workspacePath), {
|
||||
signal,
|
||||
timeout: WORKSPACE_RAW_TIMEOUT_MS,
|
||||
})
|
||||
},
|
||||
|
||||
getWorkspaceDiff(sessionId: string, workspacePath: string) {
|
||||
return api.get<WorkspaceDiffResult>(buildWorkspacePath(sessionId, 'diff', workspacePath))
|
||||
},
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
import '@testing-library/jest-dom'
|
||||
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useSettingsStore } from '@/stores/settingsStore'
|
||||
import type { UIMessage } from '@/types/chat'
|
||||
import { ActivityGroup } from './ActivityGroup'
|
||||
import type { ActivityStep } from './activityGroupModel'
|
||||
|
||||
type ToolCall = Extract<UIMessage, { type: 'tool_use' }>
|
||||
type ToolResult = Extract<UIMessage, { type: 'tool_result' }>
|
||||
type GalleryImages = Array<{ src: string; name: string; path?: string }>
|
||||
|
||||
// The lightbox has its own tests; here it only records what each strip hands it.
|
||||
const lightbox = vi.hoisted(() => ({ images: [] as GalleryImages }))
|
||||
|
||||
vi.mock('./ImageGalleryModal', () => ({
|
||||
ImageGalleryModal: (props: { images: GalleryImages; activeIndex: number }) => {
|
||||
lightbox.images = props.images
|
||||
return <div role="dialog" aria-label={props.images[props.activeIndex]?.name} />
|
||||
},
|
||||
}))
|
||||
|
||||
function picture(label: string, mediaType = 'image/png') {
|
||||
return { type: 'image', source: { type: 'base64', media_type: mediaType, data: Buffer.from(label).toString('base64') } }
|
||||
}
|
||||
|
||||
function toolCall(overrides: Partial<ToolCall> & Pick<ToolCall, 'id' | 'toolUseId' | 'toolName'>): ToolCall {
|
||||
return { type: 'tool_use', input: {}, timestamp: 0, ...overrides }
|
||||
}
|
||||
|
||||
function toolResult(toolUseId: string, content: unknown): ToolResult {
|
||||
return { id: `result-${toolUseId}`, type: 'tool_result', toolUseId, content, isError: false, timestamp: 0 }
|
||||
}
|
||||
|
||||
function resultsOf(results: ToolResult[]): Map<string, ToolResult> {
|
||||
return new Map(results.map((result) => [result.toolUseId, result]))
|
||||
}
|
||||
|
||||
function stepsOf(...calls: ToolCall[]): ActivityStep[] {
|
||||
return calls.map((call) => ({ kind: 'tool' as const, toolCall: call }))
|
||||
}
|
||||
|
||||
const readA = toolCall({ id: 'use-read-a', toolUseId: 'read-a', toolName: 'Read', input: { file_path: '/repo/shots/a.png' }, timestamp: 1_000 })
|
||||
const readB = toolCall({ id: 'use-read-b', toolUseId: 'read-b', toolName: 'Read', input: { file_path: '/repo/shots/b.png' }, timestamp: 2_000 })
|
||||
// A screenshot tool that may have written a file: that path is not what its pixels are a copy of.
|
||||
const shot = toolCall({
|
||||
id: 'use-shot',
|
||||
toolUseId: 'shot-1',
|
||||
toolName: 'mcp__browser__screenshot',
|
||||
input: { file_path: '/repo/shots/written-by-the-tool.png' },
|
||||
timestamp: 3_000,
|
||||
})
|
||||
const bash = toolCall({ id: 'use-bash', toolUseId: 'bash-1', toolName: 'Bash', input: { command: 'ls shots' }, timestamp: 4_000 })
|
||||
|
||||
let counter = 0
|
||||
let createObjectURL: ReturnType<typeof vi.fn>
|
||||
const savedUrlMembers = {
|
||||
create: Object.getOwnPropertyDescriptor(URL, 'createObjectURL'),
|
||||
revoke: Object.getOwnPropertyDescriptor(URL, 'revokeObjectURL'),
|
||||
}
|
||||
|
||||
function restoreUrlMember(name: 'createObjectURL' | 'revokeObjectURL', saved: PropertyDescriptor | undefined) {
|
||||
if (saved) Object.defineProperty(URL, name, saved)
|
||||
else delete (URL as unknown as Record<string, unknown>)[name]
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
counter = 0
|
||||
lightbox.images = []
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
createObjectURL = vi.fn(() => `blob:run-${(counter += 1)}`)
|
||||
Object.defineProperty(URL, 'createObjectURL', { configurable: true, writable: true, value: createObjectURL })
|
||||
Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, writable: true, value: vi.fn() })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
restoreUrlMember('createObjectURL', savedUrlMembers.create)
|
||||
restoreUrlMember('revokeObjectURL', savedUrlMembers.revoke)
|
||||
})
|
||||
|
||||
function renderGroup(
|
||||
steps: ActivityStep[],
|
||||
resultMap: Map<string, ToolResult>,
|
||||
props: Partial<Parameters<typeof ActivityGroup>[0]> = {},
|
||||
) {
|
||||
return render(
|
||||
<ActivityGroup steps={steps} resultMap={resultMap} childToolCallsByParent={new Map()} {...props} />,
|
||||
)
|
||||
}
|
||||
|
||||
function groupOf(): HTMLElement {
|
||||
return screen.getByTestId('activity-group')
|
||||
}
|
||||
|
||||
function summaryOf(group: HTMLElement): HTMLElement {
|
||||
return group.querySelector<HTMLElement>('[data-chat-disclosure="true"]')!
|
||||
}
|
||||
|
||||
function stripsIn(root: ParentNode): HTMLElement[] {
|
||||
return [...root.querySelectorAll<HTMLElement>('[data-tool-result-images]')]
|
||||
}
|
||||
|
||||
/** How many times a base64 string was copied for validation while `run` ran. */
|
||||
function countValidations(run: () => void): number {
|
||||
const original = String.prototype.replace as (this: string, ...args: unknown[]) => string
|
||||
let validations = 0
|
||||
const spy = vi.spyOn(String.prototype, 'replace').mockImplementation(function (this: string, ...args: unknown[]) {
|
||||
const [pattern] = args
|
||||
if (pattern instanceof RegExp && pattern.source === '[\\t\\n\\f\\r ]+') validations += 1
|
||||
return original.apply(this, args)
|
||||
} as never)
|
||||
try {
|
||||
run()
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
return validations
|
||||
}
|
||||
|
||||
describe('ActivityGroup · pictures in a folded run (#1397)', () => {
|
||||
const steps = stepsOf(readA, readB)
|
||||
const results = resultsOf([
|
||||
toolResult('read-a', [picture('first picture')]),
|
||||
toolResult('read-b', [picture('second picture')]),
|
||||
])
|
||||
|
||||
it('shows each tool\'s pictures directly under the folded summary', () => {
|
||||
renderGroup(steps, results)
|
||||
const group = groupOf()
|
||||
|
||||
expect(group).toHaveAttribute('data-expanded', 'false')
|
||||
const [summary, first, second, ...rest] = [...group.children] as HTMLElement[]
|
||||
expect(rest).toEqual([])
|
||||
expect(summary).toBe(summaryOf(group))
|
||||
expect(first).toHaveAttribute('data-tool-result-images')
|
||||
expect(second).toHaveAttribute('data-tool-result-images')
|
||||
expect(within(first!).getByRole('button', { name: 'Open image 1 of 1' })).toBeInTheDocument()
|
||||
expect(within(second!).getByRole('button', { name: 'Open image 1 of 1' })).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('group', { name: 'Read result' })).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('keys each strip by its own call, so two Reads do not collide', () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
renderGroup(steps, results)
|
||||
|
||||
const errors = consoleError.mock.calls.map((call) => String(call[0]))
|
||||
consoleError.mockRestore()
|
||||
expect(errors).toEqual([])
|
||||
expect(stripsIn(groupOf())).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('pads the strips a little and does not indent them past the summary text', () => {
|
||||
renderGroup(steps, results)
|
||||
|
||||
for (const strip of stripsIn(groupOf())) {
|
||||
expect(strip).toHaveClass('pt-1', 'pb-1')
|
||||
expect(strip.className).not.toMatch(/(^|\s)(?:pl|px|ml|mx)-/)
|
||||
}
|
||||
})
|
||||
|
||||
it('offers each strip\'s own original in its lightbox', () => {
|
||||
renderGroup(steps, results)
|
||||
const [first, second] = stripsIn(groupOf())
|
||||
|
||||
fireEvent.click(within(first!).getByRole('button', { name: 'Open image 1 of 1' }))
|
||||
expect(lightbox.images).toEqual([{ src: 'blob:run-1', name: 'a.png', path: '/repo/shots/a.png' }])
|
||||
|
||||
fireEvent.click(within(second!).getByRole('button', { name: 'Open image 1 of 1' }))
|
||||
expect(lightbox.images).toEqual([{ src: 'blob:run-2', name: 'b.png', path: '/repo/shots/b.png' }])
|
||||
})
|
||||
|
||||
it('keeps every strip with its own tool, in step order, and skips calls with nothing to show', () => {
|
||||
const mixed = stepsOf(readA, bash, shot, readB)
|
||||
renderGroup(mixed, resultsOf([
|
||||
toolResult('read-a', [picture('a')]),
|
||||
toolResult('bash-1', 'shots listed'),
|
||||
toolResult('shot-1', [{ type: 'text', text: 'captured' }, picture('shot'), picture('shot too')]),
|
||||
toolResult('read-b', [picture('b')]),
|
||||
]))
|
||||
const strips = stripsIn(groupOf())
|
||||
|
||||
expect(strips.map((strip) => strip.getAttribute('aria-label'))).toEqual([
|
||||
'Read result',
|
||||
'mcp__browser__screenshot result',
|
||||
'Read result',
|
||||
])
|
||||
expect(strips.map((strip) => within(strip).getAllByRole('img').length)).toEqual([1, 2, 1])
|
||||
|
||||
fireEvent.click(within(strips[0]!).getByRole('button', { name: 'Open image 1 of 1' }))
|
||||
expect(lightbox.images[0]).toMatchObject({ path: '/repo/shots/a.png' })
|
||||
|
||||
// Only Read names its source: the screenshot tool's file_path must not leak in.
|
||||
fireEvent.click(within(strips[1]!).getByRole('button', { name: 'Open image 2 of 2' }))
|
||||
expect(lightbox.images.every((image) => !('path' in image))).toBe(true)
|
||||
|
||||
fireEvent.click(within(strips[2]!).getByRole('button', { name: 'Open image 1 of 1' }))
|
||||
expect(lightbox.images[0]).toMatchObject({ path: '/repo/shots/b.png' })
|
||||
})
|
||||
|
||||
it('says so under the summary when a picture could not be shown', () => {
|
||||
renderGroup(stepsOf(readA, bash), resultsOf([
|
||||
toolResult('read-a', [picture('<svg/>', 'image/svg+xml')]),
|
||||
toolResult('bash-1', 'ok'),
|
||||
]))
|
||||
const [strip, ...others] = stripsIn(groupOf())
|
||||
|
||||
expect(others).toEqual([])
|
||||
expect(within(strip!).getByText('Images not shown: 1')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
expect(createObjectURL).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows nothing for a call whose result has not arrived', () => {
|
||||
renderGroup(stepsOf(readA, readB), resultsOf([toolResult('read-b', [picture('b')])]))
|
||||
|
||||
const strips = stripsIn(groupOf())
|
||||
expect(strips).toHaveLength(1)
|
||||
expect(strips[0]).toHaveAccessibleName('Read result')
|
||||
})
|
||||
|
||||
it('picks up a picture whose result lands after the group first rendered', () => {
|
||||
const steps = stepsOf(readA, readB)
|
||||
const { rerender } = renderGroup(steps, resultsOf([toolResult('read-b', 'text only')]))
|
||||
expect(stripsIn(groupOf())).toEqual([])
|
||||
|
||||
rerender(
|
||||
<ActivityGroup
|
||||
steps={steps}
|
||||
resultMap={resultsOf([toolResult('read-a', [picture('late picture')]), toolResult('read-b', 'text only')])}
|
||||
childToolCallsByParent={new Map()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(stripsIn(groupOf())).toHaveLength(1)
|
||||
expect(screen.getByRole('button', { name: 'Open image 1 of 1' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('leaves the pictures of calls a tool dispatched to their own rows', () => {
|
||||
const child = toolCall({ id: 'use-child', toolUseId: 'child-1', toolName: 'Read', input: { file_path: '/repo/shots/child.png' }, parentToolUseId: 'bash-1' })
|
||||
render(
|
||||
<ActivityGroup
|
||||
steps={stepsOf(readA, bash)}
|
||||
resultMap={resultsOf([
|
||||
toolResult('read-a', 'text only'),
|
||||
toolResult('bash-1', 'ok'),
|
||||
toolResult('child-1', [picture('child')]),
|
||||
])}
|
||||
childToolCallsByParent={new Map([['bash-1', [child]]])}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(stripsIn(groupOf())).toEqual([])
|
||||
expect(createObjectURL).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stays out of the way of a run that returned no pictures', () => {
|
||||
renderGroup(stepsOf(readA, bash), resultsOf([
|
||||
toolResult('read-a', [{ type: 'text', text: 'const a = 1' }]),
|
||||
toolResult('bash-1', 'ok'),
|
||||
]))
|
||||
const group = groupOf()
|
||||
|
||||
// Just the summary line, as before: no wrapper, no empty strip.
|
||||
expect([...group.children]).toEqual([summaryOf(group)])
|
||||
expect(stripsIn(document.body)).toEqual([])
|
||||
expect(createObjectURL).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ActivityGroup · pictures in an open run', () => {
|
||||
const steps = stepsOf(readA, readB)
|
||||
const results = resultsOf([
|
||||
toolResult('read-a', [picture('first picture')]),
|
||||
toolResult('read-b', [picture('second picture'), picture('third picture')]),
|
||||
])
|
||||
|
||||
it('shows each picture once, in its own row, and none under the summary', () => {
|
||||
renderGroup(steps, results, { isLive: true })
|
||||
const group = groupOf()
|
||||
|
||||
expect(group).toHaveAttribute('data-expanded', 'true')
|
||||
const strips = stripsIn(group)
|
||||
expect(strips).toHaveLength(2)
|
||||
for (const strip of strips) {
|
||||
expect(strip.closest('[data-tool-call-chrome="row"]')).not.toBeNull()
|
||||
expect(strip.parentElement).not.toBe(group)
|
||||
}
|
||||
expect(within(group).getAllByRole('img')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('moves the pictures between the rows and the summary as the reader folds and opens it', () => {
|
||||
renderGroup(steps, results, { isLive: true })
|
||||
const group = groupOf()
|
||||
const imagesShown = () => within(group).getAllByRole('img').length
|
||||
|
||||
fireEvent.click(summaryOf(group))
|
||||
expect(group).toHaveAttribute('data-expanded', 'false')
|
||||
expect(stripsIn(group)).toHaveLength(2)
|
||||
expect(stripsIn(group).every((strip) => strip.parentElement === group)).toBe(true)
|
||||
expect(imagesShown()).toBe(3)
|
||||
|
||||
fireEvent.click(summaryOf(group))
|
||||
expect(group).toHaveAttribute('data-expanded', 'true')
|
||||
expect(stripsIn(group)).toHaveLength(2)
|
||||
expect(stripsIn(group).every((strip) => strip.parentElement !== group)).toBe(true)
|
||||
expect(imagesShown()).toBe(3)
|
||||
})
|
||||
|
||||
it('shows the pictures of a live run the reader has folded', () => {
|
||||
renderGroup(steps, results, { isLive: true, isStreaming: true })
|
||||
expect(stripsIn(groupOf()).every((strip) => strip.closest('[data-tool-call-chrome="row"]'))).toBe(true)
|
||||
|
||||
fireEvent.click(summaryOf(groupOf()))
|
||||
|
||||
expect(groupOf()).toHaveAttribute('data-expanded', 'false')
|
||||
expect(stripsIn(groupOf())).toHaveLength(2)
|
||||
expect(stripsIn(groupOf()).every((strip) => strip.parentElement === groupOf())).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ActivityGroup · a run of one call', () => {
|
||||
it('shows the picture once, in its row, as it did before', () => {
|
||||
renderGroup(stepsOf(readA), resultsOf([toolResult('read-a', [picture('only picture')])]))
|
||||
const group = groupOf()
|
||||
|
||||
expect(group).toHaveAttribute('data-single-step', 'true')
|
||||
const strips = stripsIn(group)
|
||||
expect(strips).toHaveLength(1)
|
||||
expect(strips[0]!.closest('[data-tool-call-chrome="row"]')).not.toBeNull()
|
||||
expect(within(group).getAllByRole('img')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('shows the picture of its row once the result arrives', () => {
|
||||
const steps = stepsOf(readA)
|
||||
const { rerender } = renderGroup(steps, resultsOf([]))
|
||||
expect(stripsIn(document.body)).toEqual([])
|
||||
|
||||
rerender(
|
||||
<ActivityGroup
|
||||
steps={steps}
|
||||
resultMap={resultsOf([toolResult('read-a', [picture('late picture')])])}
|
||||
childToolCallsByParent={new Map()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(stripsIn(groupOf())).toHaveLength(1)
|
||||
expect(stripsIn(groupOf())[0]!.closest('[data-tool-call-chrome="row"]')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ActivityGroup · reading pictures while a turn streams', () => {
|
||||
it('validates each picture once, however often the group renders around it', () => {
|
||||
const steps = stepsOf(readA, readB)
|
||||
const contents = { a: [picture('first picture')], b: [picture('second picture')] }
|
||||
const results = resultsOf([toolResult('read-a', contents.a), toolResult('read-b', contents.b)])
|
||||
|
||||
const validations = countValidations(() => {
|
||||
const { rerender } = renderGroup(steps, results)
|
||||
// The transcript rebuilds its result map and step arrays for every token; the
|
||||
// results in them are the same objects.
|
||||
for (let token = 0; token < 4; token += 1) {
|
||||
rerender(
|
||||
<ActivityGroup
|
||||
steps={[...steps]}
|
||||
resultMap={new Map(results)}
|
||||
childToolCallsByParent={new Map()}
|
||||
isStreaming={token % 2 === 0}
|
||||
activeThinkingId={`thinking-${token}`}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
// Opening and folding again mounts the rows and the summary strips anew.
|
||||
fireEvent.click(summaryOf(groupOf()))
|
||||
fireEvent.click(summaryOf(groupOf()))
|
||||
})
|
||||
|
||||
expect(validations).toBe(2)
|
||||
expect(stripsIn(groupOf())).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('does not decode the pictures again either', () => {
|
||||
const results = resultsOf([toolResult('read-a', [picture('first picture')]), toolResult('bash-1', 'ok')])
|
||||
const { rerender } = renderGroup(stepsOf(readA, bash), results)
|
||||
expect(createObjectURL).toHaveBeenCalledTimes(1)
|
||||
|
||||
rerender(
|
||||
<ActivityGroup
|
||||
steps={stepsOf(readA, bash)}
|
||||
resultMap={new Map(results)}
|
||||
childToolCallsByParent={new Map()}
|
||||
isStreaming
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(createObjectURL).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,10 @@
|
||||
import { memo, useMemo, useState } from 'react'
|
||||
import { getDisclosure, setDisclosure } from '../../lib/disclosureMemory'
|
||||
import { toolResultImagesFor, type ToolResultImageExtraction } from '@/lib/toolResultContent'
|
||||
import { CircleX } from 'lucide-react'
|
||||
import { ToolCallBlock, formatDuration } from './ToolCallBlock'
|
||||
import { ThinkingBlock } from './ThinkingBlock'
|
||||
import { ToolResultImages } from './ToolResultImages'
|
||||
import {
|
||||
activityDurationMs,
|
||||
activityStepToolCalls,
|
||||
@@ -147,6 +149,10 @@ export const ActivityGroup = memo(function ActivityGroup({
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Folding hides the rows, and with them any picture a tool returned.
|
||||
Open, each row shows its own, so these exist only while folded. */}
|
||||
{collapsed ? <CollapsedRunImages toolCalls={toolCalls} resultMap={resultMap} /> : null}
|
||||
|
||||
{/* Rows hang off the summary that names them, so they take the guide
|
||||
line and its indent. That is also why they can afford it here and not
|
||||
when there is no summary — nothing to hang from, nothing to indent. */}
|
||||
@@ -173,6 +179,65 @@ export const ActivityGroup = memo(function ActivityGroup({
|
||||
)
|
||||
})
|
||||
|
||||
type RunImageStrip = ToolResultImageExtraction & {
|
||||
id: string
|
||||
toolName: string
|
||||
originalPath?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The pictures a folded run's tools returned, under its summary line.
|
||||
*
|
||||
* A picture is what the tool was called for, not machinery to skim past, so it
|
||||
* does not vanish with the rows. One strip per top-level call, in step order, each
|
||||
* named for its own tool and offering its own original; calls the run dispatched
|
||||
* are left to their rows.
|
||||
*
|
||||
* Read from the result map on every render, but each result's content is
|
||||
* validated once: the map is rebuilt for every streamed token (and so is this
|
||||
* component's input), which is why the memoization sits on the content object in
|
||||
* `extractToolResultImages` rather than on the map or the calls.
|
||||
*/
|
||||
function CollapsedRunImages({
|
||||
toolCalls,
|
||||
resultMap,
|
||||
}: {
|
||||
toolCalls: ToolCall[]
|
||||
resultMap: Map<string, ToolResult>
|
||||
}) {
|
||||
const strips = useMemo(() => {
|
||||
const found: RunImageStrip[] = []
|
||||
for (const toolCall of toolCalls) {
|
||||
const result = resultMap.get(toolCall.toolUseId)
|
||||
if (!result) continue
|
||||
const shown = toolResultImagesFor({
|
||||
toolName: toolCall.toolName,
|
||||
input: toolCall.input,
|
||||
content: result.content,
|
||||
})
|
||||
if (shown.images.length > 0 || shown.dropped > 0) {
|
||||
found.push({ ...shown, id: toolCall.id, toolName: toolCall.toolName })
|
||||
}
|
||||
}
|
||||
return found
|
||||
}, [resultMap, toolCalls])
|
||||
|
||||
return (
|
||||
<>
|
||||
{strips.map((strip) => (
|
||||
<ToolResultImages
|
||||
key={strip.id}
|
||||
images={strip.images}
|
||||
omitted={strip.dropped}
|
||||
originalPath={strip.originalPath}
|
||||
toolName={strip.toolName}
|
||||
className="pb-1 pt-1"
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** A tool row plus, indented under it, the rows of anything it dispatched. */
|
||||
function ActivityToolRow({
|
||||
toolCall,
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import '@testing-library/jest-dom'
|
||||
import { act, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { WorkspaceStatusResult } from '../../api/sessions'
|
||||
import { browserHost } from '../../lib/desktopHost/browserHost'
|
||||
import { openLocalFileWithSystem } from '../../lib/systemFileOpen'
|
||||
import { useOverlayStore } from '../../stores/overlayStore'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { useWorkspaceContentStore } from '../../stores/workspaceContentStore'
|
||||
import { AssistantMessage } from './AssistantMessage'
|
||||
|
||||
const BASE = 'http://127.0.0.1:4321'
|
||||
|
||||
vi.mock('../../lib/desktopRuntime', async (orig) => ({
|
||||
...(await orig<Record<string, unknown>>()),
|
||||
getServerBaseUrl: () => 'http://127.0.0.1:4321',
|
||||
}))
|
||||
vi.mock('../../lib/systemFileOpen', () => ({
|
||||
openLocalFileWithSystem: vi.fn().mockResolvedValue(undefined),
|
||||
reportOpenFailure: vi.fn(),
|
||||
}))
|
||||
|
||||
const filesystem = (path: string) => `${BASE}/api/filesystem/file?path=${encodeURIComponent(path)}`
|
||||
|
||||
function withWorkDir(workDir: string | undefined) {
|
||||
const status: WorkspaceStatusResult | undefined = workDir
|
||||
? { state: 'ok', workDir, repoName: null, branch: null, isGitRepo: false, changedFiles: [] }
|
||||
: undefined
|
||||
useWorkspaceContentStore.setState({ statusBySession: status ? { s1: status } : {} })
|
||||
}
|
||||
|
||||
/** The pictures the prose itself carries, not the gallery under it. */
|
||||
function proseImages(container: HTMLElement): HTMLImageElement[] {
|
||||
return Array.from(container.querySelectorAll<HTMLImageElement>('.markdown-prose img'))
|
||||
}
|
||||
|
||||
function renderMessage(content: string, props: { isStreaming?: boolean } = {}) {
|
||||
return render(<AssistantMessage sessionId="s1" content={content} isStreaming={props.isStreaming ?? false} />)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
useOverlayStore.setState(useOverlayStore.getInitialState(), true)
|
||||
withWorkDir('/repo')
|
||||
vi.mocked(openLocalFileWithSystem).mockClear()
|
||||
Reflect.deleteProperty(window, 'desktopHost')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
withWorkDir(undefined)
|
||||
Reflect.deleteProperty(window, 'desktopHost')
|
||||
})
|
||||
|
||||
describe('AssistantMessage · Markdown pictures on disk', () => {
|
||||
it('serves a picture in the workdir from the session sandbox', () => {
|
||||
const { container } = renderMessage('')
|
||||
|
||||
expect(proseImages(container).map((image) => image.getAttribute('src'))).toEqual([
|
||||
`${BASE}/preview-fs/s1//repo/output/chart.png`,
|
||||
])
|
||||
})
|
||||
|
||||
it('shows a picture outside the workdir, which used to be a broken image', () => {
|
||||
const { container } = renderMessage('')
|
||||
|
||||
expect(proseImages(container).map((image) => image.getAttribute('src'))).toEqual([
|
||||
filesystem('/Users/me/Pictures/chart.png'),
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a file:// URL with a drive letter', '', 'C:/Users/me/chart.png'],
|
||||
['a Windows path', '', 'C:/Users/me/chart.png'],
|
||||
['a home-relative path', '', '~/Pictures/chart.png'],
|
||||
['a file:// URL', '', '/Users/me/chart.png'],
|
||||
])('shows a picture written as %s', (_label, markdown, path) => {
|
||||
const { container } = renderMessage(markdown)
|
||||
|
||||
expect(proseImages(container).map((image) => image.getAttribute('src'))).toEqual([filesystem(path)])
|
||||
})
|
||||
|
||||
it('routes a picture again once the workdir arrives, which is fetched after the reply first renders', () => {
|
||||
withWorkDir(undefined)
|
||||
const { container } = renderMessage('')
|
||||
// Without a workdir every absolute path is tried on the session route.
|
||||
expect(proseImages(container).map((image) => image.getAttribute('src'))).toEqual([
|
||||
`${BASE}/preview-fs/s1//Users/me/Pictures/chart.png`,
|
||||
])
|
||||
|
||||
act(() => withWorkDir('/repo'))
|
||||
|
||||
expect(proseImages(container).map((image) => image.getAttribute('src'))).toEqual([
|
||||
filesystem('/Users/me/Pictures/chart.png'),
|
||||
])
|
||||
})
|
||||
|
||||
it('does not ask the filesystem route for what is not a picture', () => {
|
||||
const { container } = renderMessage('')
|
||||
|
||||
expect(proseImages(container)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('still refuses a remote picture', () => {
|
||||
const { container } = renderMessage('')
|
||||
|
||||
expect(proseImages(container)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('shows none while the reply is still being written', () => {
|
||||
const { container } = renderMessage('', { isStreaming: true })
|
||||
|
||||
expect(proseImages(container)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AssistantMessage · looking closer at a picture', () => {
|
||||
const TWO = '\n\n'
|
||||
|
||||
it('opens the picture clicked in a viewer, and says which of how many', () => {
|
||||
const { container } = renderMessage(TWO)
|
||||
|
||||
fireEvent.click(proseImages(container)[1]!)
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: 'second' })
|
||||
expect(within(dialog).getByText('2 / 2')).toBeInTheDocument()
|
||||
expect(dialog.querySelector('img')).toHaveAttribute('src', `${BASE}/preview-fs/s1//repo/two.png`)
|
||||
})
|
||||
|
||||
it('moves between the pictures of the reply', () => {
|
||||
const { container } = renderMessage(TWO)
|
||||
fireEvent.click(proseImages(container)[0]!)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next image' }))
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'second' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('closes', () => {
|
||||
const { container } = renderMessage(TWO)
|
||||
fireEvent.click(proseImages(container)[0]!)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('names a picture that has no description after its file', () => {
|
||||
const { container } = renderMessage('')
|
||||
|
||||
fireEvent.click(proseImages(container)[0]!)
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'chart.png' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('leaves a picture inside a link to the link', () => {
|
||||
const { container } = renderMessage('[](https://example.com)')
|
||||
|
||||
fireEvent.click(proseImages(container)[0]!)
|
||||
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('opening the original', () => {
|
||||
const desktop = () => {
|
||||
window.desktopHost = {
|
||||
...browserHost,
|
||||
kind: 'electron',
|
||||
isDesktop: true,
|
||||
capabilities: { ...browserHost.capabilities, shell: true },
|
||||
}
|
||||
}
|
||||
const open = () => screen.queryByRole('button', { name: 'Open in system app' })
|
||||
|
||||
it.each([
|
||||
['outside the workdir', '', '/Users/me/Pictures/c.png'],
|
||||
['inside the workdir', '', '/repo/out/c.png'],
|
||||
['relative to the workdir', '', '/repo/out/c.png'],
|
||||
['under the home directory', '', '~/Pictures/c.png'],
|
||||
['on a Windows drive', '', 'C:/Users/me/c.png'],
|
||||
])('hands the file to the system for a picture %s', (_label, markdown, path) => {
|
||||
desktop()
|
||||
const { container } = renderMessage(markdown)
|
||||
fireEvent.click(proseImages(container)[0]!)
|
||||
|
||||
fireEvent.click(open()!)
|
||||
|
||||
expect(openLocalFileWithSystem).toHaveBeenCalledWith(path)
|
||||
})
|
||||
|
||||
it('places a picture relative to the workdir once the workdir arrives', () => {
|
||||
desktop()
|
||||
withWorkDir(undefined)
|
||||
const { container } = renderMessage('')
|
||||
act(() => withWorkDir('/repo'))
|
||||
fireEvent.click(proseImages(container)[0]!)
|
||||
|
||||
fireEvent.click(open()!)
|
||||
|
||||
expect(openLocalFileWithSystem).toHaveBeenCalledWith('/repo/out/c.png')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a launcher', ''],
|
||||
['a document', ''],
|
||||
])('does not offer it for %s written as a picture', (_label, markdown) => {
|
||||
desktop()
|
||||
const { container } = renderMessage(markdown)
|
||||
// A picture that will not load can still be clicked; the viewer just has no
|
||||
// original to hand to the system, which would open a launcher as one.
|
||||
fireEvent.click(proseImages(container)[0]!)
|
||||
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
expect(open()).not.toBeInTheDocument()
|
||||
expect(openLocalFileWithSystem).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not offer it in a browser', () => {
|
||||
const { container } = renderMessage('')
|
||||
fireEvent.click(proseImages(container)[0]!)
|
||||
|
||||
expect(open()).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not offer it for a picture that is not a file', () => {
|
||||
desktop()
|
||||
const { container } = renderMessage('')
|
||||
fireEvent.click(proseImages(container)[0]!)
|
||||
|
||||
expect(open()).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -8,13 +8,15 @@ import type { OpenWithItem } from '../../lib/openWithItems'
|
||||
import { MessageActionBar, type MessageBranchAction } from './MessageActionBar'
|
||||
import { TurnCompletionStamp } from './TurnCompletionStamp'
|
||||
import type { TurnCompletion } from '../../lib/turnCompletion'
|
||||
import { ImageGalleryModal } from './ImageGalleryModal'
|
||||
import { InlineImageGallery } from './InlineImageGallery'
|
||||
import { InlineVideoGallery } from './InlineVideoGallery'
|
||||
import { AssistantOutputTargetCard } from './AssistantOutputTargetCard'
|
||||
import { openPreviewLink } from '../../lib/openPreviewLink'
|
||||
import { extractAssistantOutputTargets } from '../../lib/assistantOutputTargets'
|
||||
import { resolveAssistantFileHref } from '@/lib/assistantFileContext'
|
||||
import { createAssistantMarkdownImageResolver } from '../../lib/markdownImages'
|
||||
import { createAssistantMarkdownImageResolver, localPathFromMarkdownImageUrl } from '../../lib/markdownImages'
|
||||
import type { MarkdownImageClick } from '../markdown/MarkdownRenderer'
|
||||
import { getServerBaseUrl } from '../../lib/desktopRuntime'
|
||||
import { isManagedGeneratedImagePath } from '../../lib/attachmentImages'
|
||||
import { useWorkspaceContentStore } from '../../stores/workspaceContentStore'
|
||||
@@ -49,6 +51,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
const workDir = useWorkspaceContentStore((s) => (sessionId ? s.statusBySession[sessionId]?.workDir : undefined))
|
||||
|
||||
const [openWith, setOpenWith] = useState<{ items: OpenWithItem[]; anchor: DOMRect } | null>(null)
|
||||
const [viewer, setViewer] = useState<{ images: Array<{ src: string; name: string; path?: string }>; index: number } | null>(null)
|
||||
|
||||
const handleLinkClick = useCallback(
|
||||
(href: string, event: ReactMouseEvent<HTMLDivElement>): boolean => {
|
||||
@@ -107,12 +110,31 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
const resolveLocalImage = createAssistantMarkdownImageResolver({
|
||||
baseUrl: getServerBaseUrl(),
|
||||
sessionId,
|
||||
workDir,
|
||||
})
|
||||
return (src: string) => isManagedGeneratedImagePath(src)
|
||||
? null
|
||||
: resolveLocalImage(src)
|
||||
},
|
||||
[isStreaming, sessionId],
|
||||
[isStreaming, sessionId, workDir],
|
||||
)
|
||||
|
||||
// A click on a picture in the prose opens it, and its neighbours in the reply, in
|
||||
// the viewer. Each one that is a file on disk carries its path, which is read back
|
||||
// from the URL it was served under rather than from anything on the element.
|
||||
const handleImageClick = useCallback(
|
||||
({ images, index }: MarkdownImageClick) => {
|
||||
const baseUrl = getServerBaseUrl()
|
||||
setViewer({
|
||||
index,
|
||||
images: images.map((image) => {
|
||||
const path = localPathFromMarkdownImageUrl(image.src, { baseUrl, workDir }) ?? undefined
|
||||
const name = image.alt.trim() || path?.split(/[\\/]/).filter(Boolean).pop() || t('assistantOutputs.kind.image')
|
||||
return { src: image.src, name, ...(path ? { path } : {}) }
|
||||
}),
|
||||
})
|
||||
},
|
||||
[t, workDir],
|
||||
)
|
||||
|
||||
if (!content.trim()) return null
|
||||
@@ -148,6 +170,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
streaming={isStreaming}
|
||||
onLinkClick={sessionId ? handleLinkClick : undefined}
|
||||
resolveImageSrc={resolveAssistantImageSrc}
|
||||
onImageClick={resolveAssistantImageSrc ? handleImageClick : undefined}
|
||||
/>
|
||||
{!isStreaming && (
|
||||
<InlineImageGallery
|
||||
@@ -192,6 +215,16 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewer && (
|
||||
<ImageGalleryModal
|
||||
open
|
||||
images={viewer.images}
|
||||
activeIndex={viewer.index}
|
||||
onClose={() => setViewer(null)}
|
||||
onSelect={(index) => setViewer((current) => (current ? { ...current, index } : current))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/*
|
||||
Only the reply that closes a turn gets this footer. A turn emits many
|
||||
intermediate replies ("checking the lint spread now") and nobody copies
|
||||
|
||||
@@ -427,6 +427,33 @@ describe('AttachmentGallery', () => {
|
||||
expect(await view.findByText('1 / 1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('lets the gallery hand the path-backed image to the system app', async () => {
|
||||
const view = render(
|
||||
<AttachmentGallery
|
||||
variant="composer"
|
||||
attachments={[{ id: 'pasted-1', type: 'image', name: 'shot.png', path: '/Users/nanmi/Desktop/shot.png' }]}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: 'Open shot.png' }))
|
||||
|
||||
fireEvent.click(await view.findByRole('button', { name: 'Open in system app' }))
|
||||
|
||||
await waitFor(() => expect(openPath).toHaveBeenCalledWith('/Users/nanmi/Desktop/shot.png'))
|
||||
})
|
||||
|
||||
it('offers no original to open for an image that has no file behind it', async () => {
|
||||
const view = render(
|
||||
<AttachmentGallery
|
||||
variant="composer"
|
||||
attachments={[{ id: 'inline-1', type: 'image', name: 'inline.png', previewUrl: 'data:image/png;base64,AAAA' }]}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: 'Open inline.png' }))
|
||||
|
||||
await view.findByText('1 / 1')
|
||||
expect(view.queryByRole('button', { name: 'Open in system app' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to the file card when a path-only image cannot be loaded', () => {
|
||||
const view = render(
|
||||
<AttachmentGallery
|
||||
|
||||
@@ -75,7 +75,7 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove }
|
||||
if (attachment.type !== 'image') return []
|
||||
const src = attachmentImageSource(attachment)
|
||||
if (!src || unloadableImageSources.has(src)) return []
|
||||
return [{ src, name: attachment.name }]
|
||||
return [{ src, name: attachment.name, ...(attachment.path ? { path: attachment.path } : {}) }]
|
||||
}),
|
||||
[attachments, unloadableImageSources],
|
||||
)
|
||||
|
||||
@@ -407,6 +407,78 @@ describe('CurrentTurnChangeCard – row opens the workspace diff', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('CurrentTurnChangeCard – a document the workspace can draw', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
openPreviewSpy.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('opens the document itself rather than its diff, since a document has no line diff', () => {
|
||||
renderExpandedCard(['/w/proj/out/thesis.pdf'])
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /turnChangesOpenInWorkspaceAria/ }))
|
||||
|
||||
// The turn's recorded change for a binary file is empty: the review view
|
||||
// would open onto nothing. What the reader asked for is the document.
|
||||
expect(openPreviewSpy).toHaveBeenCalledWith('s1', 'out/thesis.pdf', 'file', expect.objectContaining({ sourceTurnKey: 'msg-1' }))
|
||||
expect(reviewOpenSpy).not.toHaveBeenCalled()
|
||||
expect(openSystemFileSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens a Word document itself too, not its diff', () => {
|
||||
renderExpandedCard(['/w/proj/out/thesis.docx'])
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /turnChangesOpenInWorkspaceAria/ }))
|
||||
|
||||
expect(openPreviewSpy).toHaveBeenCalledWith('s1', 'out/thesis.docx', 'file', expect.objectContaining({ sourceTurnKey: 'msg-1' }))
|
||||
expect(reviewOpenSpy).not.toHaveBeenCalled()
|
||||
expect(openSystemFileSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['out/budget.xlsx', 'out/legacy.xls'])('opens an Excel workbook (%s) itself too, not its diff', (relative) => {
|
||||
renderExpandedCard([`/w/proj/${relative}`])
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /turnChangesOpenInWorkspaceAria/ }))
|
||||
|
||||
expect(openPreviewSpy).toHaveBeenCalledWith('s1', relative, 'file', expect.objectContaining({ sourceTurnKey: 'msg-1' }))
|
||||
expect(reviewOpenSpy).not.toHaveBeenCalled()
|
||||
expect(openSystemFileSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('labels the row as opening in the workspace, not as handing the file to another app', () => {
|
||||
renderExpandedCard(['/w/proj/out/thesis.pdf'])
|
||||
|
||||
expect(screen.getByRole('button', { name: /turnChangesOpenInWorkspaceAria/ })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /turnChangesOpenFileAria/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens one outside the workdir as a file too, as it does any other file there', () => {
|
||||
renderExpandedCard(['/other/place/thesis.pdf'])
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /turnChangesOpenInWorkspaceAria/ }))
|
||||
|
||||
expect(openPreviewSpy).toHaveBeenCalledWith('s1', '/other/place/thesis.pdf', 'file', expect.objectContaining({ sourceTurnKey: 'msg-1' }))
|
||||
expect(reviewOpenSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps a source file on its diff', () => {
|
||||
renderExpandedCard(['/w/proj/src/main.ts'])
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /turnChangesOpenInWorkspaceAria/ }))
|
||||
|
||||
expect(reviewOpenSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps a format without a viewer with the system application', () => {
|
||||
renderExpandedCard(['/w/proj/out/launch.pptx'])
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /turnChangesOpenFileAria/ }))
|
||||
|
||||
expect(openSystemFileSpy).toHaveBeenCalledWith('/w/proj/out/launch.pptx')
|
||||
expect(openPreviewSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('CurrentTurnChangeCard – open-with buttons', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -540,12 +612,12 @@ describe('CurrentTurnChangeCard – open-with buttons', () => {
|
||||
expect(getTargetsForPathMock).toHaveBeenCalledWith('/w/proj/README.md')
|
||||
})
|
||||
|
||||
it('opens an office changed file with the system application instead of the binary workspace preview', () => {
|
||||
renderExpandedCard(['/w/proj/reports/brief.docx'])
|
||||
it('opens an office changed file the workspace cannot draw with the system application instead of the binary workspace preview', () => {
|
||||
renderExpandedCard(['/w/proj/reports/brief.pptx'])
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /turnChangesOpenFileAria/ }))
|
||||
|
||||
expect(openSystemFileSpy).toHaveBeenCalledWith('/w/proj/reports/brief.docx')
|
||||
expect(openSystemFileSpy).toHaveBeenCalledWith('/w/proj/reports/brief.pptx')
|
||||
expect(openPreviewSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { shouldOfferStaticHtmlPreview } from '../../lib/htmlPreviewPolicy'
|
||||
import { getServerBaseUrl } from '../../lib/desktopRuntime'
|
||||
import { useOpenTargetStore } from '../../stores/openTargetStore'
|
||||
import { workspaceOpen } from '../../lib/workspace/openTarget'
|
||||
import { isWorkspacePreviewableFile } from '../../lib/fileCapabilities'
|
||||
import { isWorkspaceDocumentFile, isWorkspacePreviewableFile } from '../../lib/fileCapabilities'
|
||||
import { openLocalFileWithSystem, reportOpenFailure } from '../../lib/systemFileOpen'
|
||||
|
||||
type CurrentTurnChangeCardProps = {
|
||||
@@ -94,6 +94,12 @@ export function CurrentTurnChangeCard({
|
||||
workspaceOpen.file(sessionId, fileEntry.displayPath, { origin })
|
||||
return
|
||||
}
|
||||
// A document has no line diff: the turn's recorded change for it is empty, so
|
||||
// the review view would open onto nothing. What was asked for is the document.
|
||||
if (isWorkspaceDocumentFile(fileEntry.displayPath)) {
|
||||
workspaceOpen.file(sessionId, fileEntry.displayPath, { origin })
|
||||
return
|
||||
}
|
||||
// Jump to the right-side workspace and show this turn's own recorded change
|
||||
// for that file. The `turn` source deliberately does not become a current-Git
|
||||
// comparison: the card is about what this turn did, not about what the
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import '@testing-library/jest-dom'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ImageGalleryModal } from './ImageGalleryModal'
|
||||
import { browserHost } from '../../lib/desktopHost/browserHost'
|
||||
import { openLocalFileWithSystem, reportOpenFailure } from '../../lib/systemFileOpen'
|
||||
import { useOverlayStore } from '../../stores/overlayStore'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
|
||||
vi.mock('../../lib/systemFileOpen', () => ({
|
||||
openLocalFileWithSystem: vi.fn().mockResolvedValue(undefined),
|
||||
reportOpenFailure: vi.fn(),
|
||||
}))
|
||||
|
||||
const images = [{ src: 'data:image/png;base64,AAAA', name: 'a.png' }]
|
||||
|
||||
const gallery = [
|
||||
@@ -171,3 +178,122 @@ describe('ImageGalleryModal · navigation', () => {
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ImageGalleryModal · closer look', () => {
|
||||
it('gives the picture the zoom controls of the workspace image viewer', () => {
|
||||
render(<ImageGalleryModal open images={images} activeIndex={0} onClose={() => {}} onSelect={() => {}} />)
|
||||
|
||||
expect(screen.getByRole('group', { name: 'Zoom controls' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Zoom in' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Zoom out' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Fit to window' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the active picture in the viewer, and the next one when the reader moves on', () => {
|
||||
const { rerender } = render(<ImageGalleryModal open images={gallery} activeIndex={0} onClose={() => {}} onSelect={() => {}} />)
|
||||
const viewer = () => screen.getByRole('group', { name: /\.png$/ }).querySelector('img')!
|
||||
|
||||
expect(viewer()).toHaveAttribute('src', 'data:image/png;base64,AAAA')
|
||||
|
||||
rerender(<ImageGalleryModal open images={gallery} activeIndex={1} onClose={() => {}} onSelect={() => {}} />)
|
||||
|
||||
expect(viewer()).toHaveAttribute('src', 'data:image/png;base64,BBBB')
|
||||
})
|
||||
|
||||
it('starts each picture fitted: the zoom of the last one is not the next one\'s', () => {
|
||||
const { rerender } = render(<ImageGalleryModal open images={gallery} activeIndex={0} onClose={() => {}} onSelect={() => {}} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }))
|
||||
expect(screen.getByRole('button', { name: 'Fit to window' })).not.toHaveAttribute('aria-pressed', 'true')
|
||||
|
||||
rerender(<ImageGalleryModal open images={gallery} activeIndex={1} onClose={() => {}} onSelect={() => {}} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Fit to window' })).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ImageGalleryModal · open the original', () => {
|
||||
const openPath = vi.fn().mockResolvedValue(undefined)
|
||||
const desktop = () => {
|
||||
window.desktopHost = {
|
||||
...browserHost,
|
||||
kind: 'electron',
|
||||
isDesktop: true,
|
||||
capabilities: { ...browserHost.capabilities, shell: true },
|
||||
shell: { ...browserHost.shell, openPath },
|
||||
}
|
||||
}
|
||||
const withPath = (path?: string) => [{ src: 'blob:https://desktop.invalid/1', name: 'chart.png', ...(path ? { path } : {}) }]
|
||||
const original = () => screen.queryByRole('button', { name: 'Open in system app' })
|
||||
|
||||
beforeEach(() => {
|
||||
openPath.mockClear()
|
||||
vi.mocked(openLocalFileWithSystem).mockClear()
|
||||
vi.mocked(reportOpenFailure).mockClear()
|
||||
Reflect.deleteProperty(window, 'desktopHost')
|
||||
})
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window, 'desktopHost')
|
||||
})
|
||||
|
||||
it('offers it, in the desktop app, for a picture that is a file', () => {
|
||||
desktop()
|
||||
render(<ImageGalleryModal open images={withPath('/Users/me/chart.png')} activeIndex={0} onClose={() => {}} onSelect={() => {}} />)
|
||||
|
||||
fireEvent.click(original()!)
|
||||
|
||||
expect(openLocalFileWithSystem).toHaveBeenCalledWith('/Users/me/chart.png')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a Windows path', 'C:\\Users\\me\\chart.png'],
|
||||
['a home-relative path', '~/Pictures/chart.png'],
|
||||
])('offers it for %s too', (_label, path) => {
|
||||
desktop()
|
||||
render(<ImageGalleryModal open images={withPath(path)} activeIndex={0} onClose={() => {}} onSelect={() => {}} />)
|
||||
|
||||
expect(original()).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers it for the picture on show, not for another', () => {
|
||||
desktop()
|
||||
const both = [
|
||||
{ src: 'blob:https://desktop.invalid/1', name: 'a.png', path: '/Users/me/a.png' },
|
||||
{ src: 'blob:https://desktop.invalid/2', name: 'b.png' },
|
||||
]
|
||||
const { rerender } = render(<ImageGalleryModal open images={both} activeIndex={0} onClose={() => {}} onSelect={() => {}} />)
|
||||
expect(original()).toBeInTheDocument()
|
||||
|
||||
rerender(<ImageGalleryModal open images={both} activeIndex={1} onClose={() => {}} onSelect={() => {}} />)
|
||||
expect(original()).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not offer it for a picture that is not a file: an inline one, a blob', () => {
|
||||
desktop()
|
||||
render(<ImageGalleryModal open images={withPath()} activeIndex={0} onClose={() => {}} onSelect={() => {}} />)
|
||||
|
||||
expect(original()).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not offer it for a relative path, which names no file to hand to the system', () => {
|
||||
desktop()
|
||||
render(<ImageGalleryModal open images={withPath('figures/chart.png')} activeIndex={0} onClose={() => {}} onSelect={() => {}} />)
|
||||
|
||||
expect(original()).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not offer it in a browser, where the "system" is the machine the server runs on', () => {
|
||||
render(<ImageGalleryModal open images={withPath('/Users/me/chart.png')} activeIndex={0} onClose={() => {}} onSelect={() => {}} />)
|
||||
|
||||
expect(original()).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says so when the system could not open it, instead of doing nothing', async () => {
|
||||
desktop()
|
||||
vi.mocked(openLocalFileWithSystem).mockRejectedValueOnce(new Error('gone'))
|
||||
render(<ImageGalleryModal open images={withPath('/Users/me/chart.png')} activeIndex={0} onClose={() => {}} onSelect={() => {}} />)
|
||||
|
||||
fireEvent.click(original()!)
|
||||
|
||||
await waitFor(() => expect(reportOpenFailure).toHaveBeenCalledWith('/Users/me/chart.png'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import { useEffect } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { ExternalLink, X } from 'lucide-react'
|
||||
import { IconButton } from '@/components/ui/IconButton'
|
||||
import { Modal } from '@/components/ui/Modal'
|
||||
import { ZoomableImage } from '@/components/ui/ZoomableImage'
|
||||
import { getDesktopHost } from '@/lib/desktopHost'
|
||||
import { isRootedLocalPath } from '@/lib/handlePreviewLink'
|
||||
import { openLocalFileWithSystem, reportOpenFailure } from '@/lib/systemFileOpen'
|
||||
import { useOverlayStore } from '../../stores/overlayStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
type GalleryImage = {
|
||||
src: string
|
||||
name: string
|
||||
/**
|
||||
* The file on disk, when the picture is one (a blob or inline image is not).
|
||||
* It is what "open in system app" hands to the operating system.
|
||||
*/
|
||||
path?: string
|
||||
}
|
||||
|
||||
type Props = {
|
||||
@@ -49,6 +58,14 @@ export function ImageGalleryModal({ open, images, activeIndex, onClose, onSelect
|
||||
|
||||
if (!activeImage) return null
|
||||
|
||||
// Only in the desktop app: in a browser the "system" is the machine the server
|
||||
// runs on, which is not the one the reader is looking at. (Same rule as the
|
||||
// attachment chips.)
|
||||
const host = getDesktopHost()
|
||||
const originalPath = activeImage.path && isRootedLocalPath(activeImage.path) && host.isDesktop && host.capabilities.shell
|
||||
? activeImage.path
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={activeImage.name} variant="media">
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
@@ -67,11 +84,31 @@ export function ImageGalleryModal({ open, images, activeIndex, onClose, onSelect
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative flex min-h-0 flex-1 items-center justify-center px-4 pb-4">
|
||||
<img
|
||||
<div className="relative flex min-h-0 flex-1 flex-col px-4 pb-4">
|
||||
<ZoomableImage
|
||||
// The size it measures belongs to one picture, and so does the zoom.
|
||||
key={activeImage.src}
|
||||
src={activeImage.src}
|
||||
alt={activeImage.name}
|
||||
className="max-h-full max-w-full rounded-[var(--radius-md)] object-contain"
|
||||
surface="media"
|
||||
labels={{
|
||||
group: t('workspace.zoom.group'),
|
||||
zoomIn: t('workspace.zoom.in'),
|
||||
zoomOut: t('workspace.zoom.out'),
|
||||
fit: t('workspace.zoom.fitWindow'),
|
||||
}}
|
||||
actions={originalPath ? (
|
||||
<IconButton
|
||||
icon={<ExternalLink size={16} strokeWidth={1.9} />}
|
||||
label={t('workspace.openInSystemApp')}
|
||||
size="md"
|
||||
tone="secondary"
|
||||
surface="media"
|
||||
onClick={() => {
|
||||
void openLocalFileWithSystem(originalPath).catch(() => reportOpenFailure(originalPath))
|
||||
}}
|
||||
/>
|
||||
) : undefined}
|
||||
/>
|
||||
|
||||
{images.length > 1 ? (
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import '@testing-library/jest-dom'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { browserHost } from '../../lib/desktopHost/browserHost'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { ToolCallBlock } from './ToolCallBlock'
|
||||
|
||||
const result = JSON.stringify({
|
||||
type: 'image_generation_result',
|
||||
providerId: 'grok-official',
|
||||
providerKind: 'grok_oauth',
|
||||
model: 'grok-imagine-image-quality',
|
||||
prompt: 'two foxes',
|
||||
durationMs: 1200,
|
||||
images: [
|
||||
{ path: '/tmp/generated-one.jpg', mimeType: 'image/jpeg' },
|
||||
{ path: '/tmp/generated-two.jpg', mimeType: 'image/jpeg' },
|
||||
],
|
||||
})
|
||||
|
||||
describe('ImageGenerationBlock · opening the original', () => {
|
||||
const openPath = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
openPath.mockClear()
|
||||
window.desktopHost = {
|
||||
...browserHost,
|
||||
kind: 'electron',
|
||||
isDesktop: true,
|
||||
capabilities: { ...browserHost.capabilities, shell: true },
|
||||
shell: { ...browserHost.shell, openPath },
|
||||
}
|
||||
})
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window, 'desktopHost')
|
||||
})
|
||||
|
||||
it('hands the generated file on show in the viewer to the system app, and the next one after moving on', async () => {
|
||||
render(<ToolCallBlock toolName="ImageGen" input={{ prompt: 'two foxes', count: 2 }} result={{ content: result, isError: false }} />)
|
||||
|
||||
fireEvent.click(screen.getAllByRole('img')[0]!.closest('button')!)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open in system app' }))
|
||||
await waitFor(() => expect(openPath).toHaveBeenLastCalledWith('/tmp/generated-one.jpg'))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next image' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open in system app' }))
|
||||
await waitFor(() => expect(openPath).toHaveBeenLastCalledWith('/tmp/generated-two.jpg'))
|
||||
})
|
||||
})
|
||||
@@ -109,7 +109,7 @@ function ImageGenerationCollection({
|
||||
})
|
||||
const allEdits = itemViews.length > 0 && itemViews.every((item) => item.isEdit)
|
||||
const totalSlotCount = itemViews.reduce((sum, item) => sum + item.slotCount, 0)
|
||||
const galleryImages: Array<{ src: string; name: string }> = []
|
||||
const galleryImages: Array<{ src: string; name: string; path: string }> = []
|
||||
const slots: ImageSlot[] = []
|
||||
|
||||
for (const item of itemViews) {
|
||||
@@ -121,6 +121,7 @@ function ImageGenerationCollection({
|
||||
galleryImages.push({
|
||||
src: localImageFileUrl(image.path),
|
||||
name: fileName(image.path) || t('tool.generatedImageAlt', { index: galleryIndex + 1 }),
|
||||
path: image.path,
|
||||
})
|
||||
slots.push({
|
||||
key: `${item.id}-${index}`,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import '@testing-library/jest-dom'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { browserHost } from '../../lib/desktopHost/browserHost'
|
||||
|
||||
// getBaseUrl backs the absolute-path src (/api/filesystem/file).
|
||||
vi.mock('../../api/client', () => ({
|
||||
@@ -135,6 +137,49 @@ describe('InlineImageGallery', () => {
|
||||
])
|
||||
})
|
||||
|
||||
describe('opening the original from the viewer', () => {
|
||||
const openPath = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
beforeEach(() => {
|
||||
openPath.mockClear()
|
||||
window.desktopHost = {
|
||||
...browserHost,
|
||||
kind: 'electron',
|
||||
isDesktop: true,
|
||||
capabilities: { ...browserHost.capabilities, shell: true },
|
||||
shell: { ...browserHost.shell, openPath },
|
||||
}
|
||||
})
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window, 'desktopHost')
|
||||
})
|
||||
|
||||
it('hands an absolute image to the system app', async () => {
|
||||
render(<InlineImageGallery text={'see /Users/me/out/result.png done'} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open in system app' }))
|
||||
|
||||
await waitFor(() => expect(openPath).toHaveBeenCalledWith('/Users/me/out/result.png'))
|
||||
})
|
||||
|
||||
it('hands a workspace image to the system app by its place in the workdir', async () => {
|
||||
render(<InlineImageGallery text={'render saved to outputs/a/frame.png'} sessionId="s1" workDir="/w" />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open in system app' }))
|
||||
|
||||
await waitFor(() => expect(openPath).toHaveBeenCalledWith('/w/outputs/a/frame.png'))
|
||||
})
|
||||
|
||||
it('offers nothing for a workspace image whose workdir is not known yet', () => {
|
||||
render(<InlineImageGallery text={'render saved to outputs/a/frame.png'} sessionId="s1" />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Open in system app' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('scopes image hover overlays to each image tile', () => {
|
||||
render(
|
||||
<div className="group">
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '../../lib/assistantOutputTargets'
|
||||
import { isAbsoluteLocalPath, previewFsUrl } from '../../lib/handlePreviewLink'
|
||||
import { getServerBaseUrl } from '../../lib/desktopRuntime'
|
||||
import { resolveAbsoluteOpenPath } from '../../lib/systemFileOpen'
|
||||
|
||||
const IMAGE_EXTENSIONS = /\.(png|jpe?g|gif|webp|svg|bmp|avif|ico)$/i
|
||||
|
||||
@@ -51,6 +52,8 @@ function normalizeImageReference(value: string): string {
|
||||
type GalleryImage = {
|
||||
src: string
|
||||
name: string
|
||||
/** Where the file is, for "open in system app". Relative until the workdir is known. */
|
||||
path: string
|
||||
}
|
||||
|
||||
type Props = {
|
||||
@@ -97,7 +100,7 @@ export function InlineImageGallery({ text, sessionId, workDir, changedFiles, sup
|
||||
|
||||
const images = useMemo<GalleryImage[]>(() => {
|
||||
// 1. Absolute paths (legacy behavior) — served via /api/filesystem/file.
|
||||
const absolute: GalleryImage[] = imagePaths.map((p) => ({ src: localImageFileUrl(p), name: fileName(p) }))
|
||||
const absolute: GalleryImage[] = imagePaths.map((p) => ({ src: localImageFileUrl(p), name: fileName(p), path: p }))
|
||||
|
||||
if (!sessionId) {
|
||||
return absolute
|
||||
@@ -136,7 +139,7 @@ export function InlineImageGallery({ text, sessionId, workDir, changedFiles, sup
|
||||
continue
|
||||
}
|
||||
seenSrc.add(src)
|
||||
relative.push({ src, name })
|
||||
relative.push({ src, name, path: resolveAbsoluteOpenPath(relPath, workDir ?? undefined) })
|
||||
}
|
||||
|
||||
return [...absolute, ...relative]
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
import '@testing-library/jest-dom'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { ComponentProps } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useChatStore } from '@/stores/chatStore'
|
||||
import { useSettingsStore } from '@/stores/settingsStore'
|
||||
import { useTabStore } from '@/stores/tabStore'
|
||||
import type { UIMessage } from '@/types/chat'
|
||||
import { ToolCallBlock } from './ToolCallBlock'
|
||||
import { ToolCallGroup } from './ToolCallGroup'
|
||||
|
||||
type GalleryImages = Array<{ src: string; name: string; path?: string }>
|
||||
|
||||
// The real lightbox renders; it is wrapped only to record what it was handed.
|
||||
const lightbox = vi.hoisted(() => ({ images: [] as GalleryImages }))
|
||||
|
||||
vi.mock('./ImageGalleryModal', async () => {
|
||||
const actual = await vi.importActual<typeof import('./ImageGalleryModal')>('./ImageGalleryModal')
|
||||
const { createElement } = await import('react')
|
||||
return {
|
||||
ImageGalleryModal: (props: ComponentProps<typeof actual.ImageGalleryModal>) => {
|
||||
lightbox.images = props.images as GalleryImages
|
||||
return createElement(actual.ImageGalleryModal, props)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
/** Unmistakable in the DOM if it ever leaks: the base64 of a sentence. */
|
||||
const MARKER = Buffer.from('UNIQUE_IMAGE_PAYLOAD_MARKER').toString('base64')
|
||||
const IMAGE_BLOCK = { type: 'image', source: { type: 'base64', media_type: 'image/png', data: MARKER } }
|
||||
const SVG_BLOCK = {
|
||||
type: 'image',
|
||||
source: { type: 'base64', media_type: 'image/svg+xml', data: Buffer.from('<svg onload="alert(1)"/>').toString('base64') },
|
||||
}
|
||||
|
||||
let counter = 0
|
||||
let createObjectURL: ReturnType<typeof vi.fn>
|
||||
let revokeObjectURL: ReturnType<typeof vi.fn>
|
||||
const savedUrlMembers = {
|
||||
create: Object.getOwnPropertyDescriptor(URL, 'createObjectURL'),
|
||||
revoke: Object.getOwnPropertyDescriptor(URL, 'revokeObjectURL'),
|
||||
}
|
||||
|
||||
function restoreUrlMember(name: 'createObjectURL' | 'revokeObjectURL', saved: PropertyDescriptor | undefined) {
|
||||
if (saved) Object.defineProperty(URL, name, saved)
|
||||
else delete (URL as unknown as Record<string, unknown>)[name]
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
counter = 0
|
||||
lightbox.images = []
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
useTabStore.setState({ activeTabId: 'active-tab', tabs: [{ sessionId: 'active-tab', title: 'Test', type: 'session' as const, status: 'idle' }] })
|
||||
useChatStore.setState({ sessions: {} })
|
||||
createObjectURL = vi.fn(() => `blob:tool-image-${(counter += 1)}`)
|
||||
revokeObjectURL = vi.fn()
|
||||
Object.defineProperty(URL, 'createObjectURL', { configurable: true, writable: true, value: createObjectURL })
|
||||
Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, writable: true, value: revokeObjectURL })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
restoreUrlMember('createObjectURL', savedUrlMembers.create)
|
||||
restoreUrlMember('revokeObjectURL', savedUrlMembers.revoke)
|
||||
})
|
||||
|
||||
function disclosureOf(container: HTMLElement): HTMLElement {
|
||||
return container.querySelector<HTMLElement>('[data-chat-disclosure="true"]')!
|
||||
}
|
||||
|
||||
describe('ToolCallBlock · images returned by a tool (#1397)', () => {
|
||||
it('shows an image-only shell result as a thumbnail, never as "No output"', () => {
|
||||
const { container } = render(
|
||||
<ToolCallBlock
|
||||
toolName="Bash"
|
||||
input={{ command: 'python plot.py', description: 'Plot' }}
|
||||
result={{ content: [IMAGE_BLOCK], isError: false }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Open image 1 of 1' })).toBeInTheDocument()
|
||||
expect(container.textContent).not.toContain('No output')
|
||||
|
||||
fireEvent.click(disclosureOf(container))
|
||||
|
||||
expect(container.textContent).toContain('python plot.py')
|
||||
expect(container.textContent).not.toContain('No output')
|
||||
// Still exactly one picture: expanding must not add a second copy.
|
||||
expect(screen.getAllByRole('img')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('never prints the base64, collapsed or expanded', () => {
|
||||
const { container } = render(
|
||||
<ToolCallBlock
|
||||
toolName="Bash"
|
||||
input={{ command: 'python plot.py', description: 'Plot' }}
|
||||
result={{ content: [IMAGE_BLOCK], isError: false }}
|
||||
/>,
|
||||
)
|
||||
expect(container.innerHTML).not.toContain(MARKER)
|
||||
|
||||
fireEvent.click(disclosureOf(container))
|
||||
|
||||
expect(container.innerHTML).not.toContain(MARKER)
|
||||
expect(screen.getByRole('img')).toHaveAttribute('src', 'blob:tool-image-1')
|
||||
})
|
||||
|
||||
it('shows the thumbnail while the row is still collapsed', () => {
|
||||
const { container } = render(
|
||||
<ToolCallBlock
|
||||
toolName="Bash"
|
||||
input={{ command: 'python plot.py', description: 'Plot' }}
|
||||
result={{ content: [IMAGE_BLOCK], isError: false }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(disclosureOf(container)).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(container.querySelector('[data-tool-call-details]')).toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'Open image 1 of 1' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the strip outside the disclosure, after the header, in the card chrome', () => {
|
||||
const { container } = render(
|
||||
<ToolCallBlock
|
||||
toolName="mcp__shots__take"
|
||||
input={{ url: 'https://example.com' }}
|
||||
result={{ content: [IMAGE_BLOCK], isError: false }}
|
||||
/>,
|
||||
)
|
||||
|
||||
const card = container.querySelector('[data-tool-call-chrome="card"]')!
|
||||
const strip = card.querySelector('[data-tool-result-images]')!
|
||||
const header = disclosureOf(container)
|
||||
expect(strip).toBeTruthy()
|
||||
expect(header.contains(strip)).toBe(false)
|
||||
expect(header.compareDocumentPosition(strip) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders it in the row chrome too, hanging off the row it belongs to', () => {
|
||||
const { container } = render(
|
||||
<ToolCallBlock
|
||||
chrome="row"
|
||||
toolName="mcp__shots__take"
|
||||
input={{ url: 'https://example.com' }}
|
||||
result={{ content: [IMAGE_BLOCK], isError: false }}
|
||||
/>,
|
||||
)
|
||||
|
||||
const row = container.querySelector('[data-tool-call-chrome="row"]')!
|
||||
const strip = row.querySelector('[data-tool-result-images]')!
|
||||
expect(strip).toBeTruthy()
|
||||
expect(disclosureOf(container).contains(strip)).toBe(false)
|
||||
// The row has no card padding; the strip lines up with the row's own text.
|
||||
expect(strip.className).toMatch(/\bpl-\[21px\]/)
|
||||
expect(strip.className).not.toMatch(/\bpx-4\b/)
|
||||
expect(screen.getByRole('button', { name: 'Open image 1 of 1' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows both the text and the picture of a mixed result', () => {
|
||||
const { container } = render(
|
||||
<ToolCallBlock
|
||||
toolName="mcp__shots__take"
|
||||
input={{ url: 'https://example.com' }}
|
||||
result={{
|
||||
content: [{ type: 'text', text: 'Captured example.com' }, IMAGE_BLOCK],
|
||||
isError: false,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Open image 1 of 1' })).toBeInTheDocument()
|
||||
// One line of output shows in the collapsed header.
|
||||
expect(container.textContent).toContain('Captured example.com')
|
||||
|
||||
fireEvent.click(disclosureOf(container))
|
||||
|
||||
expect(container.textContent).toContain('Tool Output')
|
||||
expect(container.textContent).toContain('Captured example.com')
|
||||
expect(container.innerHTML).not.toContain(MARKER)
|
||||
expect(screen.getAllByRole('img')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('still shows a picture that arrives with an error', () => {
|
||||
const { container } = render(
|
||||
<ToolCallBlock
|
||||
toolName="mcp__shots__take"
|
||||
input={{ url: 'https://example.com' }}
|
||||
result={{ content: [{ type: 'text', text: 'Timed out after 30s' }, IMAGE_BLOCK], isError: true }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.textContent).toContain('Timed out after 30s')
|
||||
expect(screen.getByRole('button', { name: 'Open image 1 of 1' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders no thumbnail for an image the allowlist refuses, and does not crash', () => {
|
||||
const { container } = render(
|
||||
<ToolCallBlock
|
||||
toolName="mcp__shots__take"
|
||||
input={{ url: 'https://example.com' }}
|
||||
result={{ content: [SVG_BLOCK], isError: false }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('button', { name: /Open image/ })).toBeNull()
|
||||
expect(container.querySelector('img')).toBeNull()
|
||||
expect(createObjectURL).not.toHaveBeenCalled()
|
||||
// It does say something was left out, instead of silently showing nothing.
|
||||
expect(screen.getByText('Images not shown: 1')).toBeInTheDocument()
|
||||
expect(container.innerHTML).not.toContain('onload')
|
||||
})
|
||||
|
||||
it('shows the pictures it can and counts the ones it cannot', () => {
|
||||
render(
|
||||
<ToolCallBlock
|
||||
toolName="mcp__shots__take"
|
||||
input={{}}
|
||||
result={{ content: [IMAGE_BLOCK, SVG_BLOCK], isError: false }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getAllByRole('img')).toHaveLength(1)
|
||||
expect(screen.getByRole('button', { name: 'Open image 1 of 1' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Images not shown: 1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows nothing for a call that has not returned', () => {
|
||||
const { container } = render(
|
||||
<ToolCallBlock toolName="Read" input={{ file_path: '/tmp/pic.png' }} isPending />,
|
||||
)
|
||||
|
||||
expect(container.querySelector('[data-tool-result-images]')).toBeNull()
|
||||
expect(createObjectURL).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a plain string', 'const answer = 42'],
|
||||
['text blocks', [{ type: 'text', text: 'line one\nline two' }]],
|
||||
])('leaves %s alone', (_label, content) => {
|
||||
const { container } = render(
|
||||
<ToolCallBlock toolName="Grep" input={{ pattern: 'answer' }} result={{ content, isError: false }} />,
|
||||
)
|
||||
|
||||
expect(container.querySelector('[data-tool-result-images]')).toBeNull()
|
||||
expect(createObjectURL).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not decode again when the parent rebuilds the result object around the same content', () => {
|
||||
// ToolCallGroup builds a fresh { content, isError } on every render.
|
||||
const content = [IMAGE_BLOCK]
|
||||
const { rerender } = render(
|
||||
<ToolCallBlock toolName="mcp__shots__take" input={{}} result={{ content, isError: false }} />,
|
||||
)
|
||||
|
||||
rerender(<ToolCallBlock toolName="mcp__shots__take" input={{}} result={{ content, isError: false }} />)
|
||||
|
||||
expect(createObjectURL).toHaveBeenCalledTimes(1)
|
||||
expect(revokeObjectURL).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('releases the picture when the row goes away', () => {
|
||||
const { unmount } = render(
|
||||
<ToolCallBlock toolName="mcp__shots__take" input={{}} result={{ content: [IMAGE_BLOCK], isError: false }} />,
|
||||
)
|
||||
|
||||
unmount()
|
||||
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith('blob:tool-image-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ToolCallBlock · opening a returned image', () => {
|
||||
it('opens the lightbox on the clicked picture', () => {
|
||||
render(
|
||||
<ToolCallBlock toolName="mcp__shots__take" input={{}} result={{ content: [IMAGE_BLOCK, IMAGE_BLOCK], isError: false }} />,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open image 2 of 2' }))
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: 'Image 2 of 2' })
|
||||
expect(dialog).toBeInTheDocument()
|
||||
expect(dialog.querySelector('img')).toHaveAttribute('src', 'blob:tool-image-2')
|
||||
expect(lightbox.images.map((image) => image.src)).toEqual(['blob:tool-image-1', 'blob:tool-image-2'])
|
||||
})
|
||||
|
||||
it('offers the original file for a Read of an absolute path', () => {
|
||||
render(
|
||||
<ToolCallBlock
|
||||
toolName="Read"
|
||||
input={{ file_path: '/Users/me/project/shots/pic.png' }}
|
||||
result={{ content: [IMAGE_BLOCK], isError: false }}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open image 1 of 1' }))
|
||||
|
||||
expect(lightbox.images).toEqual([
|
||||
{ src: 'blob:tool-image-1', name: 'pic.png', path: '/Users/me/project/shots/pic.png' },
|
||||
])
|
||||
expect(screen.getByRole('dialog', { name: 'pic.png' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers no original for a Read of a relative path', () => {
|
||||
render(
|
||||
<ToolCallBlock
|
||||
toolName="Read"
|
||||
input={{ file_path: 'shots/pic.png' }}
|
||||
result={{ content: [IMAGE_BLOCK], isError: false }}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open image 1 of 1' }))
|
||||
|
||||
expect(lightbox.images[0]).not.toHaveProperty('path')
|
||||
})
|
||||
|
||||
it('offers no original when a tool other than Read merely has a file_path input', () => {
|
||||
// A screenshot tool's file_path is where it may have saved to, not a source
|
||||
// the returned pixels are a copy of.
|
||||
render(
|
||||
<ToolCallBlock
|
||||
toolName="mcp__shots__take"
|
||||
input={{ file_path: '/Users/me/project/shots/pic.png' }}
|
||||
result={{ content: [IMAGE_BLOCK], isError: false }}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open image 1 of 1' }))
|
||||
|
||||
expect(lightbox.images[0]).not.toHaveProperty('path')
|
||||
})
|
||||
|
||||
it('offers no original for pictures no file produced', () => {
|
||||
render(
|
||||
<ToolCallBlock
|
||||
toolName="Bash"
|
||||
input={{ command: 'python plot.py' }}
|
||||
result={{ content: [IMAGE_BLOCK], isError: false }}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open image 1 of 1' }))
|
||||
|
||||
expect(lightbox.images[0]).not.toHaveProperty('path')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ToolCallGroup · images returned by a tool', () => {
|
||||
const readCall: Extract<UIMessage, { type: 'tool_use' }> = {
|
||||
id: 'read-use',
|
||||
type: 'tool_use',
|
||||
toolName: 'Read',
|
||||
toolUseId: 'read-1',
|
||||
input: { file_path: '/Users/me/pic.png' },
|
||||
timestamp: 1,
|
||||
}
|
||||
const grepCall: Extract<UIMessage, { type: 'tool_use' }> = {
|
||||
id: 'grep-use',
|
||||
type: 'tool_use',
|
||||
toolName: 'Grep',
|
||||
toolUseId: 'grep-1',
|
||||
input: { pattern: 'x' },
|
||||
timestamp: 2,
|
||||
}
|
||||
const resultMap = new Map<string, Extract<UIMessage, { type: 'tool_result' }>>([
|
||||
['read-1', { id: 'read-result', type: 'tool_result', toolUseId: 'read-1', content: [IMAGE_BLOCK], isError: false, timestamp: 3 }],
|
||||
['grep-1', { id: 'grep-result', type: 'tool_result', toolUseId: 'grep-1', content: 'none', isError: false, timestamp: 4 }],
|
||||
])
|
||||
|
||||
function renderGroup(toolCalls: Array<Extract<UIMessage, { type: 'tool_use' }>>) {
|
||||
return render(
|
||||
<ToolCallGroup
|
||||
toolCalls={toolCalls}
|
||||
resultMap={resultMap}
|
||||
childToolCallsByParent={new Map()}
|
||||
agentTaskNotifications={{}}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
it('shows the picture of a lone tool call', () => {
|
||||
renderGroup([readCall])
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Open image 1 of 1' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the picture once a run of several calls is opened', () => {
|
||||
renderGroup([readCall, grepCall])
|
||||
|
||||
fireEvent.click(screen.getByTestId('activity-group').querySelector('[data-chat-disclosure="true"]')!)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Open image 1 of 1' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -7,9 +7,11 @@ import { CodeViewer } from './CodeViewer'
|
||||
import { DiffViewer } from './DiffViewer'
|
||||
import { TerminalChrome } from './TerminalChrome'
|
||||
import { CopyButton } from '@/components/ui/CopyButton'
|
||||
import { toolResultImagesFor } from '@/lib/toolResultContent'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { TranslationKey } from '../../i18n'
|
||||
import { InlineImageGallery } from './InlineImageGallery'
|
||||
import { ToolResultImages } from './ToolResultImages'
|
||||
import { ImageGenerationBlock } from './ImageGenerationBlock'
|
||||
import { isImageGenerationToolName } from './imageGenerationTools'
|
||||
import type { AgentTaskNotification } from '../../types/chat'
|
||||
@@ -170,6 +172,11 @@ export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, resu
|
||||
[isPending, obj, partialInput, toolName],
|
||||
)
|
||||
const liveStatsSummary = liveStats ? formatContentStats(liveStats, t) : ''
|
||||
// The text extractors below skip image blocks; this is what gives them a thumbnail.
|
||||
const toolImages = useMemo(
|
||||
() => toolResultImagesFor({ toolName, input, content: result?.content }),
|
||||
[input, result?.content, toolName],
|
||||
)
|
||||
|
||||
const preview = useMemo(
|
||||
() => renderPreview(toolName, obj, result, t, isRow),
|
||||
@@ -354,6 +361,19 @@ export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, resu
|
||||
|
||||
{SESSION_TOOL_NAMES.has(toolName) ? <SessionToolLinks input={input} result={result?.content} /> : null}
|
||||
|
||||
{/* Outside the disclosure on purpose: a picture the tool returned is the
|
||||
answer, not a detail to open a panel for. */}
|
||||
{toolImages.images.length > 0 || toolImages.dropped > 0 ? (
|
||||
<ToolResultImages
|
||||
images={toolImages.images}
|
||||
omitted={toolImages.dropped}
|
||||
originalPath={toolImages.originalPath}
|
||||
toolName={toolName}
|
||||
// Row: line up with the tool name (icon + gap), where the expanded rail's content also starts.
|
||||
className={isRow ? 'pb-1.5 pl-[21px] pt-0.5' : compact ? 'px-3.5 pb-2.5' : 'px-4 pb-3'}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{expandable && expanded && (
|
||||
<div
|
||||
data-tool-call-details={isRow ? 'inline' : 'panel'}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import '@testing-library/jest-dom'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useSettingsStore } from '@/stores/settingsStore'
|
||||
import { ToolResultBlock } from './ToolResultBlock'
|
||||
|
||||
const MARKER = Buffer.from('UNIQUE_IMAGE_PAYLOAD_MARKER').toString('base64')
|
||||
const IMAGE_BLOCK = { type: 'image', source: { type: 'base64', media_type: 'image/png', data: MARKER } }
|
||||
const SVG_BLOCK = {
|
||||
type: 'image',
|
||||
source: { type: 'base64', media_type: 'image/svg+xml', data: Buffer.from('<svg/>').toString('base64') },
|
||||
}
|
||||
|
||||
let counter = 0
|
||||
let createObjectURL: ReturnType<typeof vi.fn>
|
||||
const savedUrlMembers = {
|
||||
create: Object.getOwnPropertyDescriptor(URL, 'createObjectURL'),
|
||||
revoke: Object.getOwnPropertyDescriptor(URL, 'revokeObjectURL'),
|
||||
}
|
||||
|
||||
function restoreUrlMember(name: 'createObjectURL' | 'revokeObjectURL', saved: PropertyDescriptor | undefined) {
|
||||
if (saved) Object.defineProperty(URL, name, saved)
|
||||
else delete (URL as unknown as Record<string, unknown>)[name]
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
counter = 0
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
createObjectURL = vi.fn(() => `blob:standalone-${(counter += 1)}`)
|
||||
Object.defineProperty(URL, 'createObjectURL', { configurable: true, writable: true, value: createObjectURL })
|
||||
Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, writable: true, value: vi.fn() })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
restoreUrlMember('createObjectURL', savedUrlMembers.create)
|
||||
restoreUrlMember('revokeObjectURL', savedUrlMembers.revoke)
|
||||
})
|
||||
|
||||
describe('ToolResultBlock · images returned by a tool (#1397)', () => {
|
||||
it('shows the picture of an image-only result, with no empty text preview under it', () => {
|
||||
const { container } = render(
|
||||
<ToolResultBlock content={[IMAGE_BLOCK]} isError={false} toolName="mcp__shots__take" />,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('group', { name: 'mcp__shots__take result' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Open image 1 of 1' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('img')).toHaveAttribute('src', 'blob:standalone-1')
|
||||
// The text preview is a monospace band; there is no text to put in it.
|
||||
expect(container.querySelector('.font-mono')).toBeNull()
|
||||
expect(container.innerHTML).not.toContain(MARKER)
|
||||
})
|
||||
|
||||
it('keeps the text preview when the result has text as well', () => {
|
||||
const { container } = render(
|
||||
<ToolResultBlock content={[{ type: 'text', text: 'Captured example.com' }, IMAGE_BLOCK]} isError={false} />,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Open image 1 of 1' })).toBeInTheDocument()
|
||||
expect(container.querySelector('.font-mono')).toHaveTextContent('Captured example.com')
|
||||
expect(screen.getByRole('group', { name: 'Tool result' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the picture of a failed result as well', () => {
|
||||
render(
|
||||
<ToolResultBlock content={[{ type: 'text', text: 'Partial capture' }, IMAGE_BLOCK]} isError />,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Partial capture')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Open image 1 of 1' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says when a returned image is not shown, and shows no text band for it', () => {
|
||||
const { container } = render(<ToolResultBlock content={[SVG_BLOCK]} isError={false} />)
|
||||
|
||||
expect(screen.getByText('Images not shown: 1')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
expect(container.querySelector('.font-mono')).toBeNull()
|
||||
expect(createObjectURL).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders a text-only result exactly as before', () => {
|
||||
const { container } = render(<ToolResultBlock content="plain output" isError={false} />)
|
||||
|
||||
expect(container.querySelector('[data-tool-result-images]')).toBeNull()
|
||||
expect(container.querySelector('.font-mono')).toHaveTextContent('plain output')
|
||||
expect(createObjectURL).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders nothing, and makes no picture, when it is not standalone', () => {
|
||||
const { container } = render(<ToolResultBlock content={[IMAGE_BLOCK]} isError={false} standalone={false} />)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
expect(createObjectURL).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,10 @@
|
||||
import { CodeViewer } from './CodeViewer'
|
||||
import { memo, useState } from 'react'
|
||||
import { memo, useMemo, useState } from 'react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { getDisclosure, setDisclosure } from '../../lib/disclosureMemory'
|
||||
import { extractToolResultImages } from '../../lib/toolResultContent'
|
||||
import { InlineImageGallery } from './InlineImageGallery'
|
||||
import { ToolResultImages } from './ToolResultImages'
|
||||
|
||||
type Props = {
|
||||
content: unknown
|
||||
@@ -22,6 +24,7 @@ export const ToolResultBlock = memo(function ToolResultBlock({ content, isError,
|
||||
const [localExpanded, setLocalExpanded] = useState(false)
|
||||
const expanded = disclosureKey ? (getDisclosure(disclosureKey) ?? localExpanded) : localExpanded
|
||||
const t = useTranslation()
|
||||
const toolImages = useMemo(() => extractToolResultImages(content), [content])
|
||||
|
||||
// Don't render standalone if this result is already rendered inline
|
||||
if (!standalone) return null
|
||||
@@ -29,6 +32,7 @@ export const ToolResultBlock = memo(function ToolResultBlock({ content, isError,
|
||||
const text = extractText(content)
|
||||
const preview = text.slice(0, 200)
|
||||
const hasMore = text.length > 200
|
||||
const hasImages = toolImages.images.length > 0 || toolImages.dropped > 0
|
||||
|
||||
return (
|
||||
<div className={`overflow-hidden rounded-[var(--radius-lg)] border ${
|
||||
@@ -65,11 +69,21 @@ export const ToolResultBlock = memo(function ToolResultBlock({ content, isError,
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Pictures the tool returned as image blocks */}
|
||||
{hasImages ? (
|
||||
<ToolResultImages
|
||||
images={toolImages.images}
|
||||
omitted={toolImages.dropped}
|
||||
toolName={toolName}
|
||||
className="px-3 py-2"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Inline image gallery from detected paths */}
|
||||
<InlineImageGallery text={text} />
|
||||
|
||||
{/* Content */}
|
||||
{expanded ? (
|
||||
{/* Content. A result that is only pictures has no text to preview. */}
|
||||
{!text && hasImages ? null : expanded ? (
|
||||
isError ? (
|
||||
<div className="bg-[var(--color-error-container)] px-3 py-2.5 font-mono text-[11px] leading-[1.5] whitespace-pre-wrap break-words text-[var(--color-on-error-container)]">
|
||||
{text}
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
import '@testing-library/jest-dom'
|
||||
import { StrictMode } from 'react'
|
||||
import { renderToString } from 'react-dom/server'
|
||||
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ToolResultImage } from '@/lib/toolResultContent'
|
||||
import { useSettingsStore } from '@/stores/settingsStore'
|
||||
import { ToolResultImages } from './ToolResultImages'
|
||||
|
||||
type GalleryProps = {
|
||||
open: boolean
|
||||
images: Array<{ src: string; name: string; path?: string }>
|
||||
activeIndex: number
|
||||
onClose: () => void
|
||||
onSelect: (index: number) => void
|
||||
}
|
||||
|
||||
// The lightbox has its own tests. Here it is a probe: what it was handed, and a
|
||||
// way to drive the callbacks it is handed.
|
||||
const lightbox = vi.hoisted(() => ({ latest: null as null | GalleryProps }))
|
||||
|
||||
vi.mock('./ImageGalleryModal', () => ({
|
||||
ImageGalleryModal: (props: GalleryProps) => {
|
||||
lightbox.latest = props
|
||||
return (
|
||||
<div role="dialog" aria-label={props.images[props.activeIndex]?.name}>
|
||||
<span data-testid="lightbox-position">{props.activeIndex + 1} / {props.images.length}</span>
|
||||
<button type="button" onClick={() => props.onSelect((props.activeIndex + 1) % props.images.length)}>
|
||||
lightbox next
|
||||
</button>
|
||||
<button type="button" onClick={props.onClose}>lightbox close</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
const FIRST: ToolResultImage = { mediaType: 'image/png', data: Buffer.from('first picture bytes').toString('base64') }
|
||||
const SECOND: ToolResultImage = { mediaType: 'image/jpeg', data: Buffer.from('second picture bytes').toString('base64') }
|
||||
const THIRD: ToolResultImage = { mediaType: 'image/webp', data: Buffer.from('third picture bytes').toString('base64') }
|
||||
|
||||
let counter = 0
|
||||
let createObjectURL: ReturnType<typeof vi.fn>
|
||||
let revokeObjectURL: ReturnType<typeof vi.fn>
|
||||
const blobs = new Map<string, Blob>()
|
||||
const savedUrlMembers = {
|
||||
create: Object.getOwnPropertyDescriptor(URL, 'createObjectURL'),
|
||||
revoke: Object.getOwnPropertyDescriptor(URL, 'revokeObjectURL'),
|
||||
}
|
||||
|
||||
function defineUrlMember(name: 'createObjectURL' | 'revokeObjectURL', value: unknown) {
|
||||
Object.defineProperty(URL, name, { configurable: true, writable: true, value })
|
||||
}
|
||||
|
||||
function restoreUrlMember(name: 'createObjectURL' | 'revokeObjectURL', saved: PropertyDescriptor | undefined) {
|
||||
if (saved) Object.defineProperty(URL, name, saved)
|
||||
else delete (URL as unknown as Record<string, unknown>)[name]
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
counter = 0
|
||||
blobs.clear()
|
||||
lightbox.latest = null
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
createObjectURL = vi.fn((blob: Blob) => {
|
||||
counter += 1
|
||||
const url = `blob:test-${counter}`
|
||||
blobs.set(url, blob)
|
||||
return url
|
||||
})
|
||||
revokeObjectURL = vi.fn()
|
||||
defineUrlMember('createObjectURL', createObjectURL)
|
||||
defineUrlMember('revokeObjectURL', revokeObjectURL)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
restoreUrlMember('createObjectURL', savedUrlMembers.create)
|
||||
restoreUrlMember('revokeObjectURL', savedUrlMembers.revoke)
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function created(): string[] {
|
||||
return createObjectURL.mock.results.map((result) => result.value as string)
|
||||
}
|
||||
|
||||
function revoked(): string[] {
|
||||
return revokeObjectURL.mock.calls.map((call) => call[0] as string)
|
||||
}
|
||||
|
||||
function liveUrls(): string[] {
|
||||
const gone = new Set(revoked())
|
||||
return created().filter((url) => !gone.has(url))
|
||||
}
|
||||
|
||||
function thumbnailSources(container: HTMLElement): string[] {
|
||||
return [...container.querySelectorAll('img')].map((image) => image.getAttribute('src') ?? '')
|
||||
}
|
||||
|
||||
function readBytes(blob: Blob): Promise<number[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve([...new Uint8Array(reader.result as ArrayBuffer)])
|
||||
reader.onerror = () => reject(reader.error)
|
||||
reader.readAsArrayBuffer(blob)
|
||||
})
|
||||
}
|
||||
|
||||
describe('ToolResultImages · thumbnails', () => {
|
||||
it('renders one named thumbnail button per image, showing the object URL', () => {
|
||||
const { container } = render(<ToolResultImages images={[FIRST, SECOND]} toolName="Read" />)
|
||||
|
||||
const first = screen.getByRole('button', { name: 'Open image 1 of 2' })
|
||||
const second = screen.getByRole('button', { name: 'Open image 2 of 2' })
|
||||
expect(first).toHaveAttribute('type', 'button')
|
||||
expect(within(first).getByRole('img', { name: 'Image 1 of 2' })).toHaveAttribute('src', 'blob:test-1')
|
||||
expect(within(second).getByRole('img', { name: 'Image 2 of 2' })).toHaveAttribute('src', 'blob:test-2')
|
||||
expect(thumbnailSources(container)).toEqual(['blob:test-1', 'blob:test-2'])
|
||||
})
|
||||
|
||||
it('names the strip after the tool, or generically when there is none', () => {
|
||||
const { rerender } = render(<ToolResultImages images={[FIRST]} toolName="mcp__shots__take" />)
|
||||
expect(screen.getByRole('group', { name: 'mcp__shots__take result' })).toBeInTheDocument()
|
||||
|
||||
rerender(<ToolResultImages images={[FIRST]} />)
|
||||
expect(screen.getByRole('group', { name: 'Tool result' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('localizes its labels', () => {
|
||||
useSettingsStore.setState({ locale: 'zh' })
|
||||
render(<ToolResultImages images={[FIRST, SECOND]} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: '打开图片 2/2' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('group', { name: '工具结果' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('decodes each image into a Blob with its own type and bytes', async () => {
|
||||
render(<ToolResultImages images={[FIRST, SECOND]} />)
|
||||
|
||||
expect(createObjectURL).toHaveBeenCalledTimes(2)
|
||||
const firstBlob = blobs.get('blob:test-1')!
|
||||
const secondBlob = blobs.get('blob:test-2')!
|
||||
expect(firstBlob.type).toBe('image/png')
|
||||
expect(secondBlob.type).toBe('image/jpeg')
|
||||
expect(await readBytes(firstBlob)).toEqual([...Buffer.from('first picture bytes')])
|
||||
expect(await readBytes(secondBlob)).toEqual([...Buffer.from('second picture bytes')])
|
||||
})
|
||||
|
||||
it('does not touch the object URL API while rendering', () => {
|
||||
// A server or a discarded render has no effects; creating URLs in the render
|
||||
// body would leak one per discarded render because nothing revokes them.
|
||||
const markup = renderToString(<ToolResultImages images={[FIRST, SECOND]} />)
|
||||
|
||||
expect(createObjectURL).not.toHaveBeenCalled()
|
||||
// Until the URLs exist the strip only reserves its height.
|
||||
expect(markup).not.toContain('<button')
|
||||
expect(markup).not.toContain('<img')
|
||||
expect(markup.match(/aria-hidden="true"/g)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('renders nothing for no images and nothing omitted', () => {
|
||||
const { container } = render(<ToolResultImages images={[]} />)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
expect(createObjectURL).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('applies the spacing its chrome asks for', () => {
|
||||
render(<ToolResultImages images={[FIRST]} className="px-4 pb-3" />)
|
||||
|
||||
expect(screen.getByRole('group')).toHaveClass('px-4', 'pb-3')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ToolResultImages · object URL lifecycle', () => {
|
||||
it('revokes exactly the URLs it made when unmounted', () => {
|
||||
const { unmount } = render(<ToolResultImages images={[FIRST, SECOND]} />)
|
||||
expect(revoked()).toEqual([])
|
||||
|
||||
unmount()
|
||||
|
||||
expect([...revoked()].sort()).toEqual(['blob:test-1', 'blob:test-2'])
|
||||
expect(liveUrls()).toEqual([])
|
||||
})
|
||||
|
||||
it('revokes the old URLs and makes new ones when the images change', () => {
|
||||
const { container, rerender } = render(<ToolResultImages images={[FIRST, SECOND]} />)
|
||||
|
||||
rerender(<ToolResultImages images={[THIRD]} />)
|
||||
|
||||
expect([...revoked()].sort()).toEqual(['blob:test-1', 'blob:test-2'])
|
||||
expect(created()).toEqual(['blob:test-1', 'blob:test-2', 'blob:test-3'])
|
||||
expect(thumbnailSources(container)).toEqual(['blob:test-3'])
|
||||
expect(liveUrls()).toEqual(['blob:test-3'])
|
||||
})
|
||||
|
||||
it('never leaves a thumbnail pointing at a URL at the moment that URL is revoked', () => {
|
||||
// The cleanup runs right after the commit that switches to the new images. If
|
||||
// that commit still shows the old URLs, an <img> is left holding a revoked
|
||||
// one, and one that mounts in that state fails to load and is written off.
|
||||
const { container, rerender } = render(<ToolResultImages images={[FIRST, SECOND]} />)
|
||||
const stillShown: string[] = []
|
||||
revokeObjectURL.mockImplementation((url: string) => {
|
||||
if (thumbnailSources(container).includes(url)) stillShown.push(url)
|
||||
})
|
||||
|
||||
rerender(<ToolResultImages images={[THIRD]} />)
|
||||
|
||||
expect(revokeObjectURL).toHaveBeenCalledTimes(2)
|
||||
expect(stillShown).toEqual([])
|
||||
})
|
||||
|
||||
it('does not decode again for an equal array in a new object', () => {
|
||||
// History reloads and wrapper rebuilds hand over new arrays with the same
|
||||
// pictures; each swap would decode megabytes again and flash every thumbnail.
|
||||
const { container, rerender } = render(<ToolResultImages images={[FIRST, SECOND]} />)
|
||||
|
||||
rerender(<ToolResultImages images={[{ ...FIRST }, { ...SECOND }]} />)
|
||||
|
||||
expect(createObjectURL).toHaveBeenCalledTimes(2)
|
||||
expect(revokeObjectURL).not.toHaveBeenCalled()
|
||||
expect(thumbnailSources(container)).toEqual(['blob:test-1', 'blob:test-2'])
|
||||
})
|
||||
|
||||
it('does decode again when a picture keeps its length but changes its content', () => {
|
||||
const { container, rerender } = render(<ToolResultImages images={[FIRST]} />)
|
||||
const sameLength = { ...FIRST, data: `Y${FIRST.data.slice(1)}` }
|
||||
expect(sameLength.data).toHaveLength(FIRST.data.length)
|
||||
expect(sameLength.data).not.toBe(FIRST.data)
|
||||
|
||||
rerender(<ToolResultImages images={[sameLength]} />)
|
||||
|
||||
expect(revoked()).toEqual(['blob:test-1'])
|
||||
expect(thumbnailSources(container)).toEqual(['blob:test-2'])
|
||||
})
|
||||
|
||||
it('does not decode again when only the surrounding props change', () => {
|
||||
const { rerender } = render(<ToolResultImages images={[FIRST]} className="px-4" />)
|
||||
|
||||
rerender(<ToolResultImages images={[FIRST]} className="px-3" originalPath="/tmp/a.png" toolName="Read" omitted={2} />)
|
||||
|
||||
expect(createObjectURL).toHaveBeenCalledTimes(1)
|
||||
expect(revokeObjectURL).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves no URL behind under StrictMode double mounting', () => {
|
||||
const { container, unmount } = render(
|
||||
<StrictMode>
|
||||
<ToolResultImages images={[FIRST, SECOND]} />
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
// Two mounts of the effect made four URLs; the first pair was released by
|
||||
// the simulated unmount, and what the thumbnails show is what is still live.
|
||||
expect(created()).toHaveLength(4)
|
||||
expect(liveUrls()).toHaveLength(2)
|
||||
expect(thumbnailSources(container).sort()).toEqual([...liveUrls()].sort())
|
||||
|
||||
unmount()
|
||||
|
||||
expect(liveUrls()).toEqual([])
|
||||
})
|
||||
|
||||
it('revokes when the strip empties out, not only when it unmounts', () => {
|
||||
const { container, rerender } = render(<ToolResultImages images={[FIRST]} />)
|
||||
|
||||
rerender(<ToolResultImages images={[]} />)
|
||||
|
||||
expect(revoked()).toEqual(['blob:test-1'])
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ToolResultImages · lightbox', () => {
|
||||
it('opens on the clicked thumbnail, with only object URLs', () => {
|
||||
render(<ToolResultImages images={[FIRST, SECOND]} />)
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open image 2 of 2' }))
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'Image 2 of 2' })).toBeInTheDocument()
|
||||
expect(screen.getByTestId('lightbox-position')).toHaveTextContent('2 / 2')
|
||||
expect(lightbox.latest?.open).toBe(true)
|
||||
expect(lightbox.latest?.images.map((image) => image.src)).toEqual(['blob:test-1', 'blob:test-2'])
|
||||
})
|
||||
|
||||
it('follows the lightbox when it moves to another picture', () => {
|
||||
render(<ToolResultImages images={[FIRST, SECOND]} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open image 1 of 2' }))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'lightbox next' }))
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'Image 2 of 2' })).toBeInTheDocument()
|
||||
expect(lightbox.latest?.activeIndex).toBe(1)
|
||||
})
|
||||
|
||||
it('closes when the lightbox asks to', () => {
|
||||
render(<ToolResultImages images={[FIRST]} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open image 1 of 1' }))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'lightbox close' }))
|
||||
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
it('closes a lightbox whose picture is replaced instead of showing another one', () => {
|
||||
const { rerender } = render(<ToolResultImages images={[FIRST]} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open image 1 of 1' }))
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
|
||||
rerender(<ToolResultImages images={[THIRD]} />)
|
||||
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
describe('handing the original file to the lightbox', () => {
|
||||
it.each([
|
||||
['a POSIX path', '/Users/me/pic.png', 'pic.png'],
|
||||
['a Windows path', 'C:\\Users\\me\\pic.png', 'pic.png'],
|
||||
['a home-relative path', '~/Pictures/pic.png', 'pic.png'],
|
||||
])('passes %s as the image path and names the image after the file', (_label, originalPath, name) => {
|
||||
render(<ToolResultImages images={[FIRST, SECOND]} originalPath={originalPath} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open image 1 of 2' }))
|
||||
|
||||
expect(lightbox.latest?.images).toEqual([
|
||||
{ src: 'blob:test-1', name, path: originalPath },
|
||||
{ src: 'blob:test-2', name, path: originalPath },
|
||||
])
|
||||
expect(screen.getByRole('dialog', { name })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('names the image after a relative path but offers no original to open', () => {
|
||||
render(<ToolResultImages images={[FIRST]} originalPath="shots/pic.png" />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open image 1 of 1' }))
|
||||
|
||||
expect(lightbox.latest?.images).toEqual([{ src: 'blob:test-1', name: 'pic.png' }])
|
||||
expect(lightbox.latest?.images[0]).not.toHaveProperty('path')
|
||||
})
|
||||
|
||||
it('has no original for pictures no file produced (screenshots, MCP tools)', () => {
|
||||
render(<ToolResultImages images={[FIRST]} toolName="mcp__shots__take" />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open image 1 of 1' }))
|
||||
|
||||
expect(lightbox.latest?.images).toEqual([{ src: 'blob:test-1', name: 'Image 1 of 1' }])
|
||||
expect(lightbox.latest?.images[0]).not.toHaveProperty('path')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('ToolResultImages · pictures that cannot be shown', () => {
|
||||
it('hides a thumbnail whose image fails to load and keeps the others', () => {
|
||||
const { container } = render(<ToolResultImages images={[FIRST, SECOND, THIRD]} />)
|
||||
|
||||
fireEvent.error(within(screen.getByRole('button', { name: 'Open image 2 of 3' })).getByRole('img'))
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Open image 2 of 3' })).toBeNull()
|
||||
expect(thumbnailSources(container)).toEqual(['blob:test-1', 'blob:test-3'])
|
||||
// The survivors keep their place in the tool result's own numbering.
|
||||
expect(screen.getByRole('button', { name: 'Open image 3 of 3' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('leaves a hidden thumbnail out of the lightbox', () => {
|
||||
render(<ToolResultImages images={[FIRST, SECOND, THIRD]} />)
|
||||
fireEvent.error(within(screen.getByRole('button', { name: 'Open image 1 of 3' })).getByRole('img'))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open image 3 of 3' }))
|
||||
|
||||
expect(lightbox.latest?.images.map((image) => image.src)).toEqual(['blob:test-2', 'blob:test-3'])
|
||||
expect(lightbox.latest?.activeIndex).toBe(1)
|
||||
expect(screen.getByTestId('lightbox-position')).toHaveTextContent('2 / 2')
|
||||
})
|
||||
|
||||
it('renders nothing once every thumbnail has failed', () => {
|
||||
const { container } = render(<ToolResultImages images={[FIRST, SECOND]} />)
|
||||
|
||||
for (const image of screen.getAllByRole('img')) fireEvent.error(image)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('shows a fresh thumbnail for new images after an earlier one failed', () => {
|
||||
const { container, rerender } = render(<ToolResultImages images={[FIRST]} />)
|
||||
fireEvent.error(screen.getByRole('img'))
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
|
||||
rerender(<ToolResultImages images={[SECOND]} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Open image 1 of 1' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('drops an image that will not decode without a crash or a console error', () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const undecodable: ToolResultImage = { mediaType: 'image/png', data: 'Q' }
|
||||
|
||||
const { container } = render(<ToolResultImages images={[undecodable, FIRST]} />)
|
||||
|
||||
expect(createObjectURL).toHaveBeenCalledTimes(1)
|
||||
expect(screen.queryByRole('button', { name: 'Open image 1 of 2' })).toBeNull()
|
||||
expect(within(screen.getByRole('button', { name: 'Open image 2 of 2' })).getByRole('img')).toHaveAttribute('src', 'blob:test-1')
|
||||
expect(thumbnailSources(container)).toEqual(['blob:test-1'])
|
||||
expect(consoleError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders nothing when every image will not decode', () => {
|
||||
const { container } = render(<ToolResultImages images={[{ mediaType: 'image/png', data: 'Q' }]} />)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('renders nothing, without throwing, where object URLs do not exist', () => {
|
||||
delete (URL as unknown as Record<string, unknown>).createObjectURL
|
||||
delete (URL as unknown as Record<string, unknown>).revokeObjectURL
|
||||
|
||||
expect(() => render(<ToolResultImages images={[FIRST]} />)).not.toThrow()
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('drops an image whose object URL cannot be created', () => {
|
||||
createObjectURL.mockImplementationOnce(() => {
|
||||
throw new Error('quota')
|
||||
})
|
||||
|
||||
render(<ToolResultImages images={[FIRST, SECOND]} />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Open image 1 of 2' })).toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'Open image 2 of 2' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ToolResultImages · omitted images', () => {
|
||||
it('says how many images were not shown, next to the ones that were', () => {
|
||||
render(<ToolResultImages images={[FIRST]} omitted={3} />)
|
||||
|
||||
expect(screen.getByText('Images not shown: 3')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Open image 1 of 1' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('still says so when none could be shown', () => {
|
||||
render(<ToolResultImages images={[]} omitted={1} />)
|
||||
|
||||
expect(screen.getByText('Images not shown: 1')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
})
|
||||
|
||||
it('says nothing when nothing was left out', () => {
|
||||
render(<ToolResultImages images={[FIRST]} omitted={0} />)
|
||||
|
||||
expect(screen.queryByText(/not shown/)).toBeNull()
|
||||
})
|
||||
|
||||
it('localizes the note', () => {
|
||||
useSettingsStore.setState({ locale: 'zh' })
|
||||
render(<ToolResultImages images={[]} omitted={2} />)
|
||||
|
||||
expect(screen.getByText('另有 2 张图片未显示')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from '@/i18n'
|
||||
import { isRootedLocalPath } from '@/lib/handlePreviewLink'
|
||||
import { toolResultImageToBlob, type ToolResultImage } from '@/lib/toolResultContent'
|
||||
import { ImageGalleryModal } from './ImageGalleryModal'
|
||||
|
||||
type Props = {
|
||||
images: ToolResultImage[]
|
||||
/** Image blocks the tool sent that could not be shown (see `extractToolResultImages`). */
|
||||
omitted?: number
|
||||
/**
|
||||
* The local file the pictures were read from (the `Read` tool). A rooted path is
|
||||
* handed to the lightbox so it can offer the original in the system app.
|
||||
*/
|
||||
originalPath?: string
|
||||
toolName?: string
|
||||
/** Outer spacing, decided by the chrome this strip sits in. */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Object URLs made for one exact array of images; `null` marks one that would not decode. */
|
||||
type ObjectUrls = { sources: ToolResultImage[]; urls: Array<string | null> }
|
||||
|
||||
/**
|
||||
* The pictures a tool returned, as a strip of thumbnails under the tool row.
|
||||
* Clicking one opens the lightbox.
|
||||
*
|
||||
* The bytes live in the message store as base64. Each picture is decoded into a
|
||||
* Blob and shown through an object URL, which keeps megabytes of base64 out of
|
||||
* `src` attributes. Object URLs pin their Blob until revoked, so the URLs are made
|
||||
* in an effect and revoked by that same effect's cleanup: remounting, changing
|
||||
* images and StrictMode's double mount all release exactly what they created.
|
||||
* A thumbnail that fails to decode or to load is dropped rather than shown broken.
|
||||
*/
|
||||
export function ToolResultImages({ images, omitted = 0, originalPath, toolName, className = '' }: Props) {
|
||||
const t = useTranslation()
|
||||
const sources = useStableImages(images)
|
||||
const [objectUrls, setObjectUrls] = useState<ObjectUrls | null>(null)
|
||||
const [brokenUrls, setBrokenUrls] = useState<ReadonlySet<string>>(() => new Set())
|
||||
const [activeUrl, setActiveUrl] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (sources.length === 0) return
|
||||
const created: string[] = []
|
||||
const urls = sources.map((image) => {
|
||||
try {
|
||||
const url = URL.createObjectURL(toolResultImageToBlob(image))
|
||||
created.push(url)
|
||||
return url
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
setObjectUrls({ sources, urls })
|
||||
return () => {
|
||||
for (const url of created) URL.revokeObjectURL(url)
|
||||
}
|
||||
}, [sources])
|
||||
|
||||
// URLs made for an earlier array are revoked by now; never hand them to an <img>.
|
||||
const urls = objectUrls?.sources === sources ? objectUrls.urls : null
|
||||
const count = sources.length
|
||||
const slots = sources
|
||||
.map((_, index) => ({ index, url: urls?.[index] ?? null }))
|
||||
// Waiting on the effect keeps a placeholder so the row does not grow later;
|
||||
// once URLs exist, a picture without a usable one is simply not shown.
|
||||
.filter(({ url }) => urls === null || (url !== null && !brokenUrls.has(url)))
|
||||
const shown = slots.flatMap(({ index, url }) => (url === null ? [] : [{ index, url }]))
|
||||
|
||||
const galleryPath = originalPath && isRootedLocalPath(originalPath) ? originalPath : undefined
|
||||
const originalName = originalPath ? fileNameOf(originalPath) : ''
|
||||
const galleryImages = shown.map(({ index, url }) => ({
|
||||
src: url,
|
||||
name: originalName || t('chat.toolResultImage.alt', { index: index + 1, count }),
|
||||
...(galleryPath ? { path: galleryPath } : {}),
|
||||
}))
|
||||
const activeIndex = activeUrl === null ? -1 : galleryImages.findIndex((image) => image.src === activeUrl)
|
||||
|
||||
if (slots.length === 0 && omitted <= 0) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
role="group"
|
||||
aria-label={toolName ? t('tool.result', { toolName }) : t('tool.resultGeneric')}
|
||||
data-tool-result-images=""
|
||||
className={`flex flex-wrap items-center gap-2 ${className}`}
|
||||
>
|
||||
{slots.map(({ index, url }) => (url === null ? (
|
||||
<span
|
||||
key={index}
|
||||
aria-hidden="true"
|
||||
className="h-24 w-24 shrink-0 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)]"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
aria-label={t('chat.toolResultImage.open', { index: index + 1, count })}
|
||||
onClick={() => setActiveUrl(url)}
|
||||
className="flex h-24 shrink-0 overflow-hidden rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] transition-colors hover:border-[var(--color-primary-fixed-dim)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] motion-reduce:transition-none"
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt={t('chat.toolResultImage.alt', { index: index + 1, count })}
|
||||
onError={() => setBrokenUrls((previous) => new Set(previous).add(url))}
|
||||
className="block h-full w-auto min-w-[64px] max-w-[240px] object-cover"
|
||||
/>
|
||||
</button>
|
||||
)))}
|
||||
{omitted > 0 ? (
|
||||
<span className="text-[12px] text-[var(--color-text-tertiary)]">
|
||||
{t('chat.toolResultImage.omitted', { count: omitted })}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{activeIndex >= 0 ? (
|
||||
<ImageGalleryModal
|
||||
open
|
||||
images={galleryImages}
|
||||
activeIndex={activeIndex}
|
||||
onClose={() => setActiveUrl(null)}
|
||||
onSelect={(next) => setActiveUrl(galleryImages[next]?.src ?? null)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the first array for as long as its contents stay the same. A parent that
|
||||
* hands over an equal but new array on every render (history reloaded, a wrapper
|
||||
* rebuilt) would otherwise decode every picture again and swap every `src`.
|
||||
*/
|
||||
function useStableImages(images: ToolResultImage[]): ToolResultImage[] {
|
||||
const [stable, setStable] = useState(images)
|
||||
if (stable !== images && !sameImages(stable, images)) {
|
||||
setStable(images)
|
||||
return images
|
||||
}
|
||||
return stable
|
||||
}
|
||||
|
||||
function sameImages(a: ToolResultImage[], b: ToolResultImage[]): boolean {
|
||||
return a.length === b.length && a.every((image, index) => (
|
||||
image.mediaType === b[index]!.mediaType && image.data === b[index]!.data
|
||||
))
|
||||
}
|
||||
|
||||
function fileNameOf(filePath: string): string {
|
||||
return filePath.split(/[\\/]/).filter(Boolean).pop() ?? filePath
|
||||
}
|
||||
@@ -431,6 +431,155 @@ describe('MarkdownRenderer', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('MarkdownRenderer local image destinations', () => {
|
||||
// These are the shapes a model writes for a picture on disk. DOMPurify used to drop
|
||||
// the src of the first two (a `file:` scheme; `C:` read as a scheme), so the image
|
||||
// lost its source before the resolver ever saw it.
|
||||
function seenByResolver(markdown: string): string[] {
|
||||
const seen: string[] = []
|
||||
render(
|
||||
<MarkdownRenderer
|
||||
content={markdown}
|
||||
resolveImageSrc={(src) => {
|
||||
seen.push(src)
|
||||
return `resolved:${src}`
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
return seen
|
||||
}
|
||||
|
||||
it.each([
|
||||
['a file:// URL with a drive letter', '', '/C:/Users/me/chart.png'],
|
||||
['a file:// URL', '', '/Users/me/chart.png'],
|
||||
['a Windows path with backslashes', '', '/C:/Users/me/chart.png'],
|
||||
['a Windows path with slashes', '', '/C:/Users/me/chart.png'],
|
||||
// Markdown reads `\.` and `\_` as escapes, but here the backslash is the separator.
|
||||
['a Windows path through a hidden folder', '', '/C:/Users/me/.claude/chart.png'],
|
||||
['a Windows path through an underscored folder', '', '/C:/Users/me/_out/chart.png'],
|
||||
// The renderer escapes the space, as for any destination; the resolver decodes it.
|
||||
['a Windows path in angle brackets, which may hold spaces and parentheses', ' pics\\chart.png>)', '/C:/Users/me/(new)%20pics/chart.png'],
|
||||
['a home-relative path', '', '~/Pictures/chart.png'],
|
||||
['an absolute POSIX path', '', '/Users/me/chart.png'],
|
||||
['a relative path', '', 'figures/chart.png'],
|
||||
])('hands the resolver %s', (_label, markdown, expected) => {
|
||||
expect(seenByResolver(markdown)).toEqual([expected])
|
||||
})
|
||||
|
||||
it('keeps a percent-escaped name as written, for the resolver to decode', () => {
|
||||
expect(seenByResolver('')).toEqual(['/Users/me/My%20Pics/chart.png'])
|
||||
})
|
||||
|
||||
it('still strips a file:// URL that names no local root, and a script scheme', () => {
|
||||
const seen = seenByResolver([
|
||||
'',
|
||||
')',
|
||||
].join('\n\n'))
|
||||
|
||||
expect(seen).toEqual([])
|
||||
})
|
||||
|
||||
it('does not rewrite a raw HTML image, which the author did not write as Markdown', () => {
|
||||
expect(seenByResolver('<img alt="raw" src="file:///Users/me/chart.png">')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('MarkdownRenderer image clicks', () => {
|
||||
const TWO_IMAGES = '\n\n'
|
||||
|
||||
it('reports the image that was clicked, with every image in the text and its place among them', () => {
|
||||
const onImageClick = vi.fn()
|
||||
const { container } = render(<MarkdownRenderer content={TWO_IMAGES} onImageClick={onImageClick} />)
|
||||
|
||||
fireEvent.click(container.querySelectorAll('img')[1]!)
|
||||
|
||||
expect(onImageClick).toHaveBeenCalledExactlyOnceWith({
|
||||
images: [
|
||||
{ src: 'data:image/png;base64,AAAA', alt: 'one' },
|
||||
{ src: 'blob:https://desktop.invalid/2', alt: 'two' },
|
||||
],
|
||||
index: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('counts the images across code blocks, which split the text into parts', () => {
|
||||
const onImageClick = vi.fn()
|
||||
const { container } = render(
|
||||
<MarkdownRenderer
|
||||
content={'\n\n```js\nconst x = 1\n```\n\n'}
|
||||
onImageClick={onImageClick}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(container.querySelectorAll('img')[1]!)
|
||||
|
||||
expect(onImageClick.mock.calls[0]![0]).toMatchObject({ index: 1 })
|
||||
expect(onImageClick.mock.calls[0]![0].images).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('reports the resolved source, since that is the one that loads', () => {
|
||||
const onImageClick = vi.fn()
|
||||
const { container } = render(
|
||||
<MarkdownRenderer content="" resolveImageSrc={(src) => `http://127.0.0.1:3456/${src}`} onImageClick={onImageClick} />,
|
||||
)
|
||||
|
||||
fireEvent.click(container.querySelector('img')!)
|
||||
|
||||
expect(onImageClick.mock.calls[0]![0].images).toEqual([{ src: 'http://127.0.0.1:3456/figures/chart.png', alt: 'chart' }])
|
||||
})
|
||||
|
||||
it('leaves an image inside a link to the link', () => {
|
||||
const onImageClick = vi.fn()
|
||||
const onLinkClick = vi.fn(() => false)
|
||||
const { container } = render(
|
||||
<MarkdownRenderer
|
||||
content="[](https://example.com)"
|
||||
onImageClick={onImageClick}
|
||||
onLinkClick={onLinkClick}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(container.querySelector('img')!)
|
||||
|
||||
expect(onImageClick).not.toHaveBeenCalled()
|
||||
expect(onLinkClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('ignores a picture that has lost its source: there is nothing to show', () => {
|
||||
const onImageClick = vi.fn()
|
||||
const { container } = render(<MarkdownRenderer content={TWO_IMAGES} onImageClick={onImageClick} />)
|
||||
const [first] = Array.from(container.querySelectorAll('img'))
|
||||
first!.removeAttribute('src')
|
||||
|
||||
fireEvent.click(first!)
|
||||
|
||||
expect(onImageClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores a click that is not on an image', () => {
|
||||
const onImageClick = vi.fn()
|
||||
render(<MarkdownRenderer content={`words\n\n${TWO_IMAGES}`} onImageClick={onImageClick} />)
|
||||
|
||||
fireEvent.click(screen.getByText('words'))
|
||||
|
||||
expect(onImageClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing about an image click when nobody asked to hear about it', () => {
|
||||
const { container } = render(<MarkdownRenderer content={TWO_IMAGES} />)
|
||||
|
||||
expect(() => fireEvent.click(container.querySelector('img')!)).not.toThrow()
|
||||
})
|
||||
|
||||
it('shows the zoom cursor on images only when a click will be handled', () => {
|
||||
const { container, rerender } = render(<MarkdownRenderer content={TWO_IMAGES} />)
|
||||
expect(container.firstElementChild!.className).not.toContain('cursor-zoom-in')
|
||||
|
||||
rerender(<MarkdownRenderer content={TWO_IMAGES} onImageClick={() => undefined} />)
|
||||
expect(container.firstElementChild!.className).toContain('[&_img]:cursor-zoom-in')
|
||||
})
|
||||
})
|
||||
|
||||
describe('MarkdownRenderer parse cache', () => {
|
||||
beforeEach(() => {
|
||||
__markdownParseCacheInternals.reset()
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
unwrapFileLinks,
|
||||
} from '@/lib/markdownAutolink'
|
||||
import { classifyPreviewLink } from '@/lib/previewLinkRouter'
|
||||
import { isSafeMarkdownImageSource } from '@/lib/markdownImages'
|
||||
import { isSafeMarkdownImageSource, normalizeMarkdownImageDestination } from '@/lib/markdownImages'
|
||||
import { CodeViewer } from '../chat/CodeViewer'
|
||||
import { MermaidRenderer } from '../chat/MermaidRenderer'
|
||||
import { copyTextToClipboard } from '@/lib/clipboard'
|
||||
@@ -37,6 +37,17 @@ type Props = {
|
||||
* sources survive.
|
||||
*/
|
||||
resolveImageSrc?: (src: string) => string | null
|
||||
/**
|
||||
* Called when an image is clicked (not one inside a link, which belongs to the
|
||||
* link), with every image in the text in reading order — sources as they load,
|
||||
* so a viewer can show them — and the place of the one clicked.
|
||||
*/
|
||||
onImageClick?: (click: MarkdownImageClick) => void
|
||||
}
|
||||
|
||||
export type MarkdownImageClick = {
|
||||
images: Array<{ src: string; alt: string }>
|
||||
index: number
|
||||
}
|
||||
|
||||
type CodeBlock = {
|
||||
@@ -140,6 +151,16 @@ renderer.link = function (token: Tokens.Link) {
|
||||
return renderDefaultLink.call(this, token)
|
||||
}
|
||||
|
||||
// `file:///C:/x.png` and `C:\x.png` lose their source in sanitization as written
|
||||
// (see normalizeMarkdownImageDestination), and this is the last place the author's
|
||||
// destination is still separate from the markup around it — and, in `raw`, still
|
||||
// as written, where the parser has taken the backslashes out of a Windows path.
|
||||
const renderDefaultImage = renderer.image
|
||||
renderer.image = function (token: Tokens.Image) {
|
||||
const href = normalizeMarkdownImageDestination(token.href, token.raw)
|
||||
return renderDefaultImage.call(this, href === token.href ? token : { ...token, href })
|
||||
}
|
||||
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
@@ -576,25 +597,43 @@ const COMPACT_PROSE_CLASSES = `
|
||||
[&_.md-math-display]:my-2 [&_.md-math-display]:py-1 [&_.md-math-display_.katex]:text-[1.04em]
|
||||
[&_.md-table-wrap]:my-2`
|
||||
|
||||
function getProseClasses(variant: 'default' | 'document' | 'compact', className?: string) {
|
||||
function getProseClasses(variant: 'default' | 'document' | 'compact', className?: string, zoomableImages = false) {
|
||||
return [
|
||||
BASE_PROSE_CLASSES,
|
||||
variant === 'document' ? DOCUMENT_PROSE_CLASSES : '',
|
||||
variant === 'compact' ? COMPACT_PROSE_CLASSES : '',
|
||||
// Only where a click on the picture does something.
|
||||
zoomableImages ? '[&_img]:cursor-zoom-in' : '',
|
||||
className ?? '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
export const MarkdownRenderer = memo(function MarkdownRenderer({ content, variant = 'default', className, cache = true, streaming = false, onLinkClick, resolveImageSrc }: Props) {
|
||||
function reportImageClick(
|
||||
target: HTMLElement | null,
|
||||
container: HTMLElement,
|
||||
onImageClick: (click: MarkdownImageClick) => void,
|
||||
): void {
|
||||
const clicked = target?.closest<HTMLImageElement>('img')
|
||||
if (!clicked || !container.contains(clicked)) return
|
||||
const images = Array.from(container.querySelectorAll<HTMLImageElement>('img')).filter((image) => image.getAttribute('src'))
|
||||
const index = images.indexOf(clicked)
|
||||
if (index < 0) return
|
||||
onImageClick({
|
||||
images: images.map((image) => ({ src: image.getAttribute('src')!, alt: image.getAttribute('alt') ?? '' })),
|
||||
index,
|
||||
})
|
||||
}
|
||||
|
||||
export const MarkdownRenderer = memo(function MarkdownRenderer({ content, variant = 'default', className, cache = true, streaming = false, onLinkClick, resolveImageSrc, onImageClick }: Props) {
|
||||
const { html, codeBlocks, mathBlocks } = useMemo(
|
||||
() => cache ? getCachedMarkdownParse(content, streaming) : parseMarkdown(content),
|
||||
[cache, content, streaming],
|
||||
)
|
||||
const proseClasses = useMemo(
|
||||
() => getProseClasses(variant, className),
|
||||
[variant, className],
|
||||
() => getProseClasses(variant, className, Boolean(onImageClick)),
|
||||
[variant, className, onImageClick],
|
||||
)
|
||||
|
||||
const parts = useMemo(() => {
|
||||
@@ -648,7 +687,12 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ content, varian
|
||||
const button = target?.closest<HTMLButtonElement>('[data-copy-code]')
|
||||
if (!button) {
|
||||
const link = target?.closest<HTMLAnchorElement>('a[href], a[data-file-path]')
|
||||
if (!link || !onLinkClick) return
|
||||
if (!link) {
|
||||
// A picture in a link is the link's; a bare one opens in the viewer.
|
||||
if (onImageClick) reportImageClick(target, event.currentTarget, onImageClick)
|
||||
return
|
||||
}
|
||||
if (!onLinkClick) return
|
||||
|
||||
const handled = onLinkClick(fileRefFromElement(link) ?? link.getAttribute('href') ?? '', event)
|
||||
if (handled) {
|
||||
@@ -669,7 +713,7 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ content, varian
|
||||
window.setTimeout(() => {
|
||||
button.textContent = original
|
||||
}, 1500)
|
||||
}, [onLinkClick])
|
||||
}, [onImageClick, onLinkClick])
|
||||
|
||||
if (codeBlocks.length === 0) {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ZoomControls, type ZoomControlsProps } from './ZoomControls'
|
||||
|
||||
const LABELS = { group: 'Zoom controls', zoomIn: 'Zoom in', zoomOut: 'Zoom out', fit: 'Fit to window' }
|
||||
|
||||
function renderControls(overrides: Partial<ZoomControlsProps> = {}) {
|
||||
const handlers = { onZoomIn: vi.fn(), onZoomOut: vi.fn(), onFit: vi.fn() }
|
||||
const view = render(
|
||||
<ZoomControls
|
||||
percent={110}
|
||||
fitActive={false}
|
||||
canZoomIn
|
||||
canZoomOut
|
||||
labels={LABELS}
|
||||
{...handlers}
|
||||
{...overrides}
|
||||
/>,
|
||||
)
|
||||
return { ...view, ...handlers }
|
||||
}
|
||||
|
||||
describe('ZoomControls', () => {
|
||||
it('names the cluster and each control from the labels the caller supplies', () => {
|
||||
renderControls()
|
||||
|
||||
expect(screen.getByRole('group', { name: 'Zoom controls' })).toBeInTheDocument()
|
||||
for (const name of ['Zoom in', 'Zoom out', 'Fit to window']) {
|
||||
expect(screen.getByRole('button', { name })).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
it('shows the current zoom as a readout', () => {
|
||||
renderControls({ percent: 87 })
|
||||
|
||||
expect(screen.getByText('87%')).toBeInTheDocument()
|
||||
// A readout, not a control: it does not compete for tab stops.
|
||||
expect(screen.queryByRole('button', { name: /87/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports each control through its own handler', () => {
|
||||
const { onZoomIn, onZoomOut, onFit } = renderControls()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Zoom out' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Fit to window' }))
|
||||
|
||||
expect(onZoomIn).toHaveBeenCalledTimes(1)
|
||||
expect(onZoomOut).toHaveBeenCalledTimes(1)
|
||||
expect(onFit).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('disables a step that has nowhere left to go', () => {
|
||||
renderControls({ canZoomIn: false, canZoomOut: false })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Zoom in' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Zoom out' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Fit to window' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('marks fit as pressed while the viewer is fitting', () => {
|
||||
const { rerender } = renderControls({ fitActive: true })
|
||||
expect(screen.getByRole('button', { name: 'Fit to window' })).toHaveAttribute('aria-pressed', 'true')
|
||||
|
||||
rerender(
|
||||
<ZoomControls percent={100} fitActive={false} canZoomIn canZoomOut labels={LABELS} onZoomIn={vi.fn()} onZoomOut={vi.fn()} onFit={vi.fn()} />,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Fit to window' })).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
|
||||
it('floats with a shadow by default and sits flat in a toolbar on request', () => {
|
||||
const { container, rerender } = renderControls()
|
||||
expect(container.firstElementChild?.className).toContain('--shadow-card')
|
||||
|
||||
rerender(
|
||||
<ZoomControls percent={100} fitActive canZoomIn canZoomOut labels={LABELS} onZoomIn={vi.fn()} onZoomOut={vi.fn()} onFit={vi.fn()} flat />,
|
||||
)
|
||||
expect(container.firstElementChild?.className).not.toContain('--shadow-card')
|
||||
})
|
||||
|
||||
it('uses the fixed dark palette on the media surface and theme tokens on a panel', () => {
|
||||
const { container, rerender } = renderControls({ surface: 'media' })
|
||||
expect(container.firstElementChild?.className).toContain('--color-media-header')
|
||||
|
||||
rerender(
|
||||
<ZoomControls percent={100} fitActive canZoomIn canZoomOut labels={LABELS} onZoomIn={vi.fn()} onZoomOut={vi.fn()} onFit={vi.fn()} />,
|
||||
)
|
||||
expect(container.firstElementChild?.className).toContain('--color-surface-container-lowest')
|
||||
expect(container.firstElementChild?.className).not.toContain('--color-media')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Maximize2, Minus, Plus } from 'lucide-react'
|
||||
|
||||
import { cx } from '@/lib/cx'
|
||||
import { IconButton } from './IconButton'
|
||||
|
||||
export type ZoomControlsLabels = {
|
||||
/** Names the whole cluster ("Zoom controls"). */
|
||||
group: string
|
||||
zoomIn: string
|
||||
zoomOut: string
|
||||
/** What the fit button does for this viewer: "Fit to window" or "Fit to width". */
|
||||
fit: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The look of a control floating over a viewer: a hairline-bordered pill. Shared
|
||||
* so an extra action placed beside the zoom cluster reads as part of it.
|
||||
*/
|
||||
export function floatingPillClass(surface: 'default' | 'media', { flat = false }: { flat?: boolean } = {}): string {
|
||||
return cx(
|
||||
'inline-flex items-center gap-0.5 rounded-[var(--radius-lg)] border p-0.5',
|
||||
// A pill floating over content lifts off it with a shadow; one sitting in a
|
||||
// toolbar is already on a surface of its own.
|
||||
!flat && 'shadow-[var(--shadow-card)]',
|
||||
surface === 'media'
|
||||
? 'border-[var(--color-media-border)] bg-[var(--color-media-header)] text-[var(--color-media-muted)]'
|
||||
: 'border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] text-[var(--color-text-secondary)]',
|
||||
)
|
||||
}
|
||||
|
||||
export type ZoomControlsProps = {
|
||||
/** The zoom as the whole-number percentage a person reads. */
|
||||
percent: number
|
||||
/** The viewer is showing whatever scale fits, rather than a scale the user chose. */
|
||||
fitActive: boolean
|
||||
canZoomIn: boolean
|
||||
canZoomOut: boolean
|
||||
onZoomIn: () => void
|
||||
onZoomOut: () => void
|
||||
onFit: () => void
|
||||
/** Caller-supplied so this primitive never carries user-visible text of its own. */
|
||||
labels: ZoomControlsLabels
|
||||
/**
|
||||
* `media` for a control that floats over the dark lightbox, whose colors do
|
||||
* not follow the theme; `default` for one that sits on a panel.
|
||||
*/
|
||||
surface?: 'default' | 'media'
|
||||
/** No drop shadow: for a cluster that sits in a toolbar rather than floating over content. */
|
||||
flat?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The zoom cluster shared by every viewer: − / percentage / + / fit.
|
||||
*
|
||||
* The percentage is a readout, not a button. "Actual size" is reached by the "+"
|
||||
* ladder passing through 100%, by a double click on the content, and by the
|
||||
* keyboard, so it does not need a control of its own competing for room in a
|
||||
* narrow panel.
|
||||
*/
|
||||
export function ZoomControls({
|
||||
percent,
|
||||
fitActive,
|
||||
canZoomIn,
|
||||
canZoomOut,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
onFit,
|
||||
labels,
|
||||
surface = 'default',
|
||||
flat = false,
|
||||
className,
|
||||
}: ZoomControlsProps) {
|
||||
const media = surface === 'media'
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={labels.group}
|
||||
className={cx(floatingPillClass(surface, { flat }), className)}
|
||||
>
|
||||
<IconButton
|
||||
icon={<Minus size={16} strokeWidth={1.9} />}
|
||||
label={labels.zoomOut}
|
||||
size="md"
|
||||
tone="secondary"
|
||||
surface={media ? 'media' : 'default'}
|
||||
disabled={!canZoomOut}
|
||||
onClick={onZoomOut}
|
||||
/>
|
||||
<span className="min-w-[3.25rem] px-1 text-center font-mono text-xs tabular-nums">{percent}%</span>
|
||||
<IconButton
|
||||
icon={<Plus size={16} strokeWidth={1.9} />}
|
||||
label={labels.zoomIn}
|
||||
size="md"
|
||||
tone="secondary"
|
||||
surface={media ? 'media' : 'default'}
|
||||
disabled={!canZoomIn}
|
||||
onClick={onZoomIn}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<Maximize2 size={15} strokeWidth={1.9} />}
|
||||
label={labels.fit}
|
||||
size="md"
|
||||
tone="secondary"
|
||||
surface={media ? 'media' : 'default'}
|
||||
pressed={fitActive}
|
||||
onClick={onFit}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { act, createEvent, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DOUBLE_PRESS_MS } from '@/lib/zoomPan'
|
||||
import { ZoomableImage, type ZoomableImageProps } from './ZoomableImage'
|
||||
|
||||
const LABELS = { group: 'Zoom controls', zoomIn: 'Zoom in', zoomOut: 'Zoom out', fit: 'Fit to window' }
|
||||
|
||||
const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth')
|
||||
const originalClientHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientHeight')
|
||||
const originalNaturalWidth = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'naturalWidth')
|
||||
const originalNaturalHeight = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'naturalHeight')
|
||||
|
||||
/** jsdom lays nothing out: give the scroll area and the picture real sizes. */
|
||||
function stubGeometry({ container = [800, 600], natural = [1600, 1200] }: { container?: [number, number]; natural?: [number, number] } = {}) {
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, get: () => container[0] })
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientHeight', { configurable: true, get: () => container[1] })
|
||||
Object.defineProperty(HTMLImageElement.prototype, 'naturalWidth', { configurable: true, get: () => natural[0] })
|
||||
Object.defineProperty(HTMLImageElement.prototype, 'naturalHeight', { configurable: true, get: () => natural[1] })
|
||||
}
|
||||
|
||||
function restore(property: string, target: object, original: PropertyDescriptor | undefined) {
|
||||
if (original) Object.defineProperty(target, property, original)
|
||||
else Reflect.deleteProperty(target, property)
|
||||
}
|
||||
|
||||
function renderImage(overrides: Partial<ZoomableImageProps> = {}) {
|
||||
const view = render(<ZoomableImage src="data:image/png;base64,AAAA" alt="figure.png" labels={LABELS} {...overrides} />)
|
||||
const image = screen.getByRole('img', { name: 'figure.png' }) as HTMLImageElement
|
||||
// The picture finishing its load is what gives the viewer its natural size.
|
||||
act(() => { fireEvent.load(image) })
|
||||
return { ...view, image, area: screen.getByRole('group', { name: 'figure.png' }) }
|
||||
}
|
||||
|
||||
/**
|
||||
* jsdom has no `PointerEvent`, so `fireEvent.pointerDown(node, { clientX })` drops
|
||||
* the coordinates and the button. Build the event and pin the fields — including
|
||||
* the time, which is what decides whether two presses are a double click.
|
||||
*/
|
||||
function press(node: Element, { time, at = [100, 100], button = 0 }: { time: number; at?: [number, number]; button?: number }) {
|
||||
const event = createEvent.pointerDown(node)
|
||||
Object.defineProperty(event, 'button', { value: button })
|
||||
Object.defineProperty(event, 'clientX', { value: at[0] })
|
||||
Object.defineProperty(event, 'clientY', { value: at[1] })
|
||||
Object.defineProperty(event, 'timeStamp', { value: time })
|
||||
fireEvent(node, event)
|
||||
}
|
||||
|
||||
beforeEach(() => stubGeometry())
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
restore('clientWidth', HTMLElement.prototype, originalClientWidth)
|
||||
restore('clientHeight', HTMLElement.prototype, originalClientHeight)
|
||||
restore('naturalWidth', HTMLImageElement.prototype, originalNaturalWidth)
|
||||
restore('naturalHeight', HTMLImageElement.prototype, originalNaturalHeight)
|
||||
})
|
||||
|
||||
describe('ZoomableImage', () => {
|
||||
it('starts fitted: the whole picture is visible inside the padded scroll area', () => {
|
||||
const { image } = renderImage()
|
||||
|
||||
// 800×600 area, 16px padding each side → 768×568 available; 1600×1200 → min(0.48, 0.4733)
|
||||
expect(image.style.width).toBe(`${1600 * (568 / 1200)}px`)
|
||||
expect(screen.getByText('47%')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Fit to window' })).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
it('does not enlarge a small picture beyond 100% when fitting', () => {
|
||||
stubGeometry({ natural: [200, 100] })
|
||||
const { image } = renderImage()
|
||||
|
||||
expect(image.style.width).toBe('200px')
|
||||
expect(screen.getByText('100%')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('steps to the next rung with + and back with −, leaving fit mode', () => {
|
||||
const { image } = renderImage()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }))
|
||||
expect(screen.getByText('50%')).toBeInTheDocument()
|
||||
expect(image.style.width).toBe('800px')
|
||||
expect(screen.getByRole('button', { name: 'Fit to window' })).toHaveAttribute('aria-pressed', 'false')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Zoom out' }))
|
||||
expect(screen.getByText('33%')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('returns to fit from a chosen zoom', () => {
|
||||
renderImage()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Fit to window' }))
|
||||
|
||||
expect(screen.getByText('47%')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('toggles between fit and 100% on a double click', () => {
|
||||
const { area } = renderImage()
|
||||
|
||||
press(area, { time: 1000 })
|
||||
press(area, { time: 1150 })
|
||||
expect(screen.getByText('100%')).toBeInTheDocument()
|
||||
|
||||
press(area, { time: 3000 })
|
||||
press(area, { time: 3150 })
|
||||
expect(screen.getByText('47%')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('takes the press after a double click as the start of a new one, not a third click', () => {
|
||||
const { area } = renderImage()
|
||||
|
||||
press(area, { time: 1000 })
|
||||
press(area, { time: 1100 })
|
||||
press(area, { time: 1200 })
|
||||
|
||||
expect(screen.getByText('100%')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not take the trailing half of a double click that opened the viewer for its own', () => {
|
||||
// The first click was on a thumbnail, and opened this viewer under the pointer.
|
||||
// The surface sees one press, then a `dblclick` that only names it as the target.
|
||||
const { area } = renderImage()
|
||||
|
||||
press(area, { time: 1000 })
|
||||
fireEvent.doubleClick(area)
|
||||
|
||||
expect(screen.getByText('47%')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('needs the second press to follow the first closely, in time and in place', () => {
|
||||
const { area } = renderImage()
|
||||
|
||||
press(area, { time: 1000 })
|
||||
press(area, { time: 1000 + DOUBLE_PRESS_MS + 1 })
|
||||
press(area, { time: 1100 + DOUBLE_PRESS_MS + 1, at: [300, 300] })
|
||||
|
||||
expect(screen.getByText('47%')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('leaves the other buttons to the browser', () => {
|
||||
const { area } = renderImage()
|
||||
|
||||
press(area, { time: 1000, button: 2 })
|
||||
press(area, { time: 1100, button: 2 })
|
||||
|
||||
expect(screen.getByText('47%')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('zooms with Ctrl+wheel (also how a trackpad pinch arrives) and lets a plain wheel scroll', () => {
|
||||
const { area } = renderImage()
|
||||
|
||||
fireEvent.wheel(area, { deltaY: 120 })
|
||||
expect(screen.getByText('47%')).toBeInTheDocument()
|
||||
|
||||
fireEvent.wheel(area, { deltaY: -100, ctrlKey: true })
|
||||
expect(Number.parseInt(screen.getByText(/%$/).textContent ?? '0', 10)).toBeGreaterThan(47)
|
||||
})
|
||||
|
||||
it('cancels the page-level zoom that a Ctrl+wheel would otherwise trigger', () => {
|
||||
// The listener must be native and non-passive for preventDefault to count.
|
||||
const { area } = renderImage()
|
||||
const event = new WheelEvent('wheel', { deltaY: -100, ctrlKey: true, bubbles: true, cancelable: true })
|
||||
|
||||
act(() => { area.dispatchEvent(event) })
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it('does not cancel an ordinary wheel, which must keep scrolling', () => {
|
||||
const { area } = renderImage()
|
||||
const event = new WheelEvent('wheel', { deltaY: 100, bubbles: true, cancelable: true })
|
||||
|
||||
act(() => { area.dispatchEvent(event) })
|
||||
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('handles + - 0 1 from the keyboard', () => {
|
||||
const { area } = renderImage()
|
||||
|
||||
fireEvent.keyDown(area, { key: '+' })
|
||||
expect(screen.getByText('50%')).toBeInTheDocument()
|
||||
fireEvent.keyDown(area, { key: '1' })
|
||||
expect(screen.getByText('100%')).toBeInTheDocument()
|
||||
fireEvent.keyDown(area, { key: '-' })
|
||||
expect(screen.getByText('90%')).toBeInTheDocument()
|
||||
fireEvent.keyDown(area, { key: '0' })
|
||||
expect(screen.getByText('47%')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('leaves shortcuts with a modifier to the browser', () => {
|
||||
const { area } = renderImage()
|
||||
|
||||
fireEvent.keyDown(area, { key: '+', ctrlKey: true })
|
||||
fireEvent.keyDown(area, { key: '0', metaKey: true })
|
||||
|
||||
expect(screen.getByText('47%')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('stops zooming out at the smallest and in at the largest zoom', () => {
|
||||
const { rerender } = renderImage({ zoom: 0.1 })
|
||||
expect(screen.getByRole('button', { name: 'Zoom out' })).toBeDisabled()
|
||||
|
||||
rerender(<ZoomableImage src="data:image/png;base64,AAAA" alt="figure.png" labels={LABELS} zoom={8} />)
|
||||
expect(screen.getByRole('button', { name: 'Zoom in' })).toBeDisabled()
|
||||
})
|
||||
|
||||
describe('controlled zoom', () => {
|
||||
it('shows the zoom it is given and reports a request without applying it itself', () => {
|
||||
const onZoomChange = vi.fn()
|
||||
const { image } = renderImage({ zoom: 1, onZoomChange })
|
||||
expect(image.style.width).toBe('1600px')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }))
|
||||
|
||||
expect(onZoomChange).toHaveBeenCalledWith(1.1)
|
||||
// The parent owns the value: nothing changes until it passes a new one.
|
||||
expect(image.style.width).toBe('1600px')
|
||||
})
|
||||
|
||||
it('reports fit as the string "fit"', () => {
|
||||
const onZoomChange = vi.fn()
|
||||
renderImage({ zoom: 2, onZoomChange })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Fit to window' }))
|
||||
|
||||
expect(onZoomChange).toHaveBeenCalledWith('fit')
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the extra actions beside the zoom cluster', () => {
|
||||
renderImage({ actions: <button type="button">Open in system app</button> })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Open in system app' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports a picture that fails to load', () => {
|
||||
const onError = vi.fn()
|
||||
const { image } = renderImage({ onError })
|
||||
|
||||
fireEvent.error(image)
|
||||
|
||||
expect(onError).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('falls back to CSS containment until the picture has a size, instead of guessing', () => {
|
||||
// Before load nothing can be measured; forcing a width then would be a guess.
|
||||
render(<ZoomableImage src="data:image/png;base64,AAAA" alt="figure.png" labels={LABELS} />)
|
||||
const image = screen.getByRole('img', { name: 'figure.png' })
|
||||
|
||||
expect(image.style.width).toBe('')
|
||||
expect(image.className).toContain('max-w-full')
|
||||
})
|
||||
|
||||
it('keeps the scroll surface marker the workspace uses to restore scroll position', () => {
|
||||
const { area } = renderImage()
|
||||
|
||||
expect(area).toHaveAttribute('data-workspace-scroll-surface')
|
||||
})
|
||||
|
||||
it('draws a picture that reports no size by CSS containment, not as a 0×0 nothing', () => {
|
||||
// An SVG that has only a viewBox has no natural size in some engines.
|
||||
stubGeometry({ natural: [0, 0] })
|
||||
const { image } = renderImage()
|
||||
|
||||
expect(image.style.width).toBe('')
|
||||
expect(image.className).toContain('max-w-full')
|
||||
})
|
||||
|
||||
describe('restoring where the reader left it', () => {
|
||||
const LEFT_AT = { left: 120, top: 80 }
|
||||
|
||||
function renderUnloaded(overrides: Partial<ZoomableImageProps> = {}) {
|
||||
const view = render(<ZoomableImage src="data:image/png;base64,AAAA" alt="figure.png" labels={LABELS} {...overrides} />)
|
||||
return {
|
||||
...view,
|
||||
image: screen.getByRole('img', { name: 'figure.png' }) as HTMLImageElement,
|
||||
area: screen.getByRole('group', { name: 'figure.png' }),
|
||||
}
|
||||
}
|
||||
|
||||
it('scrolls there once the picture is laid out, not before: until then there is nothing to scroll', () => {
|
||||
const { image, area } = renderUnloaded({ zoom: 2, initialScroll: LEFT_AT })
|
||||
expect(area.scrollLeft).toBe(0)
|
||||
expect(area.scrollTop).toBe(0)
|
||||
|
||||
act(() => { fireEvent.load(image) })
|
||||
|
||||
expect(area.scrollLeft).toBe(LEFT_AT.left)
|
||||
expect(area.scrollTop).toBe(LEFT_AT.top)
|
||||
})
|
||||
|
||||
it('does it once, and leaves the reader\'s own scrolling alone', () => {
|
||||
const { image, area, rerender } = renderUnloaded({ zoom: 2, initialScroll: LEFT_AT })
|
||||
act(() => { fireEvent.load(image) })
|
||||
area.scrollLeft = 5
|
||||
|
||||
rerender(<ZoomableImage src="data:image/png;base64,AAAA" alt="figure.png" labels={LABELS} zoom={2} initialScroll={{ ...LEFT_AT }} />)
|
||||
|
||||
expect(area.scrollLeft).toBe(5)
|
||||
})
|
||||
|
||||
it('asks the workspace to leave restoring to it, and only then', () => {
|
||||
const restoring = renderUnloaded({ initialScroll: LEFT_AT })
|
||||
expect(restoring.area).toHaveAttribute('data-workspace-scroll-surface', 'deferred')
|
||||
restoring.unmount()
|
||||
|
||||
const plain = renderUnloaded()
|
||||
expect(plain.area).toHaveAttribute('data-workspace-scroll-surface', '')
|
||||
})
|
||||
})
|
||||
|
||||
it('does not let the browser start a native image drag, which would fight panning', () => {
|
||||
const { image } = renderImage()
|
||||
|
||||
expect(image).toHaveAttribute('draggable', 'false')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,309 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
|
||||
import { useElementSize } from '@/hooks/useElementSize'
|
||||
import { cx } from '@/lib/cx'
|
||||
import {
|
||||
ZOOM_MAX,
|
||||
ZOOM_MIN,
|
||||
anchoredScroll,
|
||||
continuesDoublePress,
|
||||
fitScale,
|
||||
stepZoom,
|
||||
wheelZoom,
|
||||
zoomPercent,
|
||||
type Press,
|
||||
} from '@/lib/zoomPan'
|
||||
import { ZoomControls, floatingPillClass, type ZoomControlsLabels } from './ZoomControls'
|
||||
|
||||
/** `'fit'` shows the whole image; a number is a scale against its natural size. */
|
||||
export type ImageZoom = 'fit' | number
|
||||
|
||||
export type ZoomableImageProps = {
|
||||
src: string
|
||||
alt: string
|
||||
/** Caller-supplied so this primitive never carries user-visible text of its own. */
|
||||
labels: ZoomControlsLabels
|
||||
/**
|
||||
* Extra controls beside the zoom cluster — "Open in system app", for one. Pass
|
||||
* bare `IconButton`s (with this `surface`): they are wrapped in the same
|
||||
* floating pill as the zoom cluster.
|
||||
*/
|
||||
actions?: ReactNode
|
||||
/** Controlled zoom. Omit it and the component keeps its own. */
|
||||
zoom?: ImageZoom
|
||||
defaultZoom?: ImageZoom
|
||||
onZoomChange?: (zoom: ImageZoom) => void
|
||||
/**
|
||||
* Where to scroll once the picture has its size — the position it was left at. A
|
||||
* zoomed picture has nothing to scroll until it is laid out at that zoom, so a host
|
||||
* that restores earlier is clamped to 0 and then records the 0. Giving this marks
|
||||
* the scroll area `deferred`, which tells such a host to leave the restoring here.
|
||||
*/
|
||||
initialScroll?: { left: number; top: number }
|
||||
onError?: () => void
|
||||
/** `media` on the dark lightbox, `default` on a panel. */
|
||||
surface?: 'default' | 'media'
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Space kept clear around a fitted image, and inside the scroll area. */
|
||||
const FIT_PADDING = 16
|
||||
|
||||
/**
|
||||
* An image you can look at closely: fit-to-window, − / + steps, Ctrl/⌘+wheel and
|
||||
* pinch zoom anchored under the pointer, drag to pan, double-click between fit and
|
||||
* 100%, and `+` `-` `0` `1` from the keyboard.
|
||||
*
|
||||
* Give it a `key` per image when one instance shows several: the natural size it
|
||||
* measures belongs to the picture, not to the component.
|
||||
*/
|
||||
export function ZoomableImage({
|
||||
src,
|
||||
alt,
|
||||
labels,
|
||||
actions,
|
||||
zoom: controlledZoom,
|
||||
defaultZoom = 'fit',
|
||||
onZoomChange,
|
||||
initialScroll,
|
||||
onError,
|
||||
surface = 'default',
|
||||
className,
|
||||
}: ZoomableImageProps) {
|
||||
const media = surface === 'media'
|
||||
const [ownZoom, setOwnZoom] = useState<ImageZoom>(defaultZoom)
|
||||
const zoom = controlledZoom ?? ownZoom
|
||||
const [natural, setNatural] = useState<{ width: number; height: number } | null>(null)
|
||||
const [sizeRef, size] = useElementSize<HTMLDivElement>()
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null)
|
||||
const imageRef = useRef<HTMLImageElement | null>(null)
|
||||
const pendingAnchor = useRef<{ anchorX: number; anchorY: number; oldScale: number } | null>(null)
|
||||
const drag = useRef<{ pointerId: number; x: number; y: number; left: number; top: number } | null>(null)
|
||||
const lastPress = useRef<Press | null>(null)
|
||||
const [panning, setPanning] = useState(false)
|
||||
|
||||
const setScrollNode = useCallback((node: HTMLDivElement | null) => {
|
||||
scrollRef.current = node
|
||||
sizeRef(node)
|
||||
}, [sizeRef])
|
||||
|
||||
const fit = natural && size
|
||||
? fitScale({
|
||||
containerWidth: size.width,
|
||||
containerHeight: size.height,
|
||||
contentWidth: natural.width,
|
||||
contentHeight: natural.height,
|
||||
padding: FIT_PADDING,
|
||||
})
|
||||
: null
|
||||
// `null` means the scale cannot be computed yet (not loaded, not measured): the
|
||||
// image is then laid out by CSS, which approximates fit until it can be exact.
|
||||
const scale = zoom === 'fit' ? fit : zoom
|
||||
const currentScale = scale ?? 1
|
||||
|
||||
const changeZoom = useCallback((next: ImageZoom, anchor?: { x: number; y: number }) => {
|
||||
const node = scrollRef.current
|
||||
if (node && typeof next === 'number') {
|
||||
pendingAnchor.current = {
|
||||
anchorX: anchor?.x ?? node.clientWidth / 2,
|
||||
anchorY: anchor?.y ?? node.clientHeight / 2,
|
||||
oldScale: currentScale,
|
||||
}
|
||||
}
|
||||
if (controlledZoom === undefined) setOwnZoom(next)
|
||||
onZoomChange?.(next)
|
||||
}, [controlledZoom, currentScale, onZoomChange])
|
||||
|
||||
// Latest values for the native wheel listener, which is bound once.
|
||||
const latest = useRef({ changeZoom, currentScale })
|
||||
useLayoutEffect(() => {
|
||||
latest.current = { changeZoom, currentScale }
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const node = scrollRef.current
|
||||
if (!node) return
|
||||
// Native and non-passive on purpose: React attaches `onWheel` as a passive
|
||||
// listener, where preventDefault is ignored — and a pinch would then zoom the
|
||||
// whole page as well as the image.
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
if (!event.ctrlKey && !event.metaKey) return
|
||||
event.preventDefault()
|
||||
const rect = node.getBoundingClientRect()
|
||||
latest.current.changeZoom(
|
||||
wheelZoom(latest.current.currentScale, event.deltaY),
|
||||
{ x: event.clientX - rect.left, y: event.clientY - rect.top },
|
||||
)
|
||||
}
|
||||
node.addEventListener('wheel', onWheel, { passive: false })
|
||||
return () => node.removeEventListener('wheel', onWheel)
|
||||
}, [])
|
||||
|
||||
// The scroll offset only makes sense once the content has its new size, so the
|
||||
// anchor is applied after the scale that changed it has rendered.
|
||||
useLayoutEffect(() => {
|
||||
const pending = pendingAnchor.current
|
||||
const node = scrollRef.current
|
||||
if (!pending || !node || scale === null) return
|
||||
pendingAnchor.current = null
|
||||
const next = anchoredScroll({
|
||||
scrollLeft: node.scrollLeft,
|
||||
scrollTop: node.scrollTop,
|
||||
anchorX: pending.anchorX,
|
||||
anchorY: pending.anchorY,
|
||||
oldScale: pending.oldScale,
|
||||
newScale: scale,
|
||||
})
|
||||
node.scrollLeft = next.scrollLeft
|
||||
node.scrollTop = next.scrollTop
|
||||
}, [scale])
|
||||
|
||||
useEffect(() => {
|
||||
// The load event can have fired before React attached its handler when the
|
||||
// image was already decoded, so read the size directly as well.
|
||||
const image = imageRef.current
|
||||
setNatural(image && image.complete && image.naturalWidth > 0
|
||||
? { width: image.naturalWidth, height: image.naturalHeight }
|
||||
: null)
|
||||
}, [src])
|
||||
|
||||
const sized = scale !== null && natural !== null
|
||||
const canPan = natural !== null && scale !== null && size !== null
|
||||
&& (natural.width * scale + FIT_PADDING * 2 > size.width || natural.height * scale + FIT_PADDING * 2 > size.height)
|
||||
|
||||
// Once, and only when the picture is laid out at its size: that is when there is
|
||||
// something to scroll to. After that the reader's own scrolling is not ours to undo.
|
||||
const restoredScroll = useRef(false)
|
||||
useLayoutEffect(() => {
|
||||
const node = scrollRef.current
|
||||
if (restoredScroll.current || !initialScroll || !sized || !node) return
|
||||
restoredScroll.current = true
|
||||
node.scrollLeft = initialScroll.left
|
||||
node.scrollTop = initialScroll.top
|
||||
}, [initialScroll, sized])
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.ctrlKey || event.metaKey || event.altKey) return
|
||||
if (event.key === '+' || event.key === '=') changeZoom(stepZoom(currentScale, 1))
|
||||
else if (event.key === '-' || event.key === '_') changeZoom(stepZoom(currentScale, -1))
|
||||
else if (event.key === '0') changeZoom('fit')
|
||||
else if (event.key === '1') changeZoom(1)
|
||||
else return
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0) return
|
||||
// Two presses on this surface, not the `dblclick` event: see continuesDoublePress.
|
||||
const press = { time: event.timeStamp, x: event.clientX, y: event.clientY }
|
||||
if (continuesDoublePress(lastPress.current, press)) {
|
||||
lastPress.current = null
|
||||
changeZoom(zoom === 'fit' ? 1 : 'fit')
|
||||
return
|
||||
}
|
||||
lastPress.current = press
|
||||
|
||||
const node = scrollRef.current
|
||||
if (!canPan || !node) return
|
||||
drag.current = {
|
||||
pointerId: event.pointerId,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
left: node.scrollLeft,
|
||||
top: node.scrollTop,
|
||||
}
|
||||
node.setPointerCapture?.(event.pointerId)
|
||||
setPanning(true)
|
||||
}
|
||||
|
||||
const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const node = scrollRef.current
|
||||
const active = drag.current
|
||||
if (!node || !active || active.pointerId !== event.pointerId) return
|
||||
node.scrollLeft = active.left - (event.clientX - active.x)
|
||||
node.scrollTop = active.top - (event.clientY - active.y)
|
||||
}
|
||||
|
||||
const endPan = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (drag.current?.pointerId !== event.pointerId) return
|
||||
drag.current = null
|
||||
scrollRef.current?.releasePointerCapture?.(event.pointerId)
|
||||
setPanning(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cx('relative flex min-h-0 flex-1 flex-col', className)}>
|
||||
<div
|
||||
ref={setScrollNode}
|
||||
data-workspace-scroll-surface={initialScroll ? 'deferred' : ''}
|
||||
role="group"
|
||||
aria-label={alt}
|
||||
tabIndex={0}
|
||||
onKeyDown={onKeyDown}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={endPan}
|
||||
onPointerCancel={endPan}
|
||||
className={cx(
|
||||
'min-h-0 flex-1 overflow-auto outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-border-focus)]',
|
||||
!media && 'bg-[var(--color-surface)]',
|
||||
canPan && (panning ? 'cursor-grabbing' : 'cursor-grab'),
|
||||
)}
|
||||
>
|
||||
{/* `m-auto` on the image, not `justify-center` on the row: centering by
|
||||
justification clips the start of an image wider than its container,
|
||||
where auto margins collapse to zero and let it scroll from the edge. */}
|
||||
<div className="flex min-h-full min-w-full p-4">
|
||||
<img
|
||||
ref={imageRef}
|
||||
src={src}
|
||||
alt={alt}
|
||||
draggable={false}
|
||||
onLoad={(event) => {
|
||||
const image = event.currentTarget
|
||||
// A picture that reports no size (an SVG with only a viewBox, in engines
|
||||
// that do not invent one) stays laid out by CSS, as it is before loading:
|
||||
// a 0×0 natural size would draw it as nothing.
|
||||
if (image.naturalWidth > 0 && image.naturalHeight > 0) {
|
||||
setNatural({ width: image.naturalWidth, height: image.naturalHeight })
|
||||
}
|
||||
}}
|
||||
onError={onError}
|
||||
style={sized ? { width: natural.width * scale, height: natural.height * scale, maxWidth: 'none' } : undefined}
|
||||
className={cx(
|
||||
'm-auto select-none rounded-[var(--radius-md)]',
|
||||
sized ? 'shrink-0' : 'max-h-full max-w-full object-contain',
|
||||
!media && 'border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-card)]',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-none absolute bottom-3 right-3 flex items-center gap-2">
|
||||
<div className="pointer-events-auto flex items-center gap-2">
|
||||
<ZoomControls
|
||||
percent={zoomPercent(currentScale)}
|
||||
fitActive={zoom === 'fit'}
|
||||
canZoomIn={currentScale < ZOOM_MAX - 1e-3}
|
||||
canZoomOut={currentScale > ZOOM_MIN + 1e-3}
|
||||
onZoomIn={() => changeZoom(stepZoom(currentScale, 1))}
|
||||
onZoomOut={() => changeZoom(stepZoom(currentScale, -1))}
|
||||
onFit={() => changeZoom('fit')}
|
||||
labels={labels}
|
||||
surface={surface}
|
||||
/>
|
||||
{actions ? <div className={floatingPillClass(surface)}>{actions}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -39,7 +39,19 @@ vi.mock('../workspace/surfaces/CodeSurface', () => ({
|
||||
language: string
|
||||
onAddSelection: (selection: { startLine: number; endLine: number; text: string }) => void
|
||||
}) => (
|
||||
<div data-testid="code-surface" data-language={language}>
|
||||
<div
|
||||
data-testid="code-surface"
|
||||
data-language={language}
|
||||
// Like the real surface: a scroll container the panel restores a saved
|
||||
// position onto. jsdom lays nothing out and ignores scrollTop writes, so the
|
||||
// property is made a plain one that keeps what it is given.
|
||||
data-workspace-scroll-surface=""
|
||||
ref={(node) => {
|
||||
if (node && !Object.prototype.hasOwnProperty.call(node, 'scrollTop')) {
|
||||
Object.defineProperty(node, 'scrollTop', { value: 0, writable: true, configurable: true })
|
||||
}
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
<button
|
||||
type="button"
|
||||
@@ -51,6 +63,48 @@ vi.mock('../workspace/surfaces/CodeSurface', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
/**
|
||||
* The document surface owns fetching and rendering bytes and has its own suite.
|
||||
* Here it is a marker that records what the panel handed it, and a scroll
|
||||
* container that (like a real viewer) has nothing to scroll until it has laid out.
|
||||
*/
|
||||
vi.mock('../workspace/surfaces/document/DocumentSurface', () => ({
|
||||
DocumentSurface: (props: {
|
||||
sessionId: string
|
||||
path: string
|
||||
absolutePath: string
|
||||
previewType: string
|
||||
version: string | undefined
|
||||
initialView: { scrollTop: number; scrollLeft: number } | undefined
|
||||
}) => (
|
||||
<div
|
||||
data-testid="document-surface"
|
||||
data-preview-type={props.previewType}
|
||||
data-version={props.version ?? ''}
|
||||
data-absolute-path={props.absolutePath}
|
||||
data-initial-scroll-top={props.initialView?.scrollTop ?? ''}
|
||||
>
|
||||
<div
|
||||
data-testid="deferred-scroller"
|
||||
data-workspace-scroll-surface="deferred"
|
||||
// An empty container has nothing to scroll, so a browser clamps every
|
||||
// write to 0. jsdom would remember the value; without this the clamp that
|
||||
// makes the regression below real would never happen under test.
|
||||
ref={(node) => {
|
||||
if (node && !Object.prototype.hasOwnProperty.call(node, 'scrollTop')) {
|
||||
Object.defineProperty(node, 'scrollTop', { get: () => 0, set: () => {}, configurable: true })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../lib/systemFileOpen', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../lib/systemFileOpen')>()),
|
||||
openLocalFileWithSystem: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock('../workspace/surfaces/MarkdownSurface', () => ({
|
||||
MarkdownSurface: ({ value }: { value: string }) => (
|
||||
<div data-testid="markdown-surface">{value}</div>
|
||||
@@ -73,6 +127,7 @@ vi.mock('../workspace/WorkspaceFileOpenWith', () => ({
|
||||
}))
|
||||
|
||||
import { WorkspaceFileTab } from './WorkspaceFileTab'
|
||||
import { openLocalFileWithSystem } from '../../lib/systemFileOpen'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
|
||||
import { useWorkspaceContentStore, type WorkspaceFileEntry } from '../../stores/workspaceContentStore'
|
||||
@@ -217,6 +272,22 @@ describe('content states', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves a zoomed picture\'s scroll position to the picture, which has nothing to scroll until it has loaded', () => {
|
||||
// Restoring it here would be clamped to 0 by a picture not yet laid out at its zoom,
|
||||
// and the panel would then record that 0 over the position it was trying to restore.
|
||||
seedEntry('assets/logo.png', { state: 'ok', previewType: 'image', dataUrl: 'data:image/png;base64,AAAA' })
|
||||
useWorkspaceContentStore.setState({
|
||||
fileViewByKey: { [`${SESSION}::assets/logo.png`]: { scrollTop: 442, scrollLeft: 524, zoom: 1.75 } },
|
||||
})
|
||||
renderTab('assets/logo.png')
|
||||
|
||||
expect(screen.getByRole('group', { name: 'assets/logo.png' })).toHaveAttribute('data-workspace-scroll-surface', 'deferred')
|
||||
expect(useWorkspaceContentStore.getState().fileViewByKey[`${SESSION}::assets/logo.png`]).toMatchObject({
|
||||
scrollTop: 442,
|
||||
scrollLeft: 524,
|
||||
})
|
||||
})
|
||||
|
||||
it('renders markdown through the markdown surface', () => {
|
||||
seedEntry('docs/README.md', { state: 'ok', previewType: 'text', content: '# Title' })
|
||||
renderTab('docs/README.md')
|
||||
@@ -225,6 +296,108 @@ describe('content states', () => {
|
||||
expect(screen.queryByTestId('code-surface')).toBeNull()
|
||||
})
|
||||
|
||||
describe('documents', () => {
|
||||
it.each(['pdf', 'docx', 'xlsx'] as const)('hands a %s to the document surface, not to the text viewers', (previewType) => {
|
||||
seedEntry(`out/file.${previewType}`, { state: 'ok', previewType, version: 'v7' })
|
||||
renderTab(`out/file.${previewType}`)
|
||||
|
||||
const surface = screen.getByTestId('document-surface')
|
||||
expect(surface).toHaveAttribute('data-preview-type', previewType)
|
||||
expect(surface).toHaveAttribute('data-version', 'v7')
|
||||
expect(surface).toHaveAttribute('data-absolute-path', `/repo/out/file.${previewType}`)
|
||||
expect(screen.queryByTestId('code-surface')).toBeNull()
|
||||
expect(screen.queryByTestId('markdown-surface')).toBeNull()
|
||||
})
|
||||
|
||||
it('passes the position this file was last left at, for the viewer to restore once it has laid out', () => {
|
||||
seedEntry('out/thesis.pdf', { state: 'ok', previewType: 'pdf', version: 'v1' })
|
||||
useWorkspaceContentStore.setState({
|
||||
fileViewByKey: { [`${SESSION}::out/thesis.pdf`]: { scrollTop: 320, scrollLeft: 0 } },
|
||||
})
|
||||
renderTab('out/thesis.pdf')
|
||||
|
||||
expect(screen.getByTestId('document-surface')).toHaveAttribute('data-initial-scroll-top', '320')
|
||||
})
|
||||
|
||||
it('does not overwrite a saved scroll position with 0 while the viewer has nothing to scroll yet', () => {
|
||||
// Regression anchor. A rendered document lays out asynchronously, so its
|
||||
// scroll container is empty when this panel first looks at it: assigning the
|
||||
// saved offset clamps to 0, and the position that was then written back
|
||||
// erased where the reader had been.
|
||||
seedEntry('out/thesis.pdf', { state: 'ok', previewType: 'pdf', version: 'v1' })
|
||||
useWorkspaceContentStore.setState({
|
||||
fileViewByKey: { [`${SESSION}::out/thesis.pdf`]: { scrollTop: 320, scrollLeft: 0, zoom: 1.5 } },
|
||||
})
|
||||
|
||||
renderTab('out/thesis.pdf')
|
||||
|
||||
expect(useWorkspaceContentStore.getState().fileViewByKey[`${SESSION}::out/thesis.pdf`]).toEqual({
|
||||
scrollTop: 320,
|
||||
scrollLeft: 0,
|
||||
zoom: 1.5,
|
||||
})
|
||||
})
|
||||
|
||||
it('still records where the reader scrolls a deferred viewer to', () => {
|
||||
seedEntry('out/thesis.pdf', { state: 'ok', previewType: 'pdf', version: 'v1' })
|
||||
renderTab('out/thesis.pdf')
|
||||
const scroller = screen.getByTestId('deferred-scroller')
|
||||
|
||||
Object.defineProperty(scroller, 'scrollTop', { value: 480, configurable: true })
|
||||
fireEvent.scroll(scroller)
|
||||
|
||||
expect(useWorkspaceContentStore.getState().fileViewByKey[`${SESSION}::out/thesis.pdf`]).toMatchObject({ scrollTop: 480 })
|
||||
})
|
||||
|
||||
it('still restores a saved position onto a surface that renders immediately', () => {
|
||||
// The counterpart of the guard above: text surfaces have their content on
|
||||
// the first render, and restoring them must keep working.
|
||||
seedEntry('src/a.ts', { state: 'ok', previewType: 'text', content: 'const x = 1' })
|
||||
useWorkspaceContentStore.setState({
|
||||
fileViewByKey: { [`${SESSION}::src/a.ts`]: { scrollTop: 250, scrollLeft: 0 } },
|
||||
})
|
||||
|
||||
renderTab('src/a.ts')
|
||||
|
||||
expect((screen.getByTestId('code-surface') as HTMLElement).scrollTop).toBe(250)
|
||||
})
|
||||
})
|
||||
|
||||
describe('a way out when the file cannot be shown', () => {
|
||||
it.each(['too_large', 'binary', 'error'] as const)('offers the system app for %s', (state) => {
|
||||
seedEntry('out/report.docx', { state, error: state === 'error' ? 'boom' : undefined })
|
||||
renderTab('out/report.docx')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open in system app' }))
|
||||
|
||||
expect(openLocalFileWithSystem).toHaveBeenCalledWith('/repo/out/report.docx')
|
||||
})
|
||||
|
||||
it('offers nothing for a file that is not there', () => {
|
||||
seedEntry('out/report.docx', { state: 'missing' })
|
||||
renderTab('out/report.docx')
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Open in system app' })).toBeNull()
|
||||
})
|
||||
|
||||
it('offers nothing until the workdir is known, since a relative path would open nothing', () => {
|
||||
useWorkspaceContentStore.setState({ statusBySession: {} })
|
||||
seedEntry('out/report.docx', { state: 'binary' })
|
||||
renderTab('out/report.docx')
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Open in system app' })).toBeNull()
|
||||
})
|
||||
|
||||
it('offers the system app beside an image too', () => {
|
||||
seedEntry('assets/logo.png', { state: 'ok', previewType: 'image', dataUrl: 'data:image/png;base64,AAAA' })
|
||||
renderTab('assets/logo.png')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open in system app' }))
|
||||
|
||||
expect(openLocalFileWithSystem).toHaveBeenCalledWith('/repo/assets/logo.png')
|
||||
})
|
||||
})
|
||||
|
||||
it('renders everything else as code, with the language the server detected', () => {
|
||||
seedEntry('src/a.ts', { state: 'ok', previewType: 'text', content: 'const x = 1', language: 'typescript' })
|
||||
renderTab('src/a.ts')
|
||||
|
||||
@@ -6,8 +6,11 @@ import { useWorkspaceFileOpenTargets } from '@/components/workspace/workspaceFil
|
||||
import { useDismissable } from '@/hooks/useDismissable'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { CodeSurface } from '../workspace/surfaces/CodeSurface'
|
||||
import { DocumentSurface } from '../workspace/surfaces/document/DocumentSurface'
|
||||
import { isDocumentPreviewType } from '../workspace/surfaces/document/documentViewers'
|
||||
import { ImagePreview } from '../workspace/surfaces/ImagePreview'
|
||||
import { MarkdownSurface } from '../workspace/surfaces/MarkdownSurface'
|
||||
import { OpenInSystemButton } from '../workspace/surfaces/OpenInSystemButton'
|
||||
import { PanelMessage } from '../workspace/surfaces/PanelMessage'
|
||||
import type { WorkspaceTextSelection } from '../workspace/surfaces/textSelection'
|
||||
import { WorkspaceFileOpenWith } from '../workspace/WorkspaceFileOpenWith'
|
||||
@@ -17,6 +20,7 @@ import { useMenuKeyboard } from './menuKeyboard'
|
||||
import { useWorkspaceContentStore, type WorkspaceFileView } from '../../stores/workspaceContentStore'
|
||||
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
|
||||
import { workspaceOpen } from '../../lib/workspace/openTarget'
|
||||
import { isRootedLocalPath } from '../../lib/handlePreviewLink'
|
||||
import { resolveAbsoluteOpenPath } from '../../lib/systemFileOpen'
|
||||
import { basenameOf, type WorkspaceFileTab as WorkspaceFileTabModel } from '../../lib/workspace/types'
|
||||
|
||||
@@ -88,6 +92,11 @@ export function WorkspaceFileTab({ sessionId, tab }: WorkspaceFileTabProps) {
|
||||
useLayoutEffect(() => {
|
||||
const surface = fileContentRef.current?.querySelector<HTMLElement>('[data-workspace-scroll-surface]')
|
||||
if (!surface) return
|
||||
// A surface whose content lays out asynchronously (a rendered document) has
|
||||
// nothing to scroll yet: restoring here would clamp the saved offset to 0 and
|
||||
// then write that 0 back over it. It restores its own position once laid out;
|
||||
// `onScrollCapture` below still records where the reader leaves it.
|
||||
if (surface.getAttribute('data-workspace-scroll-surface') === 'deferred') return
|
||||
if (restoredSurface.current?.node === surface && restoredSurface.current.revealNonce === tab.reveal?.nonce) return
|
||||
restoredSurface.current = { node: surface, revealNonce: tab.reveal?.nonce }
|
||||
surface.scrollTop = revealScroll ? 0 : savedView?.scrollTop ?? 0
|
||||
@@ -178,6 +187,11 @@ export function WorkspaceFileTab({ sessionId, tab }: WorkspaceFileTabProps) {
|
||||
const breadcrumbSegments = projectSegment ? [projectSegment, ...segments] : segments
|
||||
const absolutePath = resolveAbsoluteOpenPath(path, workDir ?? undefined)
|
||||
const fileOpen = useWorkspaceFileOpenTargets(path ? absolutePath : null)
|
||||
// Every state below that cannot show the file offers the way out that always
|
||||
// works. It needs a real absolute path: until the workdir is known a relative
|
||||
// one would have the OS open nothing.
|
||||
const canOpenInSystem = !!path && isRootedLocalPath(absolutePath)
|
||||
const systemAction = canOpenInSystem ? <OpenInSystemButton absolutePath={absolutePath} /> : undefined
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||
@@ -298,13 +312,29 @@ export function WorkspaceFileTab({ sessionId, tab }: WorkspaceFileTabProps) {
|
||||
) : entry.state === 'missing' ? (
|
||||
<PanelMessage icon="search_off" message={t('workspace.previewState.missing')} />
|
||||
) : entry.state === 'too_large' ? (
|
||||
<PanelMessage icon="database" message={t('workspace.previewState.tooLarge')} />
|
||||
<PanelMessage icon="database" message={t('workspace.previewState.tooLarge')} action={systemAction} />
|
||||
) : entry.state === 'binary' ? (
|
||||
<PanelMessage icon="data_object" message={t('workspace.previewState.binary')} />
|
||||
<PanelMessage icon="data_object" message={t('workspace.previewState.binary')} action={systemAction} />
|
||||
) : entry.state === 'error' ? (
|
||||
<PanelMessage icon="error" tone="error" message={entry.error || t('workspace.loadError')} />
|
||||
<PanelMessage icon="error" tone="error" message={entry.error || t('workspace.loadError')} action={systemAction} />
|
||||
) : entry.previewType === 'image' ? (
|
||||
<ImagePreview dataUrl={entry.dataUrl} path={path} error={entry.error} />
|
||||
<ImagePreview
|
||||
sessionId={sessionId}
|
||||
dataUrl={entry.dataUrl}
|
||||
path={path}
|
||||
absolutePath={canOpenInSystem ? absolutePath : undefined}
|
||||
error={entry.error}
|
||||
initialView={savedView}
|
||||
/>
|
||||
) : isDocumentPreviewType(entry.previewType) ? (
|
||||
<DocumentSurface
|
||||
sessionId={sessionId}
|
||||
path={path}
|
||||
absolutePath={absolutePath}
|
||||
previewType={entry.previewType}
|
||||
version={entry.version}
|
||||
initialView={savedView}
|
||||
/>
|
||||
) : isMarkdown(path) ? (
|
||||
<MarkdownSurface
|
||||
value={entry.content ?? ''}
|
||||
|
||||
@@ -1,14 +1,52 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useSettingsStore } from '@/stores/settingsStore'
|
||||
import { useWorkspaceContentStore, workspaceFileKey } from '@/stores/workspaceContentStore'
|
||||
import { openLocalFileWithSystem, reportOpenFailure } from '@/lib/systemFileOpen'
|
||||
import { ImagePreview } from './ImagePreview'
|
||||
|
||||
const dataUrl = 'data:image/png;base64,iVBORw0KGgo='
|
||||
vi.mock('@/lib/systemFileOpen', () => ({
|
||||
openLocalFileWithSystem: vi.fn(),
|
||||
reportOpenFailure: vi.fn(),
|
||||
}))
|
||||
|
||||
const dataUrl = 'data:image/png;base64,iVBORwoKGgo='
|
||||
|
||||
const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth')
|
||||
const originalClientHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientHeight')
|
||||
const originalNaturalWidth = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'naturalWidth')
|
||||
const originalNaturalHeight = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'naturalHeight')
|
||||
|
||||
function stubGeometry() {
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, get: () => 800 })
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientHeight', { configurable: true, get: () => 600 })
|
||||
Object.defineProperty(HTMLImageElement.prototype, 'naturalWidth', { configurable: true, get: () => 1600 })
|
||||
Object.defineProperty(HTMLImageElement.prototype, 'naturalHeight', { configurable: true, get: () => 1200 })
|
||||
}
|
||||
|
||||
function restore(property: string, target: object, original: PropertyDescriptor | undefined) {
|
||||
if (original) Object.defineProperty(target, property, original)
|
||||
else Reflect.deleteProperty(target, property)
|
||||
}
|
||||
|
||||
function loaded(image: HTMLElement) {
|
||||
act(() => { fireEvent.load(image) })
|
||||
}
|
||||
|
||||
describe('ImagePreview', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
useWorkspaceContentStore.setState({ fileViewByKey: {} })
|
||||
vi.mocked(openLocalFileWithSystem).mockReset().mockResolvedValue(undefined)
|
||||
vi.mocked(reportOpenFailure).mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
restore('clientWidth', HTMLElement.prototype, originalClientWidth)
|
||||
restore('clientHeight', HTMLElement.prototype, originalClientHeight)
|
||||
restore('naturalWidth', HTMLImageElement.prototype, originalNaturalWidth)
|
||||
restore('naturalHeight', HTMLImageElement.prototype, originalNaturalHeight)
|
||||
})
|
||||
|
||||
it('renders the decoded image and names it with the file path', () => {
|
||||
@@ -30,4 +68,144 @@ describe('ImagePreview', () => {
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Image preview is unavailable.')
|
||||
})
|
||||
|
||||
it('offers the zoom controls, in the viewer language', () => {
|
||||
render(<ImagePreview dataUrl={dataUrl} path="assets/logo.png" />)
|
||||
|
||||
for (const name of ['Zoom in', 'Zoom out', 'Fit to window']) {
|
||||
expect(screen.getByRole('button', { name })).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
describe('coming back to a zoomed picture', () => {
|
||||
const left = { scrollTop: 80, scrollLeft: 120, zoom: 2 }
|
||||
|
||||
it('scrolls it back to where it was left, once it has loaded and has a size to scroll', () => {
|
||||
stubGeometry()
|
||||
useWorkspaceContentStore.setState({ fileViewByKey: { [workspaceFileKey('s1', 'assets/logo.png')]: left } })
|
||||
render(<ImagePreview sessionId="s1" dataUrl={dataUrl} path="assets/logo.png" initialView={left} />)
|
||||
const area = screen.getByRole('group', { name: 'assets/logo.png' })
|
||||
// The workspace restores what it can, and restoring before the picture is laid out
|
||||
// at its zoom would clamp to 0 and record the 0: so it is left to the picture.
|
||||
expect(area).toHaveAttribute('data-workspace-scroll-surface', 'deferred')
|
||||
expect(area.scrollTop).toBe(0)
|
||||
|
||||
loaded(screen.getByRole('img', { name: 'assets/logo.png' }))
|
||||
|
||||
expect(area.scrollTop).toBe(80)
|
||||
expect(area.scrollLeft).toBe(120)
|
||||
})
|
||||
|
||||
it('has nothing to restore for a picture that was never left, or that keeps no view', () => {
|
||||
stubGeometry()
|
||||
const { unmount } = render(<ImagePreview sessionId="s1" dataUrl={dataUrl} path="assets/logo.png" />)
|
||||
expect(screen.getByRole('group', { name: 'assets/logo.png' })).toHaveAttribute('data-workspace-scroll-surface', '')
|
||||
unmount()
|
||||
|
||||
render(<ImagePreview dataUrl={dataUrl} path="assets/logo.png" initialView={left} />)
|
||||
expect(screen.getByRole('group', { name: 'assets/logo.png' })).toHaveAttribute('data-workspace-scroll-surface', '')
|
||||
})
|
||||
})
|
||||
|
||||
describe('system app', () => {
|
||||
it('hands the original file to the operating system', () => {
|
||||
render(<ImagePreview dataUrl={dataUrl} path="assets/logo.png" absolutePath="/work/assets/logo.png" />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open in system app' }))
|
||||
|
||||
expect(openLocalFileWithSystem).toHaveBeenCalledWith('/work/assets/logo.png')
|
||||
})
|
||||
|
||||
it('says which file could not be opened instead of failing silently', async () => {
|
||||
vi.mocked(openLocalFileWithSystem).mockRejectedValue(new Error('no application'))
|
||||
render(<ImagePreview dataUrl={dataUrl} path="assets/logo.png" absolutePath="/work/assets/logo.png" />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open in system app' }))
|
||||
|
||||
await waitFor(() => expect(reportOpenFailure).toHaveBeenCalledWith('/work/assets/logo.png'))
|
||||
})
|
||||
|
||||
it('shows no such action without an absolute path to open', () => {
|
||||
render(<ImagePreview dataUrl={dataUrl} path="assets/logo.png" />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Open in system app' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('replaces a picture that cannot be decoded with the unavailable message and a way out', () => {
|
||||
render(<ImagePreview dataUrl={dataUrl} path="assets/logo.png" absolutePath="/work/assets/logo.png" />)
|
||||
|
||||
fireEvent.error(screen.getByRole('img', { name: 'assets/logo.png' }))
|
||||
|
||||
expect(screen.queryByRole('img')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Image preview is unavailable.')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open in system app' }))
|
||||
expect(openLocalFileWithSystem).toHaveBeenCalledWith('/work/assets/logo.png')
|
||||
})
|
||||
|
||||
it('forgets a decode failure when the file changes to one that does decode', () => {
|
||||
const { rerender } = render(<ImagePreview dataUrl={dataUrl} path="assets/logo.png" />)
|
||||
fireEvent.error(screen.getByRole('img', { name: 'assets/logo.png' }))
|
||||
expect(screen.queryByRole('img')).not.toBeInTheDocument()
|
||||
|
||||
rerender(<ImagePreview dataUrl="data:image/png;base64,AAAA" path="assets/logo.png" />)
|
||||
|
||||
expect(screen.getByRole('img', { name: 'assets/logo.png' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('zoom persistence', () => {
|
||||
beforeEach(stubGeometry)
|
||||
|
||||
it('keeps the zoom across a remount, which is what a tab switch does', () => {
|
||||
const props = { sessionId: 's1', dataUrl, path: 'assets/logo.png' }
|
||||
const first = render(<ImagePreview {...props} />)
|
||||
loaded(screen.getByRole('img'))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }))
|
||||
expect(screen.getByText('50%')).toBeInTheDocument()
|
||||
expect(useWorkspaceContentStore.getState().fileViewByKey[workspaceFileKey('s1', 'assets/logo.png')]?.zoom).toBe(0.5)
|
||||
first.unmount()
|
||||
|
||||
render(<ImagePreview {...props} />)
|
||||
loaded(screen.getByRole('img'))
|
||||
|
||||
expect(screen.getByText('50%')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Fit to window' })).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
|
||||
it('stores fit as the absence of a zoom, so the default keeps following the panel size', () => {
|
||||
render(<ImagePreview sessionId="s1" dataUrl={dataUrl} path="assets/logo.png" />)
|
||||
loaded(screen.getByRole('img'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Fit to window' }))
|
||||
|
||||
expect(useWorkspaceContentStore.getState().fileViewByKey[workspaceFileKey('s1', 'assets/logo.png')]?.zoom).toBeUndefined()
|
||||
expect(screen.getByRole('button', { name: 'Fit to window' })).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
it('keeps the scroll position stored beside the zoom', () => {
|
||||
useWorkspaceContentStore.getState().setFileView('s1', 'assets/logo.png', { scrollTop: 40, scrollLeft: 8 })
|
||||
render(<ImagePreview sessionId="s1" dataUrl={dataUrl} path="assets/logo.png" />)
|
||||
loaded(screen.getByRole('img'))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }))
|
||||
|
||||
expect(useWorkspaceContentStore.getState().fileViewByKey[workspaceFileKey('s1', 'assets/logo.png')]).toMatchObject({
|
||||
scrollTop: 40,
|
||||
scrollLeft: 8,
|
||||
zoom: 0.5,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the zoom to itself when it has no session to remember it in', () => {
|
||||
render(<ImagePreview dataUrl={dataUrl} path="assets/logo.png" />)
|
||||
loaded(screen.getByRole('img'))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }))
|
||||
|
||||
expect(screen.getByText('50%')).toBeInTheDocument()
|
||||
expect(useWorkspaceContentStore.getState().fileViewByKey).toEqual({})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,27 +1,84 @@
|
||||
import { useState } from 'react'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import { IconButton } from '@/components/ui/IconButton'
|
||||
import { ZoomableImage, type ImageZoom } from '@/components/ui/ZoomableImage'
|
||||
import { useTranslation } from '@/i18n'
|
||||
import { openLocalFileWithSystem, reportOpenFailure } from '@/lib/systemFileOpen'
|
||||
import { useWorkspaceContentStore, workspaceFileKey, type WorkspaceFileView } from '@/stores/workspaceContentStore'
|
||||
import { OpenInSystemButton } from './OpenInSystemButton'
|
||||
import { PanelMessage } from './PanelMessage'
|
||||
|
||||
export function ImagePreview({ dataUrl, path, error }: { dataUrl?: string; path: string; error?: string }) {
|
||||
/**
|
||||
* The workspace's image viewer: fit-to-window by default, zoom and pan, and a way
|
||||
* to hand the original to the system's image app.
|
||||
*
|
||||
* `sessionId` lets the zoom survive a tab switch (the tab remounts this component);
|
||||
* without it the zoom is local. `absolutePath` enables the system-app action.
|
||||
* `initialView` is where the reader left the picture: a zoomed one is scrolled back
|
||||
* there once it is laid out, which is not before it has loaded.
|
||||
*/
|
||||
export function ImagePreview({
|
||||
sessionId,
|
||||
dataUrl,
|
||||
path,
|
||||
absolutePath,
|
||||
error,
|
||||
initialView,
|
||||
}: {
|
||||
sessionId?: string
|
||||
dataUrl?: string
|
||||
path: string
|
||||
absolutePath?: string
|
||||
error?: string
|
||||
initialView?: WorkspaceFileView
|
||||
}) {
|
||||
const t = useTranslation()
|
||||
const [failedUrl, setFailedUrl] = useState<string | null>(null)
|
||||
const storedZoom = useWorkspaceContentStore((state) => (
|
||||
sessionId ? state.fileViewByKey[workspaceFileKey(sessionId, path)]?.zoom : undefined
|
||||
))
|
||||
|
||||
if (!dataUrl) {
|
||||
if (!dataUrl || failedUrl === dataUrl) {
|
||||
return (
|
||||
<PanelMessage
|
||||
icon="image_not_supported"
|
||||
message={error || t('workspace.imagePreviewUnavailable')}
|
||||
message={(!dataUrl && error) || t('workspace.imagePreviewUnavailable')}
|
||||
action={absolutePath ? <OpenInSystemButton absolutePath={absolutePath} /> : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const onZoomChange = (next: ImageZoom) => {
|
||||
if (sessionId) useWorkspaceContentStore.getState().setFileZoom(sessionId, path, next === 'fit' ? undefined : next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-workspace-scroll-surface="" className="min-h-0 flex-1 overflow-auto bg-[var(--color-surface)] p-4">
|
||||
<div className="flex min-h-full items-center justify-center">
|
||||
<img
|
||||
src={dataUrl}
|
||||
alt={path}
|
||||
className="max-h-full max-w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] object-contain shadow-[var(--shadow-card)]"
|
||||
<ZoomableImage
|
||||
// The measured natural size belongs to one picture, so a new picture is a new viewer.
|
||||
key={dataUrl}
|
||||
src={dataUrl}
|
||||
alt={path}
|
||||
zoom={sessionId ? storedZoom ?? 'fit' : undefined}
|
||||
onZoomChange={onZoomChange}
|
||||
initialScroll={sessionId && initialView ? { left: initialView.scrollLeft, top: initialView.scrollTop } : undefined}
|
||||
onError={() => setFailedUrl(dataUrl)}
|
||||
labels={{
|
||||
group: t('workspace.zoom.group'),
|
||||
zoomIn: t('workspace.zoom.in'),
|
||||
zoomOut: t('workspace.zoom.out'),
|
||||
fit: t('workspace.zoom.fitWindow'),
|
||||
}}
|
||||
actions={absolutePath ? (
|
||||
<IconButton
|
||||
icon={<ExternalLink size={16} strokeWidth={1.9} />}
|
||||
label={t('workspace.openInSystemApp')}
|
||||
size="md"
|
||||
tone="secondary"
|
||||
onClick={() => {
|
||||
void openLocalFileWithSystem(absolutePath).catch(() => reportOpenFailure(absolutePath))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { openLocalFileWithSystem, reportOpenFailure } from '@/lib/systemFileOpen'
|
||||
import { useSettingsStore } from '@/stores/settingsStore'
|
||||
import { OpenInSystemButton } from './OpenInSystemButton'
|
||||
|
||||
vi.mock('@/lib/systemFileOpen', () => ({
|
||||
openLocalFileWithSystem: vi.fn(),
|
||||
reportOpenFailure: vi.fn(),
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
vi.mocked(openLocalFileWithSystem).mockReset().mockResolvedValue(undefined)
|
||||
vi.mocked(reportOpenFailure).mockReset()
|
||||
})
|
||||
|
||||
describe('OpenInSystemButton', () => {
|
||||
it('is named for what it does, in the reader’s language', () => {
|
||||
render(<OpenInSystemButton absolutePath="/work/out/thesis.pdf" />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Open in system app' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hands the file to the operating system', () => {
|
||||
render(<OpenInSystemButton absolutePath="/work/out/thesis.pdf" />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(openLocalFileWithSystem).toHaveBeenCalledWith('/work/out/thesis.pdf')
|
||||
})
|
||||
|
||||
it('says nothing when the file opened', async () => {
|
||||
render(<OpenInSystemButton absolutePath="/work/out/thesis.pdf" />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
await waitFor(() => expect(openLocalFileWithSystem).toHaveBeenCalled())
|
||||
|
||||
expect(reportOpenFailure).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('names the file it could not open: a click that does nothing reads as a broken button', async () => {
|
||||
vi.mocked(openLocalFileWithSystem).mockRejectedValue(new Error('no application'))
|
||||
render(<OpenInSystemButton absolutePath="/work/out/thesis.pdf" />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
await waitFor(() => expect(reportOpenFailure).toHaveBeenCalledWith('/work/out/thesis.pdf'))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { useTranslation } from '@/i18n'
|
||||
import { openLocalFileWithSystem, reportOpenFailure } from '@/lib/systemFileOpen'
|
||||
|
||||
/**
|
||||
* The way out of every state the workspace cannot render — too large, damaged,
|
||||
* a format it has no viewer for: hand the file to the operating system.
|
||||
*
|
||||
* It says why it failed if it does (`reportOpenFailure` names the file); a click
|
||||
* that does nothing reads as a broken button.
|
||||
*/
|
||||
export function OpenInSystemButton({ absolutePath }: { absolutePath: string }) {
|
||||
const t = useTranslation()
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<ExternalLink size={14} strokeWidth={1.9} aria-hidden="true" />}
|
||||
onClick={() => {
|
||||
void openLocalFileWithSystem(absolutePath).catch(() => reportOpenFailure(absolutePath))
|
||||
}}
|
||||
>
|
||||
{t('workspace.openInSystemApp')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -41,4 +41,33 @@ describe('PanelMessage', () => {
|
||||
expect(container.firstElementChild?.className).toContain('py-2')
|
||||
expect(container.firstElementChild?.className).not.toContain('py-8')
|
||||
})
|
||||
|
||||
describe('action', () => {
|
||||
it('renders the action next to the message, not inside the live region', () => {
|
||||
// A screen reader should announce "File is too large", not "File is too large
|
||||
// Open in system app" as one sentence — the button is reached by tabbing.
|
||||
render(<PanelMessage icon="database" message="File is too large" action={<button type="button">Open in system app</button>} />)
|
||||
|
||||
const status = screen.getByRole('status')
|
||||
expect(status).toHaveTextContent('File is too large')
|
||||
expect(status).not.toHaveTextContent('Open in system app')
|
||||
expect(screen.getByRole('button', { name: 'Open in system app' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('leaves the markup of a message without an action exactly as it was', () => {
|
||||
const { container } = render(<PanelMessage icon="error" message="Plain" />)
|
||||
|
||||
// The message row is the root: existing callers and layouts depend on it.
|
||||
expect(container.firstElementChild).toHaveAttribute('role', 'status')
|
||||
expect(container.firstElementChild?.className).toContain('py-8')
|
||||
})
|
||||
|
||||
it('trades the bottom padding for the action so the pair keeps one rhythm', () => {
|
||||
const { container } = render(<PanelMessage icon="error" message="Failed" action={<button type="button">Retry</button>} />)
|
||||
|
||||
const row = container.querySelector('[role="status"]')
|
||||
expect(row?.className).toContain('pb-3')
|
||||
expect(row?.className).not.toContain('py-8')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,24 +1,33 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export function PanelMessage({
|
||||
icon,
|
||||
message,
|
||||
tone = 'muted',
|
||||
compact = false,
|
||||
announce = true,
|
||||
action,
|
||||
}: {
|
||||
icon: string
|
||||
message: string
|
||||
tone?: 'muted' | 'error'
|
||||
compact?: boolean
|
||||
announce?: boolean
|
||||
/**
|
||||
* A way out of the state the message describes — "open in the system app" on a
|
||||
* file that cannot be previewed. Rendered beside, not inside, the live region:
|
||||
* a screen reader announces the message, not the button as part of it.
|
||||
*/
|
||||
action?: ReactNode
|
||||
}) {
|
||||
const toneClass =
|
||||
tone === 'error'
|
||||
? 'text-[var(--color-error)]'
|
||||
: 'text-[var(--color-text-tertiary)]'
|
||||
|
||||
return (
|
||||
const messageRow = (
|
||||
<div
|
||||
className={`flex items-center gap-2 px-4 ${compact ? 'py-2 text-[11px]' : 'py-8 text-xs'} ${toneClass}`}
|
||||
className={`flex items-center gap-2 px-4 ${compact ? (action ? 'pt-2 pb-1 text-[11px]' : 'py-2 text-[11px]') : (action ? 'pt-8 pb-3 text-xs' : 'py-8 text-xs')} ${toneClass}`}
|
||||
role={announce ? tone === 'error' ? 'alert' : 'status' : undefined}
|
||||
>
|
||||
<span className={`material-symbols-outlined shrink-0 text-[16px] ${icon === 'progress_activity' ? 'animate-spin' : ''}`}>
|
||||
@@ -27,4 +36,14 @@ export function PanelMessage({
|
||||
<span className="min-w-0 leading-relaxed">{message}</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!action) return messageRow
|
||||
|
||||
return (
|
||||
<div>
|
||||
{messageRow}
|
||||
{/* Indented to the message text: 16px padding + 16px icon + 8px gap. */}
|
||||
<div className={`flex flex-wrap items-center gap-2 pl-10 pr-4 ${compact ? 'pb-2' : 'pb-8'}`}>{action}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useSettingsStore } from '@/stores/settingsStore'
|
||||
import { DocumentFailure } from './DocumentFailure'
|
||||
|
||||
vi.mock('@/lib/systemFileOpen', () => ({
|
||||
openLocalFileWithSystem: vi.fn().mockResolvedValue(undefined),
|
||||
reportOpenFailure: vi.fn(),
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
})
|
||||
|
||||
const buttons = () => screen.getAllByRole('button').map((button) => button.textContent)
|
||||
|
||||
describe('DocumentFailure', () => {
|
||||
it('announces what went wrong as an alert', () => {
|
||||
render(<DocumentFailure message="This document could not be displayed." absolutePath="/work/a.pdf" />)
|
||||
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('This document could not be displayed.')
|
||||
})
|
||||
|
||||
it('offers to try again only when trying again can help', () => {
|
||||
const onRetry = vi.fn()
|
||||
const { rerender } = render(<DocumentFailure message="x" absolutePath="/work/a.pdf" onRetry={onRetry} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Try again' }))
|
||||
expect(onRetry).toHaveBeenCalledTimes(1)
|
||||
|
||||
rerender(<DocumentFailure message="x" absolutePath="/work/a.pdf" />)
|
||||
expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each(['/work/a.pdf', 'C:\\work\\a.pdf', '~/a.pdf'])('offers the system app for the rooted path %s', (absolutePath) => {
|
||||
render(<DocumentFailure message="x" absolutePath={absolutePath} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Open in system app' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each(['docs/a.pdf', ''])('does not offer the system app for %j, which the OS could not resolve', (absolutePath) => {
|
||||
render(<DocumentFailure message="x" absolutePath={absolutePath} />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Open in system app' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('puts actions specific to the kind of document between retrying and the system app', () => {
|
||||
render(
|
||||
<DocumentFailure
|
||||
message="x"
|
||||
absolutePath="/work/a.pdf"
|
||||
onRetry={() => undefined}
|
||||
extraActions={<button type="button">Open in browser</button>}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(buttons()).toEqual(['Try again', 'Open in browser', 'Open in system app'])
|
||||
})
|
||||
|
||||
it('shows an action that only the document kind knows of, even when nothing else can be offered', () => {
|
||||
render(<DocumentFailure message="x" absolutePath="docs/a.pdf" extraActions={<button type="button">Open in browser</button>} />)
|
||||
|
||||
expect(buttons()).toEqual(['Open in browser'])
|
||||
})
|
||||
|
||||
it('shows the message alone when there is nothing to offer', () => {
|
||||
render(<DocumentFailure message="This file is outside the workspace." absolutePath="docs/a.pdf" />)
|
||||
|
||||
expect(screen.queryAllByRole('button')).toHaveLength(0)
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { useTranslation } from '@/i18n'
|
||||
import { isRootedLocalPath } from '@/lib/handlePreviewLink'
|
||||
import { OpenInSystemButton } from '../OpenInSystemButton'
|
||||
import { PanelMessage } from '../PanelMessage'
|
||||
|
||||
/**
|
||||
* A document that could not be shown, with what a reader can do about it: try
|
||||
* again (the file may have been mid-write when it was read), do something the
|
||||
* viewer knows of, or open it in the application that made it.
|
||||
*/
|
||||
export function DocumentFailure({
|
||||
message,
|
||||
absolutePath,
|
||||
onRetry,
|
||||
extraActions,
|
||||
}: {
|
||||
message: string
|
||||
absolutePath: string
|
||||
/** Omit when trying again cannot help (the file is outside the workspace). */
|
||||
onRetry?: () => void
|
||||
/** Actions specific to the kind of document, shown between "try again" and the system app. */
|
||||
extraActions?: ReactNode
|
||||
}) {
|
||||
const t = useTranslation()
|
||||
const canOpenInSystem = isRootedLocalPath(absolutePath)
|
||||
return (
|
||||
<PanelMessage
|
||||
icon="error"
|
||||
tone="error"
|
||||
message={message}
|
||||
action={onRetry || extraActions || canOpenInSystem ? (
|
||||
<>
|
||||
{onRetry ? <Button variant="secondary" size="sm" onClick={onRetry}>{t('workspace.document.retry')}</Button> : null}
|
||||
{extraActions}
|
||||
{canOpenInSystem ? <OpenInSystemButton absolutePath={absolutePath} /> : null}
|
||||
</>
|
||||
) : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { lazy } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
|
||||
import { ApiError } from '@/api/client'
|
||||
import { sessionsApi } from '@/api/sessions'
|
||||
import { resetDocumentBlobCacheForTests } from '@/lib/workspace/documentBlobCache'
|
||||
import { openLocalFileWithSystem } from '@/lib/systemFileOpen'
|
||||
import { useSettingsStore } from '@/stores/settingsStore'
|
||||
import { useWorkspaceContentStore, workspaceFileKey } from '@/stores/workspaceContentStore'
|
||||
import { DocumentSurface, type DocumentSurfaceProps } from './DocumentSurface'
|
||||
import type { DocumentViewerProps, DocumentViewers } from './documentViewers'
|
||||
|
||||
vi.mock('@/lib/systemFileOpen', () => ({
|
||||
openLocalFileWithSystem: vi.fn(),
|
||||
reportOpenFailure: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/diagnosticsCapture', () => ({
|
||||
reportReactError: vi.fn(),
|
||||
}))
|
||||
|
||||
type Pending = { resolve: (blob: Blob) => void; reject: (error: unknown) => void; signal: AbortSignal | undefined }
|
||||
|
||||
let pending: Pending[]
|
||||
let getWorkspaceRaw: MockInstance<typeof sessionsApi.getWorkspaceRaw>
|
||||
let viewerProps: DocumentViewerProps[]
|
||||
|
||||
/** A stand-in for a real engine: records what it was handed and draws a marker. */
|
||||
const FakeViewer = lazy(async () => ({
|
||||
default: (props: DocumentViewerProps) => {
|
||||
viewerProps.push(props)
|
||||
return (
|
||||
<div data-testid="viewer" data-version={props.version} data-refreshing={String(props.refreshing)}>
|
||||
<button type="button" onClick={() => props.onZoomChange(2)}>set zoom</button>
|
||||
<button type="button" onClick={() => props.onZoomChange(undefined)}>reset zoom</button>
|
||||
<button type="button" onClick={() => props.onSheetChange?.('Second')}>pick sheet</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
const viewers: DocumentViewers = { pdf: FakeViewer }
|
||||
|
||||
const baseProps: DocumentSurfaceProps = {
|
||||
sessionId: 's1',
|
||||
path: 'out/thesis.pdf',
|
||||
absolutePath: '/work/out/thesis.pdf',
|
||||
previewType: 'pdf',
|
||||
version: 'v1',
|
||||
initialView: undefined,
|
||||
viewers,
|
||||
}
|
||||
|
||||
function renderSurface(overrides: Partial<DocumentSurfaceProps> = {}) {
|
||||
return render(<DocumentSurface {...baseProps} {...overrides} />)
|
||||
}
|
||||
|
||||
async function deliver(index: number, blob = new Blob(['bytes'])) {
|
||||
await act(async () => { pending[index]!.resolve(blob) })
|
||||
await screen.findByTestId('viewer')
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
useWorkspaceContentStore.setState({ fileViewByKey: {} })
|
||||
resetDocumentBlobCacheForTests()
|
||||
pending = []
|
||||
viewerProps = []
|
||||
vi.mocked(openLocalFileWithSystem).mockReset().mockResolvedValue(undefined)
|
||||
getWorkspaceRaw = vi.spyOn(sessionsApi, 'getWorkspaceRaw').mockImplementation(
|
||||
(_session, _path, signal) => new Promise<Blob>((resolve, reject) => {
|
||||
pending.push({ resolve, reject, signal })
|
||||
signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')))
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
getWorkspaceRaw.mockRestore()
|
||||
resetDocumentBlobCacheForTests()
|
||||
})
|
||||
|
||||
describe('DocumentSurface', () => {
|
||||
it('shows a loading state until the bytes arrive, then hands them to the viewer', async () => {
|
||||
renderSurface()
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Loading document...')
|
||||
expect(screen.queryByTestId('viewer')).not.toBeInTheDocument()
|
||||
|
||||
const blob = new Blob(['%PDF'])
|
||||
await deliver(0, blob)
|
||||
|
||||
expect(screen.queryByText('Loading document...')).not.toBeInTheDocument()
|
||||
expect(viewerProps.at(-1)).toMatchObject({
|
||||
path: 'out/thesis.pdf',
|
||||
absolutePath: '/work/out/thesis.pdf',
|
||||
blob,
|
||||
version: 'v1',
|
||||
refreshing: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('requests the file by its workspace path, once', async () => {
|
||||
renderSurface()
|
||||
await deliver(0)
|
||||
|
||||
expect(getWorkspaceRaw).toHaveBeenCalledTimes(1)
|
||||
expect(getWorkspaceRaw).toHaveBeenCalledWith('s1', 'out/thesis.pdf', expect.any(AbortSignal))
|
||||
})
|
||||
|
||||
it('waits for the file metadata before fetching anything', () => {
|
||||
renderSurface({ version: undefined })
|
||||
|
||||
expect(getWorkspaceRaw).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Loading document...')
|
||||
})
|
||||
|
||||
describe('a format with no viewer yet', () => {
|
||||
it('says so and offers the system app, without downloading the file', () => {
|
||||
renderSurface({ previewType: 'docx', path: 'thesis.docx', absolutePath: '/work/thesis.docx' })
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent("This file type can't be previewed here.")
|
||||
expect(getWorkspaceRaw).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open in system app' }))
|
||||
expect(openLocalFileWithSystem).toHaveBeenCalledWith('/work/thesis.docx')
|
||||
})
|
||||
|
||||
it('offers no system action for a path that is not absolute', () => {
|
||||
renderSurface({ previewType: 'docx', absolutePath: 'thesis.docx' })
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Open in system app' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the bytes cannot be fetched', () => {
|
||||
it.each([
|
||||
[403, "This file is outside the workspace and can't be previewed here."],
|
||||
[404, 'File not found.'],
|
||||
[413, 'File is too large to preview.'],
|
||||
[500, 'This document could not be displayed.'],
|
||||
])('explains a %i in words', async (status, message) => {
|
||||
renderSurface()
|
||||
await act(async () => { pending[0]!.reject(new ApiError(status, { message: 'server said no' })) })
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(message)
|
||||
})
|
||||
|
||||
it('offers the system app as the way out', async () => {
|
||||
renderSurface()
|
||||
await act(async () => { pending[0]!.reject(new ApiError(413, {})) })
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Open in system app' }))
|
||||
|
||||
expect(openLocalFileWithSystem).toHaveBeenCalledWith('/work/out/thesis.pdf')
|
||||
})
|
||||
|
||||
it('offers a retry where trying again could help, and it works', async () => {
|
||||
renderSurface()
|
||||
await act(async () => { pending[0]!.reject(new ApiError(500, {})) })
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Try again' }))
|
||||
await waitFor(() => expect(pending).toHaveLength(2))
|
||||
await deliver(1)
|
||||
|
||||
expect(screen.getByTestId('viewer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([403, 413, 415])('offers no retry for a %i, which will not change its mind', async (status) => {
|
||||
renderSurface()
|
||||
await act(async () => { pending[0]!.reject(new ApiError(status, {})) })
|
||||
|
||||
await screen.findByRole('alert')
|
||||
expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the file changes underneath a reader', () => {
|
||||
it('keeps the previous render on screen while the new version loads, and says so', async () => {
|
||||
const { rerender } = renderSurface()
|
||||
await deliver(0)
|
||||
|
||||
rerender(<DocumentSurface {...baseProps} version="v2" />)
|
||||
|
||||
const viewer = await screen.findByTestId('viewer')
|
||||
expect(viewer).toHaveAttribute('data-version', 'v1')
|
||||
expect(viewer).toHaveAttribute('data-refreshing', 'true')
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Updating preview...')
|
||||
|
||||
await act(async () => { pending[1]!.resolve(new Blob(['newer'])) })
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('viewer')).toHaveAttribute('data-version', 'v2'))
|
||||
expect(screen.getByTestId('viewer')).toHaveAttribute('data-refreshing', 'false')
|
||||
expect(screen.queryByText('Updating preview...')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the last good render and reports the failure when a refresh fails', async () => {
|
||||
const { rerender } = renderSurface()
|
||||
await deliver(0)
|
||||
|
||||
rerender(<DocumentSurface {...baseProps} version="v2" />)
|
||||
await act(async () => { pending[1]!.reject(new Error('unzip failed: incomplete file')) })
|
||||
|
||||
// The agent was mid-write. The reader's page must survive it.
|
||||
expect(screen.getByTestId('viewer')).toHaveAttribute('data-version', 'v1')
|
||||
expect(await screen.findByRole('status')).toHaveTextContent('unzip failed: incomplete file')
|
||||
})
|
||||
|
||||
it('words a refresh the server refused, instead of quoting its response body', async () => {
|
||||
const { rerender } = renderSurface()
|
||||
await deliver(0)
|
||||
|
||||
rerender(<DocumentSurface {...baseProps} version="v2" />)
|
||||
// What `apiGetBlob` throws: the response body as text, which for the workspace is JSON.
|
||||
await act(async () => {
|
||||
pending[1]!.reject(new ApiError(404, '{"error":"NOT_FOUND","message":"File not found: out/thesis.pdf"}'))
|
||||
})
|
||||
|
||||
const banner = await screen.findByRole('status')
|
||||
expect(banner).toHaveTextContent('Showing the last loaded version — refresh failed: File not found.')
|
||||
expect(banner).not.toHaveTextContent('NOT_FOUND')
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the viewer itself cannot be loaded', () => {
|
||||
// `lazy` throws on a failed chunk fetch: an old page against a newer server, a bad link.
|
||||
const brokenViewers = (): DocumentViewers => ({
|
||||
pdf: lazy(() => Promise.reject(new Error('Failed to fetch dynamically imported module'))),
|
||||
})
|
||||
let consoleError: MockInstance<typeof console.error>
|
||||
|
||||
beforeEach(() => {
|
||||
// React reports a caught render error to the console as well.
|
||||
consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
|
||||
it('says so in the panel, and offers the system app, instead of taking the window down', async () => {
|
||||
renderSurface({ viewers: brokenViewers() })
|
||||
await act(async () => { pending[0]!.resolve(new Blob(['bytes'])) })
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent("The document viewer can't start in this environment.")
|
||||
expect(screen.getByRole('button', { name: 'Open in system app' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers to load the page again, the one thing that makes a browser fetch the chunk anew', async () => {
|
||||
// A module whose fetch failed is remembered as failed for the life of the page, network
|
||||
// back or not, so trying again in place cannot work; a newer bundle, after a server
|
||||
// update, arrives the same way.
|
||||
const reloadPage = vi.fn()
|
||||
renderSurface({ viewers: brokenViewers(), reloadPage })
|
||||
await act(async () => { pending[0]!.resolve(new Blob(['bytes'])) })
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Try again' }))
|
||||
|
||||
expect(reloadPage).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('gives the next version of the file its own chance, since a viewer that broke on one may draw the next', async () => {
|
||||
const CrashesOnFirst = lazy(async () => ({
|
||||
default: (props: DocumentViewerProps) => {
|
||||
if (props.version === 'v1') throw new Error('cannot draw this one')
|
||||
return <div data-testid="viewer" data-version={props.version} />
|
||||
},
|
||||
}))
|
||||
const { rerender } = renderSurface({ viewers: { pdf: CrashesOnFirst } })
|
||||
await act(async () => { pending[0]!.resolve(new Blob(['bytes'])) })
|
||||
expect(await screen.findByRole('alert')).toBeInTheDocument()
|
||||
|
||||
// The agent rewrote the file: the version that broke the viewer is replaced.
|
||||
rerender(<DocumentSurface {...baseProps} viewers={{ pdf: CrashesOnFirst }} version="v2" />)
|
||||
await act(async () => { pending[1]!.resolve(new Blob(['newer'])) })
|
||||
|
||||
expect(await screen.findByTestId('viewer')).toHaveAttribute('data-version', 'v2')
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('zoom', () => {
|
||||
const zoomOf = () => useWorkspaceContentStore.getState().fileViewByKey[workspaceFileKey('s1', 'out/thesis.pdf')]?.zoom
|
||||
|
||||
it('hands the viewer the zoom the reader chose last time', async () => {
|
||||
useWorkspaceContentStore.getState().setFileZoom('s1', 'out/thesis.pdf', 1.5)
|
||||
renderSurface()
|
||||
await deliver(0)
|
||||
|
||||
expect(viewerProps.at(-1)?.zoom).toBe(1.5)
|
||||
})
|
||||
|
||||
it('records a zoom the viewer reports, and a reset to the default', async () => {
|
||||
renderSurface()
|
||||
await deliver(0)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'set zoom' }))
|
||||
expect(zoomOf()).toBe(2)
|
||||
await waitFor(() => expect(viewerProps.at(-1)?.zoom).toBe(2))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reset zoom' }))
|
||||
expect(zoomOf()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not mix up two files open in the same session', async () => {
|
||||
useWorkspaceContentStore.getState().setFileZoom('s1', 'out/other.pdf', 3)
|
||||
renderSurface()
|
||||
await deliver(0)
|
||||
|
||||
expect(viewerProps.at(-1)?.zoom).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('worksheet', () => {
|
||||
const sheetOf = () => useWorkspaceContentStore.getState().fileViewByKey[workspaceFileKey('s1', 'out/thesis.pdf')]?.sheet
|
||||
|
||||
it('hands the viewer the worksheet the reader was on last time', async () => {
|
||||
useWorkspaceContentStore.getState().setFileSheet('s1', 'out/thesis.pdf', 'Second')
|
||||
renderSurface()
|
||||
await deliver(0)
|
||||
|
||||
expect(viewerProps.at(-1)?.sheet).toBe('Second')
|
||||
})
|
||||
|
||||
it('records a worksheet the viewer reports, and hands it back', async () => {
|
||||
renderSurface()
|
||||
await deliver(0)
|
||||
expect(viewerProps.at(-1)?.sheet).toBeUndefined()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'pick sheet' }))
|
||||
|
||||
expect(sheetOf()).toBe('Second')
|
||||
await waitFor(() => expect(viewerProps.at(-1)?.sheet).toBe('Second'))
|
||||
})
|
||||
|
||||
it('does not mix up two files open in the same session', async () => {
|
||||
useWorkspaceContentStore.getState().setFileSheet('s1', 'out/other.xlsx', 'Elsewhere')
|
||||
renderSurface()
|
||||
await deliver(0)
|
||||
|
||||
expect(viewerProps.at(-1)?.sheet).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('passes the scroll position the tab was last left at through to the viewer', async () => {
|
||||
const initialView = { scrollTop: 320, scrollLeft: 0 }
|
||||
renderSurface({ initialView })
|
||||
await deliver(0)
|
||||
|
||||
expect(viewerProps.at(-1)?.initialView).toBe(initialView)
|
||||
})
|
||||
|
||||
it('does not offer a stale copy for a different file after the path changes', async () => {
|
||||
const { rerender } = renderSurface()
|
||||
await deliver(0, new Blob(['first']))
|
||||
|
||||
rerender(<DocumentSurface {...baseProps} path="out/second.pdf" absolutePath="/work/out/second.pdf" />)
|
||||
|
||||
// A new path is a new document: it loads, it does not borrow the previous bytes.
|
||||
await waitFor(() => expect(getWorkspaceRaw).toHaveBeenCalledWith('s1', 'out/second.pdf', expect.any(AbortSignal)))
|
||||
expect(screen.queryByTestId('viewer')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Loading document...')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
import { Suspense } from 'react'
|
||||
import type { WorkspaceDocumentPreviewType } from '@/api/sessions'
|
||||
import { useWorkspaceDocumentBlob } from '@/hooks/useWorkspaceDocumentBlob'
|
||||
import { useTranslation } from '@/i18n'
|
||||
import { isRootedLocalPath } from '@/lib/handlePreviewLink'
|
||||
import {
|
||||
useWorkspaceContentStore,
|
||||
workspaceFileKey,
|
||||
type WorkspaceFileView,
|
||||
} from '@/stores/workspaceContentStore'
|
||||
import { OpenInSystemButton } from '../OpenInSystemButton'
|
||||
import { PanelMessage } from '../PanelMessage'
|
||||
import { DocumentFailure } from './DocumentFailure'
|
||||
import { DocumentViewerBoundary } from './DocumentViewerBoundary'
|
||||
import { documentViewers, type DocumentViewers } from './documentViewers'
|
||||
|
||||
export type DocumentSurfaceProps = {
|
||||
sessionId: string
|
||||
path: string
|
||||
absolutePath: string
|
||||
previewType: WorkspaceDocumentPreviewType
|
||||
/** From the file's metadata; undefined until it has loaded. */
|
||||
version: string | undefined
|
||||
/** The scroll position this file had when its tab was last open. */
|
||||
initialView: WorkspaceFileView | undefined
|
||||
/** Overridable so a test can supply a viewer without loading a real engine. */
|
||||
viewers?: DocumentViewers
|
||||
/** What "try again" does when the viewer itself could not be loaded. Overridable so a test need not reload the window. */
|
||||
reloadPage?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* A viewer that failed to load stays failed until the page is loaded again. Browsers
|
||||
* remember a module whose fetch failed for the life of the document, so importing it
|
||||
* again in place — even with the network back — is refused, and only a reload asks for
|
||||
* it anew (which is also what brings in a newer bundle, after a server update).
|
||||
*/
|
||||
function reloadWindow(): void {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything around a document viewer: fetching its bytes, and what to show when
|
||||
* there are none yet, when they failed to arrive, or when a newer copy is on its
|
||||
* way. The viewers themselves only turn a Blob into pixels.
|
||||
*
|
||||
* A refresh — the agent rewrote the file — keeps the previous render on screen
|
||||
* and says so, instead of blanking a page someone is reading.
|
||||
*/
|
||||
export function DocumentSurface({
|
||||
sessionId,
|
||||
path,
|
||||
absolutePath,
|
||||
previewType,
|
||||
version,
|
||||
initialView,
|
||||
viewers = documentViewers,
|
||||
reloadPage = reloadWindow,
|
||||
}: DocumentSurfaceProps) {
|
||||
const t = useTranslation()
|
||||
const Viewer = viewers[previewType]
|
||||
// No viewer for this kind: nothing to fetch bytes for.
|
||||
const { blob, blobVersion, loading, error, errorStatus, retry } = useWorkspaceDocumentBlob(
|
||||
sessionId,
|
||||
path,
|
||||
Viewer ? version : undefined,
|
||||
)
|
||||
const zoom = useWorkspaceContentStore((state) => state.fileViewByKey[workspaceFileKey(sessionId, path)]?.zoom)
|
||||
const sheet = useWorkspaceContentStore((state) => state.fileViewByKey[workspaceFileKey(sessionId, path)]?.sheet)
|
||||
const canOpenInSystem = isRootedLocalPath(absolutePath)
|
||||
|
||||
if (!Viewer) {
|
||||
return (
|
||||
<PanelMessage
|
||||
icon="draft"
|
||||
message={t('workspace.document.unsupported')}
|
||||
action={canOpenInSystem ? <OpenInSystemButton absolutePath={absolutePath} /> : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (!blob) {
|
||||
if (error) {
|
||||
return (
|
||||
<DocumentFailure
|
||||
message={failureMessage(errorStatus, t)}
|
||||
absolutePath={absolutePath}
|
||||
// A file outside the workspace or over the limit will not change its mind.
|
||||
onRetry={errorStatus === 403 || errorStatus === 413 || errorStatus === 415 ? undefined : retry}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return <PanelMessage icon="progress_activity" message={t('workspace.document.loading')} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||
<DocumentViewerBoundary
|
||||
resetKey={`${path}\u0000${blobVersion ?? ''}`}
|
||||
fallback={
|
||||
<DocumentFailure
|
||||
message={t('workspace.document.engineUnavailable')}
|
||||
absolutePath={absolutePath}
|
||||
onRetry={reloadPage}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<PanelMessage icon="progress_activity" message={t('workspace.document.loading')} />}>
|
||||
<Viewer
|
||||
path={path}
|
||||
absolutePath={absolutePath}
|
||||
blob={blob}
|
||||
version={blobVersion ?? ''}
|
||||
refreshing={loading}
|
||||
zoom={zoom}
|
||||
onZoomChange={(next) => useWorkspaceContentStore.getState().setFileZoom(sessionId, path, next)}
|
||||
sheet={sheet}
|
||||
onSheetChange={(next) => useWorkspaceContentStore.getState().setFileSheet(sessionId, path, next)}
|
||||
initialView={initialView}
|
||||
/>
|
||||
</Suspense>
|
||||
</DocumentViewerBoundary>
|
||||
{error ? (
|
||||
<p role="status" className="shrink-0 border-t border-[var(--color-border)] px-3 py-1.5 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{/* The server's own words are its response body, JSON and English; a refusal has a status to word. */}
|
||||
{t('workspace.files.refreshFailed', { reason: errorStatus === null ? error : failureMessage(errorStatus, t) })}
|
||||
</p>
|
||||
) : loading ? (
|
||||
<p role="status" className="shrink-0 border-t border-[var(--color-border)] px-3 py-1.5 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{t('workspace.document.updating')}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function failureMessage(status: number | null, t: ReturnType<typeof useTranslation>): string {
|
||||
if (status === 403) return t('workspace.document.outsideWorkspace')
|
||||
if (status === 404) return t('workspace.previewState.missing')
|
||||
if (status === 413) return t('workspace.previewState.tooLarge')
|
||||
return t('workspace.document.parseFailed')
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { openLocalFileWithSystem, reportOpenFailure } from '@/lib/systemFileOpen'
|
||||
import { useSettingsStore } from '@/stores/settingsStore'
|
||||
import { DocumentToolbar, type DocumentZoomState } from './DocumentToolbar'
|
||||
|
||||
vi.mock('@/lib/systemFileOpen', () => ({
|
||||
openLocalFileWithSystem: vi.fn(),
|
||||
reportOpenFailure: vi.fn(),
|
||||
}))
|
||||
|
||||
function zoomState(overrides: Partial<DocumentZoomState> = {}): DocumentZoomState {
|
||||
return {
|
||||
percent: 100,
|
||||
fitActive: true,
|
||||
canZoomIn: true,
|
||||
canZoomOut: true,
|
||||
onZoomIn: vi.fn(),
|
||||
onZoomOut: vi.fn(),
|
||||
onFit: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
vi.mocked(openLocalFileWithSystem).mockReset().mockResolvedValue(undefined)
|
||||
vi.mocked(reportOpenFailure).mockReset()
|
||||
})
|
||||
|
||||
describe('DocumentToolbar', () => {
|
||||
it('shows what is specific to the document kind on the left', () => {
|
||||
render(<DocumentToolbar leading={<span>3 / 12</span>} absolutePath="/work/a.pdf" />)
|
||||
|
||||
expect(screen.getByText('3 / 12')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers zoom in the viewer language, and fits to the width of a document', () => {
|
||||
const zoom = zoomState({ percent: 110, fitActive: false })
|
||||
render(<DocumentToolbar zoom={zoom} absolutePath="/work/a.pdf" />)
|
||||
|
||||
expect(screen.getByText('110%')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Zoom out' }))
|
||||
// A page reads top to bottom: "fit" means the width, not the whole page.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Fit to width' }))
|
||||
|
||||
expect(zoom.onZoomIn).toHaveBeenCalledTimes(1)
|
||||
expect(zoom.onZoomOut).toHaveBeenCalledTimes(1)
|
||||
expect(zoom.onFit).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('draws the zoom cluster flat: it sits in a bar, it does not float over content', () => {
|
||||
render(<DocumentToolbar zoom={zoomState()} absolutePath="/work/a.pdf" />)
|
||||
|
||||
expect(screen.getByRole('group', { name: 'Zoom controls' }).className).not.toContain('--shadow-card')
|
||||
})
|
||||
|
||||
it('has no zoom controls for a document with nothing to zoom', () => {
|
||||
render(<DocumentToolbar absolutePath="/work/a.xlsx" />)
|
||||
|
||||
expect(screen.queryByRole('group', { name: 'Zoom controls' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('the way out to the system app', () => {
|
||||
it('hands the original file to the operating system', () => {
|
||||
render(<DocumentToolbar absolutePath="/work/out/thesis.pdf" />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open in system app' }))
|
||||
|
||||
expect(openLocalFileWithSystem).toHaveBeenCalledWith('/work/out/thesis.pdf')
|
||||
})
|
||||
|
||||
it('names the file it could not open instead of failing silently', async () => {
|
||||
vi.mocked(openLocalFileWithSystem).mockRejectedValue(new Error('no application'))
|
||||
render(<DocumentToolbar absolutePath="/work/out/thesis.pdf" />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open in system app' }))
|
||||
|
||||
await waitFor(() => expect(reportOpenFailure).toHaveBeenCalledWith('/work/out/thesis.pdf'))
|
||||
})
|
||||
|
||||
it.each(['out/thesis.pdf', ''])('is not offered for %j, which the OS could not resolve', (path) => {
|
||||
render(<DocumentToolbar absolutePath={path} />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Open in system app' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each(['/work/a.pdf', 'C:\\work\\a.pdf', '~/a.pdf'])('is offered for the rooted path %s', (path) => {
|
||||
render(<DocumentToolbar absolutePath={path} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Open in system app' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('says what a preview leaves out, in full on hover', () => {
|
||||
const note = 'Approximate preview: equations and some shapes may not appear.'
|
||||
render(<DocumentToolbar absolutePath="/work/a.docx" note={note} />)
|
||||
|
||||
expect(screen.getByText(note)).toHaveAttribute('title', note)
|
||||
})
|
||||
|
||||
it('shows no note row when there is nothing to say', () => {
|
||||
const { container } = render(<DocumentToolbar absolutePath="/work/a.pdf" />)
|
||||
|
||||
expect(container.querySelector('p')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import { IconButton } from '@/components/ui/IconButton'
|
||||
import { ZoomControls, type ZoomControlsProps } from '@/components/ui/ZoomControls'
|
||||
import { useTranslation } from '@/i18n'
|
||||
import { isRootedLocalPath } from '@/lib/handlePreviewLink'
|
||||
import { openLocalFileWithSystem, reportOpenFailure } from '@/lib/systemFileOpen'
|
||||
|
||||
export type DocumentZoomState = Omit<ZoomControlsProps, 'labels' | 'surface' | 'flat' | 'className'>
|
||||
|
||||
/**
|
||||
* The bar above a rendered document: whatever is specific to the kind of document
|
||||
* on the left (page number, sheet tabs), then zoom, then the way out to the app
|
||||
* that made the file.
|
||||
*
|
||||
* Kept as one bar for PDF, Word and Excel so switching between tabs of different
|
||||
* kinds does not move the controls.
|
||||
*/
|
||||
export function DocumentToolbar({
|
||||
leading,
|
||||
zoom,
|
||||
absolutePath,
|
||||
note,
|
||||
}: {
|
||||
leading?: ReactNode
|
||||
/** Omit for a document with nothing to zoom. */
|
||||
zoom?: DocumentZoomState
|
||||
absolutePath: string
|
||||
/** A line saying what this preview leaves out; shown under the bar. */
|
||||
note?: string
|
||||
}) {
|
||||
const t = useTranslation()
|
||||
const canOpenInSystem = isRootedLocalPath(absolutePath)
|
||||
|
||||
return (
|
||||
<div className="shrink-0 border-b border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||
<div className="flex h-11 items-center gap-2 px-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">{leading}</div>
|
||||
{zoom ? (
|
||||
<ZoomControls
|
||||
{...zoom}
|
||||
flat
|
||||
labels={{
|
||||
group: t('workspace.zoom.group'),
|
||||
zoomIn: t('workspace.zoom.in'),
|
||||
zoomOut: t('workspace.zoom.out'),
|
||||
fit: t('workspace.zoom.fitWidth'),
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{canOpenInSystem ? (
|
||||
<IconButton
|
||||
icon={<ExternalLink size={16} strokeWidth={1.9} />}
|
||||
label={t('workspace.openInSystemApp')}
|
||||
size="md"
|
||||
tone="secondary"
|
||||
onClick={() => {
|
||||
void openLocalFileWithSystem(absolutePath).catch(() => reportOpenFailure(absolutePath))
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{note ? (
|
||||
<p title={note} className="truncate px-3 pb-1.5 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{note}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
|
||||
import { reportReactError } from '@/lib/diagnosticsCapture'
|
||||
import { DocumentViewerBoundary } from './DocumentViewerBoundary'
|
||||
|
||||
vi.mock('@/lib/diagnosticsCapture', () => ({
|
||||
reportReactError: vi.fn(),
|
||||
}))
|
||||
|
||||
function Viewer({ crashes }: { crashes: boolean }) {
|
||||
if (crashes) throw new Error('cannot draw this')
|
||||
return <div>drawn</div>
|
||||
}
|
||||
|
||||
const fallback = <div role="alert">could not be shown</div>
|
||||
|
||||
let consoleError: MockInstance<typeof console.error>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(reportReactError).mockClear()
|
||||
// React reports a render error it has caught to the console as well.
|
||||
consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
|
||||
describe('DocumentViewerBoundary', () => {
|
||||
it('shows the viewer while it draws', () => {
|
||||
render(<DocumentViewerBoundary resetKey="a" fallback={fallback}><Viewer crashes={false} /></DocumentViewerBoundary>)
|
||||
|
||||
expect(screen.getByText('drawn')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the fallback in its place when the viewer throws, instead of letting it reach the window', () => {
|
||||
render(<DocumentViewerBoundary resetKey="a" fallback={fallback}><Viewer crashes /></DocumentViewerBoundary>)
|
||||
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('could not be shown')
|
||||
expect(screen.queryByText('drawn')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('records the failure in the diagnostics, where it can be found', () => {
|
||||
render(<DocumentViewerBoundary resetKey="a" fallback={fallback}><Viewer crashes /></DocumentViewerBoundary>)
|
||||
|
||||
expect(reportReactError).toHaveBeenCalledTimes(1)
|
||||
expect(vi.mocked(reportReactError).mock.calls[0]![0]).toMatchObject({ message: 'cannot draw this' })
|
||||
})
|
||||
|
||||
it('stays failed while nothing about what it shows has changed', () => {
|
||||
const { rerender } = render(<DocumentViewerBoundary resetKey="a" fallback={fallback}><Viewer crashes /></DocumentViewerBoundary>)
|
||||
|
||||
rerender(<DocumentViewerBoundary resetKey="a" fallback={fallback}><Viewer crashes={false} /></DocumentViewerBoundary>)
|
||||
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument()
|
||||
expect(screen.queryByText('drawn')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('tries the viewer again once the key changes, which is what another document or another attempt is', () => {
|
||||
const { rerender } = render(<DocumentViewerBoundary resetKey="a" fallback={fallback}><Viewer crashes /></DocumentViewerBoundary>)
|
||||
|
||||
rerender(<DocumentViewerBoundary resetKey="b" fallback={fallback}><Viewer crashes={false} /></DocumentViewerBoundary>)
|
||||
|
||||
expect(screen.getByText('drawn')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('fails again, with the fallback, when the retry crashes too', () => {
|
||||
const { rerender } = render(<DocumentViewerBoundary resetKey="a" fallback={fallback}><Viewer crashes /></DocumentViewerBoundary>)
|
||||
|
||||
rerender(<DocumentViewerBoundary resetKey="b" fallback={fallback}><Viewer crashes /></DocumentViewerBoundary>)
|
||||
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument()
|
||||
expect(reportReactError).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react'
|
||||
import { reportReactError } from '@/lib/diagnosticsCapture'
|
||||
|
||||
type Props = {
|
||||
/** A different value clears a failure: another document, or another try, deserves its own chance. */
|
||||
resetKey: string
|
||||
fallback: ReactNode
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
type State = { failed: boolean; resetKey: string }
|
||||
|
||||
/**
|
||||
* Keeps a viewer that cannot load, or that crashes drawing, inside its panel.
|
||||
*
|
||||
* A viewer's code is a chunk fetched the first time a document of its kind is opened
|
||||
* — from a page that may be older than the server it talks to, or over a poor link —
|
||||
* and `lazy` throws when that fetch fails. Left to the application's own boundary,
|
||||
* that throw replaces the whole window because one file could not be previewed.
|
||||
*/
|
||||
export class DocumentViewerBoundary extends Component<Props, State> {
|
||||
state: State = { failed: false, resetKey: this.props.resetKey }
|
||||
|
||||
static getDerivedStateFromError(): Partial<State> {
|
||||
return { failed: true }
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(props: Props, state: State): Partial<State> | null {
|
||||
return props.resetKey === state.resetKey ? null : { failed: false, resetKey: props.resetKey }
|
||||
}
|
||||
|
||||
componentDidCatch(error: unknown, errorInfo: ErrorInfo): void {
|
||||
void reportReactError(error, errorInfo)
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
return this.state.failed ? this.props.fallback : this.props.children
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,750 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { useState } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { browserHost } from '@/lib/desktopHost/browserHost'
|
||||
import { anchoredScroll } from '@/lib/zoomPan'
|
||||
import { useSettingsStore } from '@/stores/settingsStore'
|
||||
import { useUIStore } from '@/stores/uiStore'
|
||||
import type { WorkspaceFileView } from '@/stores/workspaceContentStore'
|
||||
import { blobWithBytes } from '@/test/blobs'
|
||||
import { A4_PAGE, createFakeDocxEngine } from '@/test/fakeDocxEngine'
|
||||
import { expectSameNodes } from '@/test/nodes'
|
||||
import { DocxError, type DocxEngine } from './docxEngine'
|
||||
import { FRAME_SANDBOX, INNER_CSP } from './docxFrame'
|
||||
import DocxSurface from './DocxSurface'
|
||||
|
||||
vi.mock('@/lib/systemFileOpen', () => ({
|
||||
openLocalFileWithSystem: vi.fn().mockResolvedValue(undefined),
|
||||
reportOpenFailure: vi.fn(),
|
||||
}))
|
||||
|
||||
// ---- the panel: jsdom lays nothing out, so its size is whatever a test says ----
|
||||
|
||||
let viewport = { width: 700, height: 800 }
|
||||
const resizeCallbacks = new Set<() => void>()
|
||||
const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth')
|
||||
const originalClientHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientHeight')
|
||||
|
||||
class StubResizeObserver {
|
||||
constructor(private readonly callback: () => void) {}
|
||||
observe() {
|
||||
resizeCallbacks.add(this.callback)
|
||||
}
|
||||
unobserve() {}
|
||||
disconnect() {
|
||||
resizeCallbacks.delete(this.callback)
|
||||
}
|
||||
}
|
||||
|
||||
function resizePanel(width: number, height: number) {
|
||||
viewport = { width, height }
|
||||
act(() => resizeCallbacks.forEach((callback) => callback()))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
viewport = { width: 700, height: 800 }
|
||||
resizeCallbacks.clear()
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, get: () => viewport.width })
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientHeight', { configurable: true, get: () => viewport.height })
|
||||
vi.stubGlobal('ResizeObserver', StubResizeObserver)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
Reflect.deleteProperty(window, 'desktopHost')
|
||||
document.documentElement.style.removeProperty('color-scheme')
|
||||
if (originalClientWidth) Object.defineProperty(HTMLElement.prototype, 'clientWidth', originalClientWidth)
|
||||
if (originalClientHeight) Object.defineProperty(HTMLElement.prototype, 'clientHeight', originalClientHeight)
|
||||
})
|
||||
|
||||
// ---- the surface under test ----
|
||||
|
||||
const docxBlob = blobWithBytes([80, 75, 3, 4], 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')
|
||||
|
||||
type SurfaceProps = {
|
||||
engine: DocxEngine
|
||||
blob?: Blob
|
||||
zoom?: number
|
||||
onZoomChange?: (zoom: number | undefined) => void
|
||||
initialView?: WorkspaceFileView
|
||||
absolutePath?: string
|
||||
}
|
||||
|
||||
/** Holds the zoom the way the workspace store does: chosen here, handed back down. */
|
||||
function Surface({ engine, blob = docxBlob, zoom: initialZoom, onZoomChange, initialView, absolutePath = '/work/docs/thesis.docx' }: SurfaceProps) {
|
||||
const [zoom, setZoom] = useState(initialZoom)
|
||||
return (
|
||||
<DocxSurface
|
||||
engine={engine}
|
||||
blob={blob}
|
||||
path="docs/thesis.docx"
|
||||
absolutePath={absolutePath}
|
||||
version="1"
|
||||
refreshing={false}
|
||||
zoom={zoom}
|
||||
onZoomChange={(next) => {
|
||||
setZoom(next)
|
||||
onZoomChange?.(next)
|
||||
}}
|
||||
initialView={initialView}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const scroller = () => screen.getByRole('group', { name: 'thesis.docx' })
|
||||
const frames = () => [...scroller().querySelectorAll('iframe')]
|
||||
const visibleFrames = () => frames().filter((frame) => frame.style.visibility !== 'hidden')
|
||||
const wrapperOf = (frame: HTMLIFrameElement) => frame.contentDocument!.querySelector<HTMLElement>('.docx-wrapper')!
|
||||
const zoomOf = (frame: HTMLIFrameElement) => wrapperOf(frame).style.getPropertyValue('--docx-zoom')
|
||||
const zoomIn = () => screen.getByRole('button', { name: 'Zoom in' })
|
||||
const zoomOut = () => screen.getByRole('button', { name: 'Zoom out' })
|
||||
const fitButton = () => screen.getByRole('button', { name: 'Fit to width' })
|
||||
|
||||
/** A page 600px wide is 632px with its padding: it fits a 700px panel at 100% and a 400px one at 63%. */
|
||||
const PAGE_WITH_PADDING = 600 + 32
|
||||
|
||||
async function shown(props: Partial<Omit<SurfaceProps, 'engine'>> & { engine?: DocxEngine } = {}) {
|
||||
const { engine = createFakeDocxEngine({ autoFinish: true }).engine, ...surfaceProps } = props
|
||||
const view = render(<Surface {...surfaceProps} engine={engine} />)
|
||||
await screen.findByRole('button', { name: 'Zoom in' })
|
||||
return { ...view, engine }
|
||||
}
|
||||
|
||||
function scrollTo(top: number, left = 0) {
|
||||
scroller().scrollTop = top
|
||||
scroller().scrollLeft = left
|
||||
fireEvent.scroll(scroller())
|
||||
}
|
||||
|
||||
describe('DocxSurface', () => {
|
||||
it('reserves room for the scrollbar always, so fitting to the width does not depend on whether the page overflows', async () => {
|
||||
// A classic scrollbar that comes and goes narrows the panel, which changes the fit,
|
||||
// which changes whether the page overflows: measured in Chromium, a one-page document
|
||||
// then flipped between two widths without end. jsdom has no scrollbars to measure;
|
||||
// this pins the reason.
|
||||
await shown()
|
||||
|
||||
expect(scroller().className).toContain('overflow-y-scroll')
|
||||
})
|
||||
|
||||
describe('drawing', () => {
|
||||
it('says it is loading until the first version is drawn', async () => {
|
||||
const { engine, renders } = createFakeDocxEngine()
|
||||
render(<Surface engine={engine} />)
|
||||
await waitFor(() => expect(renders).toHaveLength(1))
|
||||
|
||||
expect(screen.getByText('Loading document...')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Zoom in' })).not.toBeInTheDocument()
|
||||
|
||||
await act(async () => renders[0]!.finish())
|
||||
await screen.findByRole('button', { name: 'Zoom in' })
|
||||
expect(screen.queryByText('Loading document...')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hands the engine the bytes of the file, and a place to draw in the frame', async () => {
|
||||
const { engine, renders } = createFakeDocxEngine({ autoFinish: true })
|
||||
await shown({ engine })
|
||||
|
||||
expect(Array.from(renders[0]!.bytes)).toEqual([80, 75, 3, 4])
|
||||
expect(renders[0]!.target.body.ownerDocument).toBe(frames()[0]!.contentDocument)
|
||||
expect(renders[0]!.target.styles.ownerDocument).toBe(frames()[0]!.contentDocument)
|
||||
})
|
||||
|
||||
it('draws the pages into the frame', async () => {
|
||||
await shown()
|
||||
|
||||
expect(frames()[0]!.contentDocument!.body.textContent).toContain('A page of the document')
|
||||
})
|
||||
|
||||
it('says, under the bar, what a preview of a Word file leaves out', async () => {
|
||||
await shown()
|
||||
|
||||
expect(screen.getByText(/Approximate preview: equations and some shapes may not appear/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers the file to the system app from the bar above the pages', async () => {
|
||||
await shown()
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Open in system app' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('leaves restoring the scroll position to the viewer, as the panel expects of an async surface', async () => {
|
||||
await shown()
|
||||
|
||||
expect(scroller()).toHaveAttribute('data-workspace-scroll-surface', 'deferred')
|
||||
})
|
||||
})
|
||||
|
||||
describe('what the document brought with it', () => {
|
||||
const links = '<a href="https://example.com">web</a><a href="javascript:alert(1)">script</a><a href="file:///etc/hosts">file</a><a href="#bm1">bookmark</a>'
|
||||
|
||||
it('goes nowhere it should not: a link that leads nowhere has no href left to follow', async () => {
|
||||
const { engine } = createFakeDocxEngine({ autoFinish: true, pages: [{ ...A4_PAGE, html: links }] })
|
||||
await shown({ engine })
|
||||
|
||||
const anchors = [...frames()[0]!.contentDocument!.querySelectorAll('a')]
|
||||
expect(anchors.map((anchor) => anchor.getAttribute('href'))).toEqual(['https://example.com', null, null, '#bm1'])
|
||||
})
|
||||
|
||||
it('puts the page’s stylesheet after the document’s own, so that where they differ the page’s wins', async () => {
|
||||
await shown()
|
||||
|
||||
const children = [...frames()[0]!.contentDocument!.body.children]
|
||||
const styles = children.findIndex((child) => child.querySelector('style'))
|
||||
const pages = children.findIndex((child) => child.querySelector('.docx-wrapper'))
|
||||
const ours = children.findIndex((child) => child.tagName === 'STYLE')
|
||||
expect(styles).toBeGreaterThanOrEqual(0)
|
||||
expect(styles).toBeLessThan(pages)
|
||||
expect(pages).toBeLessThan(ours)
|
||||
expect(children[ours]!.textContent).toContain('.docx-wrapper')
|
||||
})
|
||||
|
||||
it('paints the paper with the theme’s colour, not a colour of its own', async () => {
|
||||
document.documentElement.style.setProperty('--color-document-paper', 'rgb(1, 2, 3)')
|
||||
try {
|
||||
await shown()
|
||||
|
||||
const ours = [...frames()[0]!.contentDocument!.body.children].find((child) => child.tagName === 'STYLE')
|
||||
expect(ours?.textContent).toContain('background:rgb(1, 2, 3)')
|
||||
} finally {
|
||||
document.documentElement.style.removeProperty('--color-document-paper')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('the frame', () => {
|
||||
it('cannot run script: sandboxed with exactly the one permission the page needs', async () => {
|
||||
await shown()
|
||||
|
||||
const sandbox = frames()[0]!.getAttribute('sandbox')
|
||||
expect(sandbox).toBe(FRAME_SANDBOX)
|
||||
expect(sandbox).not.toContain('allow-scripts')
|
||||
})
|
||||
|
||||
it('starts from a document whose first word is its policy', async () => {
|
||||
await shown()
|
||||
|
||||
const srcdoc = new DOMParser().parseFromString(frames()[0]!.srcdoc, 'text/html')
|
||||
expect(srcdoc.head.firstElementChild?.getAttribute('content')).toBe(INNER_CSP)
|
||||
})
|
||||
|
||||
it('is named for the file, for anyone who cannot see it', async () => {
|
||||
await shown()
|
||||
|
||||
expect(frames()[0]!.title).toBe('thesis.docx')
|
||||
})
|
||||
|
||||
it('is a single frame for a single version', async () => {
|
||||
await shown()
|
||||
|
||||
expect(frames()).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('zoom', () => {
|
||||
it('fits a page that fits the panel at 100%, and never enlarges it past that', async () => {
|
||||
await shown()
|
||||
|
||||
expect(screen.getByText('100%')).toBeInTheDocument()
|
||||
expect(zoomOf(frames()[0]!)).toBe('1')
|
||||
expect(fitButton()).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
it('shrinks a page that does not fit a narrow panel to fit it', async () => {
|
||||
viewport = { width: 400, height: 800 }
|
||||
await shown()
|
||||
|
||||
const scale = 400 / PAGE_WITH_PADDING
|
||||
expect(Number(zoomOf(frames()[0]!))).toBeCloseTo(scale, 6)
|
||||
expect(screen.getByText('63%')).toBeInTheDocument()
|
||||
// The frame is no wider than the panel, so nothing scrolls sideways.
|
||||
expect(frames()[0]!.style.width).toBe('400px')
|
||||
})
|
||||
|
||||
it('fits the widest page when they differ', async () => {
|
||||
viewport = { width: 400, height: 800 }
|
||||
const { engine } = createFakeDocxEngine({ autoFinish: true, pages: [A4_PAGE, { width: '900px', text: 'landscape' }] })
|
||||
await shown({ engine })
|
||||
|
||||
expect(Number(zoomOf(frames()[0]!))).toBeCloseTo(400 / (900 + 32), 6)
|
||||
})
|
||||
|
||||
it('starts at the zoom the reader chose before', async () => {
|
||||
await shown({ zoom: 1.5 })
|
||||
|
||||
expect(zoomOf(frames()[0]!)).toBe('1.5')
|
||||
expect(screen.getByText('150%')).toBeInTheDocument()
|
||||
expect(fitButton()).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
|
||||
it('makes the frame as wide as the pages need when they are wider than the panel, so that this area scrolls', async () => {
|
||||
await shown({ zoom: 1.5 })
|
||||
|
||||
expect(frames()[0]!.style.width).toBe(`${PAGE_WITH_PADDING * 1.5}px`)
|
||||
})
|
||||
|
||||
it('steps to the next rung and reports it, so it is remembered', async () => {
|
||||
viewport = { width: 400, height: 800 }
|
||||
const onZoomChange = vi.fn()
|
||||
await shown({ onZoomChange })
|
||||
|
||||
fireEvent.click(zoomIn())
|
||||
|
||||
expect(onZoomChange).toHaveBeenCalledWith(0.67) // the first rung above 63%
|
||||
expect(zoomOf(frames()[0]!)).toBe('0.67')
|
||||
})
|
||||
|
||||
it('leaves fit mode when the reader chooses a zoom, and returns to it on request', async () => {
|
||||
const onZoomChange = vi.fn()
|
||||
await shown({ onZoomChange })
|
||||
|
||||
fireEvent.click(zoomOut())
|
||||
expect(fitButton()).toHaveAttribute('aria-pressed', 'false')
|
||||
|
||||
fireEvent.click(fitButton())
|
||||
expect(onZoomChange).toHaveBeenLastCalledWith(undefined)
|
||||
expect(fitButton()).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
it('cannot go past either end of the ladder', async () => {
|
||||
const { unmount } = await shown({ zoom: 0.1 })
|
||||
expect(zoomOut()).toBeDisabled()
|
||||
unmount()
|
||||
await shown({ zoom: 8 })
|
||||
expect(zoomIn()).toBeDisabled()
|
||||
})
|
||||
|
||||
it('keeps the reader’s place: what was under the middle of the panel stays there', async () => {
|
||||
viewport = { width: 400, height: 800 }
|
||||
await shown()
|
||||
scrollTo(1000)
|
||||
|
||||
fireEvent.click(zoomIn())
|
||||
|
||||
const expected = anchoredScroll({
|
||||
scrollLeft: 0,
|
||||
scrollTop: 1000,
|
||||
anchorX: 200,
|
||||
anchorY: 400,
|
||||
oldScale: 400 / PAGE_WITH_PADDING,
|
||||
newScale: 0.67,
|
||||
}).scrollTop
|
||||
expect(Math.abs(expected - 1000)).toBeGreaterThan(20) // the test can tell a kept place from a lost one
|
||||
expect(scroller().scrollTop).toBeCloseTo(expected, 3)
|
||||
})
|
||||
|
||||
describe('with the wheel and keyboard', () => {
|
||||
it('zooms from a Ctrl+wheel over the surround, and stops the browser zooming the whole app', async () => {
|
||||
const onZoomChange = vi.fn()
|
||||
await shown({ onZoomChange })
|
||||
|
||||
const allowed = fireEvent.wheel(scroller(), { ctrlKey: true, deltaY: -100 })
|
||||
|
||||
expect(allowed).toBe(false)
|
||||
expect(onZoomChange).toHaveBeenCalledTimes(1)
|
||||
expect(onZoomChange.mock.calls[0]![0]).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it('leaves an ordinary wheel to scroll', async () => {
|
||||
const onZoomChange = vi.fn()
|
||||
await shown({ onZoomChange })
|
||||
|
||||
const allowed = fireEvent.wheel(scroller(), { deltaY: 100 })
|
||||
|
||||
expect(allowed).toBe(true)
|
||||
expect(onZoomChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('zooms from a Ctrl+wheel over the document itself, which the page hears of only through the frame', async () => {
|
||||
const onZoomChange = vi.fn()
|
||||
await shown({ onZoomChange, zoom: 1 })
|
||||
const doc = frames()[0]!.contentDocument!
|
||||
|
||||
const event = new WheelEvent('wheel', { ctrlKey: true, deltaY: 100, bubbles: true, cancelable: true })
|
||||
act(() => {
|
||||
doc.body.dispatchEvent(event)
|
||||
})
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
expect(onZoomChange).toHaveBeenCalledTimes(1)
|
||||
expect(onZoomChange.mock.calls[0]![0]).toBeLessThan(1)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['+', 1.5], // the rung above 125%
|
||||
['-', 1.1], // the rung below it
|
||||
['1', 1],
|
||||
['0', undefined],
|
||||
])('zooms from the keyboard: %s', async (key, expected) => {
|
||||
const onZoomChange = vi.fn()
|
||||
await shown({ onZoomChange, zoom: 1.25 })
|
||||
|
||||
fireEvent.keyDown(scroller(), { key })
|
||||
|
||||
expect(onZoomChange).toHaveBeenCalledWith(expected)
|
||||
})
|
||||
|
||||
it('zooms from the keyboard while the frame has it, too', async () => {
|
||||
const onZoomChange = vi.fn()
|
||||
await shown({ onZoomChange, zoom: 1 })
|
||||
|
||||
act(() => {
|
||||
frames()[0]!.contentDocument!.body.dispatchEvent(new KeyboardEvent('keydown', { key: '+', bubbles: true, cancelable: true }))
|
||||
})
|
||||
|
||||
expect(onZoomChange).toHaveBeenCalledWith(1.1)
|
||||
})
|
||||
|
||||
it('leaves browser shortcuts alone', async () => {
|
||||
const onZoomChange = vi.fn()
|
||||
await shown({ onZoomChange })
|
||||
|
||||
fireEvent.keyDown(scroller(), { key: '+', ctrlKey: true })
|
||||
fireEvent.keyDown(scroller(), { key: '-', metaKey: true })
|
||||
|
||||
expect(onZoomChange).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('the panel', () => {
|
||||
it('refits the pages when the panel is resized', async () => {
|
||||
viewport = { width: 400, height: 800 }
|
||||
await shown()
|
||||
expect(Number(zoomOf(frames()[0]!))).toBeCloseTo(400 / PAGE_WITH_PADDING, 6)
|
||||
|
||||
resizePanel(500, 800)
|
||||
|
||||
expect(Number(zoomOf(frames()[0]!))).toBeCloseTo(500 / PAGE_WITH_PADDING, 6)
|
||||
expect(frames()[0]!.style.width).toBe('500px')
|
||||
})
|
||||
|
||||
it('leaves a zoom the reader chose alone', async () => {
|
||||
await shown({ zoom: 1.5 })
|
||||
|
||||
resizePanel(500, 800)
|
||||
|
||||
expect(zoomOf(frames()[0]!)).toBe('1.5')
|
||||
})
|
||||
|
||||
it('puts the reader back after the panel was hidden and shown again', async () => {
|
||||
await shown()
|
||||
scrollTo(1234)
|
||||
|
||||
resizePanel(0, 0) // a hidden panel has no size
|
||||
scroller().scrollTop = 0 // and the browser forgets where it was scrolled to
|
||||
resizePanel(700, 800)
|
||||
|
||||
expect(scroller().scrollTop).toBe(1234)
|
||||
})
|
||||
})
|
||||
|
||||
describe('coming back to a file', () => {
|
||||
it('restores where the reader left it', async () => {
|
||||
await shown({ initialView: { scrollTop: 2500, scrollLeft: 0 } })
|
||||
|
||||
expect(scroller().scrollTop).toBe(2500)
|
||||
})
|
||||
|
||||
it('restores the sideways position too', async () => {
|
||||
await shown({ zoom: 2, initialView: { scrollTop: 0, scrollLeft: 300 } })
|
||||
|
||||
expect(scroller().scrollLeft).toBe(300)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the file is rewritten', () => {
|
||||
const blobA = blobWithBytes([1], 'application/octet-stream')
|
||||
const blobB = blobWithBytes([2], 'application/octet-stream')
|
||||
const blobC = blobWithBytes([3], 'application/octet-stream')
|
||||
|
||||
async function firstVersion() {
|
||||
const { engine, renders } = createFakeDocxEngine()
|
||||
const view = render(<Surface engine={engine} blob={blobA} />)
|
||||
await waitFor(() => expect(renders).toHaveLength(1))
|
||||
await act(async () => renders[0]!.finish())
|
||||
await screen.findByRole('button', { name: 'Zoom in' })
|
||||
return { ...view, engine, renders }
|
||||
}
|
||||
|
||||
it('keeps the version on show until the next one is drawn, drawing that one out of sight', async () => {
|
||||
const { rerender, engine, renders } = await firstVersion()
|
||||
const first = frames()[0]!
|
||||
|
||||
rerender(<Surface engine={engine} blob={blobB} />)
|
||||
await waitFor(() => expect(renders).toHaveLength(2))
|
||||
|
||||
expect(frames()).toHaveLength(2)
|
||||
expectSameNodes(visibleFrames(), [first])
|
||||
const next = frames()[1]!
|
||||
expect(next.style.visibility).toBe('hidden')
|
||||
expect(next.style.pointerEvents).toBe('none')
|
||||
})
|
||||
|
||||
it('swaps in the new version when it is drawn: one frame left, on show, and already sized', async () => {
|
||||
const { rerender, engine, renders } = await firstVersion()
|
||||
const first = frames()[0]!
|
||||
scrollTo(100)
|
||||
|
||||
rerender(<Surface engine={engine} blob={blobB} />)
|
||||
await waitFor(() => expect(renders).toHaveLength(2))
|
||||
await act(async () => renders[1]!.finish())
|
||||
|
||||
await waitFor(() => expect(frames()).toHaveLength(1))
|
||||
const second = frames()[0]!
|
||||
expect(second).not.toBe(first)
|
||||
expect(second.style.visibility).toBe('')
|
||||
expect(second.style.position).toBe('')
|
||||
expect(second.style.width).toBe('700px')
|
||||
expect(zoomOf(second)).toBe('1')
|
||||
// The reader is where they were.
|
||||
expect(scroller().scrollTop).toBe(100)
|
||||
})
|
||||
|
||||
it('sizes the new frame before it puts it on show, so that the swap never passes through an empty one', async () => {
|
||||
const { rerender, engine, renders } = await firstVersion()
|
||||
rerender(<Surface engine={engine} blob={blobB} />)
|
||||
await waitFor(() => expect(renders).toHaveLength(2))
|
||||
const next = frames()[1]!
|
||||
// The frame's style attribute as it changes: what it was before each change, then what it is now.
|
||||
const before: string[] = []
|
||||
const observer = new MutationObserver((records) => before.push(...records.map((record) => record.oldValue ?? '')))
|
||||
observer.observe(next, { attributes: true, attributeFilter: ['style'], attributeOldValue: true })
|
||||
|
||||
await act(async () => renders[1]!.finish())
|
||||
await waitFor(() => expect(frames()).toHaveLength(1))
|
||||
before.push(...observer.takeRecords().map((record) => record.oldValue ?? ''))
|
||||
observer.disconnect()
|
||||
const history = [...before, next.getAttribute('style') ?? '']
|
||||
|
||||
const sized = history.findIndex((style) => style.includes('width: 700px'))
|
||||
const shown = history.findIndex((style) => !style.includes('visibility: hidden'))
|
||||
expect(sized).toBeGreaterThanOrEqual(0)
|
||||
expect(shown).toBeGreaterThan(0)
|
||||
expect(sized).toBeLessThan(shown)
|
||||
})
|
||||
|
||||
it('keeps the zoom the reader chose across the swap', async () => {
|
||||
const { engine, renders, rerender } = await firstVersion()
|
||||
fireEvent.click(zoomIn())
|
||||
const chosen = zoomOf(frames()[0]!)
|
||||
|
||||
rerender(<Surface engine={engine} blob={blobB} />)
|
||||
await waitFor(() => expect(renders).toHaveLength(2))
|
||||
await act(async () => renders[1]!.finish())
|
||||
|
||||
await waitFor(() => expect(frames()).toHaveLength(1))
|
||||
expect(zoomOf(frames()[0]!)).toBe(chosen)
|
||||
})
|
||||
|
||||
it('keeps the old version, and says why, when the new one will not draw', async () => {
|
||||
const { rerender, engine, renders } = await firstVersion()
|
||||
const first = frames()[0]!
|
||||
|
||||
rerender(<Surface engine={engine} blob={blobB} />) // a half-written document
|
||||
await waitFor(() => expect(renders).toHaveLength(2))
|
||||
await act(async () => renders[1]!.fail(new DocxError('invalid', 'not a document')))
|
||||
|
||||
expect(await screen.findByRole('status')).toHaveTextContent(
|
||||
'Showing the last loaded version — refresh failed: This document could not be displayed.',
|
||||
)
|
||||
expectSameNodes(frames(), [first])
|
||||
expectSameNodes(visibleFrames(), [first])
|
||||
})
|
||||
|
||||
it('takes the note away once a good version arrives', async () => {
|
||||
const { rerender, engine, renders } = await firstVersion()
|
||||
rerender(<Surface engine={engine} blob={blobB} />)
|
||||
await waitFor(() => expect(renders).toHaveLength(2))
|
||||
await act(async () => renders[1]!.fail(new DocxError('invalid', 'x')))
|
||||
await screen.findByRole('status')
|
||||
|
||||
rerender(<Surface engine={engine} blob={blobC} />)
|
||||
await waitFor(() => expect(renders).toHaveLength(3))
|
||||
await act(async () => renders[2]!.finish())
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('never shows a version that was overtaken while it was still being drawn', async () => {
|
||||
const { rerender, engine, renders } = await firstVersion()
|
||||
const first = frames()[0]!
|
||||
|
||||
rerender(<Surface engine={engine} blob={blobB} />)
|
||||
await waitFor(() => expect(renders).toHaveLength(2))
|
||||
rerender(<Surface engine={engine} blob={blobC} />)
|
||||
await waitFor(() => expect(renders).toHaveLength(3))
|
||||
await act(async () => renders[2]!.finish()) // the newer version is drawn first…
|
||||
await waitFor(() => expect(frames()).toHaveLength(2)) // (B's frame is still being drawn, out of sight)
|
||||
await waitFor(() => expect(visibleFrames()).not.toContain(first))
|
||||
const shownNow = visibleFrames()[0]!
|
||||
await act(async () => renders[1]!.finish()) // …and the older one limps in afterwards
|
||||
|
||||
// Only the newer one is left, and it is the one on show.
|
||||
await waitFor(() => expectSameNodes(frames(), [shownNow]))
|
||||
expectSameNodes(visibleFrames(), [shownNow])
|
||||
})
|
||||
|
||||
it('does not report the failure of a version that was overtaken', async () => {
|
||||
const { rerender, engine, renders } = await firstVersion()
|
||||
rerender(<Surface engine={engine} blob={blobB} />)
|
||||
await waitFor(() => expect(renders).toHaveLength(2))
|
||||
rerender(<Surface engine={engine} blob={blobC} />)
|
||||
await waitFor(() => expect(renders).toHaveLength(3))
|
||||
|
||||
await act(async () => renders[1]!.fail(new DocxError('invalid', 'stale')))
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the document cannot be shown', () => {
|
||||
async function failing(error: DocxError, props: Partial<SurfaceProps> = {}) {
|
||||
const { engine, renders } = createFakeDocxEngine()
|
||||
render(<Surface engine={engine} {...props} />)
|
||||
await waitFor(() => expect(renders).toHaveLength(1))
|
||||
await act(async () => renders[0]!.fail(error))
|
||||
return { engine, renders }
|
||||
}
|
||||
|
||||
it.each([
|
||||
['invalid', 'This document could not be displayed.'],
|
||||
['tooComplex', 'This file is too large or too complex to preview safely.'],
|
||||
['unavailable', "The document viewer can't start in this environment."],
|
||||
] as const)('explains a %s failure in words', async (kind, message) => {
|
||||
await failing(new DocxError(kind, 'x'))
|
||||
|
||||
expect(await screen.findByText(message)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('leaves no frame behind for a document that would not draw', async () => {
|
||||
await failing(new DocxError('invalid', 'x'))
|
||||
await screen.findByText('This document could not be displayed.')
|
||||
|
||||
expect(frames()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it.each(['invalid', 'tooComplex'] as const)('does not offer to try a %s document again: the same bytes fail the same way', async (kind) => {
|
||||
await failing(new DocxError(kind, 'x'))
|
||||
await screen.findByRole('button', { name: 'Open in system app' })
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers the system app for every failure', async () => {
|
||||
await failing(new DocxError('invalid', 'x'))
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Open in system app' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('lets the reader try again when the engine itself failed to load', async () => {
|
||||
const { renders } = await failing(new DocxError('unavailable', 'x'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Try again' }))
|
||||
|
||||
await waitFor(() => expect(renders).toHaveLength(2))
|
||||
await act(async () => renders[1]!.finish())
|
||||
await screen.findByRole('button', { name: 'Zoom in' })
|
||||
})
|
||||
|
||||
it('has nothing to open in a browser: a browser cannot show a Word document', async () => {
|
||||
await failing(new DocxError('unavailable', 'x'))
|
||||
await screen.findByRole('button', { name: 'Try again' })
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Open in browser' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('links in the document', () => {
|
||||
function clickLink(href: string | null) {
|
||||
const doc = frames()[0]!.contentDocument!
|
||||
const anchor = doc.createElement('a')
|
||||
if (href !== null) anchor.setAttribute('href', href)
|
||||
doc.body.append(anchor)
|
||||
const event = new MouseEvent('click', { bubbles: true, cancelable: true })
|
||||
act(() => {
|
||||
anchor.dispatchEvent(event)
|
||||
})
|
||||
return event
|
||||
}
|
||||
|
||||
function hostWithShell(open: (url: string) => Promise<void>) {
|
||||
window.desktopHost = { ...browserHost, shell: { ...browserHost.shell, open } }
|
||||
}
|
||||
|
||||
it('opens a web link through the application, never in the frame', async () => {
|
||||
const open = vi.fn().mockResolvedValue(undefined)
|
||||
hostWithShell(open)
|
||||
await shown()
|
||||
|
||||
const event = clickLink('https://example.com/docs')
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
expect(open).toHaveBeenCalledWith('https://example.com/docs')
|
||||
})
|
||||
|
||||
it('falls back to a window of its own, without a way back into the app, when the host cannot open it', async () => {
|
||||
hostWithShell(vi.fn().mockRejectedValue(new Error('no shell')))
|
||||
const openWindow = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
await shown()
|
||||
|
||||
clickLink('https://example.com/docs')
|
||||
|
||||
await waitFor(() => expect(openWindow).toHaveBeenCalledWith('https://example.com/docs', '_blank', 'noopener'))
|
||||
})
|
||||
|
||||
it.each(['javascript:alert(1)', 'file:///etc/hosts', 'data:text/html,x', '/relative', null])('does nothing with %j', async (href) => {
|
||||
const open = vi.fn().mockResolvedValue(undefined)
|
||||
hostWithShell(open)
|
||||
const openWindow = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
await shown()
|
||||
|
||||
const event = clickLink(href)
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
expect(open).not.toHaveBeenCalled()
|
||||
expect(openWindow).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('the theme', () => {
|
||||
it('keeps the frame’s colour scheme in step with the page’s, or its canvas turns opaque over the surround', async () => {
|
||||
document.documentElement.style.colorScheme = 'light'
|
||||
await shown()
|
||||
// jsdom does not apply a frame's srcdoc; put in the meta that a browser would have.
|
||||
const doc = frames()[0]!.contentDocument!
|
||||
const meta = doc.createElement('meta')
|
||||
meta.setAttribute('name', 'color-scheme')
|
||||
meta.setAttribute('content', 'light')
|
||||
doc.head.append(meta)
|
||||
|
||||
act(() => {
|
||||
document.documentElement.style.colorScheme = 'dark'
|
||||
useUIStore.setState({ theme: 'dark' })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(meta.getAttribute('content')).toBe('dark'))
|
||||
})
|
||||
})
|
||||
|
||||
it('takes its frames with it when it goes', async () => {
|
||||
const { unmount } = await shown()
|
||||
expect(document.querySelectorAll('iframe')).toHaveLength(1)
|
||||
|
||||
unmount()
|
||||
|
||||
expect(document.querySelectorAll('iframe')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('takes a frame still being drawn with it, too', async () => {
|
||||
const { engine, renders } = createFakeDocxEngine()
|
||||
const { unmount } = render(<Surface engine={engine} />)
|
||||
await waitFor(() => expect(renders).toHaveLength(1))
|
||||
|
||||
unmount()
|
||||
await act(async () => renders[0]!.finish())
|
||||
|
||||
expect(document.querySelectorAll('iframe')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,295 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
type KeyboardEvent,
|
||||
} from 'react'
|
||||
import { useElementSize } from '@/hooks/useElementSize'
|
||||
import { useTranslation } from '@/i18n'
|
||||
import { getDesktopHost } from '@/lib/desktopHost'
|
||||
import {
|
||||
ZOOM_MAX,
|
||||
ZOOM_MIN,
|
||||
anchoredScroll,
|
||||
anchoredScrollLeft,
|
||||
clampZoom,
|
||||
stepZoom,
|
||||
wheelZoom,
|
||||
zoomPercent,
|
||||
} from '@/lib/zoomPan'
|
||||
import { useUIStore } from '@/stores/uiStore'
|
||||
import { PanelMessage } from '../PanelMessage'
|
||||
import { DocumentFailure } from './DocumentFailure'
|
||||
import type { DocumentViewerProps } from './documentViewers'
|
||||
import { DocumentToolbar, type DocumentZoomState } from './DocumentToolbar'
|
||||
import { defaultDocxEngine, type DocxEngine, type DocxError } from './docxEngine'
|
||||
import { applyZoom, contentHeight, currentColorScheme, type FrameHandlers } from './docxFrame'
|
||||
import { useDocxDocument, type RenderedDocx } from './useDocxDocument'
|
||||
|
||||
export type DocxSurfaceProps = DocumentViewerProps & {
|
||||
/** Overridable so a test can supply a document without loading docx-preview. */
|
||||
engine?: DocxEngine
|
||||
}
|
||||
|
||||
/** The padding `presentationCss` puts around the pages, on each side, at 100%. */
|
||||
const WRAPPER_PADDING = 16
|
||||
/** "Fit to width" never enlarges a page past its natural size. */
|
||||
const FIT_MAX_SCALE = 1
|
||||
/** Two zooms closer than this are the same zoom. */
|
||||
const ZOOM_EPSILON = 1e-3
|
||||
|
||||
function fileNameOf(path: string): string {
|
||||
return path.split(/[\\/]/).filter(Boolean).pop() ?? path
|
||||
}
|
||||
|
||||
function failureMessage(error: DocxError, t: ReturnType<typeof useTranslation>): string {
|
||||
if (error.kind === 'tooComplex') return t('workspace.document.tooComplex')
|
||||
if (error.kind === 'unavailable') return t('workspace.document.engineUnavailable')
|
||||
return t('workspace.document.parseFailed')
|
||||
}
|
||||
|
||||
/**
|
||||
* The Word viewer `DocumentSurface` loads for `.docx` files.
|
||||
*
|
||||
* The document is drawn into a frame that cannot run script (see `docxFrame`), sized to its
|
||||
* content so that this surface's scroll area — not the frame — does the scrolling. Zoom is
|
||||
* CSS `zoom` on the pages inside the frame, which re-lays the text out at the new size
|
||||
* instead of stretching a picture of it.
|
||||
*/
|
||||
export default function DocxSurface({
|
||||
blob,
|
||||
path,
|
||||
absolutePath,
|
||||
zoom,
|
||||
onZoomChange,
|
||||
initialView,
|
||||
engine = defaultDocxEngine,
|
||||
}: DocxSurfaceProps) {
|
||||
const t = useTranslation()
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null)
|
||||
const [sizeRef, viewport] = useElementSize<HTMLDivElement>()
|
||||
const setScroller = useCallback((node: HTMLDivElement | null) => {
|
||||
scrollRef.current = node
|
||||
sizeRef(node)
|
||||
}, [sizeRef])
|
||||
|
||||
const handlersRef = useRef<FrameHandlers>({
|
||||
onExternalLink: () => undefined,
|
||||
onWheelZoom: () => undefined,
|
||||
onZoomKey: () => undefined,
|
||||
})
|
||||
const layoutRef = useRef<(rendered: RenderedDocx) => void>(() => undefined)
|
||||
const { current, error, retry } = useDocxDocument({
|
||||
engine,
|
||||
blob,
|
||||
hostRef,
|
||||
title: fileNameOf(path),
|
||||
handlersRef,
|
||||
layoutRef,
|
||||
})
|
||||
|
||||
// `null` until there is both a document and a panel to fit it to.
|
||||
const fit = current && viewport
|
||||
? Math.min(FIT_MAX_SCALE, clampZoom(viewport.width / (current.naturalWidth + 2 * WRAPPER_PADDING)))
|
||||
: null
|
||||
const scale = fit === null ? null : zoom === undefined ? fit : clampZoom(zoom)
|
||||
|
||||
/** Zoom, width and height for a frame. Returns the width, which the scroll anchoring needs. */
|
||||
const layout = useCallback((rendered: RenderedDocx, toScale: number, panelWidth: number) => {
|
||||
const width = Math.max(panelWidth, (rendered.naturalWidth + 2 * WRAPPER_PADDING) * toScale)
|
||||
rendered.frame.style.width = `${width}px`
|
||||
applyZoom(rendered.doc, toScale)
|
||||
// Read after the zoom is applied: the height is that of the pages at the new size.
|
||||
rendered.frame.style.height = `${contentHeight(rendered.doc)}px`
|
||||
return width
|
||||
}, [])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
layoutRef.current = (rendered) => {
|
||||
if (scale !== null && viewport) layout(rendered, scale, viewport.width)
|
||||
}
|
||||
})
|
||||
|
||||
const pendingRestore = useRef(initialView ? { top: initialView.scrollTop, left: initialView.scrollLeft } : null)
|
||||
const pendingAnchor = useRef<{ x: number; y: number } | null>(null)
|
||||
const lastScroll = useRef({ top: 0, left: 0 })
|
||||
const lastLayout = useRef<{ frame: HTMLIFrameElement; scale: number; width: number } | null>(null)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const node = scrollRef.current
|
||||
if (!node) return
|
||||
if (!current || scale === null || !viewport) {
|
||||
// Hidden, or not drawn yet. A hidden panel loses its scroll offset, so remember
|
||||
// where the reader was and put them back when it returns.
|
||||
if (lastLayout.current) {
|
||||
pendingRestore.current = lastScroll.current
|
||||
lastLayout.current = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const previous = lastLayout.current
|
||||
const width = layout(current, scale, viewport.width)
|
||||
if (pendingRestore.current) {
|
||||
node.scrollTop = pendingRestore.current.top
|
||||
node.scrollLeft = pendingRestore.current.left
|
||||
pendingRestore.current = null
|
||||
} else if (previous && previous.frame === current.frame && Math.abs(previous.scale - scale) > ZOOM_EPSILON) {
|
||||
const anchor = pendingAnchor.current ?? { x: viewport.width / 2, y: 0 }
|
||||
node.scrollTop = anchoredScroll({
|
||||
scrollLeft: node.scrollLeft,
|
||||
scrollTop: node.scrollTop,
|
||||
anchorX: anchor.x,
|
||||
anchorY: anchor.y,
|
||||
oldScale: previous.scale,
|
||||
newScale: scale,
|
||||
}).scrollTop
|
||||
node.scrollLeft = anchoredScrollLeft({
|
||||
scrollLeft: node.scrollLeft,
|
||||
anchorX: anchor.x,
|
||||
oldScale: previous.scale,
|
||||
newScale: scale,
|
||||
oldContentWidth: previous.width,
|
||||
newContentWidth: width,
|
||||
})
|
||||
}
|
||||
pendingAnchor.current = null
|
||||
lastLayout.current = { frame: current.frame, scale, width }
|
||||
lastScroll.current = { top: node.scrollTop, left: node.scrollLeft }
|
||||
}, [current, scale, viewport, layout])
|
||||
|
||||
// The frame's colour scheme follows the theme, or its canvas turns opaque over the surround.
|
||||
const theme = useUIStore((state) => state.theme)
|
||||
useEffect(() => {
|
||||
const meta = current?.doc.querySelector('meta[name="color-scheme"]')
|
||||
const scheme = currentColorScheme()
|
||||
if (meta && scheme) meta.setAttribute('content', scheme)
|
||||
}, [current, theme])
|
||||
|
||||
const zoomTo = useCallback((next: number | undefined, anchor?: { x: number; y: number }) => {
|
||||
const node = scrollRef.current
|
||||
const target = next === undefined ? fit : clampZoom(next)
|
||||
if (node && scale !== null && target !== null && Math.abs(target - scale) > ZOOM_EPSILON) {
|
||||
pendingAnchor.current = { x: anchor?.x ?? node.clientWidth / 2, y: anchor?.y ?? node.clientHeight / 2 }
|
||||
}
|
||||
onZoomChange(next)
|
||||
}, [fit, scale, onZoomChange])
|
||||
|
||||
const zoomByKey = useCallback((key: string) => {
|
||||
if (scale === null) return
|
||||
if (key === '+' || key === '=') zoomTo(stepZoom(scale, 1))
|
||||
else if (key === '-' || key === '_') zoomTo(stepZoom(scale, -1))
|
||||
else if (key === '0') zoomTo(undefined)
|
||||
else if (key === '1') zoomTo(1)
|
||||
}, [scale, zoomTo])
|
||||
|
||||
/** A point in the frame, as a point in the scroll area: what the zoom anchors to. */
|
||||
const inScrollArea = useCallback((at: { x: number; y: number }) => {
|
||||
const scroller = scrollRef.current?.getBoundingClientRect()
|
||||
const frame = current?.frame.getBoundingClientRect()
|
||||
return scroller && frame ? { x: frame.left - scroller.left + at.x, y: frame.top - scroller.top + at.y } : undefined
|
||||
}, [current])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
handlersRef.current = {
|
||||
onExternalLink: (url) => {
|
||||
void getDesktopHost().shell.open(url).catch(() => window.open(url, '_blank', 'noopener'))
|
||||
},
|
||||
onWheelZoom: (deltaY, at) => {
|
||||
if (scale !== null) zoomTo(wheelZoom(scale, deltaY), inScrollArea(at))
|
||||
},
|
||||
onZoomKey: zoomByKey,
|
||||
}
|
||||
})
|
||||
|
||||
// A wheel over the surround, outside the frame. Native and non-passive on purpose: React's
|
||||
// `onWheel` is passive, where preventDefault is ignored and a pinch would zoom the whole app.
|
||||
const latest = useRef({ zoomTo, scale })
|
||||
useLayoutEffect(() => {
|
||||
latest.current = { zoomTo, scale }
|
||||
})
|
||||
useEffect(() => {
|
||||
const node = scrollRef.current
|
||||
if (!node) return
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
if (!event.ctrlKey && !event.metaKey) return
|
||||
event.preventDefault()
|
||||
const { zoomTo: zoomAt, scale: now } = latest.current
|
||||
if (now === null) return
|
||||
const rect = node.getBoundingClientRect()
|
||||
zoomAt(wheelZoom(now, event.deltaY), { x: event.clientX - rect.left, y: event.clientY - rect.top })
|
||||
}
|
||||
node.addEventListener('wheel', onWheel, { passive: false })
|
||||
return () => node.removeEventListener('wheel', onWheel)
|
||||
}, [])
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.ctrlKey || event.metaKey || event.altKey || scale === null) return
|
||||
if (!['+', '=', '-', '_', '0', '1'].includes(event.key)) return
|
||||
zoomByKey(event.key)
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const zoomState: DocumentZoomState = {
|
||||
percent: zoomPercent(scale ?? 1),
|
||||
fitActive: zoom === undefined,
|
||||
canZoomIn: scale !== null && scale < ZOOM_MAX - ZOOM_EPSILON,
|
||||
canZoomOut: scale !== null && scale > ZOOM_MIN + ZOOM_EPSILON,
|
||||
onZoomIn: () => scale !== null && zoomTo(stepZoom(scale, 1)),
|
||||
onZoomOut: () => scale !== null && zoomTo(stepZoom(scale, -1)),
|
||||
onFit: () => zoomTo(undefined),
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||
{current ? (
|
||||
<DocumentToolbar
|
||||
absolutePath={absolutePath}
|
||||
zoom={zoomState}
|
||||
note={t('workspace.document.approximate.docx')}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
ref={setScroller}
|
||||
// The pages arrive after this mounts, so the panel leaves restoring the scroll position
|
||||
// to us (see WorkspaceFileTab); it still records where the reader leaves it.
|
||||
data-workspace-scroll-surface="deferred"
|
||||
role="group"
|
||||
aria-label={fileNameOf(path)}
|
||||
tabIndex={0}
|
||||
onKeyDown={onKeyDown}
|
||||
onScroll={(event) => {
|
||||
lastScroll.current = { top: event.currentTarget.scrollTop, left: event.currentTarget.scrollLeft }
|
||||
}}
|
||||
// A permanent vertical scrollbar: whether the page overflows must not change the
|
||||
// width "fit to width" measures. With a classic scrollbar (Windows, Linux) one that
|
||||
// comes and goes narrows the panel, which shrinks the fit, which removes the
|
||||
// overflow and the scrollbar with it — and the page changes size for good.
|
||||
className="min-h-0 flex-1 overflow-x-auto overflow-y-scroll bg-[var(--color-surface-container-high)] outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-border-focus)]"
|
||||
>
|
||||
<div ref={hostRef} className="relative" />
|
||||
</div>
|
||||
{current ? null : (
|
||||
<div className="absolute inset-0 bg-[var(--color-surface)]">
|
||||
{error ? (
|
||||
<DocumentFailure
|
||||
message={failureMessage(error, t)}
|
||||
absolutePath={absolutePath}
|
||||
// The same bytes fail the same way, unless docx-preview itself was what failed to load.
|
||||
onRetry={error.kind === 'unavailable' ? retry : undefined}
|
||||
/>
|
||||
) : (
|
||||
<PanelMessage icon="progress_activity" message={t('workspace.document.loading')} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{current && error ? (
|
||||
<p role="status" className="shrink-0 border-t border-[var(--color-border)] px-3 py-1.5 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{t('workspace.files.refreshFailed', { reason: failureMessage(error, t) })}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createFakePdfDocument, type FakePdfDocument } from '@/test/fakePdfEngine'
|
||||
import { expectSameNodes } from '@/test/nodes'
|
||||
import { useSettingsStore } from '@/stores/settingsStore'
|
||||
import { PdfPage, type PdfPageProps } from './PdfPage'
|
||||
import { PDF_CSS_UNITS } from './pdfLayout'
|
||||
|
||||
let doc: FakePdfDocument
|
||||
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
doc = createFakePdfDocument({ pages: 12 })
|
||||
})
|
||||
|
||||
function page(overrides: Partial<PdfPageProps> = {}) {
|
||||
return <PdfPage doc={doc} pageNumber={3} total={12} scale={1} drawScale={1} {...overrides} />
|
||||
}
|
||||
|
||||
const frame = () => screen.getByRole('group', { name: 'Page 3 of 12' })
|
||||
const canvases = () => Array.from(frame().querySelectorAll('canvas'))
|
||||
const textLayer = () => frame().querySelector('.textLayer')
|
||||
|
||||
/**
|
||||
* What `pdfPage.css` declares for `element`: every rule whose selector matches it.
|
||||
* jsdom lays nothing out, so this is how a test can tell that the stylesheet and the
|
||||
* elements the component builds still meet — a selector written for a different
|
||||
* nesting simply stops applying, and the page renders at twice its size.
|
||||
*/
|
||||
function declaredFor(element: Element): Map<string, string> {
|
||||
const css = readFileSync(path.join(import.meta.dirname, 'pdfPage.css'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
const declared = new Map<string, string>()
|
||||
for (const [, selectors, body] of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
||||
const applies = selectors!.split(',').some((selector) => {
|
||||
try {
|
||||
return element.matches(selector.trim())
|
||||
} catch {
|
||||
return false // a pseudo-element, or half a selector list split at a comma inside :is()
|
||||
}
|
||||
})
|
||||
if (!applies) continue
|
||||
for (const declaration of body!.split(';')) {
|
||||
const colon = declaration.indexOf(':')
|
||||
if (colon > 0) declared.set(declaration.slice(0, colon).trim(), declaration.slice(colon + 1).trim())
|
||||
}
|
||||
}
|
||||
return declared
|
||||
}
|
||||
|
||||
/** Let a document's `index`-th render finish and its result be swapped onto the sheet. */
|
||||
async function drawn(index = 0, on: FakePdfDocument = doc) {
|
||||
await waitFor(() => expect(on.renders.length).toBeGreaterThan(index))
|
||||
await act(async () => on.renders[index]!.finish())
|
||||
await waitFor(() => expect(canvases().length).toBe(1))
|
||||
}
|
||||
|
||||
describe('PdfPage', () => {
|
||||
it('is named for the reader by its place in the document', () => {
|
||||
render(page())
|
||||
|
||||
expect(frame()).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('draws the page it is, at the zoom it is told to draw at', async () => {
|
||||
render(page({ pageNumber: 3, drawScale: 1.5 }))
|
||||
|
||||
await waitFor(() => expect(doc.renderPage).toHaveBeenCalledTimes(1))
|
||||
expect(doc.renderPage).toHaveBeenCalledWith(3, expect.objectContaining({ scale: 1.5 }))
|
||||
expect(doc.renderTextLayer).not.toHaveBeenCalled() // only once the bitmap is done
|
||||
})
|
||||
|
||||
it('puts the canvas and its selectable text on the page once both are ready', async () => {
|
||||
render(page())
|
||||
expect(canvases()).toHaveLength(0)
|
||||
|
||||
await drawn()
|
||||
|
||||
expect(canvases()).toHaveLength(1)
|
||||
expect(textLayer()).toHaveTextContent('text of page 3')
|
||||
// Keeps a drag selection from collapsing at the last span.
|
||||
expect(textLayer()!.querySelector('.endOfContent')).not.toBeNull()
|
||||
})
|
||||
|
||||
describe('its stylesheet', () => {
|
||||
it('shrinks the canvas to the size of the sheet, however many pixels pdf.js drew it with', async () => {
|
||||
render(page())
|
||||
await drawn()
|
||||
|
||||
// pdf.js draws at up to twice the pixels for a dense screen. Without this rule the
|
||||
// canvas shows at its own pixel size and the sheet shows only its top-left quarter.
|
||||
const style = declaredFor(canvases()[0]!)
|
||||
expect(style.get('position')).toBe('absolute')
|
||||
expect(style.get('width')).toBe('100%')
|
||||
expect(style.get('height')).toBe('100%')
|
||||
})
|
||||
|
||||
it('lays the selectable text exactly over the canvas', async () => {
|
||||
render(page())
|
||||
await drawn()
|
||||
|
||||
const style = declaredFor(textLayer()!)
|
||||
expect(style.get('position')).toBe('absolute')
|
||||
expect(style.get('inset')).toBe('0')
|
||||
expect(style.get('overflow')).toBe('clip')
|
||||
})
|
||||
|
||||
it('keeps the drag-selection sentinel below the text and out of the way of the pointer', async () => {
|
||||
render(page())
|
||||
await drawn()
|
||||
|
||||
const style = declaredFor(textLayer()!.querySelector('.endOfContent')!)
|
||||
expect(style.get('position')).toBe('absolute')
|
||||
expect(style.get('user-select')).toBe('none')
|
||||
})
|
||||
|
||||
it('makes the sheet the containing block for both, and the paper white', () => {
|
||||
render(page({ }))
|
||||
|
||||
const style = declaredFor(frame())
|
||||
expect(style.get('position')).toBe('relative')
|
||||
expect(style.get('overflow')).toBe('hidden')
|
||||
expect(style.get('background')).toBe('var(--color-document-paper)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('redrawing at another zoom', () => {
|
||||
it('keeps the previous drawing on screen until the new one is ready, so the page never flashes blank', async () => {
|
||||
const { rerender } = render(page({ drawScale: 1 }))
|
||||
await drawn(0)
|
||||
const before = canvases()[0]!
|
||||
|
||||
rerender(page({ drawScale: 2 }))
|
||||
await waitFor(() => expect(doc.renders.length).toBe(2))
|
||||
|
||||
expectSameNodes(canvases(), [before])
|
||||
expect(before.width).toBeGreaterThan(0)
|
||||
|
||||
await act(async () => doc.renders[1]!.finish())
|
||||
await waitFor(() => expect(canvases()[0]).not.toBe(before))
|
||||
expect(canvases()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('gives the replaced bitmap back at once instead of leaving it to the collector', async () => {
|
||||
const { rerender } = render(page({ drawScale: 1 }))
|
||||
await drawn(0)
|
||||
const before = canvases()[0]!
|
||||
|
||||
rerender(page({ drawScale: 2 }))
|
||||
await waitFor(() => expect(doc.renders.length).toBe(2))
|
||||
await act(async () => doc.renders[1]!.finish())
|
||||
await waitFor(() => expect(canvases()[0]).not.toBe(before))
|
||||
|
||||
expect(before.width).toBe(0)
|
||||
expect(before.height).toBe(0)
|
||||
})
|
||||
|
||||
it('cancels the draw it supersedes and does not swap its result in', async () => {
|
||||
const { rerender } = render(page({ drawScale: 1 }))
|
||||
await waitFor(() => expect(doc.renders.length).toBe(1))
|
||||
const superseded = doc.renders[0]!
|
||||
|
||||
rerender(page({ drawScale: 2 }))
|
||||
|
||||
expect(superseded.signal!.aborted).toBe(true)
|
||||
await waitFor(() => expect(doc.renders.length).toBe(2))
|
||||
await act(async () => doc.renders[1]!.finish())
|
||||
await waitFor(() => expect(canvases()).toHaveLength(1))
|
||||
expect(canvases()[0]).toBe(doc.renders[1]!.canvas)
|
||||
})
|
||||
|
||||
it('does not treat a cancelled draw as a failure', async () => {
|
||||
const { rerender } = render(page({ drawScale: 1 }))
|
||||
await waitFor(() => expect(doc.renders.length).toBe(1))
|
||||
|
||||
rerender(page({ drawScale: 2 }))
|
||||
await waitFor(() => expect(doc.renders.length).toBe(2))
|
||||
|
||||
expect(screen.queryByText('This page could not be drawn.')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not release a page that is still on screen: it is being redrawn, not dropped', async () => {
|
||||
const { rerender } = render(page({ drawScale: 1 }))
|
||||
await drawn(0)
|
||||
|
||||
rerender(page({ drawScale: 2 }))
|
||||
await waitFor(() => expect(doc.renders.length).toBe(2))
|
||||
await act(async () => doc.renders[1]!.finish())
|
||||
await waitFor(() => expect(canvases()[0]).toBe(doc.renders[1]!.canvas))
|
||||
|
||||
expect(doc.releasePage).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('sizes the sheet from the live zoom while the bitmap still waits for the settled one', () => {
|
||||
// The text layer scales itself from this variable, so it follows a gesture
|
||||
// without waiting for the page to be redrawn.
|
||||
render(page({ scale: 1.5, drawScale: 1 }))
|
||||
|
||||
expect(frame().style.getPropertyValue('--scale-factor')).toBe(String(1.5 * PDF_CSS_UNITS))
|
||||
})
|
||||
|
||||
describe('when the document is replaced (the file was rewritten)', () => {
|
||||
it('keeps the previous drawing until the new document’s page is ready', async () => {
|
||||
const { rerender } = render(page())
|
||||
await drawn(0)
|
||||
const before = canvases()[0]!
|
||||
const next = createFakePdfDocument({ pages: 12 })
|
||||
|
||||
rerender(page({ doc: next }))
|
||||
await waitFor(() => expect(next.renders.length).toBe(1))
|
||||
|
||||
// Not blank: the reader is still looking at the old version.
|
||||
expectSameNodes(canvases(), [before])
|
||||
expect(before.width).toBeGreaterThan(0)
|
||||
|
||||
await act(async () => next.renders[0]!.finish())
|
||||
await waitFor(() => expect(canvases()[0]).toBe(next.renders[0]!.canvas))
|
||||
expect(canvases()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('lets the old document release its page once the new drawing has replaced it, and only then', async () => {
|
||||
const { rerender } = render(page())
|
||||
await drawn(0)
|
||||
const next = createFakePdfDocument({ pages: 12 })
|
||||
|
||||
rerender(page({ doc: next }))
|
||||
await waitFor(() => expect(next.renders.length).toBe(1))
|
||||
expect(doc.releasePage).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => next.renders[0]!.finish())
|
||||
await waitFor(() => expect(doc.releasePage).toHaveBeenCalledWith(3))
|
||||
expect(next.releasePage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not let a failure handing back the old document’s page undo the drawing that replaced it', async () => {
|
||||
// The old document is closed by then, and a closed pdf.js document throws when asked
|
||||
// for a page. That is tidying up after a good drawing, not a failed drawing: the
|
||||
// canvas the reader is looking at must keep its pixels, and no error appears on the sheet.
|
||||
const { rerender } = render(page())
|
||||
await drawn(0)
|
||||
vi.mocked(doc.releasePage).mockImplementation(() => {
|
||||
throw new TypeError("Cannot read properties of null (reading 'sendWithPromise')")
|
||||
})
|
||||
const next = createFakePdfDocument({ pages: 12 })
|
||||
|
||||
rerender(page({ doc: next }))
|
||||
await waitFor(() => expect(next.renders.length).toBe(1))
|
||||
await act(async () => next.renders[0]!.finish())
|
||||
await waitFor(() => expect(canvases()[0]).toBe(next.renders[0]!.canvas))
|
||||
|
||||
expect(next.renders[0]!.canvas.width).toBeGreaterThan(0)
|
||||
expect(textLayer()).toHaveTextContent('text of page 3')
|
||||
expect(screen.queryByText('This page could not be drawn.')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('cancels the draw still running against the old document', async () => {
|
||||
const { rerender } = render(page())
|
||||
await waitFor(() => expect(doc.renders.length).toBe(1))
|
||||
const inFlight = doc.renders[0]!
|
||||
|
||||
rerender(page({ doc: createFakePdfDocument({ pages: 12 }) }))
|
||||
|
||||
expect(inFlight.signal!.aborted).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('memory', () => {
|
||||
it('gives its bitmap back and asks pdf.js to release the page when it unmounts', async () => {
|
||||
const { unmount } = render(page())
|
||||
await drawn()
|
||||
const canvas = canvases()[0]!
|
||||
|
||||
unmount()
|
||||
|
||||
expect(canvas.width).toBe(0)
|
||||
expect(canvas.height).toBe(0)
|
||||
expect(doc.releasePage).toHaveBeenCalledTimes(1)
|
||||
expect(doc.releasePage).toHaveBeenCalledWith(3)
|
||||
})
|
||||
|
||||
it('cancels a draw still in flight when it unmounts', async () => {
|
||||
const { unmount } = render(page())
|
||||
await waitFor(() => expect(doc.renders.length).toBe(1))
|
||||
|
||||
unmount()
|
||||
|
||||
expect(doc.renders[0]!.signal!.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('does not ask pdf.js to release a page that never finished drawing: releasing parses it', async () => {
|
||||
const { unmount } = render(page())
|
||||
await waitFor(() => expect(doc.renders.length).toBe(1))
|
||||
|
||||
unmount()
|
||||
|
||||
expect(doc.releasePage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('nor one that failed to draw', async () => {
|
||||
const { unmount } = render(page())
|
||||
await waitFor(() => expect(doc.renders.length).toBe(1))
|
||||
await act(async () => doc.renders[0]!.fail(new Error('bad glyph')))
|
||||
await screen.findByText('This page could not be drawn.')
|
||||
|
||||
unmount()
|
||||
|
||||
expect(doc.releasePage).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the page cannot be drawn', () => {
|
||||
it('says so on the sheet, in the reader’s language', async () => {
|
||||
render(page())
|
||||
await waitFor(() => expect(doc.renders.length).toBe(1))
|
||||
|
||||
await act(async () => doc.renders[0]!.fail(new Error('bad glyph')))
|
||||
|
||||
expect(await screen.findByText('This page could not be drawn.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('leaves the previous drawing in place when only the redraw failed', async () => {
|
||||
const { rerender } = render(page({ drawScale: 1 }))
|
||||
await drawn(0)
|
||||
const before = canvases()[0]!
|
||||
|
||||
rerender(page({ drawScale: 2 }))
|
||||
await waitFor(() => expect(doc.renders.length).toBe(2))
|
||||
await act(async () => doc.renders[1]!.fail(new Error('out of memory')))
|
||||
|
||||
expectSameNodes(canvases(), [before])
|
||||
expect(await screen.findByText('This page could not be drawn.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('recovers when a later draw succeeds', async () => {
|
||||
const { rerender } = render(page({ drawScale: 1 }))
|
||||
await waitFor(() => expect(doc.renders.length).toBe(1))
|
||||
await act(async () => doc.renders[0]!.fail(new Error('transient')))
|
||||
await screen.findByText('This page could not be drawn.')
|
||||
|
||||
rerender(page({ drawScale: 2 }))
|
||||
await waitFor(() => expect(doc.renders.length).toBe(2))
|
||||
await act(async () => doc.renders[1]!.finish())
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('This page could not be drawn.')).not.toBeInTheDocument())
|
||||
expect(canvases()).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('selecting text', () => {
|
||||
it('marks the text layer as selecting while the mouse is down, and clears it on release anywhere', async () => {
|
||||
render(page())
|
||||
await drawn()
|
||||
|
||||
fireEvent.mouseDown(textLayer()!)
|
||||
expect(textLayer()).toHaveClass('selecting')
|
||||
|
||||
fireEvent.mouseUp(window)
|
||||
expect(textLayer()).not.toHaveClass('selecting')
|
||||
})
|
||||
|
||||
it('stops listening for the release once it unmounts', async () => {
|
||||
const { unmount } = render(page())
|
||||
await drawn()
|
||||
|
||||
unmount()
|
||||
|
||||
// Would throw on the detached text layer if the listener were still attached.
|
||||
expect(() => fireEvent.mouseUp(window)).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from '@/i18n'
|
||||
import type { PdfDocumentHandle } from './pdfEngine'
|
||||
import { PDF_CSS_UNITS } from './pdfLayout'
|
||||
import './pdfPage.css'
|
||||
|
||||
export type PdfPageProps = {
|
||||
doc: PdfDocumentHandle
|
||||
/** 1-based, as in the PDF. */
|
||||
pageNumber: number
|
||||
/** Pages in the document, for the page's accessible name. */
|
||||
total: number
|
||||
/** The zoom the sheet is sized at: follows a gesture live. */
|
||||
scale: number
|
||||
/**
|
||||
* The zoom the bitmap and the text were drawn at. It settles a beat after a
|
||||
* gesture ends, so a wheel zoom does not re-rasterise the page dozens of times.
|
||||
*/
|
||||
drawScale: number
|
||||
}
|
||||
|
||||
function isAbort(error: unknown): boolean {
|
||||
return (error as { name?: string } | null)?.name === 'AbortError'
|
||||
}
|
||||
|
||||
/** Give a canvas's backing store back now, rather than whenever the collector gets to it. */
|
||||
function shrink(canvas: HTMLCanvasElement): void {
|
||||
canvas.width = 0
|
||||
canvas.height = 0
|
||||
}
|
||||
|
||||
function releaseChildren(host: HTMLElement): void {
|
||||
host.querySelectorAll('canvas').forEach(shrink)
|
||||
host.replaceChildren()
|
||||
}
|
||||
|
||||
/**
|
||||
* Give a page back to the engine. Tidying up after a drawing that is already gone
|
||||
* or replaced: whatever goes wrong here is no reason to fail anything that is on screen.
|
||||
*/
|
||||
function handBack(drawn: { doc: PdfDocumentHandle; pageNumber: number } | null): void {
|
||||
if (!drawn) return
|
||||
try {
|
||||
drawn.doc.releasePage(drawn.pageNumber)
|
||||
} catch {
|
||||
// The document is closed, or closing.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One page of a PDF: paper, the canvas drawn on it, and the transparent text
|
||||
* laid over the canvas so the words can be selected and copied.
|
||||
*
|
||||
* The page is drawn into elements that are not yet on screen and swapped in when
|
||||
* both are done. Drawing into the visible canvas instead would clear it the moment
|
||||
* its size is set, and every zoom would flash the page white before it came back.
|
||||
* The same holds when the document itself is replaced (the agent rewrote the file):
|
||||
* the previous drawing stays until the new one is ready.
|
||||
*
|
||||
* The parent mounts a page only while it is near the viewport, so unmounting is
|
||||
* how a page lets go of its bitmap.
|
||||
*/
|
||||
export function PdfPage({ doc, pageNumber, total, scale, drawScale }: PdfPageProps) {
|
||||
const t = useTranslation()
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const [failed, setFailed] = useState(false)
|
||||
// What is on the sheet right now. Releasing asks pdf.js for the page it is about
|
||||
// to clean up, which parses it: for a page that never finished drawing that is
|
||||
// work done only to throw it away.
|
||||
const drawnFor = useRef<{ doc: PdfDocumentHandle; pageNumber: number } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current
|
||||
if (!host) return
|
||||
|
||||
const controller = new AbortController()
|
||||
const canvas = document.createElement('canvas')
|
||||
const textLayer = document.createElement('div')
|
||||
textLayer.className = 'textLayer'
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
await doc.renderPage(pageNumber, { canvas, scale: drawScale, signal: controller.signal })
|
||||
await doc.renderTextLayer(pageNumber, { container: textLayer, scale: drawScale, signal: controller.signal })
|
||||
} catch (error) {
|
||||
// Nothing of this drawing is on the sheet yet, so what is there stays: a
|
||||
// cancelled draw is not a failure, and a failed redraw leaves the previous,
|
||||
// still-valid drawing in place.
|
||||
shrink(canvas)
|
||||
if (!isAbort(error)) setFailed(true)
|
||||
return
|
||||
}
|
||||
if (controller.signal.aborted) {
|
||||
shrink(canvas)
|
||||
return
|
||||
}
|
||||
|
||||
// From here the drawing is on the sheet, and nothing below may undo it.
|
||||
// The sentinel that keeps a drag-selection from collapsing when the pointer
|
||||
// leaves the last span (what pdf.js' own viewer appends to its text layer).
|
||||
const endOfContent = document.createElement('div')
|
||||
endOfContent.className = 'endOfContent'
|
||||
textLayer.append(endOfContent)
|
||||
releaseChildren(host)
|
||||
host.append(canvas, textLayer)
|
||||
setFailed(false)
|
||||
|
||||
const previous = drawnFor.current
|
||||
drawnFor.current = { doc, pageNumber }
|
||||
if (previous && (previous.doc !== doc || previous.pageNumber !== pageNumber)) handBack(previous)
|
||||
})()
|
||||
|
||||
return () => {
|
||||
controller.abort()
|
||||
}
|
||||
}, [doc, pageNumber, drawScale])
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current
|
||||
return () => {
|
||||
// Scrolled far away, or the panel closed: a page is megabytes of bitmap.
|
||||
if (host) releaseChildren(host)
|
||||
const drawn = drawnFor.current
|
||||
drawnFor.current = null
|
||||
handBack(drawn)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const endSelection = () => hostRef.current?.querySelector('.textLayer')?.classList.remove('selecting')
|
||||
window.addEventListener('mouseup', endSelection)
|
||||
return () => window.removeEventListener('mouseup', endSelection)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t('workspace.pdf.pageOfTotal', { page: pageNumber, total })}
|
||||
className="cc-pdf-page h-full w-full shadow-[var(--shadow-card)]"
|
||||
style={{ ['--scale-factor' as string]: scale * PDF_CSS_UNITS }}
|
||||
>
|
||||
<div
|
||||
ref={hostRef}
|
||||
className="absolute inset-0"
|
||||
onMouseDown={() => hostRef.current?.querySelector('.textLayer')?.classList.add('selecting')}
|
||||
/>
|
||||
{failed ? (
|
||||
<p role="status" className="absolute inset-x-0 top-1/3 px-4 text-center text-xs text-[var(--color-document-ink)]">
|
||||
{t('workspace.pdf.pageFailed')}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,748 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { useState } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { browserHost } from '@/lib/desktopHost/browserHost'
|
||||
import { useSettingsStore } from '@/stores/settingsStore'
|
||||
import type { WorkspaceFileView } from '@/stores/workspaceContentStore'
|
||||
import { blobWithBytes } from '@/test/blobs'
|
||||
import {
|
||||
LETTER,
|
||||
createControllablePdfEngine,
|
||||
createFakePdfDocument,
|
||||
createFakePdfEngine,
|
||||
type FakePdfDocument,
|
||||
} from '@/test/fakePdfEngine'
|
||||
import { PdfError, type PdfEngine } from './pdfEngine'
|
||||
import {
|
||||
PDF_CSS_UNITS,
|
||||
PDF_PAGE_GAP,
|
||||
PDF_PAGE_PADDING,
|
||||
anchoredScrollTop,
|
||||
layoutPdfColumn,
|
||||
type PageSize,
|
||||
} from './pdfLayout'
|
||||
import PdfSurface from './PdfSurface'
|
||||
|
||||
vi.mock('@/lib/systemFileOpen', () => ({
|
||||
openLocalFileWithSystem: vi.fn().mockResolvedValue(undefined),
|
||||
reportOpenFailure: vi.fn(),
|
||||
}))
|
||||
|
||||
// ---- the panel: jsdom lays nothing out, so its size is whatever a test says ----
|
||||
|
||||
let viewport = { width: 700, height: 800 }
|
||||
const resizeCallbacks = new Set<() => void>()
|
||||
const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth')
|
||||
const originalClientHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientHeight')
|
||||
|
||||
class StubResizeObserver {
|
||||
constructor(private readonly callback: () => void) {}
|
||||
observe() {
|
||||
resizeCallbacks.add(this.callback)
|
||||
}
|
||||
unobserve() {}
|
||||
disconnect() {
|
||||
resizeCallbacks.delete(this.callback)
|
||||
}
|
||||
}
|
||||
|
||||
function resizePanel(width: number, height: number) {
|
||||
viewport = { width, height }
|
||||
act(() => resizeCallbacks.forEach((callback) => callback()))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
viewport = { width: 700, height: 800 }
|
||||
resizeCallbacks.clear()
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, get: () => viewport.width })
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientHeight', { configurable: true, get: () => viewport.height })
|
||||
vi.stubGlobal('ResizeObserver', StubResizeObserver)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
Reflect.deleteProperty(window, 'desktopHost')
|
||||
if (originalClientWidth) Object.defineProperty(HTMLElement.prototype, 'clientWidth', originalClientWidth)
|
||||
if (originalClientHeight) Object.defineProperty(HTMLElement.prototype, 'clientHeight', originalClientHeight)
|
||||
})
|
||||
|
||||
// ---- geometry the assertions are worked out from, independently of the component ----
|
||||
|
||||
const PANEL = 700
|
||||
/** What "fit to width" is for a Letter page in the 700px panel above. */
|
||||
const FIT = (PANEL - 2 * PDF_PAGE_PADDING) / LETTER.width
|
||||
const pagesOf = (count: number, size: PageSize = LETTER) => Array.from({ length: count }, () => size)
|
||||
const boxesAt = (scale: number, sizes: PageSize[] = pagesOf(30)) =>
|
||||
layoutPdfColumn(sizes, scale, { gap: PDF_PAGE_GAP, padding: PDF_PAGE_PADDING }).boxes
|
||||
|
||||
// ---- the surface under test ----
|
||||
|
||||
const pdfBlob = blobWithBytes([37, 80, 68, 70], 'application/pdf')
|
||||
|
||||
type SurfaceProps = {
|
||||
engine: PdfEngine
|
||||
blob?: Blob
|
||||
zoom?: number
|
||||
onZoomChange?: (zoom: number | undefined) => void
|
||||
initialView?: WorkspaceFileView
|
||||
absolutePath?: string
|
||||
}
|
||||
|
||||
/** Holds the zoom the way the workspace store does: chosen here, handed back down. */
|
||||
function Surface({ engine, blob = pdfBlob, zoom: initialZoom, onZoomChange, initialView, absolutePath = '/work/docs/thesis.pdf' }: SurfaceProps) {
|
||||
const [zoom, setZoom] = useState(initialZoom)
|
||||
return (
|
||||
<PdfSurface
|
||||
engine={engine}
|
||||
blob={blob}
|
||||
path="docs/thesis.pdf"
|
||||
absolutePath={absolutePath}
|
||||
version="1"
|
||||
refreshing={false}
|
||||
zoom={zoom}
|
||||
onZoomChange={(next) => {
|
||||
setZoom(next)
|
||||
onZoomChange?.(next)
|
||||
}}
|
||||
initialView={initialView}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
async function show(props: Partial<Omit<SurfaceProps, 'engine'>> & { doc?: FakePdfDocument } = {}) {
|
||||
const { doc = createFakePdfDocument({ pages: 30, autoFinish: true }), ...surfaceProps } = props
|
||||
const engine = createFakePdfEngine(doc)
|
||||
const view = render(<Surface {...surfaceProps} engine={engine} />)
|
||||
await screen.findByRole('group', { name: 'thesis.pdf' })
|
||||
return { ...view, doc, engine }
|
||||
}
|
||||
|
||||
const scroller = () => screen.getByRole('group', { name: 'thesis.pdf' })
|
||||
const sheets = () => screen.queryAllByRole('group', { name: /^Page \d+ of \d+$/ })
|
||||
const sheetNumbers = () => sheets().map((sheet) => Number(/^Page (\d+) of/.exec(sheet.getAttribute('aria-label')!)![1]))
|
||||
/** The box a page is placed in; the sheet itself just fills it. */
|
||||
const placed = (sheet: HTMLElement) => sheet.parentElement as HTMLElement
|
||||
const pageInput = () => screen.getByRole('textbox', { name: 'Page number' })
|
||||
const zoomIn = () => screen.getByRole('button', { name: 'Zoom in' })
|
||||
const zoomOut = () => screen.getByRole('button', { name: 'Zoom out' })
|
||||
const fitButton = () => screen.getByRole('button', { name: 'Fit to width' })
|
||||
|
||||
/** The reader scrolls: the browser moves the offset and then tells us. */
|
||||
function scrollTo(top: number, left = 0) {
|
||||
const node = scroller()
|
||||
node.scrollTop = top
|
||||
node.scrollLeft = left
|
||||
fireEvent.scroll(node)
|
||||
}
|
||||
|
||||
/** Let the pages that were mounted finish drawing. */
|
||||
const flush = () => act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)) })
|
||||
|
||||
describe('PdfSurface', () => {
|
||||
describe('opening', () => {
|
||||
it('says it is loading until the document is open', () => {
|
||||
render(<Surface engine={{ open: () => new Promise(() => undefined) }} />)
|
||||
|
||||
expect(screen.getByText('Loading document...')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the bytes of the file', async () => {
|
||||
const { engine } = await show()
|
||||
|
||||
expect(engine.open).toHaveBeenCalledTimes(1)
|
||||
expect(Array.from(engine.open.mock.calls[0]![0] as Uint8Array)).toEqual([37, 80, 68, 70])
|
||||
})
|
||||
|
||||
it('leaves restoring the scroll position to the viewer, as the panel expects of an async surface', async () => {
|
||||
await show()
|
||||
|
||||
expect(scroller()).toHaveAttribute('data-workspace-scroll-surface', 'deferred')
|
||||
})
|
||||
|
||||
it('offers the file to the system app from the bar above the pages', async () => {
|
||||
await show()
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Open in system app' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('layout', () => {
|
||||
it('fits the pages to the width of the panel', async () => {
|
||||
await show()
|
||||
|
||||
expect(parseFloat(placed(sheets()[0]!).style.width)).toBeCloseTo(LETTER.width * FIT, 3)
|
||||
expect(screen.getByText('82%')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('centres a page that is narrower than the panel', async () => {
|
||||
await show()
|
||||
|
||||
expect(parseFloat(placed(sheets()[0]!).style.left)).toBeCloseTo(PDF_PAGE_PADDING, 3)
|
||||
})
|
||||
|
||||
it('does not blow a page up past its natural size to fill a wide panel', async () => {
|
||||
viewport = { width: 2000, height: 800 }
|
||||
await show()
|
||||
|
||||
const first = placed(sheets()[0]!)
|
||||
expect(parseFloat(first.style.width)).toBeCloseTo(LETTER.width, 3)
|
||||
expect(parseFloat(first.style.left)).toBeCloseTo((2000 - LETTER.width) / 2, 3)
|
||||
expect(screen.getByText('100%')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('scrolls sideways only once the zoom makes the pages wider than the panel', async () => {
|
||||
await show()
|
||||
const column = () => scroller().firstElementChild as HTMLElement
|
||||
expect(parseFloat(column().style.width)).toBe(PANEL)
|
||||
|
||||
fireEvent.click(zoomIn())
|
||||
fireEvent.click(zoomIn()) // 0.9 → 1
|
||||
fireEvent.click(zoomIn()) // → 1.1
|
||||
|
||||
expect(parseFloat(column().style.width)).toBeCloseTo(LETTER.width * 1.1 + 2 * PDF_PAGE_PADDING, 3)
|
||||
})
|
||||
|
||||
it('makes the column as tall as all of its pages, gaps and padding', async () => {
|
||||
await show()
|
||||
const boxes = boxesAt(FIT)
|
||||
const last = boxes[boxes.length - 1]!
|
||||
|
||||
const column = scroller().firstElementChild as HTMLElement
|
||||
|
||||
expect(parseFloat(column.style.height)).toBeCloseTo(last.top + last.height + PDF_PAGE_PADDING, 3)
|
||||
})
|
||||
|
||||
it('gives each page the size its own dimensions call for', async () => {
|
||||
const wide: PageSize = { width: 1056, height: 816 } // a landscape page among portrait ones
|
||||
await show({ doc: createFakePdfDocument({ sizes: [LETTER, wide, LETTER], autoFinish: true }) })
|
||||
|
||||
const [first, second] = sheets().map((sheet) => placed(sheet))
|
||||
const fitWide = (PANEL - 2 * PDF_PAGE_PADDING) / 1056 // the widest page sets the fit
|
||||
|
||||
expect(parseFloat(second!.style.width)).toBeCloseTo(1056 * fitWide, 3)
|
||||
expect(parseFloat(second!.style.height)).toBeCloseTo(816 * fitWide, 3)
|
||||
// Narrower pages are centred under it, not stretched to it.
|
||||
expect(parseFloat(first!.style.width)).toBeCloseTo(816 * fitWide, 3)
|
||||
expect(parseFloat(first!.style.left)).toBeGreaterThan(parseFloat(second!.style.left))
|
||||
})
|
||||
})
|
||||
|
||||
describe('virtualisation', () => {
|
||||
it('mounts only the pages near the viewport', async () => {
|
||||
await show()
|
||||
|
||||
expect(sheetNumbers()).toEqual([1, 2])
|
||||
expect(screen.queryByRole('group', { name: 'Page 30 of 30' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('draws the pages it mounted, at the fitted zoom', async () => {
|
||||
const { doc } = await show()
|
||||
await flush()
|
||||
|
||||
expect(doc.renderPage).toHaveBeenCalledWith(1, expect.objectContaining({ scale: expect.closeTo(FIT, 6) }))
|
||||
expect(doc.renderPage).toHaveBeenCalledWith(2, expect.anything())
|
||||
expect(doc.renderPage).not.toHaveBeenCalledWith(3, expect.anything())
|
||||
})
|
||||
|
||||
it('brings pages in as the reader scrolls, and lets the ones left behind go', async () => {
|
||||
const { doc } = await show()
|
||||
await flush()
|
||||
|
||||
scrollTo(boxesAt(FIT)[19]!.top) // page 20 at the top of the panel
|
||||
|
||||
// One panel-height of margin either side: page 19 ends inside it, page 22 starts beyond it.
|
||||
expect(sheetNumbers()).toEqual([19, 20, 21])
|
||||
// Pages 1 and 2 had been drawn: their bitmaps are returned to pdf.js.
|
||||
expect(doc.releasePage).toHaveBeenCalledWith(1)
|
||||
expect(doc.releasePage).toHaveBeenCalledWith(2)
|
||||
})
|
||||
|
||||
it('does not re-render the pages for a scroll that stays within the same window', async () => {
|
||||
const { doc } = await show()
|
||||
await flush()
|
||||
const drawn = vi.mocked(doc.renderPage).mock.calls.length
|
||||
|
||||
scrollTo(40)
|
||||
scrollTo(80)
|
||||
await flush()
|
||||
|
||||
expect(vi.mocked(doc.renderPage).mock.calls.length).toBe(drawn)
|
||||
})
|
||||
})
|
||||
|
||||
describe('zoom', () => {
|
||||
it('steps to the next rung, and reports it so it is remembered', async () => {
|
||||
const onZoomChange = vi.fn()
|
||||
await show({ onZoomChange })
|
||||
|
||||
fireEvent.click(zoomIn())
|
||||
|
||||
expect(onZoomChange).toHaveBeenCalledWith(0.9) // the first rung above 82%
|
||||
expect(screen.getByText('90%')).toBeInTheDocument()
|
||||
expect(parseFloat(placed(sheets()[0]!).style.width)).toBeCloseTo(LETTER.width * 0.9, 3)
|
||||
})
|
||||
|
||||
it('leaves fit mode when the reader chooses a zoom, and returns to it on request', async () => {
|
||||
const onZoomChange = vi.fn()
|
||||
await show({ onZoomChange })
|
||||
expect(fitButton()).toHaveAttribute('aria-pressed', 'true')
|
||||
|
||||
fireEvent.click(zoomIn())
|
||||
expect(fitButton()).toHaveAttribute('aria-pressed', 'false')
|
||||
|
||||
fireEvent.click(fitButton())
|
||||
expect(onZoomChange).toHaveBeenLastCalledWith(undefined)
|
||||
expect(fitButton()).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(screen.getByText('82%')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('starts at the zoom the reader had chosen before', async () => {
|
||||
await show({ zoom: 1.5 })
|
||||
|
||||
expect(parseFloat(placed(sheets()[0]!).style.width)).toBeCloseTo(LETTER.width * 1.5, 3)
|
||||
expect(screen.getByText('150%')).toBeInTheDocument()
|
||||
expect(fitButton()).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
|
||||
it('cannot go past either end of the ladder', async () => {
|
||||
const { unmount } = await show({ zoom: 0.1 })
|
||||
expect(zoomOut()).toBeDisabled()
|
||||
unmount()
|
||||
await show({ zoom: 8 })
|
||||
expect(zoomIn()).toBeDisabled()
|
||||
})
|
||||
|
||||
it('keeps the reader’s place: the same page, the same distance down it', async () => {
|
||||
await show()
|
||||
const top = boxesAt(FIT)[2]!.top + boxesAt(FIT)[2]!.height / 2 // the middle of page 3
|
||||
scrollTo(top)
|
||||
|
||||
fireEvent.click(zoomIn())
|
||||
|
||||
// A button zooms about the middle of the panel.
|
||||
const expected = anchoredScrollTop({ before: boxesAt(FIT), after: boxesAt(0.9), scrollTop: top, anchorY: viewport.height / 2 })
|
||||
expect(Math.abs(expected - top)).toBeGreaterThan(100) // the test can tell a kept place from a lost one
|
||||
expect(scroller().scrollTop).toBeCloseTo(expected, 3)
|
||||
})
|
||||
|
||||
describe('with the wheel and keyboard', () => {
|
||||
it('zooms about the pointer on Ctrl+wheel, and keeps the browser from zooming the whole app', async () => {
|
||||
const onZoomChange = vi.fn()
|
||||
await show({ onZoomChange })
|
||||
const top = boxesAt(FIT)[3]!.top + 120
|
||||
scrollTo(top)
|
||||
|
||||
const allowed = fireEvent.wheel(scroller(), { ctrlKey: true, deltaY: -100, clientY: 100 })
|
||||
|
||||
expect(allowed).toBe(false) // preventDefault was called
|
||||
expect(onZoomChange).toHaveBeenCalledTimes(1)
|
||||
const zoom = onZoomChange.mock.calls[0]![0] as number
|
||||
expect(zoom).toBeGreaterThan(FIT)
|
||||
const expected = anchoredScrollTop({ before: boxesAt(FIT), after: boxesAt(zoom), scrollTop: top, anchorY: 100 })
|
||||
expect(scroller().scrollTop).toBeCloseTo(expected, 3)
|
||||
})
|
||||
|
||||
it('also zooms on ⌘+wheel', async () => {
|
||||
const onZoomChange = vi.fn()
|
||||
await show({ onZoomChange })
|
||||
|
||||
fireEvent.wheel(scroller(), { metaKey: true, deltaY: -100 })
|
||||
|
||||
expect(onZoomChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('leaves an ordinary wheel to scroll', async () => {
|
||||
const onZoomChange = vi.fn()
|
||||
await show({ onZoomChange })
|
||||
|
||||
const allowed = fireEvent.wheel(scroller(), { deltaY: 100 })
|
||||
|
||||
expect(allowed).toBe(true)
|
||||
expect(onZoomChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['+', 0.9],
|
||||
['=', 0.9],
|
||||
['-', 0.75],
|
||||
['1', 1],
|
||||
['0', undefined],
|
||||
])('zooms from the keyboard: %s', async (key, expected) => {
|
||||
const onZoomChange = vi.fn()
|
||||
await show({ onZoomChange })
|
||||
|
||||
fireEvent.keyDown(scroller(), { key })
|
||||
|
||||
expect(onZoomChange).toHaveBeenCalledWith(expected)
|
||||
})
|
||||
|
||||
it('leaves browser shortcuts alone', async () => {
|
||||
const onZoomChange = vi.fn()
|
||||
await show({ onZoomChange })
|
||||
|
||||
fireEvent.keyDown(scroller(), { key: '+', ctrlKey: true })
|
||||
fireEvent.keyDown(scroller(), { key: '-', metaKey: true })
|
||||
|
||||
expect(onZoomChange).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('draws at the new zoom only once the reader has stopped changing it, while the sheet follows at once', async () => {
|
||||
const { doc } = await show()
|
||||
// Let the drawing that came with opening settle first, on the real clock: React
|
||||
// only applies the updates queued inside `act` when it exits, so a fake clock
|
||||
// advanced within it would never see the timer that opening schedules.
|
||||
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 150)) })
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
vi.mocked(doc.renderPage).mockClear()
|
||||
|
||||
fireEvent.click(zoomIn())
|
||||
|
||||
// The sheet, and the text laid over it, are at 90% already…
|
||||
expect(sheets()[0]!.style.getPropertyValue('--scale-factor')).toBe(String(0.9 * PDF_CSS_UNITS))
|
||||
// …the bitmap is not redrawn for every step of a gesture.
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(119) })
|
||||
expect(doc.renderPage).not.toHaveBeenCalled()
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(1) })
|
||||
expect(doc.renderPage).toHaveBeenCalledWith(1, expect.objectContaining({ scale: 0.9 }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('moving through the document', () => {
|
||||
it('shows which page the reader is on, and how many there are', async () => {
|
||||
await show()
|
||||
expect(pageInput()).toHaveValue('1')
|
||||
expect(screen.getByText('/ 30')).toBeInTheDocument()
|
||||
|
||||
scrollTo(boxesAt(FIT)[6]!.top)
|
||||
|
||||
expect(pageInput()).toHaveValue('7')
|
||||
})
|
||||
|
||||
it('jumps to a page typed into the box', async () => {
|
||||
await show()
|
||||
|
||||
fireEvent.focus(pageInput())
|
||||
fireEvent.change(pageInput(), { target: { value: '12' } })
|
||||
fireEvent.keyDown(pageInput(), { key: 'Enter' })
|
||||
|
||||
expect(scroller().scrollTop).toBeCloseTo(boxesAt(FIT)[11]!.top - PDF_PAGE_GAP, 3)
|
||||
fireEvent.scroll(scroller()) // the browser reports the move
|
||||
expect(pageInput()).toHaveValue('12')
|
||||
expect(sheetNumbers()).toContain(12)
|
||||
})
|
||||
|
||||
it('takes only digits', async () => {
|
||||
await show()
|
||||
|
||||
fireEvent.focus(pageInput())
|
||||
fireEvent.change(pageInput(), { target: { value: '1a2b' } })
|
||||
|
||||
expect(pageInput()).toHaveValue('12')
|
||||
})
|
||||
|
||||
it('goes to the last page for a number past the end', async () => {
|
||||
await show()
|
||||
|
||||
fireEvent.focus(pageInput())
|
||||
fireEvent.change(pageInput(), { target: { value: '999' } })
|
||||
fireEvent.keyDown(pageInput(), { key: 'Enter' })
|
||||
|
||||
expect(scroller().scrollTop).toBeCloseTo(boxesAt(FIT)[29]!.top - PDF_PAGE_GAP, 3)
|
||||
})
|
||||
|
||||
it('hands the keyboard back to the pages, so the arrow keys keep scrolling', async () => {
|
||||
await show()
|
||||
fireEvent.focus(pageInput())
|
||||
fireEvent.change(pageInput(), { target: { value: '5' } })
|
||||
|
||||
fireEvent.keyDown(pageInput(), { key: 'Enter' })
|
||||
|
||||
expect(scroller()).toHaveFocus()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Escape', { key: 'Escape' }],
|
||||
['leaving the box', null],
|
||||
])('goes nowhere when the entry is abandoned with %s', async (_label, key) => {
|
||||
await show()
|
||||
scrollTo(boxesAt(FIT)[3]!.top)
|
||||
const before = scroller().scrollTop
|
||||
fireEvent.focus(pageInput())
|
||||
fireEvent.change(pageInput(), { target: { value: '25' } })
|
||||
|
||||
if (key) fireEvent.keyDown(pageInput(), key)
|
||||
else fireEvent.blur(pageInput())
|
||||
|
||||
expect(scroller().scrollTop).toBe(before)
|
||||
expect(pageInput()).toHaveValue('4') // back to the page the reader is on
|
||||
})
|
||||
|
||||
it('does not jump for an empty box', async () => {
|
||||
await show()
|
||||
scrollTo(boxesAt(FIT)[3]!.top)
|
||||
const before = scroller().scrollTop
|
||||
fireEvent.focus(pageInput())
|
||||
fireEvent.change(pageInput(), { target: { value: '' } })
|
||||
|
||||
fireEvent.keyDown(pageInput(), { key: 'Enter' })
|
||||
|
||||
expect(scroller().scrollTop).toBe(before)
|
||||
})
|
||||
})
|
||||
|
||||
describe('coming back to a file', () => {
|
||||
it('restores where the reader left it, and draws what is there rather than the top', async () => {
|
||||
await show({ initialView: { scrollTop: 5000, scrollLeft: 0 } })
|
||||
|
||||
expect(scroller().scrollTop).toBe(5000)
|
||||
expect(sheetNumbers()).toContain(6) // page 6 spans 4398–5263
|
||||
expect(sheetNumbers()).not.toContain(1)
|
||||
})
|
||||
|
||||
it('restores the sideways position too', async () => {
|
||||
await show({ zoom: 2, initialView: { scrollTop: 0, scrollLeft: 300 } })
|
||||
|
||||
expect(scroller().scrollLeft).toBe(300)
|
||||
})
|
||||
|
||||
it('puts the reader back after the panel was hidden and shown again', async () => {
|
||||
const { doc } = await show()
|
||||
await flush()
|
||||
const top = boxesAt(FIT)[8]!.top + 50
|
||||
scrollTo(top)
|
||||
await flush()
|
||||
|
||||
resizePanel(0, 0) // a hidden panel has no size…
|
||||
expect(sheets()).toHaveLength(0) // …so no page holds a bitmap
|
||||
expect(doc.releasePage).toHaveBeenCalled()
|
||||
scroller().scrollTop = 0 // and the browser forgets where it was scrolled to
|
||||
resizePanel(700, 800)
|
||||
|
||||
expect(scroller().scrollTop).toBe(top)
|
||||
expect(sheetNumbers()).toContain(9)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the panel is resized', () => {
|
||||
it('refits the pages and keeps the reader’s place', async () => {
|
||||
await show()
|
||||
const top = boxesAt(FIT)[4]!.top + 200
|
||||
scrollTo(top)
|
||||
|
||||
resizePanel(760, 800) // wider, but still narrower than a page at 100%
|
||||
|
||||
const wider = (760 - 2 * PDF_PAGE_PADDING) / LETTER.width
|
||||
expect(wider).toBeGreaterThan(FIT)
|
||||
expect(parseFloat(placed(sheets()[0]!).style.width)).toBeCloseTo(LETTER.width * wider, 3)
|
||||
const expected = anchoredScrollTop({ before: boxesAt(FIT), after: boxesAt(wider), scrollTop: top, anchorY: 0 })
|
||||
expect(scroller().scrollTop).toBeCloseTo(expected, 3)
|
||||
})
|
||||
|
||||
it('mounts more pages when the panel grows taller', async () => {
|
||||
await show()
|
||||
expect(sheetNumbers()).toEqual([1, 2])
|
||||
|
||||
resizePanel(700, 2000)
|
||||
|
||||
expect(sheetNumbers().length).toBeGreaterThan(2)
|
||||
})
|
||||
|
||||
it('leaves a zoom the reader chose alone', async () => {
|
||||
await show({ zoom: 1.5 })
|
||||
|
||||
resizePanel(1000, 800)
|
||||
|
||||
expect(parseFloat(placed(sheets()[0]!).style.width)).toBeCloseTo(LETTER.width * 1.5, 3)
|
||||
})
|
||||
|
||||
it('does not carry the anchor of a zoom that changed nothing into the next resize', async () => {
|
||||
await show()
|
||||
const top = boxesAt(FIT)[4]!.top + 200
|
||||
scrollTo(top)
|
||||
|
||||
fireEvent.click(fitButton()) // already fitted: no layout change, so no anchor should be left behind
|
||||
resizePanel(760, 800)
|
||||
|
||||
const wider = (760 - 2 * PDF_PAGE_PADDING) / LETTER.width
|
||||
// A resize holds the top of the panel still, not the middle a button zooms about.
|
||||
const expected = anchoredScrollTop({ before: boxesAt(FIT), after: boxesAt(wider), scrollTop: top, anchorY: 0 })
|
||||
expect(scroller().scrollTop).toBeCloseTo(expected, 3)
|
||||
})
|
||||
|
||||
it('reserves room for the scrollbar always, so fitting to the width does not depend on whether the pages overflow', async () => {
|
||||
// A scrollbar that comes and goes with the content narrows the panel, which changes
|
||||
// the fit, which changes whether the content overflows: a one-page document would
|
||||
// flicker between the two. jsdom has no scrollbars to measure; this pins the reason.
|
||||
await show()
|
||||
|
||||
expect(scroller().className).toContain('overflow-y-scroll')
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the file is rewritten', () => {
|
||||
const blobA = blobWithBytes([1], 'application/pdf')
|
||||
const blobB = blobWithBytes([2], 'application/pdf')
|
||||
|
||||
async function shownFirstVersion() {
|
||||
const { engine, opens } = createControllablePdfEngine()
|
||||
const first = createFakePdfDocument({ pages: 30, autoFinish: true })
|
||||
const view = render(<Surface engine={engine} blob={blobA} />)
|
||||
await waitFor(() => expect(opens).toHaveLength(1))
|
||||
await act(async () => opens[0]!.resolve(first))
|
||||
await screen.findByRole('group', { name: 'thesis.pdf' })
|
||||
return { ...view, engine, opens, first }
|
||||
}
|
||||
|
||||
it('keeps the pages on screen until the new version is open, then swaps them and keeps the place', async () => {
|
||||
const { rerender, engine, opens, first } = await shownFirstVersion()
|
||||
await flush()
|
||||
const top = boxesAt(FIT)[9]!.top + 300
|
||||
scrollTo(top)
|
||||
|
||||
rerender(<Surface engine={engine} blob={blobB} />)
|
||||
await waitFor(() => expect(opens).toHaveLength(2))
|
||||
expect(sheetNumbers()).toContain(10) // still the version they were reading
|
||||
expect(first.destroy).not.toHaveBeenCalled()
|
||||
|
||||
const taller = pagesOf(30, { width: LETTER.width, height: 1500 })
|
||||
const second = createFakePdfDocument({ sizes: taller, autoFinish: true })
|
||||
await act(async () => opens[1]!.resolve(second))
|
||||
|
||||
await waitFor(() => expect(first.destroy).toHaveBeenCalledTimes(1))
|
||||
const expected = anchoredScrollTop({ before: boxesAt(FIT), after: boxesAt(FIT, taller), scrollTop: top, anchorY: 0 })
|
||||
expect(Math.abs(expected - top)).toBeGreaterThan(100)
|
||||
expect(scroller().scrollTop).toBeCloseTo(expected, 3)
|
||||
await waitFor(() => expect(second.renderPage).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('copes with a new version that has fewer pages than the reader had scrolled to', async () => {
|
||||
const { rerender, engine, opens } = await shownFirstVersion()
|
||||
await flush()
|
||||
scrollTo(boxesAt(FIT)[24]!.top + 100) // deep in page 25 of 30
|
||||
|
||||
rerender(<Surface engine={engine} blob={blobB} />)
|
||||
await waitFor(() => expect(opens).toHaveLength(2))
|
||||
await act(async () => opens[1]!.resolve(createFakePdfDocument({ pages: 5, autoFinish: true })))
|
||||
|
||||
await waitFor(() => expect(screen.getByText('/ 5')).toBeInTheDocument())
|
||||
// The reader lands on the end of the shorter document, with only its pages mounted.
|
||||
expect(sheetNumbers().length).toBeGreaterThan(0)
|
||||
expect(Math.max(...sheetNumbers())).toBeLessThanOrEqual(5)
|
||||
expect(sheetNumbers()).toContain(5)
|
||||
})
|
||||
|
||||
it('keeps the old version and says why when the new one will not open', async () => {
|
||||
const { rerender, engine, opens, first } = await shownFirstVersion()
|
||||
|
||||
rerender(<Surface engine={engine} blob={blobB} />) // a half-written PDF
|
||||
await waitFor(() => expect(opens).toHaveLength(2))
|
||||
await act(async () => opens[1]!.reject(new PdfError('invalid', 'Invalid PDF structure.')))
|
||||
|
||||
expect(await screen.findByRole('status')).toHaveTextContent(
|
||||
'Showing the last loaded version — refresh failed: This document could not be displayed.',
|
||||
)
|
||||
expect(sheets().length).toBeGreaterThan(0)
|
||||
expect(first.destroy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('takes the note away once a good version arrives', async () => {
|
||||
const { rerender, engine, opens } = await shownFirstVersion()
|
||||
rerender(<Surface engine={engine} blob={blobB} />)
|
||||
await waitFor(() => expect(opens).toHaveLength(2))
|
||||
await act(async () => opens[1]!.reject(new PdfError('invalid', 'Invalid PDF structure.')))
|
||||
await screen.findByRole('status')
|
||||
|
||||
rerender(<Surface engine={engine} blob={blobWithBytes([3], 'application/pdf')} />)
|
||||
await waitFor(() => expect(opens).toHaveLength(3))
|
||||
await act(async () => opens[2]!.resolve(createFakePdfDocument({ pages: 30, autoFinish: true })))
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the document cannot be shown', () => {
|
||||
const failing = (kind: 'password' | 'invalid' | 'unavailable') =>
|
||||
createFakePdfEngine(createFakePdfDocument(), new PdfError(kind, 'nope'))
|
||||
|
||||
it.each([
|
||||
['password', "This PDF is password protected and can't be previewed here."],
|
||||
['invalid', 'This document could not be displayed.'],
|
||||
['unavailable', "The document viewer can't start in this environment."],
|
||||
] as const)('explains a %s failure in words', async (kind, message) => {
|
||||
render(<Surface engine={failing(kind)} />)
|
||||
|
||||
expect(await screen.findByText(message)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each(['password', 'invalid'] as const)('does not offer to try a %s document again: the same bytes fail the same way', async (kind) => {
|
||||
render(<Surface engine={failing(kind)} />)
|
||||
await screen.findByRole('button', { name: 'Open in system app' })
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers the system app for every failure', async () => {
|
||||
render(<Surface engine={failing('invalid')} />)
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Open in system app' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('has no system app to offer for a path the OS could not resolve', async () => {
|
||||
render(<Surface engine={failing('invalid')} absolutePath="docs/thesis.pdf" />)
|
||||
await screen.findByText('This document could not be displayed.')
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Open in system app' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('lets the reader try again when the engine itself failed to start', async () => {
|
||||
const engine = failing('unavailable')
|
||||
render(<Surface engine={engine} />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Try again' }))
|
||||
|
||||
await waitFor(() => expect(engine.open).toHaveBeenCalledTimes(2))
|
||||
})
|
||||
|
||||
describe('a browser that cannot run pdf.js', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('URL', Object.assign(URL, {
|
||||
createObjectURL: vi.fn(() => 'blob:thesis'),
|
||||
revokeObjectURL: vi.fn(),
|
||||
}))
|
||||
})
|
||||
|
||||
it('hands the bytes to the browser’s own viewer, and lets go of them later', async () => {
|
||||
const open = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
render(<Surface engine={failing('unavailable')} />)
|
||||
const button = await screen.findByRole('button', { name: 'Open in browser' })
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
|
||||
fireEvent.click(button)
|
||||
|
||||
expect(URL.createObjectURL).toHaveBeenCalledWith(pdfBlob)
|
||||
expect(open).toHaveBeenCalledWith('blob:thesis', '_blank', 'noopener')
|
||||
expect(URL.revokeObjectURL).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(60_000)
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:thesis')
|
||||
})
|
||||
|
||||
it('does not offer it in the desktop app, which has no such viewer to hand off to', async () => {
|
||||
window.desktopHost = { ...browserHost, isDesktop: true }
|
||||
render(<Surface engine={failing('unavailable')} />)
|
||||
await screen.findByRole('button', { name: 'Try again' })
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Open in browser' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not offer it for a document that is simply damaged', async () => {
|
||||
render(<Surface engine={failing('invalid')} />)
|
||||
await screen.findByText('This document could not be displayed.')
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Open in browser' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { useTranslation } from '@/i18n'
|
||||
import { getDesktopHost } from '@/lib/desktopHost'
|
||||
import { PanelMessage } from '../PanelMessage'
|
||||
import { DocumentFailure } from './DocumentFailure'
|
||||
import type { DocumentViewerProps } from './documentViewers'
|
||||
import { defaultPdfEngine, type PdfEngine, type PdfError } from './pdfEngine'
|
||||
import { PdfViewer } from './PdfViewer'
|
||||
import { usePdfDocument } from './usePdfDocument'
|
||||
|
||||
export type PdfSurfaceProps = DocumentViewerProps & {
|
||||
/** Overridable so a test can supply a document without loading pdf.js. */
|
||||
engine?: PdfEngine
|
||||
}
|
||||
|
||||
/**
|
||||
* The PDF viewer `DocumentSurface` loads for `.pdf` files: opening the document,
|
||||
* then handing it to {@link PdfViewer}, or saying why it could not be shown.
|
||||
*/
|
||||
export default function PdfSurface({
|
||||
blob,
|
||||
path,
|
||||
absolutePath,
|
||||
zoom,
|
||||
onZoomChange,
|
||||
initialView,
|
||||
engine = defaultPdfEngine,
|
||||
}: PdfSurfaceProps) {
|
||||
const t = useTranslation()
|
||||
const { current, error, retry } = usePdfDocument(engine, blob)
|
||||
|
||||
if (current) {
|
||||
return (
|
||||
<PdfViewer
|
||||
doc={current.doc}
|
||||
sizes={current.sizes}
|
||||
path={path}
|
||||
absolutePath={absolutePath}
|
||||
zoom={zoom}
|
||||
onZoomChange={onZoomChange}
|
||||
initialView={initialView}
|
||||
refreshError={error}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<DocumentFailure
|
||||
message={failureMessage(error, t)}
|
||||
absolutePath={absolutePath}
|
||||
// The same bytes fail the same way, unless pdf.js itself was what failed to start.
|
||||
onRetry={error.kind === 'unavailable' ? retry : undefined}
|
||||
extraActions={error.kind === 'unavailable' && !getDesktopHost().isDesktop ? <OpenInBrowserButton blob={blob} /> : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return <PanelMessage icon="progress_activity" message={t('workspace.document.loading')} />
|
||||
}
|
||||
|
||||
function failureMessage(error: PdfError, t: ReturnType<typeof useTranslation>): string {
|
||||
if (error.kind === 'password') return t('workspace.document.passwordProtected')
|
||||
if (error.kind === 'unavailable') return t('workspace.document.engineUnavailable')
|
||||
return t('workspace.document.parseFailed')
|
||||
}
|
||||
|
||||
/**
|
||||
* A browser that cannot run pdf.js (an old phone) usually still has a PDF viewer
|
||||
* of its own. Hand it the bytes we already fetched with the session's credentials,
|
||||
* which a plain link to the server could not carry.
|
||||
*/
|
||||
function OpenInBrowserButton({ blob }: { blob: Blob }) {
|
||||
const t = useTranslation()
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<ExternalLink size={14} strokeWidth={1.9} aria-hidden="true" />}
|
||||
onClick={() => {
|
||||
const url = URL.createObjectURL(blob)
|
||||
window.open(url, '_blank', 'noopener')
|
||||
// Long enough for the new tab to have read it.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000)
|
||||
}}
|
||||
>
|
||||
{t('workspace.document.openInBrowser')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { useDebouncedValue } from '@/hooks/useDebouncedValue'
|
||||
import { useElementSize } from '@/hooks/useElementSize'
|
||||
import { useTranslation } from '@/i18n'
|
||||
import {
|
||||
ZOOM_MAX,
|
||||
ZOOM_MIN,
|
||||
anchoredScrollLeft,
|
||||
clampZoom,
|
||||
fitWidthScale,
|
||||
stepZoom,
|
||||
wheelZoom,
|
||||
zoomPercent,
|
||||
} from '@/lib/zoomPan'
|
||||
import type { WorkspaceFileView } from '@/stores/workspaceContentStore'
|
||||
import { DocumentToolbar, type DocumentZoomState } from './DocumentToolbar'
|
||||
import type { PdfDocumentHandle, PdfError } from './pdfEngine'
|
||||
import {
|
||||
anchoredScrollTop,
|
||||
currentPageIndex,
|
||||
layoutPdfColumn,
|
||||
PDF_PAGE_GAP,
|
||||
PDF_PAGE_PADDING,
|
||||
scrollTopForPage,
|
||||
visiblePageRange,
|
||||
type PageBox,
|
||||
type PageSize,
|
||||
} from './pdfLayout'
|
||||
import { PdfPage } from './PdfPage'
|
||||
|
||||
/** The bitmap catches up with the zoom this long after the reader stops changing it. */
|
||||
const REDRAW_SETTLE_MS = 120
|
||||
/** Pages stay drawn this many viewport heights beyond what is visible, on each side. */
|
||||
const OVERSCAN = 1
|
||||
/** "Fit to width" never enlarges a page past its natural size. */
|
||||
const FIT_MAX_SCALE = 1
|
||||
/** Two zooms closer than this are the same zoom. */
|
||||
const ZOOM_EPSILON = 1e-3
|
||||
|
||||
/** The pages that are mounted, and the one the reader would say they are on (0-based). */
|
||||
type PageWindow = { first: number; last: number; current: number }
|
||||
|
||||
function sameWindow(a: PageWindow | null, b: PageWindow): boolean {
|
||||
return !!a && a.first === b.first && a.last === b.last && a.current === b.current
|
||||
}
|
||||
|
||||
function fileNameOf(path: string): string {
|
||||
return path.split(/[\\/]/).filter(Boolean).pop() ?? path
|
||||
}
|
||||
|
||||
export type PdfViewerProps = {
|
||||
doc: PdfDocumentHandle
|
||||
sizes: readonly PageSize[]
|
||||
path: string
|
||||
absolutePath: string
|
||||
/** The reader's zoom as a scale factor, or `undefined` for fit to width. */
|
||||
zoom: number | undefined
|
||||
onZoomChange: (zoom: number | undefined) => void
|
||||
/** Where this file was left when its tab was last open. */
|
||||
initialView: WorkspaceFileView | undefined
|
||||
/** A newer version could not be opened; `doc` is the version before it. */
|
||||
refreshError: PdfError | null
|
||||
}
|
||||
|
||||
/**
|
||||
* The pages of an open PDF as one scrolling column, with the bar above it.
|
||||
*
|
||||
* Where each page sits is arithmetic on its size and the zoom (`pdfLayout`), so
|
||||
* which pages to draw is decided from the scroll offset alone, and only those few
|
||||
* exist as elements: a 500-page document is a tall empty box with a handful of
|
||||
* pages in it.
|
||||
*
|
||||
* Every change of layout — a zoom, a resized panel, a rewritten file — is
|
||||
* anchored to "this page, this far down", so the reader keeps their place.
|
||||
*/
|
||||
export function PdfViewer({
|
||||
doc,
|
||||
sizes,
|
||||
path,
|
||||
absolutePath,
|
||||
zoom,
|
||||
onZoomChange,
|
||||
initialView,
|
||||
refreshError,
|
||||
}: PdfViewerProps) {
|
||||
const t = useTranslation()
|
||||
const [sizeRef, viewport] = useElementSize<HTMLDivElement>()
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null)
|
||||
const setScroller = useCallback((node: HTMLDivElement | null) => {
|
||||
scrollRef.current = node
|
||||
sizeRef(node)
|
||||
}, [sizeRef])
|
||||
|
||||
const widest = useMemo(() => sizes.reduce((max, size) => Math.max(max, size.width), 0), [sizes])
|
||||
// `null` until the panel has a size: nothing is laid out, and so nothing drawn, for a hidden panel.
|
||||
const fit = viewport
|
||||
? fitWidthScale({ containerWidth: viewport.width, contentWidth: widest, padding: PDF_PAGE_PADDING, maxScale: FIT_MAX_SCALE })
|
||||
: null
|
||||
const scale = fit === null ? null : zoom === undefined ? fit : clampZoom(zoom)
|
||||
const column = useMemo(
|
||||
() => (scale === null ? null : layoutPdfColumn(sizes, scale, { gap: PDF_PAGE_GAP, padding: PDF_PAGE_PADDING })),
|
||||
[sizes, scale],
|
||||
)
|
||||
const contentWidth = viewport && column ? Math.max(viewport.width, column.width + PDF_PAGE_PADDING * 2) : 0
|
||||
const settledScale = useDebouncedValue(scale, REDRAW_SETTLE_MS)
|
||||
const drawScale = settledScale ?? scale
|
||||
|
||||
const [pageWindow, setPageWindow] = useState<PageWindow | null>(null)
|
||||
const lastScroll = useRef({ top: 0, left: 0 })
|
||||
|
||||
const syncWindow = useCallback(() => {
|
||||
const node = scrollRef.current
|
||||
if (!node || !column) return
|
||||
lastScroll.current = { top: node.scrollTop, left: node.scrollLeft }
|
||||
const height = node.clientHeight
|
||||
const range = visiblePageRange(column.boxes, node.scrollTop, height, height * OVERSCAN)
|
||||
const next: PageWindow = {
|
||||
first: range?.first ?? 0,
|
||||
last: range?.last ?? -1,
|
||||
current: currentPageIndex(column.boxes, node.scrollTop, height),
|
||||
}
|
||||
setPageWindow((previous) => (sameWindow(previous, next) ? previous : next))
|
||||
}, [column])
|
||||
|
||||
// Where to put the scroll offset once there is a layout to put it in.
|
||||
const pendingRestore = useRef(initialView ? { top: initialView.scrollTop, left: initialView.scrollLeft } : null)
|
||||
// The point to hold still through the next change of zoom (the pointer, for a wheel zoom).
|
||||
const pendingAnchor = useRef<{ x: number; y: number } | null>(null)
|
||||
const previousLayout = useRef<{ boxes: readonly PageBox[]; scale: number; contentWidth: number } | null>(null)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const node = scrollRef.current
|
||||
if (!node) return
|
||||
if (!column || scale === null) {
|
||||
// Hidden or not yet measured. A hidden panel loses its scroll offset, so
|
||||
// remember where the reader was and put them back when it returns.
|
||||
if (previousLayout.current) {
|
||||
pendingRestore.current = lastScroll.current
|
||||
previousLayout.current = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const previous = previousLayout.current
|
||||
if (pendingRestore.current) {
|
||||
node.scrollTop = pendingRestore.current.top
|
||||
node.scrollLeft = pendingRestore.current.left
|
||||
pendingRestore.current = null
|
||||
} else if (previous && previous.boxes !== column.boxes) {
|
||||
const anchor = pendingAnchor.current ?? { x: node.clientWidth / 2, y: 0 }
|
||||
node.scrollTop = anchoredScrollTop({
|
||||
before: previous.boxes,
|
||||
after: column.boxes,
|
||||
scrollTop: node.scrollTop,
|
||||
anchorY: anchor.y,
|
||||
})
|
||||
node.scrollLeft = anchoredScrollLeft({
|
||||
scrollLeft: node.scrollLeft,
|
||||
anchorX: anchor.x,
|
||||
oldScale: previous.scale,
|
||||
newScale: scale,
|
||||
oldContentWidth: previous.contentWidth,
|
||||
newContentWidth: contentWidth,
|
||||
})
|
||||
}
|
||||
pendingAnchor.current = null
|
||||
previousLayout.current = { boxes: column.boxes, scale, contentWidth }
|
||||
syncWindow()
|
||||
}, [column, scale, contentWidth, viewport?.height, syncWindow])
|
||||
|
||||
const zoomTo = useCallback((next: number | undefined, anchor?: { x: number; y: number }) => {
|
||||
const node = scrollRef.current
|
||||
const target = next === undefined ? fit : clampZoom(next)
|
||||
if (node && scale !== null && target !== null && Math.abs(target - scale) > ZOOM_EPSILON) {
|
||||
pendingAnchor.current = { x: anchor?.x ?? node.clientWidth / 2, y: anchor?.y ?? node.clientHeight / 2 }
|
||||
}
|
||||
onZoomChange(next)
|
||||
}, [fit, scale, onZoomChange])
|
||||
|
||||
// Latest values for the native wheel listener, which is bound once.
|
||||
const latest = useRef({ zoomTo, scale })
|
||||
useLayoutEffect(() => {
|
||||
latest.current = { zoomTo, scale }
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const node = scrollRef.current
|
||||
if (!node) return
|
||||
// Native and non-passive on purpose: React attaches `onWheel` as a passive
|
||||
// listener, where preventDefault is ignored — and a pinch would then zoom the
|
||||
// whole page as well as the document.
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
if (!event.ctrlKey && !event.metaKey) return
|
||||
event.preventDefault()
|
||||
const { zoomTo: zoomAt, scale: current } = latest.current
|
||||
if (current === null) return
|
||||
const rect = node.getBoundingClientRect()
|
||||
zoomAt(wheelZoom(current, event.deltaY), { x: event.clientX - rect.left, y: event.clientY - rect.top })
|
||||
}
|
||||
node.addEventListener('wheel', onWheel, { passive: false })
|
||||
return () => node.removeEventListener('wheel', onWheel)
|
||||
}, [])
|
||||
|
||||
const goToPage = (page: number) => {
|
||||
const node = scrollRef.current
|
||||
if (!node || !column) return
|
||||
node.scrollTop = scrollTopForPage(column.boxes, page - 1, PDF_PAGE_GAP)
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.ctrlKey || event.metaKey || event.altKey || scale === null) return
|
||||
if (event.key === '+' || event.key === '=') zoomTo(stepZoom(scale, 1))
|
||||
else if (event.key === '-' || event.key === '_') zoomTo(stepZoom(scale, -1))
|
||||
else if (event.key === '0') zoomTo(undefined)
|
||||
else if (event.key === '1') zoomTo(1)
|
||||
else return
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const currentPage = (pageWindow?.current ?? 0) + 1
|
||||
// What the reader is typing in the page box; `null` while they are not.
|
||||
const [draftPage, setDraftPage] = useState<string | null>(null)
|
||||
|
||||
const zoomState: DocumentZoomState = {
|
||||
percent: zoomPercent(scale ?? 1),
|
||||
fitActive: zoom === undefined,
|
||||
canZoomIn: scale !== null && scale < ZOOM_MAX - ZOOM_EPSILON,
|
||||
canZoomOut: scale !== null && scale > ZOOM_MIN + ZOOM_EPSILON,
|
||||
onZoomIn: () => scale !== null && zoomTo(stepZoom(scale, 1)),
|
||||
onZoomOut: () => scale !== null && zoomTo(stepZoom(scale, -1)),
|
||||
onFit: () => zoomTo(undefined),
|
||||
}
|
||||
|
||||
const pages: ReactNode[] = []
|
||||
if (column && scale !== null && drawScale !== null && pageWindow) {
|
||||
// The window can be a beat behind a layout that has fewer pages (a rewritten file).
|
||||
const last = Math.min(pageWindow.last, column.boxes.length - 1)
|
||||
for (let index = pageWindow.first; index <= last; index += 1) {
|
||||
const box = column.boxes[index]!
|
||||
pages.push(
|
||||
<div
|
||||
key={index}
|
||||
className="absolute"
|
||||
style={{ top: box.top, left: (contentWidth - box.width) / 2, width: box.width, height: box.height }}
|
||||
>
|
||||
<PdfPage doc={doc} pageNumber={index + 1} total={sizes.length} scale={scale} drawScale={drawScale} />
|
||||
</div>,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||
<DocumentToolbar
|
||||
absolutePath={absolutePath}
|
||||
zoom={zoomState}
|
||||
leading={
|
||||
<div role="group" aria-label={t('workspace.pdf.pages')} className="flex items-center gap-1.5">
|
||||
<Input
|
||||
aria-label={t('workspace.pdf.pageNumber')}
|
||||
size="sm"
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
containerClassName="w-14"
|
||||
className="text-center tabular-nums"
|
||||
value={draftPage ?? String(currentPage)}
|
||||
onFocus={(event) => {
|
||||
setDraftPage(String(currentPage))
|
||||
event.currentTarget.select()
|
||||
}}
|
||||
onChange={(event) => setDraftPage(event.target.value.replace(/\D/g, ''))}
|
||||
onBlur={() => setDraftPage(null)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter' && event.key !== 'Escape') return
|
||||
const page = Number.parseInt(draftPage ?? '', 10)
|
||||
if (event.key === 'Enter' && Number.isFinite(page)) goToPage(page)
|
||||
setDraftPage(null)
|
||||
// Hand the keyboard back to the document, so the arrow keys keep scrolling it.
|
||||
scrollRef.current?.focus({ preventScroll: true })
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs tabular-nums text-[var(--color-text-tertiary)]">/ {sizes.length}</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div
|
||||
ref={setScroller}
|
||||
// The pages lay out asynchronously, so this panel leaves restoring the scroll
|
||||
// position to us (see WorkspaceFileTab); it still records where the reader leaves it.
|
||||
data-workspace-scroll-surface="deferred"
|
||||
role="group"
|
||||
aria-label={fileNameOf(path)}
|
||||
tabIndex={0}
|
||||
onKeyDown={onKeyDown}
|
||||
onScroll={syncWindow}
|
||||
// A permanent vertical scrollbar: whether the column overflows must not change
|
||||
// the width "fit to width" measures, or a one-page document would flicker
|
||||
// between the two widths.
|
||||
className="min-h-0 flex-1 overflow-x-auto overflow-y-scroll bg-[var(--color-surface-container-high)] outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-border-focus)]"
|
||||
>
|
||||
{column ? (
|
||||
<div className="relative" style={{ width: contentWidth, height: column.height }}>
|
||||
{pages}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{refreshError ? (
|
||||
<p role="status" className="shrink-0 border-t border-[var(--color-border)] px-3 py-1.5 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{t('workspace.files.refreshFailed', { reason: refreshReason(refreshError, t) })}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function refreshReason(error: PdfError, t: ReturnType<typeof useTranslation>): string {
|
||||
if (error.kind === 'password') return t('workspace.document.passwordProtected')
|
||||
if (error.kind === 'unavailable') return t('workspace.document.engineUnavailable')
|
||||
return t('workspace.document.parseFailed')
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { useSettingsStore } from '@/stores/settingsStore'
|
||||
import { gridOf } from '@/test/fakeSpreadsheetEngine'
|
||||
import { SheetGrid, columnLabel, mergeCoverage } from './SheetGrid'
|
||||
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
})
|
||||
|
||||
const table = () => screen.getByRole('table')
|
||||
const cells = () => within(table()).queryAllByRole('cell')
|
||||
const rowHeaders = () => within(table()).queryAllByRole('rowheader').map((node) => node.textContent)
|
||||
const columnHeaders = () => within(table()).queryAllByRole('columnheader').map((node) => node.textContent)
|
||||
const cellsText = () => cells().map((node) => node.textContent)
|
||||
|
||||
describe('columnLabel', () => {
|
||||
it.each([
|
||||
[0, 'A'],
|
||||
[1, 'B'],
|
||||
[25, 'Z'],
|
||||
[26, 'AA'],
|
||||
[27, 'AB'],
|
||||
[51, 'AZ'],
|
||||
[52, 'BA'],
|
||||
[701, 'ZZ'],
|
||||
[702, 'AAA'],
|
||||
])('names column %i as %s, as a spreadsheet does', (index, label) => {
|
||||
expect(columnLabel(index)).toBe(label)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SheetGrid', () => {
|
||||
describe('the grid', () => {
|
||||
it('is a table named for the sheet, with its full size for a screen reader', () => {
|
||||
render(<SheetGrid grid={gridOf([['a', 'b'], ['c', 'd'], ['e', 'f']])} label="Budget" />)
|
||||
|
||||
expect(screen.getByRole('table', { name: 'Budget' })).toHaveAttribute('aria-rowcount', '3')
|
||||
expect(table()).toHaveAttribute('aria-colcount', '2')
|
||||
})
|
||||
|
||||
it('numbers the rows and letters the columns', () => {
|
||||
render(<SheetGrid grid={gridOf([['a', 'b', 'c'], ['d', 'e', 'f']])} label="Budget" />)
|
||||
|
||||
expect(rowHeaders()).toEqual(['1', '2'])
|
||||
expect(columnHeaders()).toEqual(['A', 'B', 'C'])
|
||||
})
|
||||
|
||||
it('draws every cell, the empty ones included, so the grid lines run all the way across', () => {
|
||||
render(<SheetGrid grid={gridOf([['a', undefined, 'c'], [undefined, 'e', undefined]])} label="Budget" />)
|
||||
|
||||
expect(cellsText()).toEqual(['a', '', 'c', '', 'e', ''])
|
||||
})
|
||||
|
||||
it('puts numbers to the right, as a spreadsheet does, and text to the left', () => {
|
||||
render(<SheetGrid grid={gridOf([['Rent', 1200]])} label="Budget" />)
|
||||
|
||||
expect(screen.getByRole('cell', { name: 'Rent' })).toHaveClass('text-left')
|
||||
expect(screen.getByRole('cell', { name: '1200' })).toHaveClass('text-right')
|
||||
})
|
||||
|
||||
it('draws what a cell holds as text, and never as markup', () => {
|
||||
const hostile = '<img src=x onerror="window.__sheetPwned = 1">'
|
||||
const link = '=HYPERLINK("https://evil.example","click")'
|
||||
render(<SheetGrid grid={gridOf([[hostile, link, '<script>window.__sheetPwned = 1</script>']])} label="Budget" />)
|
||||
|
||||
expect(screen.getByText(hostile)).toBeInTheDocument()
|
||||
expect(screen.getByText(link)).toBeInTheDocument()
|
||||
expect(table().querySelector('img, script, a, iframe, style, object, embed')).toBeNull()
|
||||
expect((window as unknown as { __sheetPwned?: number }).__sheetPwned).toBeUndefined()
|
||||
})
|
||||
|
||||
it('shows a long cell in full as a tooltip, and leaves a short one without', () => {
|
||||
const long = 'A sentence long enough that the cell has to cut it short'
|
||||
render(<SheetGrid grid={gridOf([[long, 'short']])} label="Budget" />)
|
||||
|
||||
expect(screen.getByRole('cell', { name: long })).toHaveAttribute('title', long)
|
||||
expect(screen.getByRole('cell', { name: 'short' })).not.toHaveAttribute('title')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sizes', () => {
|
||||
it('gives the row headings a track of their own, then a track of the sheet\'s width per column', () => {
|
||||
render(<SheetGrid grid={gridOf([['a', 'b']], { columnWidths: [100, 150] })} label="Budget" />)
|
||||
|
||||
expect(table().style.gridTemplateColumns).toBe('44px 100px 150px')
|
||||
})
|
||||
|
||||
it('sets a row to the height the sheet gives it, and lets the rest fit their text', () => {
|
||||
render(<SheetGrid grid={gridOf([['a'], ['b']], { rowHeights: [40, undefined] })} label="Budget" />)
|
||||
|
||||
expect(table().style.gridTemplateRows).toBe('24px 40px minmax(22px, auto)')
|
||||
})
|
||||
|
||||
it('draws a hidden column and a hidden row as tracks of no size with nothing in them', () => {
|
||||
render(
|
||||
<SheetGrid
|
||||
grid={gridOf([['a', 'b', 'c'], ['d', 'e', 'f']], { columnWidths: [72, 0, 72], rowHeights: [undefined, 0] })}
|
||||
label="Budget"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(columnHeaders()).toEqual(['A', 'C'])
|
||||
expect(rowHeaders()).toEqual(['1'])
|
||||
expect(cellsText()).toEqual(['a', 'c'])
|
||||
expect(table().style.gridTemplateColumns).toBe('44px 72px 0px 72px')
|
||||
expect(table().style.gridTemplateRows).toBe('24px minmax(22px, auto) 0px')
|
||||
})
|
||||
|
||||
it('places every cell by its own row and column, so a hidden neighbour does not shift it', () => {
|
||||
render(
|
||||
<SheetGrid
|
||||
grid={gridOf([['a', 'b', 'c']], { columnWidths: [72, 0, 72] })}
|
||||
label="Budget"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Row 1 is grid row 2 (the headings are row 1); column C is grid column 4 (the row headings are column 1).
|
||||
expect(screen.getByRole('cell', { name: 'c' }).style.gridColumn).toBe('4')
|
||||
expect(screen.getByRole('cell', { name: 'c' }).style.gridRow).toBe('2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('merged cells', () => {
|
||||
it('lets the first cell span, and draws none of the cells it covers', () => {
|
||||
render(
|
||||
<SheetGrid
|
||||
grid={gridOf([['Title', 'x', undefined], ['a', 'b', 'c']], {
|
||||
merges: [{ row: 0, column: 0, rowSpan: 1, columnSpan: 2 }],
|
||||
})}
|
||||
label="Budget"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(cellsText()).toEqual(['Title', '', 'a', 'b', 'c'])
|
||||
const title = screen.getByRole('cell', { name: 'Title' })
|
||||
expect(title.style.gridColumn).toBe('2 / span 2')
|
||||
expect(title.style.gridRow).toBe('2 / span 1')
|
||||
})
|
||||
|
||||
it('spans rows as well as columns', () => {
|
||||
render(
|
||||
<SheetGrid
|
||||
grid={gridOf([['Tall', 'x'], [undefined, 'y'], [undefined, 'z']], {
|
||||
merges: [{ row: 0, column: 0, rowSpan: 3, columnSpan: 1 }],
|
||||
})}
|
||||
label="Budget"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('cell', { name: 'Tall' }).style.gridRow).toBe('2 / span 3')
|
||||
expect(cellsText()).toEqual(['Tall', 'x', 'y', 'z'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('a sheet longer than one draw', () => {
|
||||
// Three columns and a budget of nine cells: three rows at a time.
|
||||
const tall = gridOf(Array.from({ length: 10 }, (_, row) => [`r${row}a`, `r${row}b`, `r${row}c`]))
|
||||
|
||||
it('draws as many rows as fit the budget and offers the rest', () => {
|
||||
render(<SheetGrid grid={tall} label="Budget" cellBudget={9} />)
|
||||
|
||||
expect(rowHeaders()).toEqual(['1', '2', '3'])
|
||||
expect(cells()).toHaveLength(9)
|
||||
expect(screen.getByRole('button', { name: 'Show more rows' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('draws the next rows when asked, until the sheet runs out, then stops offering', () => {
|
||||
render(<SheetGrid grid={tall} label="Budget" cellBudget={9} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show more rows' }))
|
||||
expect(rowHeaders()).toEqual(['1', '2', '3', '4', '5', '6'])
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show more rows' }))
|
||||
expect(rowHeaders()).toHaveLength(9)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show more rows' }))
|
||||
|
||||
expect(rowHeaders()).toHaveLength(10)
|
||||
expect(cells()).toHaveLength(30)
|
||||
expect(screen.queryByRole('button', { name: 'Show more rows' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('still says how many rows the sheet has, the ones not drawn included', () => {
|
||||
render(<SheetGrid grid={tall} label="Budget" cellBudget={9} />)
|
||||
|
||||
expect(table()).toHaveAttribute('aria-rowcount', '10')
|
||||
})
|
||||
|
||||
it('budgets by cells, not rows: a wide sheet gets fewer rows a draw', () => {
|
||||
const wide = gridOf(Array.from({ length: 10 }, (_, row) => Array.from({ length: 6 }, (_, column) => `${row}:${column}`)))
|
||||
render(<SheetGrid grid={wide} label="Budget" cellBudget={12} />)
|
||||
|
||||
expect(rowHeaders()).toEqual(['1', '2'])
|
||||
})
|
||||
|
||||
it('always draws at least one row, however wide the sheet', () => {
|
||||
const wide = gridOf([Array.from({ length: 6 }, (_, column) => `c${column}`), ['x']])
|
||||
render(<SheetGrid grid={wide} label="Budget" cellBudget={2} />)
|
||||
|
||||
expect(rowHeaders()).toEqual(['1'])
|
||||
})
|
||||
|
||||
it('keeps the rows the reader asked for when a newer version of the sheet arrives', () => {
|
||||
const { rerender } = render(<SheetGrid grid={tall} label="Budget" cellBudget={9} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show more rows' }))
|
||||
expect(rowHeaders()).toHaveLength(6)
|
||||
|
||||
rerender(<SheetGrid grid={gridOf(Array.from({ length: 10 }, (_, row) => [`new${row}`, 'b', 'c']))} label="Budget" cellBudget={9} />)
|
||||
|
||||
expect(rowHeaders()).toHaveLength(6)
|
||||
expect(screen.getByRole('cell', { name: 'new5' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('cuts a merge at the last row drawn, and lets it out again when the rest is', () => {
|
||||
const merged = gridOf(Array.from({ length: 6 }, (_, row) => [`r${row}`, 'b', 'c']), {
|
||||
merges: [{ row: 1, column: 0, rowSpan: 4, columnSpan: 1 }],
|
||||
})
|
||||
render(<SheetGrid grid={merged} label="Budget" cellBudget={6} />)
|
||||
|
||||
// Two rows drawn: the merge starts on the second and can take only that one.
|
||||
expect(screen.getByRole('cell', { name: 'r1' }).style.gridRow).toBe('3 / span 1')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show more rows' }))
|
||||
expect(rowHeaders()).toHaveLength(4)
|
||||
expect(screen.getByRole('cell', { name: 'r1' }).style.gridRow).toBe('3 / span 3')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show more rows' }))
|
||||
expect(rowHeaders()).toHaveLength(6)
|
||||
expect(screen.getByRole('cell', { name: 'r1' }).style.gridRow).toBe('3 / span 4')
|
||||
})
|
||||
})
|
||||
|
||||
describe('a sheet that was cut short', () => {
|
||||
it('says how much of it is here', () => {
|
||||
render(<SheetGrid grid={gridOf([['a', 'b'], ['c', 'd']], { truncatedRows: true })} label="Budget" />)
|
||||
|
||||
expect(screen.getByText('Showing the first 2 rows and 2 columns.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says so for columns cut as well', () => {
|
||||
render(<SheetGrid grid={gridOf([['a', 'b']], { truncatedColumns: true })} label="Budget" />)
|
||||
|
||||
expect(screen.getByText('Showing the first 1 rows and 2 columns.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says nothing about a sheet that is all here', () => {
|
||||
render(<SheetGrid grid={gridOf([['a', 'b']])} label="Budget" />)
|
||||
|
||||
expect(screen.queryByText(/Showing the first/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Show more rows' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeCoverage', () => {
|
||||
const merge = (row: number, column: number, rowSpan: number, columnSpan: number) => ({ row, column, rowSpan, columnSpan })
|
||||
const keys = (values: Iterable<number>) => [...values].sort((a, b) => a - b)
|
||||
|
||||
it('marks the cells a merged range covers, but not the one it starts at', () => {
|
||||
const { origins, covered } = mergeCoverage([merge(1, 1, 2, 2)], 5, 10)
|
||||
|
||||
expect(keys(origins.keys())).toEqual([6])
|
||||
expect(keys(covered)).toEqual([7, 11, 12])
|
||||
})
|
||||
|
||||
it('marks only the rows that are drawn, however far the range runs on', () => {
|
||||
const { covered } = mergeCoverage([merge(0, 0, 5000, 3)], 3, 4)
|
||||
|
||||
expect(covered.size).toBe(4 * 3 - 1)
|
||||
})
|
||||
|
||||
it('leaves a range that starts below the drawn rows for when they are drawn', () => {
|
||||
const { origins, covered } = mergeCoverage([merge(8, 0, 2, 2)], 4, 5)
|
||||
|
||||
expect(origins.size).toBe(0)
|
||||
expect(covered.size).toBe(0)
|
||||
expect(mergeCoverage([merge(8, 0, 2, 2)], 4, 9).origins.size).toBe(1)
|
||||
})
|
||||
|
||||
it('skips a range that starts inside another, which no workbook Excel wrote has', () => {
|
||||
const { origins } = mergeCoverage([merge(0, 0, 3, 3), merge(1, 1, 2, 2)], 5, 10)
|
||||
|
||||
expect(keys(origins.keys())).toEqual([0])
|
||||
})
|
||||
|
||||
it('takes the same range listed again as one range, not as another to mark', () => {
|
||||
// 4 positions a time against a budget of 5: were each listing marked, the two
|
||||
// repeats would use it up and the distinct range after them would be lost.
|
||||
const { origins } = mergeCoverage(
|
||||
[merge(0, 0, 2, 2), merge(0, 0, 2, 2), merge(0, 0, 2, 2), merge(5, 0, 1, 2)],
|
||||
10,
|
||||
20,
|
||||
5,
|
||||
)
|
||||
|
||||
expect(keys(origins.keys())).toEqual([0, 50])
|
||||
})
|
||||
|
||||
it('stops marking once the budget is spent, and draws the rest unmerged', () => {
|
||||
const { origins } = mergeCoverage([merge(0, 0, 2, 2), merge(5, 0, 2, 2), merge(9, 0, 2, 2)], 10, 20, 8)
|
||||
|
||||
expect(keys(origins.keys())).toEqual([0, 50])
|
||||
})
|
||||
|
||||
it('costs one range when a small workbook lists the same sweeping one hundreds of times', () => {
|
||||
const sweeping = Array.from({ length: 500 }, () => merge(0, 0, 5000, 100))
|
||||
|
||||
const { origins, covered } = mergeCoverage(sweeping, 100, 5000)
|
||||
|
||||
expect(origins.size).toBe(1)
|
||||
expect(covered.size).toBe(5000 * 100 - 1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,191 @@
|
||||
import { Fragment, useMemo, useState } from 'react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { useTranslation } from '@/i18n'
|
||||
import type { Grid, GridCell } from './spreadsheetEngine'
|
||||
|
||||
/** How many cells are put on the page at once. A sheet has room for far more than a page can hold. */
|
||||
export const MAX_RENDERED_CELLS = 30_000
|
||||
/**
|
||||
* How many cell positions are marked as covered by merged ranges, over all merges. A
|
||||
* workbook of a few kilobytes can list hundreds of ranges that each span the whole
|
||||
* sheet; marking every cell of each one froze the panel for seconds. Merges past this
|
||||
* budget are drawn unmerged, which no real workbook comes near.
|
||||
*/
|
||||
export const MERGE_SCAN_BUDGET = 2_000_000
|
||||
/** The row-number column. */
|
||||
const ROW_HEADER_WIDTH = 44
|
||||
const HEADER_HEIGHT = 24
|
||||
const MIN_ROW_HEIGHT = 22
|
||||
|
||||
/** A, B, … Z, AA, AB, … as a spreadsheet numbers its columns. */
|
||||
export function columnLabel(index: number): string {
|
||||
let label = ''
|
||||
let rest = index
|
||||
do {
|
||||
label = String.fromCharCode(65 + (rest % 26)) + label
|
||||
rest = Math.floor(rest / 26) - 1
|
||||
} while (rest >= 0)
|
||||
return label
|
||||
}
|
||||
|
||||
/**
|
||||
* Which cells a sheet's merged ranges start at, and which they cover, for the first
|
||||
* `rows` rows: the only ones that are drawn.
|
||||
*
|
||||
* A range inside another, or listed twice, is not a merge Excel would have written and
|
||||
* is skipped rather than marked again. Marking is bounded by `budget` cell positions
|
||||
* over all ranges; ranges past it are left unmerged.
|
||||
*/
|
||||
export function mergeCoverage(
|
||||
merges: Grid['merges'],
|
||||
columns: number,
|
||||
rows: number,
|
||||
budget = MERGE_SCAN_BUDGET,
|
||||
): { origins: Map<number, Grid['merges'][number]>; covered: Set<number> } {
|
||||
const origins = new Map<number, Grid['merges'][number]>()
|
||||
const covered = new Set<number>()
|
||||
let remaining = budget
|
||||
for (const merge of merges) {
|
||||
if (remaining <= 0) break
|
||||
const key = merge.row * columns + merge.column
|
||||
if (merge.row >= rows || origins.has(key) || covered.has(key)) continue
|
||||
origins.set(key, merge)
|
||||
const lastRow = Math.min(merge.row + merge.rowSpan, rows)
|
||||
const lastColumn = Math.min(merge.column + merge.columnSpan, columns)
|
||||
for (let row = merge.row; row < lastRow; row += 1) {
|
||||
for (let column = merge.column; column < lastColumn; column += 1) {
|
||||
if (row !== merge.row || column !== merge.column) covered.add(row * columns + column)
|
||||
}
|
||||
}
|
||||
remaining -= (lastRow - merge.row) * (lastColumn - merge.column)
|
||||
}
|
||||
return { origins, covered }
|
||||
}
|
||||
|
||||
const ALIGN_CLASS: Record<GridCell['align'], string> = {
|
||||
left: 'text-left',
|
||||
right: 'text-right tabular-nums',
|
||||
center: 'text-center',
|
||||
}
|
||||
|
||||
const HEADER_CELL =
|
||||
'flex items-center justify-center border-b border-r border-[var(--color-border)] bg-[var(--color-surface-container)] text-[11px] text-[var(--color-text-tertiary)] select-none'
|
||||
|
||||
/**
|
||||
* One worksheet as a grid of text.
|
||||
*
|
||||
* Every cell is a text node, never markup: a cell that holds `<img onerror=…>` shows those
|
||||
* characters. Merged cells span tracks; a hidden row or column is a track of no size with no
|
||||
* cells in it. Row and column headings stay put while the sheet scrolls under them.
|
||||
*
|
||||
* Only as many rows are drawn as fit a budget of cells; the reader asks for more.
|
||||
*/
|
||||
export function SheetGrid({
|
||||
grid,
|
||||
label,
|
||||
cellBudget = MAX_RENDERED_CELLS,
|
||||
}: {
|
||||
grid: Grid
|
||||
/** The sheet's name. */
|
||||
label: string
|
||||
/** Cells drawn at first, and added by each "show more rows". A parameter so a test need not draw thirty thousand. */
|
||||
cellBudget?: number
|
||||
}) {
|
||||
const t = useTranslation()
|
||||
const chunk = Math.max(1, Math.floor(cellBudget / Math.max(grid.columns, 1)))
|
||||
// Rows the reader has asked for, kept across a refresh of the same sheet.
|
||||
const [limit, setLimit] = useState(chunk)
|
||||
const rows = Math.min(grid.rows, limit)
|
||||
|
||||
const { origins, covered } = useMemo(
|
||||
() => mergeCoverage(grid.merges, grid.columns, rows),
|
||||
[grid.merges, grid.columns, rows],
|
||||
)
|
||||
|
||||
const templateColumns = `${ROW_HEADER_WIDTH}px ${grid.columnWidths.map((width) => `${width}px`).join(' ')}`
|
||||
const templateRows = [
|
||||
`${HEADER_HEIGHT}px`,
|
||||
...grid.rowHeights.slice(0, rows).map((height) => (height === undefined ? `minmax(${MIN_ROW_HEIGHT}px, auto)` : `${height}px`)),
|
||||
].join(' ')
|
||||
|
||||
const cells = []
|
||||
for (let row = 0; row < rows; row += 1) {
|
||||
if (grid.rowHeights[row] === 0) continue
|
||||
cells.push(
|
||||
<div key={`r${row}`} role="row" className="contents">
|
||||
<div
|
||||
role="rowheader"
|
||||
className={`${HEADER_CELL} sticky left-0 z-[1]`}
|
||||
style={{ gridRow: row + 2, gridColumn: 1 }}
|
||||
>
|
||||
{row + 1}
|
||||
</div>
|
||||
{Array.from({ length: grid.columns }, (_, column) => {
|
||||
if (grid.columnWidths[column] === 0 || covered.has(row * grid.columns + column)) return null
|
||||
const cell = grid.cells[row]?.[column]
|
||||
const merge = origins.get(row * grid.columns + column)
|
||||
const rowSpan = merge ? Math.min(merge.rowSpan, rows - row) : 1
|
||||
return (
|
||||
<div
|
||||
key={column}
|
||||
role="cell"
|
||||
title={cell && cell.text.length > 24 ? cell.text : undefined}
|
||||
className={`min-w-0 overflow-hidden text-ellipsis whitespace-nowrap border-b border-r border-[var(--color-border)] px-2 py-0.5 text-xs leading-[18px] text-[var(--color-text-primary)] ${ALIGN_CLASS[cell?.align ?? 'left']}`}
|
||||
style={{
|
||||
gridRow: merge ? `${row + 2} / span ${rowSpan}` : row + 2,
|
||||
gridColumn: merge ? `${column + 2} / span ${merge.columnSpan}` : column + 2,
|
||||
}}
|
||||
>
|
||||
{cell?.text}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>,
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div
|
||||
role="table"
|
||||
aria-label={label}
|
||||
aria-rowcount={grid.rows}
|
||||
aria-colcount={grid.columns}
|
||||
className="grid w-max bg-[var(--color-surface)] border-l border-t border-[var(--color-border)]"
|
||||
style={{ gridTemplateColumns: templateColumns, gridTemplateRows: templateRows }}
|
||||
>
|
||||
<div
|
||||
role="row"
|
||||
className="contents"
|
||||
>
|
||||
<div className={`${HEADER_CELL} sticky left-0 top-0 z-[2]`} style={{ gridRow: 1, gridColumn: 1 }} />
|
||||
{Array.from({ length: grid.columns }, (_, column) => (
|
||||
grid.columnWidths[column] === 0 ? null : (
|
||||
<div
|
||||
key={column}
|
||||
role="columnheader"
|
||||
className={`${HEADER_CELL} sticky top-0 z-[1]`}
|
||||
style={{ gridRow: 1, gridColumn: column + 2 }}
|
||||
>
|
||||
{columnLabel(column)}
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
{cells}
|
||||
</div>
|
||||
{rows < grid.rows || grid.truncatedRows || grid.truncatedColumns ? (
|
||||
<div className="sticky left-0 flex flex-wrap items-center gap-3 px-3 py-2 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{rows < grid.rows ? (
|
||||
<Button variant="secondary" size="sm" onClick={() => setLimit((current) => current + chunk)}>
|
||||
{t('workspace.sheet.showMoreRows')}
|
||||
</Button>
|
||||
) : null}
|
||||
{grid.truncatedRows || grid.truncatedColumns ? (
|
||||
<span>{t('workspace.sheet.truncated', { rows: grid.rows, columns: grid.columns })}</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,592 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { useState } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useSettingsStore } from '@/stores/settingsStore'
|
||||
import type { WorkspaceFileView } from '@/stores/workspaceContentStore'
|
||||
import { blobWithBytes } from '@/test/blobs'
|
||||
import { createFakeSpreadsheetEngine, gridOf, type FakeSheet } from '@/test/fakeSpreadsheetEngine'
|
||||
import { SpreadsheetError, type SpreadsheetEngine } from './spreadsheetEngine'
|
||||
import SpreadsheetSurface from './SpreadsheetSurface'
|
||||
|
||||
vi.mock('@/lib/systemFileOpen', () => ({
|
||||
openLocalFileWithSystem: vi.fn().mockResolvedValue(undefined),
|
||||
reportOpenFailure: vi.fn(),
|
||||
}))
|
||||
|
||||
// ---- the panel: jsdom lays nothing out, so its size is whatever a test says ----
|
||||
|
||||
let viewport = { width: 700, height: 800 }
|
||||
const resizeCallbacks = new Set<() => void>()
|
||||
const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth')
|
||||
const originalClientHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientHeight')
|
||||
|
||||
class StubResizeObserver {
|
||||
constructor(private readonly callback: () => void) {}
|
||||
observe() {
|
||||
resizeCallbacks.add(this.callback)
|
||||
}
|
||||
unobserve() {}
|
||||
disconnect() {
|
||||
resizeCallbacks.delete(this.callback)
|
||||
}
|
||||
}
|
||||
|
||||
function resizePanel(width: number, height: number) {
|
||||
viewport = { width, height }
|
||||
act(() => resizeCallbacks.forEach((callback) => callback()))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
viewport = { width: 700, height: 800 }
|
||||
resizeCallbacks.clear()
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, get: () => viewport.width })
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientHeight', { configurable: true, get: () => viewport.height })
|
||||
vi.stubGlobal('ResizeObserver', StubResizeObserver)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
if (originalClientWidth) Object.defineProperty(HTMLElement.prototype, 'clientWidth', originalClientWidth)
|
||||
if (originalClientHeight) Object.defineProperty(HTMLElement.prototype, 'clientHeight', originalClientHeight)
|
||||
})
|
||||
|
||||
// ---- the surface under test ----
|
||||
|
||||
const xlsxBlob = blobWithBytes([80, 75, 3, 4], 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
||||
|
||||
const BUDGET: FakeSheet = { name: 'Budget', grid: gridOf([['Item', 'Cost'], ['Rent', 1200], ['Food', 340]]) }
|
||||
const NOTES: FakeSheet = { name: 'Notes', grid: gridOf([['Remember'], ['the milk']]) }
|
||||
const SCRATCH: FakeSheet = { name: 'Scratch', grid: gridOf([['x']]), hidden: true }
|
||||
const WORKBOOK = [BUDGET, NOTES, SCRATCH]
|
||||
|
||||
type SurfaceProps = {
|
||||
engine: SpreadsheetEngine
|
||||
blob?: Blob
|
||||
sheet?: string
|
||||
onSheetChange?: (sheet: string) => void
|
||||
initialView?: WorkspaceFileView
|
||||
absolutePath?: string
|
||||
}
|
||||
|
||||
/** Holds the worksheet the way the workspace store does: chosen here, handed back down. */
|
||||
function Surface({ engine, blob = xlsxBlob, sheet: initialSheet, onSheetChange, initialView, absolutePath = '/work/data/budget.xlsx' }: SurfaceProps) {
|
||||
const [sheet, setSheet] = useState(initialSheet)
|
||||
return (
|
||||
<SpreadsheetSurface
|
||||
engine={engine}
|
||||
blob={blob}
|
||||
path="data/budget.xlsx"
|
||||
absolutePath={absolutePath}
|
||||
version="1"
|
||||
refreshing={false}
|
||||
zoom={undefined}
|
||||
onZoomChange={() => undefined}
|
||||
sheet={sheet}
|
||||
onSheetChange={(next) => {
|
||||
setSheet(next)
|
||||
onSheetChange?.(next)
|
||||
}}
|
||||
initialView={initialView}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** The scroll area, whatever it is called or covered by at the moment. */
|
||||
const scroller = () => document.querySelector<HTMLElement>('[data-workspace-scroll-surface]')!
|
||||
const tab = (name: string) => screen.getByRole('tab', { name })
|
||||
const tabNames = () => screen.queryAllByRole('tab').map((node) => node.textContent)
|
||||
|
||||
/** The surface with a workbook that opens and reads at once, once it has drawn what it opened. */
|
||||
async function shown(props: Partial<Omit<SurfaceProps, 'engine'>> & { sheets?: FakeSheet[] } = {}) {
|
||||
const { sheets = WORKBOOK, ...surfaceProps } = props
|
||||
const fake = createFakeSpreadsheetEngine({ sheets, autoFinish: true })
|
||||
const view = render(<Surface {...surfaceProps} engine={fake.engine} />)
|
||||
await waitFor(() => expect(screen.queryByText('Loading document...')).not.toBeInTheDocument())
|
||||
return { ...view, ...fake }
|
||||
}
|
||||
|
||||
function scrollTo(top: number, left = 0) {
|
||||
scroller().scrollTop = top
|
||||
scroller().scrollLeft = left
|
||||
fireEvent.scroll(scroller())
|
||||
}
|
||||
|
||||
describe('SpreadsheetSurface', () => {
|
||||
describe('opening a workbook', () => {
|
||||
it('says it is loading until the workbook is open and its first sheet read', async () => {
|
||||
const { engine, opens, reads } = createFakeSpreadsheetEngine({ sheets: WORKBOOK })
|
||||
render(<Surface engine={engine} />)
|
||||
await waitFor(() => expect(opens).toHaveLength(1))
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Loading document...')
|
||||
expect(screen.queryByRole('tablist')).not.toBeInTheDocument()
|
||||
|
||||
await act(async () => opens[0]!.finish())
|
||||
await waitFor(() => expect(reads).toHaveLength(1))
|
||||
// The tabs are there as soon as the workbook is open; the sheet under them is still on its way.
|
||||
expect(screen.getByRole('tablist', { name: 'Worksheets' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Loading document...')
|
||||
|
||||
await act(async () => reads[0]!.finish())
|
||||
await screen.findByRole('cell', { name: 'Rent' })
|
||||
expect(screen.queryByText('Loading document...')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hands the engine the bytes of the file', async () => {
|
||||
const { opens } = await shown()
|
||||
|
||||
expect(Array.from(opens[0]!.bytes)).toEqual([80, 75, 3, 4])
|
||||
})
|
||||
|
||||
it('opens a workbook once, and reads only the sheet on screen', async () => {
|
||||
const { engine, reads } = await shown()
|
||||
|
||||
expect(engine.open).toHaveBeenCalledTimes(1)
|
||||
expect(reads.map((read) => read.index)).toEqual([0])
|
||||
})
|
||||
|
||||
it('shows a tab for each sheet the workbook shows, and none for a hidden one', async () => {
|
||||
await shown()
|
||||
|
||||
expect(tabNames()).toEqual(['Budget', 'Notes'])
|
||||
expect(tab('Budget')).toHaveAttribute('aria-selected', 'true')
|
||||
expect(tab('Notes')).toHaveAttribute('aria-selected', 'false')
|
||||
})
|
||||
|
||||
it('names the sheet panel after its sheet', async () => {
|
||||
await shown()
|
||||
|
||||
expect(screen.getByRole('tabpanel', { name: 'Budget' })).toBe(scroller())
|
||||
})
|
||||
|
||||
it('leaves the scroll position to the sheet, which is not there when the panel mounts', async () => {
|
||||
await shown()
|
||||
|
||||
expect(scroller()).toHaveAttribute('data-workspace-scroll-surface', 'deferred')
|
||||
})
|
||||
|
||||
it('says what the preview leaves out', async () => {
|
||||
await shown()
|
||||
|
||||
expect(screen.getByText("Shows each cell as formatted text. Charts, images and formulas aren't shown.")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers the system app for a file that has a place on disk, and only for that', async () => {
|
||||
const { unmount } = await shown()
|
||||
expect(screen.getByRole('button', { name: 'Open in system app' })).toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
await shown({ absolutePath: 'budget.xlsx' })
|
||||
expect(screen.queryByRole('button', { name: 'Open in system app' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('what a cell holds', () => {
|
||||
it('is drawn as text, however much it looks like markup or a formula', async () => {
|
||||
const hostile = '<img src=x onerror="window.__surfacePwned = 1">'
|
||||
await shown({ sheets: [{ name: 'Data', grid: gridOf([[hostile], ['=HYPERLINK("https://evil.example","x")'], ['<script>window.__surfacePwned = 1</script>']]) }] })
|
||||
|
||||
expect(screen.getByText(hostile)).toBeInTheDocument()
|
||||
expect(scroller().querySelector('img, script, a, iframe')).toBeNull()
|
||||
expect((window as unknown as { __surfacePwned?: number }).__surfacePwned).toBeUndefined()
|
||||
})
|
||||
|
||||
it('is drawn under the name of its sheet, even when that is markup too', async () => {
|
||||
const name = '<b>Q1</b>'
|
||||
await shown({ sheets: [{ name, grid: gridOf([['x']]) }] })
|
||||
|
||||
expect(screen.getByRole('tab', { name })).toBeInTheDocument()
|
||||
expect(document.querySelector('[role="tablist"] b')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('worksheets', () => {
|
||||
it('opens on the sheet the reader was on last time', async () => {
|
||||
const { reads } = await shown({ sheet: 'Notes' })
|
||||
|
||||
expect(tab('Notes')).toHaveAttribute('aria-selected', 'true')
|
||||
expect(screen.getByRole('cell', { name: 'the milk' })).toBeInTheDocument()
|
||||
expect(reads.map((read) => read.index)).toEqual([1])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['one that is hidden', 'Scratch'],
|
||||
['one the workbook no longer has', 'Gone'],
|
||||
])('opens on the first sheet when the remembered one is %s', async (_, remembered) => {
|
||||
await shown({ sheet: remembered })
|
||||
|
||||
expect(tab('Budget')).toHaveAttribute('aria-selected', 'true')
|
||||
expect(screen.getByRole('cell', { name: 'Rent' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports the tab the reader picks, and shows that sheet', async () => {
|
||||
const onSheetChange = vi.fn()
|
||||
await shown({ onSheetChange })
|
||||
|
||||
fireEvent.click(tab('Notes'))
|
||||
|
||||
expect(onSheetChange).toHaveBeenCalledExactlyOnceWith('Notes')
|
||||
expect(await screen.findByRole('cell', { name: 'the milk' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('cell', { name: 'Rent' })).not.toBeInTheDocument()
|
||||
expect(tab('Notes')).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
|
||||
it('keeps the sheet on show until the next one is read, and says the panel is busy', async () => {
|
||||
const { engine, opens, reads } = createFakeSpreadsheetEngine({ sheets: WORKBOOK })
|
||||
render(<Surface engine={engine} />)
|
||||
await waitFor(() => expect(opens).toHaveLength(1))
|
||||
await act(async () => opens[0]!.finish())
|
||||
await waitFor(() => expect(reads).toHaveLength(1))
|
||||
await act(async () => reads[0]!.finish())
|
||||
await screen.findByRole('cell', { name: 'Rent' })
|
||||
|
||||
fireEvent.click(tab('Notes'))
|
||||
await waitFor(() => expect(reads).toHaveLength(2))
|
||||
|
||||
// The reader is not shown a blank page while a sheet of any size is read.
|
||||
expect(screen.getByRole('cell', { name: 'Rent' })).toBeInTheDocument()
|
||||
expect(scroller()).toHaveAttribute('aria-busy', 'true')
|
||||
expect(screen.queryByText('Loading document...')).not.toBeInTheDocument()
|
||||
|
||||
await act(async () => reads[1]!.finish())
|
||||
await screen.findByRole('cell', { name: 'the milk' })
|
||||
expect(screen.queryByRole('cell', { name: 'Rent' })).not.toBeInTheDocument()
|
||||
expect(scroller()).toHaveAttribute('aria-busy', 'false')
|
||||
})
|
||||
|
||||
it('goes back to a sheet it has read without reading it again', async () => {
|
||||
const { reads } = await shown()
|
||||
|
||||
fireEvent.click(tab('Notes'))
|
||||
await screen.findByRole('cell', { name: 'the milk' })
|
||||
fireEvent.click(tab('Budget'))
|
||||
await screen.findByRole('cell', { name: 'Rent' })
|
||||
|
||||
expect(reads.map((read) => read.index)).toEqual([0, 1])
|
||||
})
|
||||
|
||||
it('moves between tabs with the arrow keys', async () => {
|
||||
const onSheetChange = vi.fn()
|
||||
await shown({ onSheetChange })
|
||||
|
||||
tab('Budget').focus()
|
||||
fireEvent.keyDown(tab('Budget'), { key: 'ArrowRight' })
|
||||
|
||||
expect(onSheetChange).toHaveBeenCalledWith('Notes')
|
||||
expect(await screen.findByRole('cell', { name: 'the milk' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the one tab of a one-sheet workbook, which is how the reader learns the sheet\'s name', async () => {
|
||||
await shown({ sheets: [BUDGET] })
|
||||
|
||||
expect(tabNames()).toEqual(['Budget'])
|
||||
})
|
||||
|
||||
it('says so when the workbook has a sheet, but nothing in it', async () => {
|
||||
await shown({ sheets: [{ name: 'Blank', grid: gridOf([]) }] })
|
||||
|
||||
expect(screen.getByText('This worksheet is empty.')).toBeInTheDocument()
|
||||
expect(tab('Blank')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says so when the workbook has no sheet to show', async () => {
|
||||
await shown({ sheets: [SCRATCH] })
|
||||
|
||||
expect(screen.getByText('This worksheet is empty.')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('tablist')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the workbook cannot be shown', () => {
|
||||
async function failing(error: unknown) {
|
||||
const fake = createFakeSpreadsheetEngine({ sheets: WORKBOOK })
|
||||
render(<Surface engine={fake.engine} />)
|
||||
await waitFor(() => expect(fake.opens).toHaveLength(1))
|
||||
await act(async () => fake.opens[0]!.fail(error))
|
||||
return fake
|
||||
}
|
||||
|
||||
it.each([
|
||||
['a file that is not a workbook', new SpreadsheetError('invalid', 'Not a spreadsheet file'), 'This document could not be displayed.'],
|
||||
['an archive too large to inflate safely', new SpreadsheetError('tooComplex', 'too big'), 'This file is too large or too complex to preview safely.'],
|
||||
['the engine failing to load', new SpreadsheetError('unavailable', 'chunk failed'), "The document viewer can't start in this environment."],
|
||||
['anything else that goes wrong', new Error('boom'), 'This document could not be displayed.'],
|
||||
])('explains %s', async (_, error, message) => {
|
||||
await failing(error)
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(message)
|
||||
expect(screen.queryByRole('tablist')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers the system app as the way out', async () => {
|
||||
await failing(new SpreadsheetError('invalid', 'bad'))
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Open in system app' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each(['invalid', 'tooComplex'] as const)('offers no retry for %s, which the same bytes would fail again', async (kind) => {
|
||||
await failing(new SpreadsheetError(kind, 'bad'))
|
||||
|
||||
await screen.findByRole('alert')
|
||||
expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers a retry when the engine could not load, and opens the workbook again when it is used', async () => {
|
||||
const fake = await failing(new SpreadsheetError('unavailable', 'chunk failed'))
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Try again' }))
|
||||
await waitFor(() => expect(fake.opens).toHaveLength(2))
|
||||
await act(async () => fake.opens[1]!.finish())
|
||||
await waitFor(() => expect(fake.reads).toHaveLength(1))
|
||||
await act(async () => fake.reads[0]!.finish())
|
||||
|
||||
expect(await screen.findByRole('cell', { name: 'Rent' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('when one sheet cannot be read', () => {
|
||||
async function opened() {
|
||||
const fake = createFakeSpreadsheetEngine({ sheets: WORKBOOK })
|
||||
render(<Surface engine={fake.engine} />)
|
||||
await waitFor(() => expect(fake.opens).toHaveLength(1))
|
||||
await act(async () => fake.opens[0]!.finish())
|
||||
await waitFor(() => expect(fake.reads).toHaveLength(1))
|
||||
return fake
|
||||
}
|
||||
|
||||
it('says so under tabs that still work, and another sheet can still be opened', async () => {
|
||||
const fake = await opened()
|
||||
await act(async () => fake.reads[0]!.fail(new SpreadsheetError('invalid', 'bad sheet')))
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('This document could not be displayed.')
|
||||
expect(tabNames()).toEqual(['Budget', 'Notes'])
|
||||
|
||||
fireEvent.click(tab('Notes'))
|
||||
await waitFor(() => expect(fake.reads).toHaveLength(2))
|
||||
await act(async () => fake.reads[1]!.finish())
|
||||
|
||||
expect(await screen.findByRole('cell', { name: 'the milk' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('covers the sheet that was on show with the failure, and uncovers it on going back', async () => {
|
||||
const fake = await opened()
|
||||
await act(async () => fake.reads[0]!.finish())
|
||||
await screen.findByRole('cell', { name: 'Rent' })
|
||||
|
||||
fireEvent.click(tab('Notes'))
|
||||
await waitFor(() => expect(fake.reads).toHaveLength(2))
|
||||
await act(async () => fake.reads[1]!.fail(new SpreadsheetError('invalid', 'bad sheet')))
|
||||
|
||||
expect(await screen.findByRole('alert')).toBeInTheDocument()
|
||||
// Behind the message, out of reach of a screen reader and the keyboard.
|
||||
expect(scroller()).toHaveAttribute('aria-hidden', 'true')
|
||||
expect(scroller()).toHaveAttribute('tabindex', '-1')
|
||||
|
||||
fireEvent.click(tab('Budget'))
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument())
|
||||
expect(scroller()).not.toHaveAttribute('aria-hidden')
|
||||
expect(screen.getByRole('cell', { name: 'Rent' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the file is rewritten', () => {
|
||||
const blobA = blobWithBytes([1], 'application/octet-stream')
|
||||
const blobB = blobWithBytes([2], 'application/octet-stream')
|
||||
|
||||
async function firstVersion(sheets = WORKBOOK) {
|
||||
const fake = createFakeSpreadsheetEngine({ sheets })
|
||||
const view = render(<Surface engine={fake.engine} blob={blobA} />)
|
||||
await waitFor(() => expect(fake.opens).toHaveLength(1))
|
||||
await act(async () => fake.opens[0]!.finish())
|
||||
await waitFor(() => expect(fake.reads).toHaveLength(1))
|
||||
await act(async () => fake.reads[0]!.finish())
|
||||
await screen.findByRole('cell', { name: 'Rent' })
|
||||
return { ...view, ...fake }
|
||||
}
|
||||
|
||||
it('keeps the version on show while the next one opens, without a word about loading', async () => {
|
||||
const { rerender, engine, opens } = await firstVersion()
|
||||
|
||||
rerender(<Surface engine={engine} blob={blobB} />)
|
||||
await waitFor(() => expect(opens).toHaveLength(2))
|
||||
|
||||
expect(screen.getByRole('cell', { name: 'Rent' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('Loading document...')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('swaps in the new version when its sheet is read', async () => {
|
||||
const { rerender, engine, opens, reads, setSheets } = await firstVersion()
|
||||
setSheets([{ name: 'Budget', grid: gridOf([['Item', 'Cost'], ['Rent', 1300]]) }, NOTES])
|
||||
|
||||
rerender(<Surface engine={engine} blob={blobB} />)
|
||||
await waitFor(() => expect(opens).toHaveLength(2))
|
||||
await act(async () => opens[1]!.finish())
|
||||
await waitFor(() => expect(reads).toHaveLength(2))
|
||||
// Read, but not yet arrived: still the old one.
|
||||
expect(screen.getByRole('cell', { name: '1200' })).toBeInTheDocument()
|
||||
await act(async () => reads[1]!.finish())
|
||||
|
||||
expect(await screen.findByRole('cell', { name: '1300' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('cell', { name: '1200' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('leaves the reader where they were on the sheet', async () => {
|
||||
const { rerender, engine, opens, reads, setSheets } = await firstVersion()
|
||||
scrollTo(250, 20)
|
||||
setSheets([{ name: 'Budget', grid: gridOf([['Item', 'Cost'], ['Rent', 1300]]) }, NOTES])
|
||||
|
||||
rerender(<Surface engine={engine} blob={blobB} />)
|
||||
await waitFor(() => expect(opens).toHaveLength(2))
|
||||
await act(async () => opens[1]!.finish())
|
||||
await waitFor(() => expect(reads).toHaveLength(2))
|
||||
await act(async () => reads[1]!.finish())
|
||||
// Only once the new version is what is on screen does the position mean anything.
|
||||
await screen.findByRole('cell', { name: '1300' })
|
||||
|
||||
expect(scroller().scrollTop).toBe(250)
|
||||
expect(scroller().scrollLeft).toBe(20)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['the reader has not picked a tab', undefined],
|
||||
['the reader picked it', 'Budget'],
|
||||
])('stays on the same sheet when one is added in front of it, and %s', async (_, picked) => {
|
||||
const fake = createFakeSpreadsheetEngine({ sheets: WORKBOOK })
|
||||
const { rerender } = render(<Surface engine={fake.engine} blob={blobA} sheet={picked} />)
|
||||
await waitFor(() => expect(fake.opens).toHaveLength(1))
|
||||
await act(async () => fake.opens[0]!.finish())
|
||||
await waitFor(() => expect(fake.reads).toHaveLength(1))
|
||||
await act(async () => fake.reads[0]!.finish())
|
||||
await screen.findByRole('cell', { name: 'Rent' })
|
||||
fake.setSheets([{ name: 'New', grid: gridOf([['brand new']]) }, BUDGET, NOTES])
|
||||
|
||||
rerender(<Surface engine={fake.engine} blob={blobB} sheet={picked} />)
|
||||
await waitFor(() => expect(fake.opens).toHaveLength(2))
|
||||
await act(async () => fake.opens[1]!.finish())
|
||||
await waitFor(() => expect(fake.reads).toHaveLength(2))
|
||||
// It is the sheet called Budget that is read, now the second in the workbook.
|
||||
expect(fake.reads[1]!.index).toBe(1)
|
||||
await act(async () => fake.reads[1]!.finish())
|
||||
|
||||
await waitFor(() => expect(tabNames()).toEqual(['New', 'Budget', 'Notes']))
|
||||
expect(tab('Budget')).toHaveAttribute('aria-selected', 'true')
|
||||
expect(screen.getByRole('cell', { name: 'Rent' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('cell', { name: 'brand new' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('goes to the first sheet when the one on show was taken out', async () => {
|
||||
const { rerender, engine, opens, reads, setSheets } = await firstVersion()
|
||||
setSheets([NOTES])
|
||||
|
||||
rerender(<Surface engine={engine} blob={blobB} />)
|
||||
await waitFor(() => expect(opens).toHaveLength(2))
|
||||
await act(async () => opens[1]!.finish())
|
||||
await waitFor(() => expect(reads).toHaveLength(2))
|
||||
await act(async () => reads[1]!.finish())
|
||||
|
||||
expect(await screen.findByRole('cell', { name: 'the milk' })).toBeInTheDocument()
|
||||
expect(tabNames()).toEqual(['Notes'])
|
||||
})
|
||||
|
||||
it('keeps the last good version, and says why, when the new one will not open', async () => {
|
||||
const { rerender, engine, opens } = await firstVersion()
|
||||
|
||||
rerender(<Surface engine={engine} blob={blobB} />)
|
||||
await waitFor(() => expect(opens).toHaveLength(2))
|
||||
await act(async () => opens[1]!.fail(new SpreadsheetError('invalid', 'unzip failed: incomplete file')))
|
||||
|
||||
// The agent was mid-write. The reader's page survives it.
|
||||
expect(screen.getByRole('cell', { name: 'Rent' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('status')).toHaveTextContent('Showing the last loaded version — refresh failed: This document could not be displayed.')
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the last good version when the new one opens but its sheet will not read', async () => {
|
||||
const { rerender, engine, opens, reads } = await firstVersion()
|
||||
|
||||
rerender(<Surface engine={engine} blob={blobB} />)
|
||||
await waitFor(() => expect(opens).toHaveLength(2))
|
||||
await act(async () => opens[1]!.finish())
|
||||
await waitFor(() => expect(reads).toHaveLength(2))
|
||||
await act(async () => reads[1]!.fail(new SpreadsheetError('invalid', 'half a sheet')))
|
||||
|
||||
expect(screen.getByRole('cell', { name: 'Rent' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('status')).toHaveTextContent('refresh failed')
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('forgets the failure once a version opens', async () => {
|
||||
const { rerender, engine, opens, reads } = await firstVersion()
|
||||
rerender(<Surface engine={engine} blob={blobB} />)
|
||||
await waitFor(() => expect(opens).toHaveLength(2))
|
||||
await act(async () => opens[1]!.fail(new SpreadsheetError('invalid', 'half a file')))
|
||||
await screen.findByRole('status')
|
||||
|
||||
rerender(<Surface engine={engine} blob={xlsxBlob} />)
|
||||
await waitFor(() => expect(opens).toHaveLength(3))
|
||||
await act(async () => opens[2]!.finish())
|
||||
await waitFor(() => expect(reads).toHaveLength(2))
|
||||
await act(async () => reads[1]!.finish())
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
|
||||
describe('scroll position', () => {
|
||||
it('restores where the reader left the file', async () => {
|
||||
await shown({ initialView: { scrollTop: 400, scrollLeft: 30 } })
|
||||
|
||||
expect(scroller().scrollTop).toBe(400)
|
||||
expect(scroller().scrollLeft).toBe(30)
|
||||
})
|
||||
|
||||
it('remembers where the reader was on each sheet, while the file stays open', async () => {
|
||||
await shown()
|
||||
scrollTo(300, 10)
|
||||
|
||||
fireEvent.click(tab('Notes'))
|
||||
await screen.findByRole('cell', { name: 'the milk' })
|
||||
// A sheet not visited before starts at its corner.
|
||||
expect(scroller().scrollTop).toBe(0)
|
||||
expect(scroller().scrollLeft).toBe(0)
|
||||
scrollTo(50, 0)
|
||||
|
||||
fireEvent.click(tab('Budget'))
|
||||
await screen.findByRole('cell', { name: 'Rent' })
|
||||
expect(scroller().scrollTop).toBe(300)
|
||||
expect(scroller().scrollLeft).toBe(10)
|
||||
|
||||
fireEvent.click(tab('Notes'))
|
||||
await screen.findByRole('cell', { name: 'the milk' })
|
||||
expect(scroller().scrollTop).toBe(50)
|
||||
})
|
||||
|
||||
it('puts the reader back after the panel was hidden and shown again', async () => {
|
||||
await shown()
|
||||
scrollTo(123, 45)
|
||||
|
||||
resizePanel(0, 0) // a hidden panel has no size
|
||||
scroller().scrollTop = 0 // and the browser forgets where it was scrolled to
|
||||
scroller().scrollLeft = 0
|
||||
resizePanel(700, 800)
|
||||
|
||||
expect(scroller().scrollTop).toBe(123)
|
||||
expect(scroller().scrollLeft).toBe(45)
|
||||
})
|
||||
|
||||
it('does not move the reader for a resize', async () => {
|
||||
await shown()
|
||||
scrollTo(123)
|
||||
|
||||
resizePanel(500, 600)
|
||||
|
||||
expect(scroller().scrollTop).toBe(123)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useCallback, useLayoutEffect, useRef, type ReactNode } from 'react'
|
||||
import { SegmentedControl } from '@/components/ui/SegmentedControl'
|
||||
import { useElementSize } from '@/hooks/useElementSize'
|
||||
import { useTranslation } from '@/i18n'
|
||||
import { PanelMessage } from '../PanelMessage'
|
||||
import { DocumentFailure } from './DocumentFailure'
|
||||
import { DocumentToolbar } from './DocumentToolbar'
|
||||
import type { DocumentViewerProps } from './documentViewers'
|
||||
import { SheetGrid } from './SheetGrid'
|
||||
import { defaultSpreadsheetEngine, type SpreadsheetEngine, type SpreadsheetError } from './spreadsheetEngine'
|
||||
import { useSheetGrid, useSpreadsheetDocument } from './useSpreadsheetDocument'
|
||||
|
||||
export type SpreadsheetSurfaceProps = DocumentViewerProps & {
|
||||
/** Overridable so a test can supply a workbook without loading SheetJS. */
|
||||
engine?: SpreadsheetEngine
|
||||
}
|
||||
|
||||
type ScrollAt = { top: number; left: number }
|
||||
|
||||
function fileNameOf(path: string): string {
|
||||
return path.split(/[\\/]/).filter(Boolean).pop() ?? path
|
||||
}
|
||||
|
||||
function failureMessage(error: SpreadsheetError, t: ReturnType<typeof useTranslation>): string {
|
||||
if (error.kind === 'tooComplex') return t('workspace.document.tooComplex')
|
||||
if (error.kind === 'unavailable') return t('workspace.document.engineUnavailable')
|
||||
return t('workspace.document.parseFailed')
|
||||
}
|
||||
|
||||
/**
|
||||
* The spreadsheet viewer `DocumentSurface` loads for `.xlsx` files.
|
||||
*
|
||||
* A workbook is a row of sheet tabs over one sheet at a time, drawn as a grid of text (see
|
||||
* `SheetGrid`). Which sheet the reader is on is remembered with the file, and so is where
|
||||
* they scrolled to on each sheet while the tab stays open.
|
||||
*/
|
||||
export default function SpreadsheetSurface({
|
||||
blob,
|
||||
path,
|
||||
absolutePath,
|
||||
sheet,
|
||||
onSheetChange,
|
||||
initialView,
|
||||
engine = defaultSpreadsheetEngine,
|
||||
}: SpreadsheetSurfaceProps) {
|
||||
const t = useTranslation()
|
||||
const { current, error, retry } = useSpreadsheetDocument(engine, blob)
|
||||
|
||||
const { selected, grid, gridName, error: gridError } = useSheetGrid(current, sheet)
|
||||
// The sheet asked for is the one on screen. While it is not, the last one stays up.
|
||||
const onScreen = selected !== null && gridName === selected.name
|
||||
|
||||
// ---- scroll: one position per sheet, and back where it was when the panel returns ----
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null)
|
||||
const [sizeRef, viewport] = useElementSize<HTMLDivElement>()
|
||||
const setScroller = useCallback((node: HTMLDivElement | null) => {
|
||||
scrollRef.current = node
|
||||
sizeRef(node)
|
||||
}, [sizeRef])
|
||||
|
||||
const lastScroll = useRef<ScrollAt>({ top: 0, left: 0 })
|
||||
/** Where each sheet was left, by name, when the reader moved to another. */
|
||||
const leftAt = useRef(new Map<string, ScrollAt>())
|
||||
const pendingRestore = useRef<(ScrollAt & { name: string | null }) | null>(
|
||||
initialView ? { name: null, top: initialView.scrollTop, left: initialView.scrollLeft } : null,
|
||||
)
|
||||
/** The sheet the scroller is currently laid out for; `null` before the first, and while hidden. */
|
||||
const placed = useRef<string | null>(null)
|
||||
const visible = viewport !== null
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const node = scrollRef.current
|
||||
if (!node || grid === null || gridName === null) return
|
||||
if (!visible) {
|
||||
// A hidden panel loses its scroll offset. Remember where the reader was, to put them back.
|
||||
if (placed.current !== null) {
|
||||
pendingRestore.current = { name: placed.current, ...lastScroll.current }
|
||||
placed.current = null
|
||||
}
|
||||
return
|
||||
}
|
||||
if (placed.current === gridName) return // a newer version of the sheet on screen: stay put
|
||||
|
||||
const pending = pendingRestore.current
|
||||
const target = pending && (pending.name === null || pending.name === gridName)
|
||||
? pending
|
||||
: leftAt.current.get(gridName) ?? { top: 0, left: 0 }
|
||||
pendingRestore.current = null
|
||||
node.scrollTop = target.top
|
||||
node.scrollLeft = target.left
|
||||
lastScroll.current = { top: node.scrollTop, left: node.scrollLeft }
|
||||
placed.current = gridName
|
||||
}, [grid, gridName, visible])
|
||||
|
||||
const selectSheet = (name: string) => {
|
||||
const node = scrollRef.current
|
||||
if (node && placed.current !== null) leftAt.current.set(placed.current, { top: node.scrollTop, left: node.scrollLeft })
|
||||
onSheetChange?.(name)
|
||||
}
|
||||
|
||||
// ---- what covers the grid, if anything ----
|
||||
|
||||
let cover: ReactNode = null
|
||||
if (!current) {
|
||||
cover = error
|
||||
? (
|
||||
<DocumentFailure
|
||||
message={failureMessage(error, t)}
|
||||
absolutePath={absolutePath}
|
||||
// The same bytes fail the same way, unless SheetJS itself was what failed to load.
|
||||
onRetry={error.kind === 'unavailable' ? retry : undefined}
|
||||
/>
|
||||
)
|
||||
: <PanelMessage icon="progress_activity" message={t('workspace.document.loading')} />
|
||||
} else if (!selected) {
|
||||
cover = <PanelMessage icon="table_chart" message={t('workspace.sheet.empty')} />
|
||||
} else if (!onScreen) {
|
||||
if (gridError) cover = <DocumentFailure message={failureMessage(gridError, t)} absolutePath={absolutePath} />
|
||||
else if (grid === null) cover = <PanelMessage icon="progress_activity" message={t('workspace.document.loading')} />
|
||||
} else if (grid && (grid.rows === 0 || grid.columns === 0)) {
|
||||
cover = <PanelMessage icon="table_chart" message={t('workspace.sheet.empty')} />
|
||||
}
|
||||
|
||||
// A newer version that would not open, or would not read, leaves the last good one up.
|
||||
const refreshFailure = current ? (error ?? (onScreen ? gridError : null)) : null
|
||||
|
||||
const tabs = current && selected
|
||||
? (
|
||||
<div className="min-w-0 overflow-x-auto">
|
||||
<SegmentedControl
|
||||
as="tablist"
|
||||
appearance="underline"
|
||||
size="sm"
|
||||
label={t('workspace.sheet.tabs')}
|
||||
items={current.sheets.map((item) => ({ value: item.name, label: item.name }))}
|
||||
value={selected.name}
|
||||
onChange={selectSheet}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||
{current ? (
|
||||
<DocumentToolbar
|
||||
leading={tabs}
|
||||
absolutePath={absolutePath}
|
||||
note={t('workspace.document.approximate.xlsx')}
|
||||
/>
|
||||
) : null}
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<div
|
||||
ref={setScroller}
|
||||
// The grid arrives after this mounts, so the panel leaves restoring the scroll position
|
||||
// to us (see WorkspaceFileTab); it still records where the reader leaves it.
|
||||
data-workspace-scroll-surface="deferred"
|
||||
role="tabpanel"
|
||||
aria-label={gridName ?? fileNameOf(path)}
|
||||
aria-busy={selected !== null && !onScreen && !gridError}
|
||||
aria-hidden={cover ? true : undefined}
|
||||
tabIndex={cover ? -1 : 0}
|
||||
onScroll={(event) => {
|
||||
lastScroll.current = { top: event.currentTarget.scrollTop, left: event.currentTarget.scrollLeft }
|
||||
}}
|
||||
className="absolute inset-0 overflow-auto bg-[var(--color-surface)] outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-border-focus)]"
|
||||
>
|
||||
{grid && gridName !== null ? <SheetGrid key={gridName} grid={grid} label={gridName} /> : null}
|
||||
</div>
|
||||
{cover ? <div className="absolute inset-0 bg-[var(--color-surface)]">{cover}</div> : null}
|
||||
</div>
|
||||
{refreshFailure ? (
|
||||
<p role="status" className="shrink-0 border-t border-[var(--color-border)] px-3 py-1.5 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{t('workspace.files.refreshFailed', { reason: failureMessage(refreshFailure, t) })}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { documentViewers, isDocumentPreviewType } from './documentViewers'
|
||||
|
||||
describe('isDocumentPreviewType', () => {
|
||||
it.each(['pdf', 'docx', 'xlsx'])('names %s as a document, whose bytes are fetched apart from its metadata', (type) => {
|
||||
expect(isDocumentPreviewType(type)).toBe(true)
|
||||
})
|
||||
|
||||
it.each(['text', 'image', undefined, ''])('does not take %s for one', (type) => {
|
||||
expect(isDocumentPreviewType(type)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the document viewers', () => {
|
||||
it('has a viewer for each kind of document the panel can draw', () => {
|
||||
expect(Object.keys(documentViewers).sort()).toEqual(['docx', 'pdf', 'xlsx'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import { lazy, type ComponentType, type LazyExoticComponent } from 'react'
|
||||
import type { WorkspaceDocumentPreviewType } from '@/api/sessions'
|
||||
import type { WorkspaceFileView } from '@/stores/workspaceContentStore'
|
||||
|
||||
/**
|
||||
* What every document viewer is handed. `DocumentSurface` owns fetching the bytes
|
||||
* and every state around them (loading, failed, refreshing); a viewer's job is to
|
||||
* turn a `Blob` into pixels and report the zoom the reader chose.
|
||||
*/
|
||||
export type DocumentViewerProps = {
|
||||
/** The path as the workspace knows it (relative to the workdir when it is inside). */
|
||||
path: string
|
||||
/** Where the file lives on disk, for handing it to the operating system. */
|
||||
absolutePath: string
|
||||
/**
|
||||
* The document's bytes. Take your own `arrayBuffer()`: an engine may transfer
|
||||
* (and so detach) the buffer it is given, and the same Blob is held for the
|
||||
* next tab switch.
|
||||
*/
|
||||
blob: Blob
|
||||
/** The version `blob` holds. */
|
||||
version: string
|
||||
/** A newer version is being fetched; `blob` is still the previous one. */
|
||||
refreshing: boolean
|
||||
/**
|
||||
* The zoom the reader chose, as a scale factor, or `undefined` for the viewer's
|
||||
* default (fit). Controlled: report a change through `onZoomChange`.
|
||||
*/
|
||||
zoom: number | undefined
|
||||
onZoomChange: (zoom: number | undefined) => void
|
||||
/** For a workbook: the worksheet it was left on, by name, and how to remember a change. */
|
||||
sheet?: string
|
||||
onSheetChange?: (sheet: string) => void
|
||||
/**
|
||||
* The scroll position this file had when its tab was last open. A viewer's
|
||||
* content lays out asynchronously, so restoring it is the viewer's job, once
|
||||
* there is something to scroll: mark the scroll container
|
||||
* `data-workspace-scroll-surface="deferred"` so the panel keeps recording the
|
||||
* position but leaves restoring it to you.
|
||||
*/
|
||||
initialView: WorkspaceFileView | undefined
|
||||
}
|
||||
|
||||
export type DocumentViewer = LazyExoticComponent<ComponentType<DocumentViewerProps>>
|
||||
|
||||
export type DocumentViewers = Partial<Record<WorkspaceDocumentPreviewType, DocumentViewer>>
|
||||
|
||||
/** Whether a file's `previewType` names a document (bytes fetched separately) rather than text or an image. */
|
||||
export function isDocumentPreviewType(value: string | undefined): value is WorkspaceDocumentPreviewType {
|
||||
return value === 'pdf' || value === 'docx' || value === 'xlsx'
|
||||
}
|
||||
|
||||
/**
|
||||
* Viewers by document type.
|
||||
*
|
||||
* A type joins this map when its viewer lands, not before: the server already
|
||||
* classifies more documents than the workspace can draw, and `DocumentSurface`
|
||||
* answers the rest with "open it in the system app". Each entry is a lazy import
|
||||
* so an engine — pdf.js, docx-preview and SheetJS are hundreds of KB apiece — is
|
||||
* fetched when the first document of its kind is opened, not at startup.
|
||||
*/
|
||||
export const documentViewers: DocumentViewers = {
|
||||
pdf: lazy(() => import('./PdfSurface')),
|
||||
docx: lazy(() => import('./DocxSurface')),
|
||||
xlsx: lazy(() => import('./SpreadsheetSurface')),
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { strToU8, zipSync } from 'fflate'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
LINKS,
|
||||
altChunkDocx,
|
||||
injectionDocx,
|
||||
packDocx,
|
||||
tallDocx,
|
||||
thesisDocx,
|
||||
} from '@/test/fixtures/docx'
|
||||
import { DEFAULT_OFFICE_ZIP_LIMITS } from '@/lib/workspace/officeZipGuard'
|
||||
import { DOCX_RENDER_OPTIONS, DocxError, createDocxEngine, defaultDocxEngine } from './docxEngine'
|
||||
|
||||
/** The real docx-preview, drawing into a plain container: what the engine does, without a frame around it. */
|
||||
const realEngine = createDocxEngine({ loadDocx: () => import('docx-preview') })
|
||||
|
||||
let body: HTMLElement
|
||||
let styles: HTMLElement
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.replaceChildren()
|
||||
body = document.body.appendChild(document.createElement('div'))
|
||||
styles = document.body.appendChild(document.createElement('div'))
|
||||
})
|
||||
|
||||
async function draw(bytes: Uint8Array) {
|
||||
await realEngine.render(bytes, { body, styles })
|
||||
}
|
||||
|
||||
describe('docxEngine with the real docx-preview', () => {
|
||||
it('draws a thesis-shaped document: text, Chinese, a table, a picture', async () => {
|
||||
await draw(thesisDocx())
|
||||
|
||||
const text = body.textContent ?? ''
|
||||
expect(text).toContain('第一章 绪论 Introduction')
|
||||
expect(text).toContain('italic red 中文段落,用于检查换行与字体。')
|
||||
expect(text).toContain('R2C3 单元格')
|
||||
expect(body.querySelectorAll('table').length).toBe(1)
|
||||
expect(body.querySelectorAll('td').length).toBe(6)
|
||||
})
|
||||
|
||||
it('breaks the document into pages', async () => {
|
||||
await draw(thesisDocx())
|
||||
|
||||
expect(body.querySelectorAll('.docx-wrapper > section.docx').length).toBe(3)
|
||||
})
|
||||
|
||||
it('embeds pictures as data: URLs, the one kind of source the frame’s policy allows', async () => {
|
||||
await draw(thesisDocx())
|
||||
|
||||
const sources = [...body.querySelectorAll('img')].map((image) => image.getAttribute('src') ?? '')
|
||||
expect(sources).toHaveLength(1)
|
||||
expect(sources[0]).toMatch(/^data:image\/png;base64,/)
|
||||
})
|
||||
|
||||
it('does not draw the HTML a document can carry: that would be a frame without a sandbox', async () => {
|
||||
await draw(altChunkDocx('http://probe.invalid'))
|
||||
|
||||
expect(body.querySelector('iframe')).toBeNull()
|
||||
expect(body.textContent).not.toContain('ALTCHUNK-HTML')
|
||||
expect(body.innerHTML).not.toContain('probe.invalid')
|
||||
// The document around it still reads.
|
||||
expect(body.textContent).toContain('Before altChunk')
|
||||
expect(body.textContent).toContain('After altChunk')
|
||||
})
|
||||
|
||||
it('draws markup in a paragraph’s text as text, not as elements', async () => {
|
||||
await draw(injectionDocx('http://probe.invalid'))
|
||||
|
||||
expect(body.querySelector('script')).toBeNull()
|
||||
expect(body.querySelector('img[src="x"]')).toBeNull()
|
||||
expect(body.querySelector('[onerror]')).toBeNull()
|
||||
expect(body.textContent).toContain('<img src=x onerror="window.parent.__textXss=1">')
|
||||
})
|
||||
|
||||
it('leaves the links exactly as the document wrote them: making them safe is the frame’s job', async () => {
|
||||
await draw(thesisDocx())
|
||||
|
||||
const hrefs = [...body.querySelectorAll('a')].map((anchor) => anchor.getAttribute('href'))
|
||||
expect(hrefs).toEqual(expect.arrayContaining([LINKS.https, LINKS.javascript, LINKS.file, LINKS.mailto, '#bm1']))
|
||||
})
|
||||
|
||||
it('draws into the container it is given and nowhere else', async () => {
|
||||
const elsewhere = document.body.appendChild(document.createElement('div'))
|
||||
|
||||
await draw(tallDocx(2))
|
||||
|
||||
expect(body.textContent).toContain('Page 2 line 20')
|
||||
expect(elsewhere.children).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('writes its stylesheet to the styles container, not the pages', async () => {
|
||||
await draw(thesisDocx())
|
||||
|
||||
expect(styles.querySelectorAll('style').length).toBeGreaterThan(0)
|
||||
expect(body.querySelectorAll('style')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('docxEngine failures', () => {
|
||||
it.each([
|
||||
['text', strToU8('this is not a document')],
|
||||
['nothing', new Uint8Array(0)],
|
||||
])('answers %s with `invalid`', async (_label, bytes) => {
|
||||
const failure = await realEngine.render(bytes, { body, styles }).catch((error: unknown) => error)
|
||||
|
||||
expect(failure).toBeInstanceOf(DocxError)
|
||||
expect(failure).toMatchObject({ kind: 'invalid' })
|
||||
})
|
||||
|
||||
it('answers a document cut off mid-write (an agent still at it) with `invalid`', async () => {
|
||||
const whole = thesisDocx()
|
||||
|
||||
const failure = await realEngine.render(whole.slice(0, Math.floor(whole.length / 2)), { body, styles }).catch((error: unknown) => error)
|
||||
|
||||
expect(failure).toMatchObject({ kind: 'invalid' })
|
||||
})
|
||||
|
||||
it('answers a zip that is not a Word document with `invalid`', async () => {
|
||||
const notAWordDocument = zipSync({ 'hello.txt': strToU8('hello') })
|
||||
|
||||
const failure = await realEngine.render(notAWordDocument, { body, styles }).catch((error: unknown) => error)
|
||||
|
||||
expect(failure).toMatchObject({ kind: 'invalid' })
|
||||
})
|
||||
|
||||
it('answers a zip whose main part is broken with `invalid`', async () => {
|
||||
const broken = packDocx({ body: '<w:p><w:r><w:t>unclosed' })
|
||||
|
||||
const failure = await realEngine.render(broken, { body, styles }).catch((error: unknown) => error)
|
||||
|
||||
expect(failure).toMatchObject({ kind: 'invalid' })
|
||||
})
|
||||
|
||||
it('answers an archive that promises to inflate past all reason with `tooComplex`, before unpacking any of it', async () => {
|
||||
const loadDocx = vi.fn(async () => ({ renderAsync: vi.fn() }) as never)
|
||||
// Limits small enough to break with a fixture that fits in a test; the ones the app uses are the guard's own.
|
||||
const zipLimits = { ...DEFAULT_OFFICE_ZIP_LIMITS, maxEntryBytes: 1024 * 1024 }
|
||||
const engine = createDocxEngine({ loadDocx, zipLimits })
|
||||
const bomb = zipSync({ 'word/media/huge.bmp': new Uint8Array(4 * 1024 * 1024) })
|
||||
expect(bomb.length).toBeLessThan(16 * 1024)
|
||||
|
||||
const failure = await engine.render(bomb, { body, styles }).catch((error: unknown) => error)
|
||||
|
||||
expect(failure).toMatchObject({ kind: 'tooComplex' })
|
||||
expect(loadDocx).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports docx-preview failing to load as `unavailable`, not as a broken document', async () => {
|
||||
const engine = createDocxEngine({ loadDocx: () => Promise.reject(new Error('Failed to fetch dynamically imported module')) })
|
||||
|
||||
const failure = await engine.render(thesisDocx(), { body, styles }).catch((error: unknown) => error)
|
||||
|
||||
expect(failure).toMatchObject({ kind: 'unavailable' })
|
||||
})
|
||||
|
||||
it('keeps the original error for whoever wants to log it', async () => {
|
||||
const cause = new Error('boom')
|
||||
const engine = createDocxEngine({ loadDocx: () => Promise.reject(cause) })
|
||||
|
||||
const failure = await engine.render(thesisDocx(), { body, styles }).catch((error: unknown) => error)
|
||||
|
||||
expect((failure as DocxError).reason).toBe(cause)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the options docx-preview is given', () => {
|
||||
it('leaves off the two things that would put an unsandboxed or unreachable thing in the frame', () => {
|
||||
expect(DOCX_RENDER_OPTIONS.renderAltChunks).toBe(false)
|
||||
expect(DOCX_RENDER_OPTIONS.useBase64URL).toBe(true)
|
||||
})
|
||||
|
||||
it('passes them on to the library', async () => {
|
||||
const renderAsync = vi.fn().mockResolvedValue(undefined)
|
||||
const engine = createDocxEngine({ loadDocx: async () => ({ renderAsync }) as never })
|
||||
|
||||
await engine.render(thesisDocx(), { body, styles })
|
||||
|
||||
expect(renderAsync).toHaveBeenCalledWith(expect.any(Uint8Array), body, styles, DOCX_RENDER_OPTIONS)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the default engine', () => {
|
||||
it('is built on docx-preview, loaded when a document is first drawn', async () => {
|
||||
await defaultDocxEngine.render(tallDocx(1), { body, styles })
|
||||
|
||||
expect(body.textContent).toContain('Page 1 line 1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
import type * as DocxPreview from 'docx-preview'
|
||||
import { OfficeZipError, inspectOfficeZip, type OfficeZipLimits } from '@/lib/workspace/officeZipGuard'
|
||||
|
||||
type DocxLibrary = typeof DocxPreview
|
||||
|
||||
/**
|
||||
* Why a Word document could not be shown, in the terms the reader can act on.
|
||||
*
|
||||
* - `invalid`: not a .docx, damaged, or half-written by an agent that is still at it.
|
||||
* - `tooComplex`: a well-formed archive that promises to inflate to more than the page can hold.
|
||||
* - `unavailable`: docx-preview itself could not be loaded.
|
||||
*/
|
||||
export type DocxErrorKind = 'invalid' | 'tooComplex' | 'unavailable'
|
||||
|
||||
export class DocxError extends Error {
|
||||
readonly kind: DocxErrorKind
|
||||
readonly reason: unknown
|
||||
|
||||
constructor(kind: DocxErrorKind, message: string, reason?: unknown) {
|
||||
super(message)
|
||||
this.name = 'DocxError'
|
||||
this.kind = kind
|
||||
this.reason = reason
|
||||
}
|
||||
}
|
||||
|
||||
/** Where the document is drawn: its pages, and the stylesheet docx-preview writes for them. */
|
||||
export type DocxRenderTarget = {
|
||||
body: HTMLElement
|
||||
/** docx-preview empties this before it writes, so give it a container of its own. */
|
||||
styles: HTMLElement
|
||||
}
|
||||
|
||||
export type DocxEngine = {
|
||||
render(bytes: Uint8Array, target: DocxRenderTarget): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* docx-preview's options, and why each is not left to its default.
|
||||
*
|
||||
* `renderAltChunks`: Word lets a .docx carry an HTML document, and docx-preview draws it in
|
||||
* an `<iframe>` of its own, with no sandbox. Off: the document is drawn without it.
|
||||
* `useBase64URL`: pictures become `data:` URLs, the one source the frame's policy allows,
|
||||
* and there are no `blob:` URLs left behind to revoke.
|
||||
*/
|
||||
export const DOCX_RENDER_OPTIONS: Partial<DocxPreview.Options> = {
|
||||
className: 'docx',
|
||||
inWrapper: true,
|
||||
breakPages: true,
|
||||
renderAltChunks: false,
|
||||
useBase64URL: true,
|
||||
renderComments: false,
|
||||
renderChanges: false,
|
||||
experimental: false,
|
||||
debug: false,
|
||||
}
|
||||
|
||||
export type DocxEngineOptions = {
|
||||
loadDocx: () => Promise<DocxLibrary>
|
||||
/** What an archive may promise to inflate to. For tests; the defaults are what the app uses. */
|
||||
zipLimits?: OfficeZipLimits
|
||||
}
|
||||
|
||||
/**
|
||||
* The Word engine, built on docx-preview. It checks the archive's table of contents before
|
||||
* handing it over — docx-preview unpacks the whole thing in the page's own thread — and
|
||||
* turns whatever goes wrong into one of {@link DocxErrorKind}.
|
||||
*/
|
||||
export function createDocxEngine({ loadDocx, zipLimits }: DocxEngineOptions): DocxEngine {
|
||||
return {
|
||||
async render(bytes, target) {
|
||||
try {
|
||||
inspectOfficeZip(bytes, zipLimits)
|
||||
} catch (error) {
|
||||
if (error instanceof OfficeZipError) {
|
||||
throw new DocxError(error.reason === 'not-a-zip' ? 'invalid' : 'tooComplex', error.message, error)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
let docx: DocxLibrary
|
||||
try {
|
||||
docx = await loadDocx()
|
||||
} catch (error) {
|
||||
throw new DocxError('unavailable', error instanceof Error ? error.message : String(error), error)
|
||||
}
|
||||
|
||||
try {
|
||||
await docx.renderAsync(bytes, target.body, target.styles, DOCX_RENDER_OPTIONS)
|
||||
} catch (error) {
|
||||
// A package that opens as a zip but is not a document (no main part, broken XML).
|
||||
throw new DocxError('invalid', error instanceof Error ? error.message : String(error), error)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** The engine the app uses. docx-preview loads on the first Word document, not at startup. */
|
||||
export const defaultDocxEngine: DocxEngine = createDocxEngine({
|
||||
loadDocx: () => import('docx-preview'),
|
||||
})
|
||||
@@ -0,0 +1,457 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
FRAME_SANDBOX,
|
||||
INNER_CSP,
|
||||
applyZoom,
|
||||
attachFrameListeners,
|
||||
classifyHref,
|
||||
contentHeight,
|
||||
createDocxFrame,
|
||||
currentColorScheme,
|
||||
documentPaperColor,
|
||||
frameSrcdoc,
|
||||
naturalPageWidth,
|
||||
neutraliseLinks,
|
||||
presentationCss,
|
||||
type FrameHandlers,
|
||||
} from './docxFrame'
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
document.body.replaceChildren()
|
||||
document.documentElement.style.removeProperty('color-scheme')
|
||||
document.documentElement.style.removeProperty('--color-document-paper')
|
||||
})
|
||||
|
||||
describe('the sandbox', () => {
|
||||
it('is exactly allow-same-origin: the page can draw into the frame, and nothing in the frame can run', () => {
|
||||
expect(FRAME_SANDBOX).toBe('allow-same-origin')
|
||||
})
|
||||
|
||||
it.each(['allow-scripts', 'allow-top-navigation', 'allow-popups', 'allow-forms', 'allow-modals', 'allow-downloads'])(
|
||||
'never grants %s',
|
||||
(permission) => {
|
||||
expect(FRAME_SANDBOX.split(/\s+/)).not.toContain(permission)
|
||||
},
|
||||
)
|
||||
|
||||
it('is what a frame is created with', () => {
|
||||
const { frame } = createDocxFrame(document.body, 'light')
|
||||
|
||||
expect(frame.getAttribute('sandbox')).toBe(FRAME_SANDBOX)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the policy inside the frame', () => {
|
||||
it('allows nothing by default, and only data: pictures and fonts and inline style on top of that', () => {
|
||||
const directives = Object.fromEntries(INNER_CSP.split(';').map((part) => {
|
||||
const [name, ...values] = part.trim().split(/\s+/)
|
||||
return [name, values]
|
||||
}))
|
||||
|
||||
expect(directives['default-src']).toEqual(["'none'"])
|
||||
expect(directives['img-src']).toEqual(['data:'])
|
||||
expect(directives['font-src']).toEqual(['data:'])
|
||||
expect(directives['style-src']).toEqual(["'unsafe-inline'"])
|
||||
// Nothing that would let a script or a request out.
|
||||
expect(Object.keys(directives).sort()).toEqual(['default-src', 'font-src', 'img-src', 'style-src'])
|
||||
})
|
||||
|
||||
it('names no host, and no scheme that fetches anything', () => {
|
||||
expect(INNER_CSP).not.toMatch(/https?:|\*|blob:|filesystem:|'self'|script-src|connect-src|frame-src/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('frameSrcdoc', () => {
|
||||
const parse = (html: string) => new DOMParser().parseFromString(html, 'text/html')
|
||||
|
||||
it('puts the policy first in the document, so that it governs everything after it', () => {
|
||||
const head = parse(frameSrcdoc('dark')).head
|
||||
|
||||
const first = head.firstElementChild
|
||||
expect(first?.tagName).toBe('META')
|
||||
expect(first?.getAttribute('http-equiv')).toBe('Content-Security-Policy')
|
||||
expect(first?.getAttribute('content')).toBe(INNER_CSP)
|
||||
})
|
||||
|
||||
it('carries the page’s colour scheme, so that the frame stays transparent over the page’s surround', () => {
|
||||
expect(parse(frameSrcdoc('dark')).querySelector('meta[name="color-scheme"]')?.getAttribute('content')).toBe('dark')
|
||||
expect(parse(frameSrcdoc('light')).querySelector('meta[name="color-scheme"]')?.getAttribute('content')).toBe('light')
|
||||
})
|
||||
|
||||
it('says nothing about a scheme it does not know', () => {
|
||||
expect(parse(frameSrcdoc(null)).querySelector('meta[name="color-scheme"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('starts empty and transparent', () => {
|
||||
const doc = parse(frameSrcdoc('light'))
|
||||
|
||||
expect(doc.body.children).toHaveLength(0)
|
||||
expect(doc.head.querySelector('style')?.textContent).toContain('background:transparent')
|
||||
expect(doc.querySelector('script')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not mention a script or a remote address anywhere', () => {
|
||||
expect(frameSrcdoc('light')).not.toMatch(/<script|https?:\/\//i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('currentColorScheme', () => {
|
||||
it.each(['light', 'dark'] as const)('reads %s from the page', (scheme) => {
|
||||
document.documentElement.style.colorScheme = scheme
|
||||
|
||||
expect(currentColorScheme()).toBe(scheme)
|
||||
})
|
||||
|
||||
it('does not guess when the page says nothing', () => {
|
||||
document.documentElement.style.removeProperty('color-scheme')
|
||||
|
||||
expect(currentColorScheme()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createDocxFrame', () => {
|
||||
it('starts the frame from the sandboxed document, and adds it to the host', () => {
|
||||
const host = document.body.appendChild(document.createElement('div'))
|
||||
|
||||
const { frame } = createDocxFrame(host, 'dark')
|
||||
|
||||
expect(frame.parentElement).toBe(host)
|
||||
expect(frame.srcdoc).toBe(frameSrcdoc('dark'))
|
||||
})
|
||||
|
||||
it('resolves with the frame’s document once it has loaded', async () => {
|
||||
const { frame, ready } = createDocxFrame(document.body, 'light')
|
||||
|
||||
const doc = await ready
|
||||
|
||||
expect(doc).toBe(frame.contentDocument)
|
||||
})
|
||||
|
||||
it('gives up on a frame that never loads', async () => {
|
||||
vi.useFakeTimers()
|
||||
const host = document.body.appendChild(document.createElement('div'))
|
||||
// A frame that is not in a document never fires `load`.
|
||||
vi.spyOn(host, 'append').mockImplementation(() => undefined)
|
||||
const { ready } = createDocxFrame(host, 'light')
|
||||
const outcome = ready.then(() => 'loaded', (error: Error) => error.message)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
|
||||
expect(await outcome).toBe('The document frame did not load')
|
||||
})
|
||||
|
||||
it('is not focusable by tabbing, and has no height until it has something to show', () => {
|
||||
const { frame } = createDocxFrame(document.body, 'light')
|
||||
|
||||
expect(frame.getAttribute('tabindex')).toBe('-1')
|
||||
expect(frame.style.height).toBe('0px')
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifyHref', () => {
|
||||
it.each([
|
||||
['https://example.com/docs', { kind: 'external', url: 'https://example.com/docs' }],
|
||||
['http://example.com/', { kind: 'external', url: 'http://example.com/' }],
|
||||
['mailto:someone@example.com', { kind: 'external', url: 'mailto:someone@example.com' }],
|
||||
['#bm1', { kind: 'anchor', id: 'bm1' }],
|
||||
['#%E7%AB%A0%E8%8A%82', { kind: 'anchor', id: '章节' }],
|
||||
])('lets %s through as %j', (href, expected) => {
|
||||
expect(classifyHref(href)).toEqual(expected)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'javascript:alert(1)',
|
||||
'JaVaScRiPt:alert(1)',
|
||||
' javascript:alert(1)',
|
||||
'file:///etc/hosts',
|
||||
'data:text/html,<script>alert(1)</script>',
|
||||
'blob:https://example.com/abc',
|
||||
'vbscript:msgbox(1)',
|
||||
'ftp://example.com/x',
|
||||
'tel:+123',
|
||||
'/api/sessions',
|
||||
'../secret',
|
||||
'relative/path.html',
|
||||
'',
|
||||
'#%E0%A4%A',
|
||||
])('leaves %j nowhere to go', (href) => {
|
||||
expect(classifyHref(href)).toEqual({ kind: 'inert' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('neutraliseLinks', () => {
|
||||
function links(html: string) {
|
||||
const root = document.createElement('div')
|
||||
root.innerHTML = html
|
||||
neutraliseLinks(root)
|
||||
return [...root.querySelectorAll('a')]
|
||||
}
|
||||
|
||||
it('takes the href off a link that leads nowhere, so that no gesture can follow it', () => {
|
||||
const [js, file, data] = links('<a href="javascript:alert(1)">a</a><a href="file:///etc/hosts">b</a><a href="data:text/html,x">c</a>')
|
||||
|
||||
expect(js!.hasAttribute('href')).toBe(false)
|
||||
expect(file!.hasAttribute('href')).toBe(false)
|
||||
expect(data!.hasAttribute('href')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the href of a link that may go somewhere', () => {
|
||||
const [web, mail, bookmark] = links('<a href="https://example.com">a</a><a href="mailto:x@y.z">b</a><a href="#bm1">c</a>')
|
||||
|
||||
expect(web!.getAttribute('href')).toBe('https://example.com')
|
||||
expect(mail!.getAttribute('href')).toBe('mailto:x@y.z')
|
||||
expect(bookmark!.getAttribute('href')).toBe('#bm1')
|
||||
})
|
||||
|
||||
it('stops any link opening a window of its own, or telling the destination where it came from', () => {
|
||||
const anchors = links('<a href="https://example.com" target="_blank">a</a><a href="javascript:1" target="_top">b</a>')
|
||||
|
||||
for (const anchor of anchors) {
|
||||
expect(anchor.hasAttribute('target')).toBe(false)
|
||||
expect(anchor.getAttribute('rel')).toBe('noopener noreferrer')
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves a bookmark that is not a link alone', () => {
|
||||
const root = document.createElement('div')
|
||||
root.innerHTML = '<a name="bm1">target</a>'
|
||||
|
||||
neutraliseLinks(root)
|
||||
|
||||
expect(root.querySelector('a')?.hasAttribute('href')).toBe(false)
|
||||
expect(root.querySelector('a')?.getAttribute('name')).toBe('bm1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('attachFrameListeners', () => {
|
||||
let frameDocument: Document
|
||||
let handlers: { [K in keyof FrameHandlers]: ReturnType<typeof vi.fn> }
|
||||
let detach: () => void
|
||||
|
||||
beforeEach(() => {
|
||||
frameDocument = document.implementation.createHTMLDocument('frame')
|
||||
handlers = { onExternalLink: vi.fn(), onWheelZoom: vi.fn(), onZoomKey: vi.fn() }
|
||||
detach = attachFrameListeners(frameDocument, handlers as unknown as FrameHandlers)
|
||||
})
|
||||
|
||||
function click(target: Element) {
|
||||
const event = new MouseEvent('click', { bubbles: true, cancelable: true })
|
||||
target.dispatchEvent(event)
|
||||
return event
|
||||
}
|
||||
|
||||
function anchorWith(href: string | null, content = 'link') {
|
||||
const anchor = frameDocument.createElement('a')
|
||||
if (href !== null) anchor.setAttribute('href', href)
|
||||
anchor.innerHTML = `<span>${content}</span>`
|
||||
frameDocument.body.append(anchor)
|
||||
return anchor
|
||||
}
|
||||
|
||||
it('hands a web link to the application instead of letting the frame navigate', () => {
|
||||
const event = click(anchorWith('https://example.com/docs'))
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
expect(handlers.onExternalLink).toHaveBeenCalledWith('https://example.com/docs')
|
||||
})
|
||||
|
||||
it('finds the link when the click lands on something inside it', () => {
|
||||
const anchor = anchorWith('https://example.com/inside')
|
||||
|
||||
click(anchor.querySelector('span')!)
|
||||
|
||||
expect(handlers.onExternalLink).toHaveBeenCalledWith('https://example.com/inside')
|
||||
})
|
||||
|
||||
it.each(['javascript:alert(1)', 'file:///etc/hosts', 'data:text/html,x', '/relative', null])(
|
||||
'does nothing at all with %j, beyond keeping the frame where it is',
|
||||
(href) => {
|
||||
const event = click(anchorWith(href))
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
expect(handlers.onExternalLink).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
|
||||
it('scrolls to a bookmark inside the document', () => {
|
||||
const target = frameDocument.createElement('p')
|
||||
target.id = 'bm1'
|
||||
target.scrollIntoView = vi.fn()
|
||||
frameDocument.body.append(target)
|
||||
|
||||
click(anchorWith('#bm1'))
|
||||
|
||||
expect(target.scrollIntoView).toHaveBeenCalled()
|
||||
expect(handlers.onExternalLink).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('finds a bookmark by name too, which is how Word writes them', () => {
|
||||
const target = frameDocument.createElement('a')
|
||||
target.setAttribute('name', 'bm2')
|
||||
target.scrollIntoView = vi.fn()
|
||||
frameDocument.body.append(target)
|
||||
|
||||
click(anchorWith('#bm2'))
|
||||
|
||||
expect(target.scrollIntoView).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not mind a bookmark that is not there', () => {
|
||||
expect(() => click(anchorWith('#nowhere'))).not.toThrow()
|
||||
})
|
||||
|
||||
it('leaves a click on plain text alone', () => {
|
||||
const paragraph = frameDocument.createElement('p')
|
||||
frameDocument.body.append(paragraph)
|
||||
|
||||
const event = click(paragraph)
|
||||
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
expect(handlers.onExternalLink).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('the wheel', () => {
|
||||
const wheel = (init: WheelEventInit) => {
|
||||
const event = new WheelEvent('wheel', { bubbles: true, cancelable: true, ...init })
|
||||
frameDocument.body.dispatchEvent(event)
|
||||
return event
|
||||
}
|
||||
|
||||
it.each([['Ctrl', { ctrlKey: true }], ['⌘', { metaKey: true }]])('zooms the document on %s+wheel, and not the whole page', (_label, keys) => {
|
||||
const event = wheel({ ...keys, deltaY: -100, clientX: 30, clientY: 40 })
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
expect(handlers.onWheelZoom).toHaveBeenCalledWith(-100, { x: 30, y: 40 })
|
||||
})
|
||||
|
||||
it('leaves an ordinary wheel to scroll', () => {
|
||||
const event = wheel({ deltaY: 100 })
|
||||
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
expect(handlers.onWheelZoom).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('the keyboard', () => {
|
||||
const press = (key: string, init: KeyboardEventInit = {}) => {
|
||||
const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init })
|
||||
frameDocument.body.dispatchEvent(event)
|
||||
return event
|
||||
}
|
||||
|
||||
it.each(['+', '=', '-', '_', '0', '1'])('reports the zoom key %s', (key) => {
|
||||
const event = press(key)
|
||||
|
||||
expect(handlers.onZoomKey).toHaveBeenCalledWith(key)
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it.each([['a', {}], ['+', { ctrlKey: true }], ['-', { metaKey: true }], ['0', { altKey: true }]])(
|
||||
'leaves %s with %j to the browser',
|
||||
(key, init) => {
|
||||
press(key, init as KeyboardEventInit)
|
||||
|
||||
expect(handlers.onZoomKey).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it('stops listening when detached', () => {
|
||||
detach()
|
||||
|
||||
click(anchorWith('https://example.com'))
|
||||
frameDocument.body.dispatchEvent(new WheelEvent('wheel', { ctrlKey: true, deltaY: 1, bubbles: true, cancelable: true }))
|
||||
frameDocument.body.dispatchEvent(new KeyboardEvent('keydown', { key: '+', bubbles: true }))
|
||||
|
||||
expect(handlers.onExternalLink).not.toHaveBeenCalled()
|
||||
expect(handlers.onWheelZoom).not.toHaveBeenCalled()
|
||||
expect(handlers.onZoomKey).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('measuring', () => {
|
||||
function pages(...widths: string[]) {
|
||||
const doc = document.implementation.createHTMLDocument('frame')
|
||||
const wrapper = doc.body.appendChild(doc.createElement('div'))
|
||||
wrapper.className = 'docx-wrapper'
|
||||
for (const width of widths) {
|
||||
const section = wrapper.appendChild(doc.createElement('section'))
|
||||
section.className = 'docx'
|
||||
section.style.width = width
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
it('reads the widest page from its declared width where nothing has been laid out', () => {
|
||||
expect(naturalPageWidth(pages('595.3pt', '842pt'))).toBeCloseTo((842 * 96) / 72, 3)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['794px', 794],
|
||||
['8.5in', 816],
|
||||
['21cm', (21 * 96) / 2.54],
|
||||
['210mm', (210 * 96) / 25.4],
|
||||
['612pt', 816],
|
||||
])('understands %s', (width, expected) => {
|
||||
expect(naturalPageWidth(pages(width))).toBeCloseTo(expected, 3)
|
||||
})
|
||||
|
||||
it('prefers the width the browser laid out to the one the document declared', () => {
|
||||
const doc = pages('100pt')
|
||||
doc.querySelector('section')!.getBoundingClientRect = () => ({ width: 777 }) as DOMRect
|
||||
|
||||
expect(naturalPageWidth(doc)).toBe(777)
|
||||
})
|
||||
|
||||
it('is zero for a document with no pages', () => {
|
||||
expect(naturalPageWidth(pages())).toBe(0)
|
||||
})
|
||||
|
||||
it('measures the content height to whole pixels, rounding up', () => {
|
||||
const doc = document.implementation.createHTMLDocument('frame')
|
||||
doc.body.getBoundingClientRect = () => ({ height: 1234.2 }) as DOMRect
|
||||
|
||||
expect(contentHeight(doc)).toBe(1235)
|
||||
})
|
||||
})
|
||||
|
||||
describe('presentation', () => {
|
||||
it('zooms the wrapper, which holds the pages and their gaps alike', () => {
|
||||
const doc = document.implementation.createHTMLDocument('frame')
|
||||
const wrapper = doc.body.appendChild(doc.createElement('div'))
|
||||
wrapper.className = 'docx-wrapper'
|
||||
|
||||
applyZoom(doc, 1.25)
|
||||
|
||||
expect(wrapper.style.getPropertyValue('--docx-zoom')).toBe('1.25')
|
||||
// …which the frame's stylesheet turns into `zoom` on that wrapper.
|
||||
expect(presentationCss('white')).toMatch(/\.docx-wrapper\{zoom:var\(--docx-zoom,1\)/)
|
||||
})
|
||||
|
||||
it('does nothing for a document that has not been drawn', () => {
|
||||
expect(() => applyZoom(document.implementation.createHTMLDocument('frame'), 2)).not.toThrow()
|
||||
})
|
||||
|
||||
it('takes the paper colour from the page’s theme', () => {
|
||||
document.documentElement.style.setProperty('--color-document-paper', ' #FFFFFF ')
|
||||
|
||||
expect(documentPaperColor()).toBe('#FFFFFF')
|
||||
})
|
||||
|
||||
it('has a paper colour even where the page defines none', () => {
|
||||
expect(documentPaperColor()).toBe('white')
|
||||
})
|
||||
|
||||
it('paints the paper with that colour, and leaves the surround to the page', () => {
|
||||
const css = presentationCss('rgb(250, 250, 250)')
|
||||
|
||||
expect(css).toContain('section.docx{background:rgb(250, 250, 250)')
|
||||
expect(css).toMatch(/\.docx-wrapper\{[^}]*background:transparent/)
|
||||
})
|
||||
|
||||
it('names no colour of its own', () => {
|
||||
expect(presentationCss('var(--x)')).not.toMatch(/#[0-9a-f]{3,8}\b/i)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* Where a Word document is drawn: a frame that can do nothing.
|
||||
*
|
||||
* A .docx is untrusted input rendered to HTML, and this app's own policy allows inline
|
||||
* script (`script-src 'unsafe-inline'`), so the page's policy is not the boundary. The
|
||||
* boundary is this frame: sandboxed with no `allow-scripts`, so nothing in it runs
|
||||
* whatever the document managed to put there — a `<script>`, an `onerror=`, a
|
||||
* `javascript:` link. `allow-same-origin` is what lets the page draw into the frame and
|
||||
* listen to it; it is safe here for the same reason, since there is no script inside to
|
||||
* take advantage of it.
|
||||
*
|
||||
* The policy `<meta>` inside the frame comes first in its document and only ever adds
|
||||
* to the page's: it closes the two ways a stylesheet or an image in the document could
|
||||
* reach out over the network.
|
||||
*/
|
||||
|
||||
/** Exactly this. Never `allow-scripts`, never `allow-top-navigation`, never `allow-popups`. */
|
||||
export const FRAME_SANDBOX = 'allow-same-origin'
|
||||
|
||||
export const INNER_CSP = "default-src 'none'; img-src data:; font-src data:; style-src 'unsafe-inline'"
|
||||
|
||||
export type FrameColorScheme = 'light' | 'dark'
|
||||
|
||||
/**
|
||||
* The document the frame starts with. The color scheme has to match the page's: a frame
|
||||
* whose scheme differs from the element embedding it gets an opaque canvas, and the
|
||||
* surround around the pages — which belongs to the page's theme — would be painted over.
|
||||
*/
|
||||
export function frameSrcdoc(colorScheme: FrameColorScheme | null): string {
|
||||
return [
|
||||
'<!doctype html><html><head>',
|
||||
`<meta http-equiv="Content-Security-Policy" content="${INNER_CSP}">`,
|
||||
'<meta charset="utf-8">',
|
||||
colorScheme ? `<meta name="color-scheme" content="${colorScheme}">` : '',
|
||||
'<style>html,body{margin:0;padding:0;background:transparent}</style>',
|
||||
'</head><body></body></html>',
|
||||
].join('')
|
||||
}
|
||||
|
||||
export function currentColorScheme(): FrameColorScheme | null {
|
||||
const scheme = getComputedStyle(document.documentElement).colorScheme
|
||||
if (scheme === 'dark') return 'dark'
|
||||
if (scheme === 'light') return 'light'
|
||||
return null
|
||||
}
|
||||
|
||||
const FRAME_LOAD_TIMEOUT_MS = 10_000
|
||||
|
||||
/** Add a frame to `host` and resolve with its document once that has loaded. */
|
||||
export function createDocxFrame(
|
||||
host: HTMLElement,
|
||||
colorScheme: FrameColorScheme | null,
|
||||
): { frame: HTMLIFrameElement; ready: Promise<Document> } {
|
||||
const frame = document.createElement('iframe')
|
||||
frame.setAttribute('sandbox', FRAME_SANDBOX)
|
||||
frame.setAttribute('title', '')
|
||||
frame.setAttribute('tabindex', '-1')
|
||||
frame.style.cssText = 'border:0;display:block;width:100%;height:0'
|
||||
|
||||
const ready = new Promise<Document>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('The document frame did not load')), FRAME_LOAD_TIMEOUT_MS)
|
||||
frame.addEventListener('load', () => {
|
||||
clearTimeout(timer)
|
||||
const doc = frame.contentDocument
|
||||
if (doc) resolve(doc)
|
||||
else reject(new Error('The document frame is not reachable'))
|
||||
}, { once: true })
|
||||
})
|
||||
frame.srcdoc = frameSrcdoc(colorScheme)
|
||||
host.append(frame)
|
||||
return { frame, ready }
|
||||
}
|
||||
|
||||
// ---- links ----
|
||||
|
||||
export type LinkAction =
|
||||
| { kind: 'anchor'; id: string }
|
||||
| { kind: 'external'; url: string }
|
||||
| { kind: 'inert' }
|
||||
|
||||
/**
|
||||
* Where a link may lead. Only http(s) and mail links leave the frame, and they leave
|
||||
* through the host application, not by navigating the frame; `#bookmarks` scroll inside
|
||||
* it; everything else — `javascript:`, `file:`, `data:`, `blob:`, a relative path that
|
||||
* would resolve against the app — goes nowhere.
|
||||
*/
|
||||
export function classifyHref(href: string): LinkAction {
|
||||
if (href.startsWith('#')) {
|
||||
try {
|
||||
return { kind: 'anchor', id: decodeURIComponent(href.slice(1)) }
|
||||
} catch {
|
||||
return { kind: 'inert' }
|
||||
}
|
||||
}
|
||||
try {
|
||||
const url = new URL(href)
|
||||
if (url.protocol === 'http:' || url.protocol === 'https:' || url.protocol === 'mailto:') {
|
||||
return { kind: 'external', url: url.href }
|
||||
}
|
||||
} catch {
|
||||
// Relative, or not a URL at all.
|
||||
}
|
||||
return { kind: 'inert' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Take the `href` off every link that leads nowhere. The click handler already keeps such
|
||||
* a link from doing anything, but a link is also reachable without a plain click — a
|
||||
* middle click, a drag, the context menu — and one with no `href` is not a link there.
|
||||
*/
|
||||
export function neutraliseLinks(root: ParentNode): void {
|
||||
for (const anchor of root.querySelectorAll('a')) {
|
||||
const href = anchor.getAttribute('href')
|
||||
if (href === null) continue
|
||||
if (classifyHref(href).kind === 'inert') anchor.removeAttribute('href')
|
||||
anchor.removeAttribute('target')
|
||||
anchor.setAttribute('rel', 'noopener noreferrer')
|
||||
}
|
||||
}
|
||||
|
||||
function escapeAttributeValue(value: string): string {
|
||||
return typeof CSS !== 'undefined' && typeof CSS.escape === 'function' ? CSS.escape(value) : value.replace(/["\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
export type FrameHandlers = {
|
||||
/** A link that leaves the document: hand it to the application, do not navigate. */
|
||||
onExternalLink: (url: string) => void
|
||||
/** Ctrl/⌘ + wheel over the document, with the pointer's place in the frame. */
|
||||
onWheelZoom: (deltaY: number, at: { x: number; y: number }) => void
|
||||
/** A zoom key pressed while the frame has the keyboard. */
|
||||
onZoomKey: (key: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen to a frame from the page. The frame has no script of its own, so anything the
|
||||
* reader does in it — a click, a wheel, a key — is reported here or not at all.
|
||||
*/
|
||||
export function attachFrameListeners(doc: Document, handlers: FrameHandlers): () => void {
|
||||
const onClick = (event: MouseEvent) => {
|
||||
const anchor = (event.target as Element | null)?.closest?.('a')
|
||||
if (!anchor) return
|
||||
// The frame must never navigate itself.
|
||||
event.preventDefault()
|
||||
const action = classifyHref(anchor.getAttribute('href') ?? '')
|
||||
if (action.kind === 'anchor') {
|
||||
const target = doc.getElementById(action.id) ?? doc.querySelector(`[name="${escapeAttributeValue(action.id)}"]`)
|
||||
target?.scrollIntoView?.()
|
||||
} else if (action.kind === 'external') {
|
||||
handlers.onExternalLink(action.url)
|
||||
}
|
||||
}
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
if (!event.ctrlKey && !event.metaKey) return
|
||||
// Ctrl+wheel is also a trackpad pinch: zoom the document, not the whole page.
|
||||
event.preventDefault()
|
||||
handlers.onWheelZoom(event.deltaY, { x: event.clientX, y: event.clientY })
|
||||
}
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.ctrlKey || event.metaKey || event.altKey) return
|
||||
if (['+', '=', '-', '_', '0', '1'].includes(event.key)) {
|
||||
event.preventDefault()
|
||||
handlers.onZoomKey(event.key)
|
||||
}
|
||||
}
|
||||
doc.addEventListener('click', onClick, true)
|
||||
doc.addEventListener('wheel', onWheel, { passive: false })
|
||||
doc.addEventListener('keydown', onKeyDown)
|
||||
return () => {
|
||||
doc.removeEventListener('click', onClick, true)
|
||||
doc.removeEventListener('wheel', onWheel)
|
||||
doc.removeEventListener('keydown', onKeyDown)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- presentation ----
|
||||
|
||||
/**
|
||||
* The paper's colour as the page's theme defines it (`--color-document-paper`). The frame is a
|
||||
* document of its own and cannot read the page's custom properties, so it is handed the value.
|
||||
*/
|
||||
export function documentPaperColor(): string {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue('--color-document-paper').trim() || 'white'
|
||||
}
|
||||
|
||||
/**
|
||||
* What the page adds to docx-preview's own stylesheet, after it: the surround belongs to the page's
|
||||
* theme (it shows through the transparent frame), the paper does not (white in every theme, or a
|
||||
* highlighted passage would change its meaning), and the wrapper is what zoom is applied to.
|
||||
*/
|
||||
export function presentationCss(paper: string): string {
|
||||
return [
|
||||
// Zoom is a property of the stylesheet, its value a variable on the wrapper: one place says
|
||||
// what zoom does, and a script-less frame has no other way to be told a number.
|
||||
'.docx-wrapper{zoom:var(--docx-zoom,1);background:transparent;padding:16px;padding-bottom:4px}',
|
||||
`.docx-wrapper>section.docx{background:${paper};box-shadow:0 1px 4px rgb(0 0 0 / 0.25);margin-bottom:12px}`,
|
||||
].join('')
|
||||
}
|
||||
|
||||
export function applyZoom(doc: Document, zoom: number): void {
|
||||
doc.querySelector<HTMLElement>('.docx-wrapper')?.style.setProperty('--docx-zoom', String(zoom))
|
||||
}
|
||||
|
||||
function lengthToPixels(value: string): number {
|
||||
const number = Number.parseFloat(value)
|
||||
if (!Number.isFinite(number)) return 0
|
||||
if (value.endsWith('pt')) return (number * 96) / 72
|
||||
if (value.endsWith('in')) return number * 96
|
||||
if (value.endsWith('cm')) return (number * 96) / 2.54
|
||||
if (value.endsWith('mm')) return (number * 96) / 25.4
|
||||
return number
|
||||
}
|
||||
|
||||
/**
|
||||
* The width of the widest page at 100%, in CSS pixels. Measured before any zoom is applied;
|
||||
* read from the page's declared width where the browser has laid nothing out.
|
||||
*/
|
||||
export function naturalPageWidth(doc: Document): number {
|
||||
let widest = 0
|
||||
for (const section of doc.querySelectorAll<HTMLElement>('.docx-wrapper > section.docx')) {
|
||||
const laidOut = section.getBoundingClientRect().width
|
||||
widest = Math.max(widest, laidOut > 0 ? laidOut : lengthToPixels(section.style.width))
|
||||
}
|
||||
return widest
|
||||
}
|
||||
|
||||
/** How tall the frame must be to show everything in it. */
|
||||
export function contentHeight(doc: Document): number {
|
||||
return Math.ceil(doc.body.getBoundingClientRect().height)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Worker entry for pdf.js. Evaluating pdf.js' worker module inside a worker global
|
||||
* is what starts it (it wires itself to `self` in a static block), so the whole
|
||||
* file is one import. Loaded through Vite's `?worker`, which bundles it into a
|
||||
* self-contained script the renderer can start from `file://`, from the packaged
|
||||
* app's asar, and from the H5 static host alike.
|
||||
*/
|
||||
import 'pdfjs-dist/legacy/build/pdf.worker.mjs'
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Where pdf.js finds the data it reads at run time, by URL, when a document needs
|
||||
* it: CMaps (the glyph tables of CJK fonts that a PDF names but does not embed),
|
||||
* the 14 standard fonts, wasm image decoders and ICC colour profiles. Without them
|
||||
* a Chinese PDF renders as blanks or boxes, and nothing in the build or the type
|
||||
* checker says so.
|
||||
*
|
||||
* Shared by the engine, which asks for these folders, and the Vite plugin
|
||||
* (`scripts/vite-pdfjs-assets.ts`), which ships them: the two cannot drift apart.
|
||||
* Plain data on purpose, so the build script can import it without a DOM.
|
||||
*/
|
||||
|
||||
/** pdf.js' `getDocument` option that takes a folder URL, and the folder in pdfjs-dist it names. */
|
||||
export const PDFJS_ASSET_FOLDERS = {
|
||||
cMapUrl: 'cmaps',
|
||||
standardFontDataUrl: 'standard_fonts',
|
||||
wasmUrl: 'wasm',
|
||||
iccUrl: 'iccs',
|
||||
} as const
|
||||
|
||||
/** The pdfjs-dist folders that ship with the app. */
|
||||
export const PDFJS_ASSET_DIRS = Object.values(PDFJS_ASSET_FOLDERS)
|
||||
|
||||
/**
|
||||
* Where the folders are served, relative to the app. The version is in the name, so
|
||||
* an upgrade cannot be served the previous version's data from a cache.
|
||||
*/
|
||||
export function pdfjsAssetsPrefix(version: string): string {
|
||||
return `assets/pdfjs-${version}/`
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
// @vitest-environment node
|
||||
/**
|
||||
* The engine against the real pdf.js, in Node, on documents built in the test.
|
||||
* There is no canvas here, so painting is left to the browser smoke; what this
|
||||
* proves is the part a mock cannot: that the import path, the getDocument
|
||||
* options and the error mapping actually work with the shipped library.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { letterPdf, pdfWithPages } from '@/test/fixtures/pdf'
|
||||
import { PdfError, createPdfEngine, type PdfDocumentHandle, type PdfEngine } from './pdfEngine'
|
||||
import { PDF_CSS_UNITS } from './pdfLayout'
|
||||
|
||||
const engine: PdfEngine = createPdfEngine({ loadPdfjs: () => import('pdfjs-dist/legacy/build/pdf.mjs') })
|
||||
const open: PdfDocumentHandle[] = []
|
||||
|
||||
async function openPdf(data: Uint8Array): Promise<PdfDocumentHandle> {
|
||||
const handle = await engine.open(data)
|
||||
open.push(handle)
|
||||
return handle
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(open.splice(0).map((handle) => handle.destroy()))
|
||||
})
|
||||
|
||||
describe('pdfEngine with the real pdf.js', () => {
|
||||
it('opens a document and reports its page count', async () => {
|
||||
const handle = await openPdf(letterPdf(3))
|
||||
|
||||
expect(handle.numPages).toBe(3)
|
||||
})
|
||||
|
||||
it('reports each page at its own natural size, in CSS pixels at 100%', async () => {
|
||||
const handle = await openPdf(pdfWithPages([
|
||||
{ width: 612, height: 792, text: 'portrait' },
|
||||
{ width: 792, height: 612, text: 'landscape' },
|
||||
]))
|
||||
|
||||
// PDF points to CSS pixels: ×96/72 — so a Letter page is 816 × 1056.
|
||||
expect(await handle.pageSize(1)).toEqual({ width: 612 * PDF_CSS_UNITS, height: 792 * PDF_CSS_UNITS })
|
||||
expect(await handle.pageSize(2)).toEqual({ width: 792 * PDF_CSS_UNITS, height: 612 * PDF_CSS_UNITS })
|
||||
})
|
||||
|
||||
it('can open several documents from one engine, one after another', async () => {
|
||||
const first = await openPdf(letterPdf(2))
|
||||
const second = await openPdf(letterPdf(5))
|
||||
|
||||
expect(first.numPages).toBe(2)
|
||||
expect(second.numPages).toBe(5)
|
||||
})
|
||||
|
||||
it('answers garbage with an `invalid` error rather than a raw pdf.js exception', async () => {
|
||||
const failure = await engine.open(new TextEncoder().encode('this is not a pdf at all')).catch((error: unknown) => error)
|
||||
|
||||
expect(failure).toBeInstanceOf(PdfError)
|
||||
expect(failure).toMatchObject({ kind: 'invalid' })
|
||||
})
|
||||
|
||||
it('answers a file cut off mid-write (an agent still at it) as `invalid`, not as a hang', async () => {
|
||||
const whole = letterPdf(2)
|
||||
const truncated = whole.slice(0, Math.floor(whole.length / 2))
|
||||
|
||||
const failure = await engine.open(truncated).catch((error: unknown) => error)
|
||||
|
||||
expect(failure).toMatchObject({ kind: 'invalid' })
|
||||
})
|
||||
|
||||
it('answers an empty file as `invalid`', async () => {
|
||||
const failure = await engine.open(new Uint8Array(0)).catch((error: unknown) => error)
|
||||
|
||||
expect(failure).toMatchObject({ kind: 'invalid' })
|
||||
})
|
||||
|
||||
it('refuses to paint into a page that has been cancelled before it began', async () => {
|
||||
const handle = await openPdf(letterPdf(1))
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
|
||||
await expect(
|
||||
handle.renderPage(1, { canvas: {} as HTMLCanvasElement, scale: 1, signal: controller.signal }),
|
||||
).rejects.toMatchObject({ name: 'AbortError' })
|
||||
await expect(
|
||||
handle.renderTextLayer(1, { container: {} as HTMLElement, scale: 1, signal: controller.signal }),
|
||||
).rejects.toMatchObject({ name: 'AbortError' })
|
||||
})
|
||||
|
||||
it('does not throw when asked to release a page that does not exist', async () => {
|
||||
const handle = await openPdf(letterPdf(1))
|
||||
|
||||
expect(() => handle.releasePage(99)).not.toThrow()
|
||||
})
|
||||
|
||||
it('does not throw when asked to release a page of a document that has been closed', async () => {
|
||||
// A rewritten file replaces the document while its pages are still on screen. Each
|
||||
// page hands back its predecessor's page once the new drawing is up, by which time
|
||||
// the old document is closed — and pdf.js throws from `getPage` on a closed
|
||||
// document, synchronously, rather than rejecting.
|
||||
const handle = await engine.open(letterPdf(2))
|
||||
await handle.destroy()
|
||||
|
||||
expect(() => handle.releasePage(1)).not.toThrow()
|
||||
})
|
||||
|
||||
it('does not throw for a page that was never drawn either, once the document is closed', async () => {
|
||||
const handle = await engine.open(letterPdf(3))
|
||||
await handle.destroy()
|
||||
|
||||
for (const page of [1, 2, 3, 99]) expect(() => handle.releasePage(page)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* pdf.js run through a worker port has no handshake and no fallback: a worker
|
||||
* whose script never loads leaves every later document waiting forever. So the
|
||||
* engine watches the worker itself. These use a stand-in pdf.js, since what is
|
||||
* under test is the watching, not the parsing.
|
||||
*/
|
||||
describe('pdfEngine worker startup', () => {
|
||||
class FakeWorker extends EventTarget {
|
||||
terminate = vi.fn()
|
||||
postMessage = vi.fn()
|
||||
/**
|
||||
* Settles once the engine is watching this worker. Events sent before that are
|
||||
* lost, as they would be for a real worker, and counting microtask ticks to
|
||||
* guess when the engine gets there is how tests like this go flaky.
|
||||
*/
|
||||
readonly watched: Promise<void>
|
||||
private markWatched!: () => void
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.watched = new Promise<void>((resolve) => { this.markWatched = resolve })
|
||||
}
|
||||
|
||||
override addEventListener(type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions): void {
|
||||
super.addEventListener(type, listener, options)
|
||||
if (type === 'error') this.markWatched()
|
||||
}
|
||||
}
|
||||
|
||||
function stubPdfjs() {
|
||||
const document = { numPages: 1 }
|
||||
const task = { promise: Promise.resolve(document), destroy: vi.fn().mockResolvedValue(undefined) }
|
||||
// Every `PDFWorker` the engine builds, and the port it was built over.
|
||||
const workers: Array<{ params: { port: unknown } }> = []
|
||||
class PDFWorker {
|
||||
static create(params: { port: unknown }) {
|
||||
return new PDFWorker(params)
|
||||
}
|
||||
|
||||
constructor(readonly params: { port: unknown }) {
|
||||
workers.push(this)
|
||||
}
|
||||
}
|
||||
return {
|
||||
pdfjs: {
|
||||
version: '0.0.0',
|
||||
PDFWorker,
|
||||
getDocument: vi.fn((_params: Record<string, unknown>) => task),
|
||||
},
|
||||
task,
|
||||
workers,
|
||||
}
|
||||
}
|
||||
|
||||
const engineWith = (stub: ReturnType<typeof stubPdfjs>, createWorker: () => FakeWorker | Promise<FakeWorker>) =>
|
||||
createPdfEngine({
|
||||
loadPdfjs: async () => stub.pdfjs as unknown as typeof import('pdfjs-dist'),
|
||||
createWorker: createWorker as unknown as () => Worker,
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('goes ahead once the worker says anything, and hands pdf.js a PDFWorker over that worker', async () => {
|
||||
const stub = stubPdfjs()
|
||||
const worker = new FakeWorker()
|
||||
const pending = engineWith(stub, () => worker).open(new Uint8Array(1))
|
||||
|
||||
// The worker's first message is pdf.js' own "ready".
|
||||
await worker.watched
|
||||
worker.dispatchEvent(new Event('message'))
|
||||
const handle = await pending
|
||||
open.push(handle)
|
||||
|
||||
expect(stub.workers).toHaveLength(1)
|
||||
expect(stub.workers[0]!.params.port).toBe(worker)
|
||||
expect(stub.pdfjs.getDocument).toHaveBeenCalledTimes(1)
|
||||
expect(stub.pdfjs.getDocument.mock.calls[0]![0]).toMatchObject({ worker: stub.workers[0] })
|
||||
})
|
||||
|
||||
it('reports a worker whose script fails to load as `unavailable`, and stops it', async () => {
|
||||
const stub = stubPdfjs()
|
||||
const worker = new FakeWorker()
|
||||
const pending = engineWith(stub, () => worker).open(new Uint8Array(1))
|
||||
|
||||
await worker.watched
|
||||
const event = new Event('error') as Event & { message?: string }
|
||||
event.message = 'SyntaxError: Unexpected token {'
|
||||
worker.dispatchEvent(event)
|
||||
|
||||
await expect(pending).rejects.toMatchObject({ kind: 'unavailable', message: 'SyntaxError: Unexpected token {' })
|
||||
expect(worker.terminate).toHaveBeenCalledTimes(1)
|
||||
// A dead worker must not be left as the port every later document is sent to.
|
||||
expect(stub.workers).toHaveLength(0)
|
||||
expect(stub.pdfjs.getDocument).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('gives up on a worker that never answers instead of waiting forever', async () => {
|
||||
vi.useFakeTimers()
|
||||
const stub = stubPdfjs()
|
||||
const worker = new FakeWorker()
|
||||
const pending = engineWith(stub, () => worker).open(new Uint8Array(1))
|
||||
const outcome = pending.catch((error: unknown) => error)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(15_000)
|
||||
|
||||
expect(await outcome).toMatchObject({ kind: 'unavailable', message: 'pdf.js worker did not start in time' })
|
||||
expect(worker.terminate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('starts a fresh worker for the next document after one failed to start', async () => {
|
||||
const stub = stubPdfjs()
|
||||
const workers: FakeWorker[] = []
|
||||
const engine = engineWith(stub, () => {
|
||||
const worker = new FakeWorker()
|
||||
workers.push(worker)
|
||||
return worker
|
||||
})
|
||||
|
||||
const first = engine.open(new Uint8Array(1)).catch((error: unknown) => error)
|
||||
await vi.waitFor(() => expect(workers).toHaveLength(1))
|
||||
await workers[0]!.watched
|
||||
workers[0]!.dispatchEvent(new Event('error'))
|
||||
expect(await first).toMatchObject({ kind: 'unavailable' })
|
||||
|
||||
const second = engine.open(new Uint8Array(1))
|
||||
await vi.waitFor(() => expect(workers).toHaveLength(2))
|
||||
await workers[1]!.watched
|
||||
workers[1]!.dispatchEvent(new Event('message'))
|
||||
open.push(await second)
|
||||
|
||||
expect(stub.workers).toHaveLength(1)
|
||||
expect(stub.workers[0]!.params.port).toBe(workers[1])
|
||||
})
|
||||
|
||||
it('shares one worker between documents opened together, and keeps it for the ones opened later', async () => {
|
||||
const stub = stubPdfjs()
|
||||
const worker = new FakeWorker()
|
||||
const createWorker = vi.fn(() => worker)
|
||||
const engine = engineWith(stub, createWorker)
|
||||
|
||||
const both = Promise.all([engine.open(new Uint8Array(1)), engine.open(new Uint8Array(1))])
|
||||
await worker.watched
|
||||
worker.dispatchEvent(new Event('message'))
|
||||
open.push(...(await both))
|
||||
open.push(await engine.open(new Uint8Array(1)))
|
||||
|
||||
expect(createWorker).toHaveBeenCalledTimes(1)
|
||||
expect(stub.workers).toHaveLength(1)
|
||||
// Every document is given the engine's own PDFWorker. Handed only the port, pdf.js
|
||||
// would give each document a worker of its own to tear down with it.
|
||||
expect(stub.pdfjs.getDocument.mock.calls.map(([params]) => params.worker)).toEqual([
|
||||
stub.workers[0],
|
||||
stub.workers[0],
|
||||
stub.workers[0],
|
||||
])
|
||||
})
|
||||
|
||||
it('does not start a worker when none is configured, which is how tests and Node run', async () => {
|
||||
const stub = stubPdfjs()
|
||||
|
||||
const handle = await createPdfEngine({
|
||||
loadPdfjs: async () => stub.pdfjs as unknown as typeof import('pdfjs-dist'),
|
||||
}).open(new Uint8Array(1))
|
||||
open.push(handle)
|
||||
|
||||
expect(stub.workers).toHaveLength(0)
|
||||
expect(stub.pdfjs.getDocument.mock.calls[0]![0]).not.toHaveProperty('worker')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The engine through a worker port, against the real pdf.js on both ends. The worker
|
||||
* half runs in this thread at the far end of a MessageChannel; what is under test is
|
||||
* how pdf.js treats the port on the main side, and that is the same for a Worker.
|
||||
*/
|
||||
describe('pdfEngine through a worker port, with the real pdf.js', () => {
|
||||
const channels: Array<InstanceType<typeof import('node:worker_threads').MessageChannel>> = []
|
||||
const handles: PdfDocumentHandle[] = []
|
||||
|
||||
async function engineOverChannel(): Promise<PdfEngine> {
|
||||
const { MessageChannel } = await import('node:worker_threads')
|
||||
const { WorkerMessageHandler } = await import('pdfjs-dist/legacy/build/pdf.worker.mjs')
|
||||
const channel = new MessageChannel()
|
||||
channels.push(channel)
|
||||
WorkerMessageHandler.initializeFromPort(channel.port2)
|
||||
return createPdfEngine({
|
||||
loadPdfjs: () => import('pdfjs-dist/legacy/build/pdf.mjs'),
|
||||
createWorker: () => Object.assign(channel.port1, { terminate: () => channel.port1.close() }) as unknown as Worker,
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(handles.splice(0).map((handle) => handle.destroy().catch(() => undefined)))
|
||||
for (const channel of channels.splice(0)) {
|
||||
channel.port1.close()
|
||||
channel.port2.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('opens a document over the port', async () => {
|
||||
const engine = await engineOverChannel()
|
||||
|
||||
const handle = await engine.open(letterPdf(3))
|
||||
handles.push(handle)
|
||||
|
||||
expect(handle.numPages).toBe(3)
|
||||
expect(await handle.pageSize(1)).toEqual({ width: 612 * PDF_CSS_UNITS, height: 792 * PDF_CSS_UNITS })
|
||||
})
|
||||
|
||||
it('opens the next document straight after closing one, without waiting for the close', async () => {
|
||||
// Switching files closes the document on screen and opens the next in the same
|
||||
// breath, and the close is not awaited: React cannot await an effect's cleanup.
|
||||
// With only the port to go on, pdf.js tore its worker down with the closing
|
||||
// document, and the next `getDocument()` threw "the worker is being destroyed".
|
||||
const engine = await engineOverChannel()
|
||||
const first = await engine.open(letterPdf(2))
|
||||
const second = await engine.open(letterPdf(3))
|
||||
handles.push(second)
|
||||
|
||||
void first.destroy()
|
||||
const third = await engine.open(letterPdf(4))
|
||||
handles.push(third)
|
||||
|
||||
expect(third.numPages).toBe(4)
|
||||
})
|
||||
|
||||
it('opens documents on top of one another as fast as a file is rewritten', async () => {
|
||||
const engine = await engineOverChannel()
|
||||
let previous = await engine.open(letterPdf(1))
|
||||
|
||||
for (let version = 2; version <= 8; version += 1) {
|
||||
const next = engine.open(letterPdf(version))
|
||||
void previous.destroy()
|
||||
previous = await next
|
||||
}
|
||||
handles.push(previous)
|
||||
|
||||
expect(previous.numPages).toBe(8)
|
||||
})
|
||||
|
||||
it('still opens a document after the last one was closed', async () => {
|
||||
const engine = await engineOverChannel()
|
||||
await (await engine.open(letterPdf(1))).destroy()
|
||||
|
||||
const handle = await engine.open(letterPdf(2))
|
||||
handles.push(handle)
|
||||
|
||||
expect(handle.numPages).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pdfEngine startup', () => {
|
||||
it('reports pdf.js failing to load as `unavailable`, not as a broken document', async () => {
|
||||
const broken = createPdfEngine({ loadPdfjs: () => Promise.reject(new Error('module blocked')) })
|
||||
|
||||
const failure = await broken.open(letterPdf(1)).catch((error: unknown) => error)
|
||||
|
||||
expect(failure).toBeInstanceOf(PdfError)
|
||||
expect(failure).toMatchObject({ kind: 'unavailable', message: 'module blocked' })
|
||||
})
|
||||
|
||||
it('tries again after a failed start instead of caching the failure for the session', async () => {
|
||||
let attempts = 0
|
||||
const flaky = createPdfEngine({
|
||||
loadPdfjs: () => {
|
||||
attempts += 1
|
||||
return attempts === 1 ? Promise.reject(new Error('offline')) : import('pdfjs-dist/legacy/build/pdf.mjs')
|
||||
},
|
||||
})
|
||||
|
||||
await expect(flaky.open(letterPdf(1))).rejects.toMatchObject({ kind: 'unavailable' })
|
||||
const handle = await flaky.open(letterPdf(1))
|
||||
open.push(handle)
|
||||
|
||||
expect(handle.numPages).toBe(1)
|
||||
expect(attempts).toBe(2)
|
||||
})
|
||||
|
||||
it('starts the library once for concurrent documents', async () => {
|
||||
const loadPdfjs = vi.fn(() => import('pdfjs-dist/legacy/build/pdf.mjs'))
|
||||
const shared = createPdfEngine({ loadPdfjs })
|
||||
|
||||
const [a, b] = await Promise.all([shared.open(letterPdf(1)), shared.open(letterPdf(2))])
|
||||
open.push(a, b)
|
||||
|
||||
expect(loadPdfjs).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,312 @@
|
||||
import { PDFJS_ASSET_FOLDERS, pdfjsAssetsPrefix } from './pdfAssets'
|
||||
import { PDF_CSS_UNITS, type PageSize } from './pdfLayout'
|
||||
|
||||
type Pdfjs = typeof import('pdfjs-dist')
|
||||
type PdfjsDocument = Awaited<ReturnType<Pdfjs['getDocument']>['promise']>
|
||||
type PdfjsLoadingTask = ReturnType<Pdfjs['getDocument']>
|
||||
type PdfjsWorker = InstanceType<Pdfjs['PDFWorker']>
|
||||
|
||||
/**
|
||||
* Why a PDF could not be shown, in the terms the reader can act on.
|
||||
*
|
||||
* - `password`: encrypted and needs a password this viewer does not ask for.
|
||||
* - `invalid`: not a PDF, damaged, or half-written by an agent that is still at it.
|
||||
* - `unavailable`: pdf.js itself could not start here (an old WebView, a blocked worker).
|
||||
*/
|
||||
export type PdfErrorKind = 'password' | 'invalid' | 'unavailable'
|
||||
|
||||
export class PdfError extends Error {
|
||||
readonly kind: PdfErrorKind
|
||||
readonly reason: unknown
|
||||
|
||||
constructor(kind: PdfErrorKind, message: string, reason?: unknown) {
|
||||
super(message)
|
||||
this.name = 'PdfError'
|
||||
this.kind = kind
|
||||
this.reason = reason
|
||||
}
|
||||
}
|
||||
|
||||
export type PdfRenderOptions = {
|
||||
canvas: HTMLCanvasElement
|
||||
/** Zoom, where 1 is 100%. */
|
||||
scale: number
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export type PdfTextLayerOptions = {
|
||||
/** Receives the positioned, transparent text. Cleared first. */
|
||||
container: HTMLElement
|
||||
scale: number
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/** An open document. All page numbers are 1-based, as in the PDF. */
|
||||
export type PdfDocumentHandle = {
|
||||
readonly numPages: number
|
||||
/** The page's natural size in CSS px at 100%. */
|
||||
pageSize(pageNumber: number): Promise<PageSize>
|
||||
/** Paint the page into `canvas`, sizing its backing store. Rejects with an `AbortError` if cancelled. */
|
||||
renderPage(pageNumber: number, options: PdfRenderOptions): Promise<void>
|
||||
/** Build the selectable text of the page. Rejects with an `AbortError` if cancelled. */
|
||||
renderTextLayer(pageNumber: number, options: PdfTextLayerOptions): Promise<void>
|
||||
/**
|
||||
* Free what the engine holds for a page that has scrolled far away. Never throws,
|
||||
* and does nothing for a page that does not exist or a document that is closed.
|
||||
*/
|
||||
releasePage(pageNumber: number): void
|
||||
destroy(): Promise<void>
|
||||
}
|
||||
|
||||
export type PdfEngine = {
|
||||
/** Takes ownership of `data`: pdf.js may transfer (and so detach) its buffer. */
|
||||
open(data: Uint8Array): Promise<PdfDocumentHandle>
|
||||
}
|
||||
|
||||
export type PdfEngineOptions = {
|
||||
loadPdfjs: () => Promise<Pdfjs>
|
||||
/**
|
||||
* How to start pdf.js' worker. Omit it to run pdf.js in-thread, which is what
|
||||
* Node and the test environment want.
|
||||
*/
|
||||
createWorker?: () => Worker | Promise<Worker>
|
||||
/** Folder holding pdf.js' CMaps, fonts and wasm, ending in "/". Omit to use none. */
|
||||
assetBaseUrl?: (pdfjs: Pdfjs) => string | undefined
|
||||
}
|
||||
|
||||
/** A device pixel ratio beyond this buys sharpness nobody can see for four times the memory. */
|
||||
const MAX_DEVICE_PIXEL_RATIO = 2
|
||||
/** Upper bound on one page canvas's backing store: 16 Mpx is ~64 MB of RGBA. */
|
||||
const MAX_CANVAS_PIXELS = 16 * 1024 * 1024
|
||||
const WORKER_START_TIMEOUT_MS = 15_000
|
||||
|
||||
function abortError(): Error {
|
||||
const error = new Error('The PDF render was cancelled')
|
||||
error.name = 'AbortError'
|
||||
return error
|
||||
}
|
||||
|
||||
function isCancelled(error: unknown): boolean {
|
||||
return (error as { name?: string } | null)?.name === 'RenderingCancelledException'
|
||||
}
|
||||
|
||||
function throwIfAborted(signal: AbortSignal | undefined): void {
|
||||
if (signal?.aborted) throw abortError()
|
||||
}
|
||||
|
||||
/** Turn whatever pdf.js threw while opening into something the panel can word. */
|
||||
function classifyOpenError(error: unknown): PdfError {
|
||||
const name = (error as { name?: string } | null)?.name
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (name === 'PasswordException') return new PdfError('password', message, error)
|
||||
// Everything else that fails while parsing is, to the reader, a document that cannot be shown.
|
||||
return new PdfError('invalid', message, error)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start pdf.js' worker and prove it came up.
|
||||
*
|
||||
* Given a worker port pdf.js does no handshake and has no fallback: a worker
|
||||
* whose script fails to load (a syntax error on an old WebView, a blocked URL)
|
||||
* leaves every later `getDocument()` waiting forever. So the worker is watched for
|
||||
* its first message — pdf.js' own "ready" — or an error, and a dead one becomes an
|
||||
* ordinary, reportable failure.
|
||||
*
|
||||
* What comes back is a `PDFWorker` this engine owns and hands to every document.
|
||||
* Left to pdf.js, a document that was given only the port adopts the port's
|
||||
* `PDFWorker` and takes it down with itself: while that teardown runs, the next
|
||||
* `getDocument()` on the port throws "the worker is being destroyed" — and the app
|
||||
* closes a document without waiting exactly when it opens the next, whenever the
|
||||
* reader switches files or the file is rewritten.
|
||||
*/
|
||||
async function startWorker(pdfjs: Pdfjs, createWorker: () => Worker | Promise<Worker>): Promise<PdfjsWorker> {
|
||||
const worker = await createWorker()
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => settle(new Error('pdf.js worker did not start in time')), WORKER_START_TIMEOUT_MS)
|
||||
const onMessage = () => settle()
|
||||
const onError = (event: Event) => settle(new Error((event as ErrorEvent).message || 'pdf.js worker failed to load'))
|
||||
const settle = (failure?: Error) => {
|
||||
clearTimeout(timer)
|
||||
worker.removeEventListener('message', onMessage)
|
||||
worker.removeEventListener('error', onError)
|
||||
if (failure) reject(failure)
|
||||
else resolve()
|
||||
}
|
||||
worker.addEventListener('message', onMessage)
|
||||
worker.addEventListener('error', onError)
|
||||
})
|
||||
} catch (error) {
|
||||
worker.terminate()
|
||||
throw error
|
||||
}
|
||||
// `create`, not `new`: pdf.js types the constructor's `port` as null, and `create` is
|
||||
// the documented way to build one around a port.
|
||||
return pdfjs.PDFWorker.create({ port: worker })
|
||||
}
|
||||
|
||||
class PdfjsDocumentHandle implements PdfDocumentHandle {
|
||||
private closed = false
|
||||
|
||||
constructor(
|
||||
private readonly pdfjs: Pdfjs,
|
||||
private readonly document: PdfjsDocument,
|
||||
private readonly task: PdfjsLoadingTask,
|
||||
) {}
|
||||
|
||||
get numPages(): number {
|
||||
return this.document.numPages
|
||||
}
|
||||
|
||||
async pageSize(pageNumber: number): Promise<PageSize> {
|
||||
const page = await this.document.getPage(pageNumber)
|
||||
const { width, height } = page.getViewport({ scale: PDF_CSS_UNITS })
|
||||
return { width, height }
|
||||
}
|
||||
|
||||
async renderPage(pageNumber: number, { canvas, scale, signal }: PdfRenderOptions): Promise<void> {
|
||||
throwIfAborted(signal)
|
||||
const page = await this.document.getPage(pageNumber)
|
||||
throwIfAborted(signal)
|
||||
|
||||
const viewport = page.getViewport({ scale: scale * PDF_CSS_UNITS })
|
||||
const output = new this.pdfjs.OutputScale()
|
||||
output.sx = Math.min(output.sx, MAX_DEVICE_PIXEL_RATIO)
|
||||
output.sy = Math.min(output.sy, MAX_DEVICE_PIXEL_RATIO)
|
||||
// Shrinks the backing store, never the on-screen size: a huge page at high zoom
|
||||
// gets softer, not an out-of-memory canvas.
|
||||
output.limitCanvas(viewport.width, viewport.height, MAX_CANVAS_PIXELS, -1)
|
||||
canvas.width = Math.floor(viewport.width * output.sx)
|
||||
canvas.height = Math.floor(viewport.height * output.sy)
|
||||
|
||||
const task = page.render({
|
||||
canvas,
|
||||
viewport,
|
||||
transform: output.scaled ? [output.sx, 0, 0, output.sy, 0, 0] : undefined,
|
||||
})
|
||||
const cancel = () => task.cancel()
|
||||
signal?.addEventListener('abort', cancel, { once: true })
|
||||
try {
|
||||
await task.promise
|
||||
} catch (error) {
|
||||
if (isCancelled(error)) throw abortError()
|
||||
throw error
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', cancel)
|
||||
}
|
||||
}
|
||||
|
||||
async renderTextLayer(pageNumber: number, { container, scale, signal }: PdfTextLayerOptions): Promise<void> {
|
||||
throwIfAborted(signal)
|
||||
const page = await this.document.getPage(pageNumber)
|
||||
throwIfAborted(signal)
|
||||
|
||||
container.replaceChildren()
|
||||
const layer = new this.pdfjs.TextLayer({
|
||||
textContentSource: page.streamTextContent(),
|
||||
container,
|
||||
viewport: page.getViewport({ scale: scale * PDF_CSS_UNITS }),
|
||||
})
|
||||
const cancel = () => layer.cancel()
|
||||
signal?.addEventListener('abort', cancel, { once: true })
|
||||
try {
|
||||
await layer.render()
|
||||
} catch (error) {
|
||||
if (signal?.aborted || isCancelled(error)) throw abortError()
|
||||
throw error
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', cancel)
|
||||
}
|
||||
throwIfAborted(signal)
|
||||
}
|
||||
|
||||
releasePage(pageNumber: number): void {
|
||||
// Best effort, and never a failure. A page that is already gone has nothing to
|
||||
// release, and neither has a document that is closed: when a file is rewritten the
|
||||
// new document replaces the old one while its pages are still on screen, and each
|
||||
// page hands back its predecessor's page only once the new drawing is up — after
|
||||
// the old document was closed. pdf.js then throws from `getPage` outright instead
|
||||
// of returning a rejected promise, which no `.catch` on the promise could catch.
|
||||
if (this.closed) return
|
||||
try {
|
||||
void this.document.getPage(pageNumber).then((page) => page.cleanup()).catch(() => undefined)
|
||||
} catch {
|
||||
// Closed between the check and the call.
|
||||
}
|
||||
}
|
||||
|
||||
async destroy(): Promise<void> {
|
||||
this.closed = true
|
||||
await this.task.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The PDF engine, built on pdf.js' core API rather than its bundled viewer: the
|
||||
* viewer drags in a 6,000-line global stylesheet and expects `globalThis.pdfjsLib`,
|
||||
* neither of which belongs in this app. What it would have provided — page layout,
|
||||
* lazy rendering, zoom — lives in `pdfLayout.ts` and `PdfSurface`.
|
||||
*/
|
||||
export function createPdfEngine(options: PdfEngineOptions): PdfEngine {
|
||||
let ready: Promise<{ pdfjs: Pdfjs; worker: PdfjsWorker | undefined }> | null = null
|
||||
|
||||
const start = async () => {
|
||||
try {
|
||||
const pdfjs = await options.loadPdfjs()
|
||||
const worker = options.createWorker ? await startWorker(pdfjs, options.createWorker) : undefined
|
||||
return { pdfjs, worker }
|
||||
} catch (error) {
|
||||
throw new PdfError('unavailable', error instanceof Error ? error.message : String(error), error)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async open(data) {
|
||||
// Memoised so concurrent opens share one worker, but not a failure: an engine
|
||||
// that could not start is worth trying again on the next document.
|
||||
ready ??= start().catch((error: unknown) => {
|
||||
ready = null
|
||||
throw error
|
||||
})
|
||||
const { pdfjs, worker } = await ready
|
||||
const base = options.assetBaseUrl?.(pdfjs)
|
||||
const task = pdfjs.getDocument({
|
||||
data,
|
||||
enableXfa: false,
|
||||
// Errors only. pdf.js narrates every font it substitutes at the default level.
|
||||
verbosity: 0,
|
||||
// Ours, so a document being closed does not take it down (see startWorker).
|
||||
...(worker ? { worker } : {}),
|
||||
...(base
|
||||
? {
|
||||
cMapUrl: `${base}${PDFJS_ASSET_FOLDERS.cMapUrl}/`,
|
||||
cMapPacked: true,
|
||||
standardFontDataUrl: `${base}${PDFJS_ASSET_FOLDERS.standardFontDataUrl}/`,
|
||||
wasmUrl: `${base}${PDFJS_ASSET_FOLDERS.wasmUrl}/`,
|
||||
iccUrl: `${base}${PDFJS_ASSET_FOLDERS.iccUrl}/`,
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
try {
|
||||
return new PdfjsDocumentHandle(pdfjs, await task.promise, task)
|
||||
} catch (error) {
|
||||
await task.destroy().catch(() => undefined)
|
||||
throw classifyOpenError(error)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The engine the app uses. pdf.js and its worker load on the first document, not
|
||||
* at startup. The asset folder is emitted next to the app by `vite-pdfjs-assets`,
|
||||
* named for the installed version so an upgrade cannot leave stale data behind.
|
||||
*/
|
||||
export const defaultPdfEngine: PdfEngine = createPdfEngine({
|
||||
loadPdfjs: () => import('pdfjs-dist/legacy/build/pdf.mjs'),
|
||||
createWorker: async () => {
|
||||
const { default: PdfWorker } = await import('./pdf.worker?worker')
|
||||
return new PdfWorker()
|
||||
},
|
||||
assetBaseUrl: (pdfjs) => new URL(`./${pdfjsAssetsPrefix(pdfjs.version)}`, document.baseURI).href,
|
||||
})
|
||||
@@ -0,0 +1,237 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
PDF_CSS_UNITS,
|
||||
anchoredScrollTop,
|
||||
currentPageIndex,
|
||||
layoutPdfColumn,
|
||||
offsetOf,
|
||||
positionAt,
|
||||
scrollTopForPage,
|
||||
visiblePageRange,
|
||||
type PageSize,
|
||||
} from './pdfLayout'
|
||||
|
||||
const A4: PageSize = { width: 800, height: 1000 }
|
||||
const GAP = 10
|
||||
const PADDING = 20
|
||||
|
||||
function column(pages: number, zoom = 1, size: PageSize = A4) {
|
||||
return layoutPdfColumn(Array.from({ length: pages }, () => size), zoom, { gap: GAP, padding: PADDING })
|
||||
}
|
||||
|
||||
describe('PDF_CSS_UNITS', () => {
|
||||
it('converts PDF points (1/72 in) to CSS pixels (1/96 in)', () => {
|
||||
expect(PDF_CSS_UNITS).toBeCloseTo(1.3333, 4)
|
||||
// A US Letter page is 612 × 792 pt = 816 × 1056 CSS px.
|
||||
expect(612 * PDF_CSS_UNITS).toBe(816)
|
||||
expect(792 * PDF_CSS_UNITS).toBe(1056)
|
||||
})
|
||||
})
|
||||
|
||||
describe('layoutPdfColumn', () => {
|
||||
it('stacks pages with a gap between them and padding around the whole column', () => {
|
||||
const { boxes, height, width } = column(3)
|
||||
|
||||
expect(boxes.map((box) => box.top)).toEqual([20, 1030, 2040])
|
||||
expect(height).toBe(20 + 1000 + 10 + 1000 + 10 + 1000 + 20)
|
||||
expect(width).toBe(800)
|
||||
})
|
||||
|
||||
it('scales the pages with the zoom but not the gutters between them', () => {
|
||||
const { boxes, height } = column(2, 2)
|
||||
|
||||
expect(boxes[0]).toEqual({ top: 20, width: 1600, height: 2000 })
|
||||
expect(boxes[1]!.top).toBe(20 + 2000 + GAP)
|
||||
expect(height).toBe(20 + 2000 + GAP + 2000 + 20)
|
||||
})
|
||||
|
||||
it('sizes each page on its own, so a landscape page in a portrait document is not squashed', () => {
|
||||
const { boxes, width } = layoutPdfColumn(
|
||||
[{ width: 800, height: 1000 }, { width: 1200, height: 700 }],
|
||||
1,
|
||||
{ gap: GAP, padding: PADDING },
|
||||
)
|
||||
|
||||
expect(boxes[1]).toEqual({ top: 20 + 1000 + GAP, width: 1200, height: 700 })
|
||||
expect(width).toBe(1200)
|
||||
})
|
||||
|
||||
it('has just its padding when there are no pages', () => {
|
||||
expect(column(0)).toEqual({ boxes: [], width: 0, height: PADDING * 2 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('visiblePageRange', () => {
|
||||
const { boxes } = column(10)
|
||||
|
||||
it('finds the pages that intersect the viewport', () => {
|
||||
// Viewport 900..1700 covers the second page (1030..2030) and the last sliver of the first.
|
||||
expect(visiblePageRange(boxes, 900, 800)).toEqual({ first: 0, last: 1 })
|
||||
expect(visiblePageRange(boxes, 1100, 800)).toEqual({ first: 1, last: 1 })
|
||||
})
|
||||
|
||||
it('reaches into neighbouring pages by the margin', () => {
|
||||
// A 1100..1900 viewport sits inside page 1; a 1000px margin pulls page 2 and page 0 in.
|
||||
expect(visiblePageRange(boxes, 1100, 800, 1000)).toEqual({ first: 0, last: 2 })
|
||||
})
|
||||
|
||||
it('starts at the first page at the top of the document', () => {
|
||||
expect(visiblePageRange(boxes, 0, 800)).toEqual({ first: 0, last: 0 })
|
||||
})
|
||||
|
||||
it('ends at the last page at the bottom of the document', () => {
|
||||
const bottom = boxes[9]!.top + boxes[9]!.height + PADDING - 800
|
||||
expect(visiblePageRange(boxes, bottom, 800)).toEqual({ first: 9, last: 9 })
|
||||
})
|
||||
|
||||
it('finds nothing over the gutter between two pages when there is no margin', () => {
|
||||
// The gutter is 1020..1030; a 4px viewport inside it shows no page.
|
||||
expect(visiblePageRange(boxes, 1023, 4)).toBeNull()
|
||||
})
|
||||
|
||||
it('finds nothing for an empty column or a viewport past the end', () => {
|
||||
expect(visiblePageRange([], 0, 800)).toBeNull()
|
||||
expect(visiblePageRange(boxes, 1e9, 800)).toBeNull()
|
||||
})
|
||||
|
||||
it('agrees with a plain scan over every scroll offset', () => {
|
||||
// The binary search is the part worth cross-checking against the obvious O(n) answer.
|
||||
const sizes: PageSize[] = Array.from({ length: 40 }, (_, i) => ({ width: 800, height: 300 + (i % 7) * 90 }))
|
||||
const { boxes: mixed } = layoutPdfColumn(sizes, 1.3, { gap: 12, padding: 24 })
|
||||
for (let scrollTop = 0; scrollTop < 20000; scrollTop += 137) {
|
||||
for (const margin of [0, 250]) {
|
||||
const start = scrollTop - margin
|
||||
const end = scrollTop + 700 + margin
|
||||
const hits = mixed.flatMap((box, index) => (box.top + box.height > start && box.top < end ? [index] : []))
|
||||
const range = visiblePageRange(mixed, scrollTop, 700, margin)
|
||||
expect(range === null ? [] : [range.first, range.last]).toEqual(hits.length ? [hits[0], hits[hits.length - 1]] : [])
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('currentPageIndex', () => {
|
||||
const { boxes } = column(5)
|
||||
|
||||
it('is the page showing the most of the viewport', () => {
|
||||
// 800px viewport from 1200: 830px of page 1 (1030..2030) vs none of page 0.
|
||||
expect(currentPageIndex(boxes, 1200, 800)).toBe(1)
|
||||
// From 700: page 0 shows 700..1020 (320px), page 1 shows 1030..1500 (470px).
|
||||
expect(currentPageIndex(boxes, 700, 800)).toBe(1)
|
||||
// From 300: page 0 shows 300..1020 (720px), page 1 shows 1030..1100 (70px).
|
||||
expect(currentPageIndex(boxes, 300, 800)).toBe(0)
|
||||
})
|
||||
|
||||
it('goes to the earlier page on a tie', () => {
|
||||
// A viewport centred on the gutter (centre 1025) shows the same amount of
|
||||
// both pages: 295px of page 0 above it, 295px of page 1 below.
|
||||
expect(currentPageIndex(boxes, 1025 - 300, 600)).toBe(0)
|
||||
})
|
||||
|
||||
it('is the first page at the top and the last page at the bottom', () => {
|
||||
expect(currentPageIndex(boxes, 0, 800)).toBe(0)
|
||||
const bottom = boxes[4]!.top + boxes[4]!.height + PADDING - 800
|
||||
expect(currentPageIndex(boxes, bottom, 800)).toBe(4)
|
||||
})
|
||||
|
||||
it('falls back to the page nearest the centre when the viewport is over nothing but gutter', () => {
|
||||
// 1023..1027 is inside the 1020..1030 gutter: its centre belongs to the page below.
|
||||
expect(currentPageIndex(boxes, 1023, 4)).toBe(1)
|
||||
})
|
||||
|
||||
it('stays on a real page when scrolled past either end', () => {
|
||||
expect(currentPageIndex(boxes, 1e9, 800)).toBe(4)
|
||||
expect(currentPageIndex(boxes, -500, 100)).toBe(0)
|
||||
})
|
||||
|
||||
it('is 0 for an empty column', () => {
|
||||
expect(currentPageIndex([], 0, 800)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('scrollTopForPage', () => {
|
||||
const { boxes } = column(4)
|
||||
|
||||
it('puts a page at the top of the viewport, optionally inset', () => {
|
||||
expect(scrollTopForPage(boxes, 2)).toBe(2040)
|
||||
expect(scrollTopForPage(boxes, 2, 20)).toBe(2020)
|
||||
})
|
||||
|
||||
it('never scrolls above the start, and tolerates an out-of-range index', () => {
|
||||
// Page 0 starts at the column's padding (20), so an inset larger than that clamps to 0.
|
||||
expect(scrollTopForPage(boxes, 0)).toBe(20)
|
||||
expect(scrollTopForPage(boxes, 0, 100)).toBe(0)
|
||||
expect(scrollTopForPage(boxes, 99)).toBe(scrollTopForPage(boxes, 3))
|
||||
expect(scrollTopForPage(boxes, -5)).toBe(scrollTopForPage(boxes, 0))
|
||||
expect(scrollTopForPage([], 0)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('document positions', () => {
|
||||
const { boxes } = column(4)
|
||||
|
||||
it('names a place as a page and how far down it', () => {
|
||||
expect(positionAt(boxes, 1030 + 250)).toEqual({ page: 1, fraction: 0.25 })
|
||||
expect(positionAt(boxes, 20)).toEqual({ page: 0, fraction: 0 })
|
||||
})
|
||||
|
||||
it('round-trips through the same layout', () => {
|
||||
for (const y of [0, 20, 500, 1025, 1030, 1900, 3000, 4100]) {
|
||||
expect(offsetOf(boxes, positionAt(boxes, y))).toBeCloseTo(y, 6)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a point in a gutter in the gutter, above the page below it', () => {
|
||||
const position = positionAt(boxes, 1025)
|
||||
expect(position.page).toBe(1)
|
||||
expect(position.fraction).toBeLessThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('anchoredScrollTop', () => {
|
||||
it('keeps the same spot in the same page under the anchor when zooming in', () => {
|
||||
const before = column(6, 1).boxes
|
||||
const after = column(6, 2).boxes
|
||||
const scrollTop = 3400
|
||||
const anchorY = 300
|
||||
|
||||
const next = anchoredScrollTop({ before, after, scrollTop, anchorY })
|
||||
|
||||
// Whatever page and fraction was under the anchor row before is under it now.
|
||||
const was = positionAt(before, scrollTop + anchorY)
|
||||
const is = positionAt(after, next + anchorY)
|
||||
expect(is.page).toBe(was.page)
|
||||
expect(is.fraction).toBeCloseTo(was.fraction, 6)
|
||||
})
|
||||
|
||||
it('does not drift with the page count the way a single scale ratio would', () => {
|
||||
const before = column(50, 1).boxes
|
||||
const after = column(50, 3).boxes
|
||||
const scrollTop = before[40]!.top + 400 // deep in the document, 400px into page 40
|
||||
const anchorY = 0
|
||||
|
||||
const exact = anchoredScrollTop({ before, after, scrollTop, anchorY })
|
||||
const naive = scrollTop * 3
|
||||
|
||||
expect(exact).toBeCloseTo(after[40]!.top + 400 * 3, 6)
|
||||
// Forty gutters that did not scale: the naive answer is hundreds of pixels off.
|
||||
expect(Math.abs(naive - exact)).toBeGreaterThan(500)
|
||||
})
|
||||
|
||||
it('round-trips a zoom in and back out', () => {
|
||||
const one = column(20, 1).boxes
|
||||
const two = column(20, 1.75).boxes
|
||||
const start = 5000
|
||||
|
||||
const zoomedIn = anchoredScrollTop({ before: one, after: two, scrollTop: start, anchorY: 220 })
|
||||
const back = anchoredScrollTop({ before: two, after: one, scrollTop: zoomedIn, anchorY: 220 })
|
||||
|
||||
expect(back).toBeCloseTo(start, 6)
|
||||
})
|
||||
|
||||
it('is unchanged when the layout is', () => {
|
||||
const boxes = column(8).boxes
|
||||
|
||||
expect(anchoredScrollTop({ before: boxes, after: boxes, scrollTop: 1234, anchorY: 90 })).toBeCloseTo(1234, 6)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* The geometry of a PDF laid out as one continuous column of pages.
|
||||
*
|
||||
* Every page's size is known before any of them is drawn, so which pages are on
|
||||
* screen is arithmetic on the scroll offset, not something to discover by
|
||||
* observing the DOM. Keeping it pure keeps the parts that are easy to get subtly
|
||||
* wrong — the binary searches, and where the scroll offset lands when the zoom
|
||||
* changes — under test rather than checked by eye.
|
||||
*/
|
||||
|
||||
/** A PDF page is measured in points (1/72 in); the screen in CSS pixels (1/96 in). */
|
||||
export const PDF_CSS_UNITS = 96 / 72
|
||||
|
||||
/** The viewer's spacing: between two pages, and around the column. Neither grows with the zoom. */
|
||||
export const PDF_PAGE_GAP = 12
|
||||
export const PDF_PAGE_PADDING = 16
|
||||
|
||||
export type PageSize = { width: number; height: number }
|
||||
|
||||
export type PageBox = {
|
||||
/** Distance from the top of the page column to the top of this page, in CSS px. */
|
||||
top: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export type PdfColumn = {
|
||||
boxes: PageBox[]
|
||||
/** Height of the whole column, including its padding. */
|
||||
height: number
|
||||
/** Width of the widest page (not including padding). */
|
||||
width: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay the pages out top to bottom at `zoom` (1 = 100%).
|
||||
*
|
||||
* `sizes` are the pages' natural sizes in CSS px at 100%. `gap` separates pages;
|
||||
* `padding` surrounds the column. Neither scales with the zoom: a page grows, the
|
||||
* gutter between two pages does not.
|
||||
*/
|
||||
export function layoutPdfColumn(
|
||||
sizes: readonly PageSize[],
|
||||
zoom: number,
|
||||
{ gap, padding }: { gap: number; padding: number },
|
||||
): PdfColumn {
|
||||
const boxes: PageBox[] = []
|
||||
let top = padding
|
||||
let width = 0
|
||||
for (const size of sizes) {
|
||||
const box = { top, width: size.width * zoom, height: size.height * zoom }
|
||||
boxes.push(box)
|
||||
top += box.height + gap
|
||||
width = Math.max(width, box.width)
|
||||
}
|
||||
return {
|
||||
boxes,
|
||||
width,
|
||||
height: boxes.length === 0 ? padding * 2 : top - gap + padding,
|
||||
}
|
||||
}
|
||||
|
||||
export type PageRange = { first: number; last: number }
|
||||
|
||||
/** Index of the first box whose bottom edge is below `y`, or `boxes.length` if none. */
|
||||
function firstBoxEndingAfter(boxes: readonly PageBox[], y: number): number {
|
||||
let low = 0
|
||||
let high = boxes.length
|
||||
while (low < high) {
|
||||
const middle = (low + high) >>> 1
|
||||
if (boxes[middle]!.top + boxes[middle]!.height > y) high = middle
|
||||
else low = middle + 1
|
||||
}
|
||||
return low
|
||||
}
|
||||
|
||||
/**
|
||||
* The pages that intersect the viewport widened by `margin` on both sides
|
||||
* (inclusive indexes), or `null` when none does.
|
||||
*/
|
||||
export function visiblePageRange(
|
||||
boxes: readonly PageBox[],
|
||||
scrollTop: number,
|
||||
viewportHeight: number,
|
||||
margin = 0,
|
||||
): PageRange | null {
|
||||
if (boxes.length === 0) return null
|
||||
const start = scrollTop - margin
|
||||
const end = scrollTop + viewportHeight + margin
|
||||
const first = firstBoxEndingAfter(boxes, start)
|
||||
if (first >= boxes.length) return null
|
||||
let last = first
|
||||
while (last + 1 < boxes.length && boxes[last + 1]!.top < end) last += 1
|
||||
if (boxes[first]!.top >= end) return null
|
||||
return { first, last }
|
||||
}
|
||||
|
||||
/**
|
||||
* The page a reader would say they are on: the one showing the most of the
|
||||
* viewport. Ties go to the earlier page. A viewport over nothing but gutter (or
|
||||
* past either end) falls back to the page nearest its centre.
|
||||
*/
|
||||
export function currentPageIndex(
|
||||
boxes: readonly PageBox[],
|
||||
scrollTop: number,
|
||||
viewportHeight: number,
|
||||
): number {
|
||||
if (boxes.length === 0) return 0
|
||||
const viewportEnd = scrollTop + viewportHeight
|
||||
const range = visiblePageRange(boxes, scrollTop, viewportHeight)
|
||||
if (!range) return Math.max(positionAt(boxes, scrollTop + viewportHeight / 2).page, 0)
|
||||
let best = range.first
|
||||
let bestVisible = -1
|
||||
for (let index = range.first; index <= range.last; index += 1) {
|
||||
const box = boxes[index]!
|
||||
const visible = Math.min(box.top + box.height, viewportEnd) - Math.max(box.top, scrollTop)
|
||||
if (visible > bestVisible) {
|
||||
best = index
|
||||
bestVisible = visible
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/** Scroll offset that puts page `index` at the top of the viewport, `inset` px below its edge. */
|
||||
export function scrollTopForPage(boxes: readonly PageBox[], index: number, inset = 0): number {
|
||||
const box = boxes[Math.min(Math.max(index, 0), boxes.length - 1)]
|
||||
return box ? Math.max(box.top - inset, 0) : 0
|
||||
}
|
||||
|
||||
/** A place in the document that survives a change of zoom: a page, and how far down it. */
|
||||
export type DocumentPosition = { page: number; fraction: number }
|
||||
|
||||
/** Which page, and how far down it, the column offset `y` falls on. */
|
||||
export function positionAt(boxes: readonly PageBox[], y: number): DocumentPosition {
|
||||
if (boxes.length === 0) return { page: 0, fraction: 0 }
|
||||
const page = Math.min(firstBoxEndingAfter(boxes, y), boxes.length - 1)
|
||||
const box = boxes[page]!
|
||||
const fraction = box.height > 0 ? (y - box.top) / box.height : 0
|
||||
// A point in the gutter above a page has a negative fraction; keep it, so
|
||||
// converting back lands in the same gutter rather than snapping onto the page.
|
||||
return { page, fraction }
|
||||
}
|
||||
|
||||
/** The column offset of a {@link DocumentPosition} in a (possibly different) layout. */
|
||||
export function offsetOf(boxes: readonly PageBox[], position: DocumentPosition): number {
|
||||
const box = boxes[Math.min(Math.max(position.page, 0), boxes.length - 1)]
|
||||
return box ? box.top + position.fraction * box.height : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* The vertical scroll offset that keeps whatever is under viewport row `anchorY`
|
||||
* under it after `before` becomes `after`.
|
||||
*
|
||||
* The column cannot be scaled as a whole: pages grow with the zoom but the gutters
|
||||
* between them do not, so a single ratio drifts a little further from the anchor
|
||||
* with every page above it. Measuring in "page N, this far down" does not.
|
||||
*/
|
||||
export function anchoredScrollTop({
|
||||
before,
|
||||
after,
|
||||
scrollTop,
|
||||
anchorY,
|
||||
}: {
|
||||
before: readonly PageBox[]
|
||||
after: readonly PageBox[]
|
||||
scrollTop: number
|
||||
anchorY: number
|
||||
}): number {
|
||||
const position = positionAt(before, scrollTop + anchorY)
|
||||
return offsetOf(after, position) - anchorY
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* One PDF page: a sheet of paper, its canvas, and the transparent text layer that
|
||||
* makes the text selectable and copyable.
|
||||
*
|
||||
* The `.textLayer` rules are the minimal subset of pdfjs-dist/web/pdf_viewer.css
|
||||
* that canvas + TextLayer need (Apache-2.0, © Mozilla Foundation), scoped under
|
||||
* `.cc-pdf-page` so nothing here can reach the rest of the app. The whole 6,000-line
|
||||
* stylesheet is annotation, editor and toolbar UI this app does not ship, and its
|
||||
* class names (`.page`, `.hidden`, `.dialog`) are generic enough to collide.
|
||||
* `pdfPageStyles.test.ts` compares these rules with the installed pdfjs-dist, so a
|
||||
* pdf.js upgrade that changes the text layer fails a test instead of shifting every
|
||||
* selection by a few pixels.
|
||||
*/
|
||||
.cc-pdf-page {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--color-document-paper);
|
||||
/*
|
||||
* The text layer positions itself from these. `--scale-factor` is set inline to
|
||||
* the page's viewport scale; the rest are constants. `--scale-round-x/y` matter
|
||||
* most: without them `round(down, …)` in the layer's own sizing is invalid and
|
||||
* the whole layer collapses to nothing.
|
||||
*/
|
||||
--user-unit: 1;
|
||||
--total-scale-factor: calc(var(--scale-factor) * var(--user-unit));
|
||||
--scale-round-x: 1px;
|
||||
--scale-round-y: 1px;
|
||||
}
|
||||
|
||||
/*
|
||||
* The canvas has the size of the sheet, whatever its backing store holds: pdf.js is
|
||||
* drawn at up to twice the pixels for a sharp page on a dense screen, and it is this
|
||||
* rule that shrinks them back. Not a child selector: the canvas sits in a container.
|
||||
*/
|
||||
.cc-pdf-page canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.cc-pdf-page .textLayer {
|
||||
color-scheme: only light;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: clip;
|
||||
text-align: initial;
|
||||
line-height: 1;
|
||||
opacity: 1;
|
||||
letter-spacing: normal;
|
||||
word-spacing: normal;
|
||||
-webkit-text-size-adjust: none;
|
||||
text-size-adjust: none;
|
||||
forced-color-adjust: none;
|
||||
transform-origin: 0 0;
|
||||
caret-color: CanvasText;
|
||||
z-index: 0;
|
||||
/* TextLayer sets --min-font-size on the container itself; these derive from it. */
|
||||
--min-font-size: 1;
|
||||
--text-scale-factor: calc(var(--total-scale-factor) * var(--min-font-size));
|
||||
--min-font-size-inv: calc(1 / var(--min-font-size));
|
||||
}
|
||||
|
||||
.cc-pdf-page .textLayer :is(span, br) {
|
||||
color: transparent;
|
||||
position: absolute;
|
||||
white-space: pre;
|
||||
cursor: text;
|
||||
transform-origin: 0% 0%;
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.cc-pdf-page .textLayer > :not(.markedContent),
|
||||
.cc-pdf-page .textLayer .markedContent span:not(.markedContent) {
|
||||
z-index: 1;
|
||||
--font-height: 0;
|
||||
font-size: calc(var(--text-scale-factor) * var(--font-height));
|
||||
--scale-x: 1;
|
||||
--rotate: 0deg;
|
||||
transform: rotate(var(--rotate)) scaleX(var(--scale-x)) scale(var(--min-font-size-inv));
|
||||
}
|
||||
|
||||
.cc-pdf-page .textLayer .markedContent {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.cc-pdf-page .textLayer ::selection {
|
||||
background: var(--color-document-selection);
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
/* A line break is not text: selecting across one should not paint a highlight of its own. */
|
||||
.cc-pdf-page .textLayer br::selection {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Keeps a drag selection from collapsing when the pointer leaves a span. */
|
||||
.cc-pdf-page .textLayer .endOfContent {
|
||||
display: block;
|
||||
position: absolute;
|
||||
inset: 100% 0 0;
|
||||
z-index: 0;
|
||||
cursor: default;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.cc-pdf-page .textLayer.selecting .endOfContent {
|
||||
top: 0;
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
// @vitest-environment node
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import path from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* `pdfPage.css` is the part of pdf.js' viewer stylesheet that a canvas and a
|
||||
* `TextLayer` need, copied so the app does not carry the other six thousand lines.
|
||||
* A copy can go stale silently: if a pdf.js upgrade changes how the text layer is
|
||||
* positioned, every selection lands a few pixels off and nothing else notices.
|
||||
*
|
||||
* This compares the copy with the installed stylesheet rule by rule, in both
|
||||
* directions. The upstream file uses native CSS nesting, so its rules are
|
||||
* flattened to plain selectors first.
|
||||
*/
|
||||
|
||||
type Block = { selector: string; declarations: Map<string, string>; children: Block[] }
|
||||
type Rule = { selector: string; declarations: Map<string, string> }
|
||||
|
||||
/** Split at the top level only: `:is(span, br)` is one selector, not two. */
|
||||
function splitTopLevel(text: string, separator: string): string[] {
|
||||
const parts: string[] = []
|
||||
let depth = 0
|
||||
let start = 0
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const char = text[index]
|
||||
if (char === '(' || char === '[') depth += 1
|
||||
else if (char === ')' || char === ']') depth -= 1
|
||||
else if (char === separator && depth === 0) {
|
||||
parts.push(text.slice(start, index))
|
||||
start = index + 1
|
||||
}
|
||||
}
|
||||
parts.push(text.slice(start))
|
||||
return parts.map((part) => part.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
const squash = (text: string) => text.replace(/\s+/g, ' ').trim()
|
||||
|
||||
/** Parse CSS, nested or not, into a tree of blocks. Strings and parentheses (`url(a;b)`) hide their contents. */
|
||||
function parseBlocks(source: string): Block[] {
|
||||
const css = source.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
const root: Block = { selector: '', declarations: new Map(), children: [] }
|
||||
const stack: Block[] = [root]
|
||||
let buffer = ''
|
||||
let quote: string | null = null
|
||||
let parens = 0
|
||||
|
||||
const declare = () => {
|
||||
const text = buffer.trim()
|
||||
buffer = ''
|
||||
const colon = text.indexOf(':')
|
||||
if (!text || colon < 0) return
|
||||
stack[stack.length - 1]!.declarations.set(squash(text.slice(0, colon)), squash(text.slice(colon + 1)))
|
||||
}
|
||||
|
||||
for (const char of css) {
|
||||
if (quote) {
|
||||
buffer += char
|
||||
if (char === quote) quote = null
|
||||
continue
|
||||
}
|
||||
if (char === '"' || char === "'") {
|
||||
quote = char
|
||||
buffer += char
|
||||
} else if (char === '(') {
|
||||
parens += 1
|
||||
buffer += char
|
||||
} else if (char === ')') {
|
||||
parens -= 1
|
||||
buffer += char
|
||||
} else if (parens > 0) {
|
||||
buffer += char
|
||||
} else if (char === '{') {
|
||||
const block: Block = { selector: squash(buffer), declarations: new Map(), children: [] }
|
||||
buffer = ''
|
||||
stack[stack.length - 1]!.children.push(block)
|
||||
stack.push(block)
|
||||
} else if (char === ';') {
|
||||
declare()
|
||||
} else if (char === '}') {
|
||||
declare()
|
||||
stack.pop()
|
||||
} else {
|
||||
buffer += char
|
||||
}
|
||||
}
|
||||
return root.children
|
||||
}
|
||||
|
||||
/** Resolve nesting: a child is a descendant of its parent unless it says where the parent goes with `&`. */
|
||||
function flatten(blocks: Block[], parents: string[] = ['']): Rule[] {
|
||||
const rules: Rule[] = []
|
||||
for (const block of blocks) {
|
||||
if (block.selector.startsWith('@')) continue // media and supports queries: none apply to text placement
|
||||
const selectors = splitTopLevel(block.selector, ',').flatMap((part) =>
|
||||
parents.map((parent) => {
|
||||
if (part.includes('&')) return squash(part.replaceAll('&', parent))
|
||||
return squash(parent ? `${parent} ${part}` : part)
|
||||
}),
|
||||
)
|
||||
for (const selector of selectors) {
|
||||
if (block.declarations.size > 0) rules.push({ selector, declarations: block.declarations })
|
||||
}
|
||||
rules.push(...flatten(block.children, selectors))
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
/** The rules of `blocks` that hang off `.textLayer`, one entry per selector, with duplicates merged. */
|
||||
function textLayerRules(blocks: Block[], scope: string): Map<string, Map<string, string>> {
|
||||
const merged = new Map<string, Map<string, string>>()
|
||||
for (const rule of flatten(blocks)) {
|
||||
// The scope is only where our copy is applied; upstream has none.
|
||||
const selector = scope && rule.selector.startsWith(`${scope} `) ? rule.selector.slice(scope.length + 1) : rule.selector
|
||||
if (!selector.startsWith('.textLayer')) continue
|
||||
const existing = merged.get(selector) ?? new Map<string, string>()
|
||||
for (const [property, value] of rule.declarations) existing.set(property, value)
|
||||
merged.set(selector, existing)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
const upstreamPath = path.join(path.dirname(createRequire(import.meta.url).resolve('pdfjs-dist/package.json')), 'web', 'pdf_viewer.css')
|
||||
const ours = readFileSync(path.join(import.meta.dirname, 'pdfPage.css'), 'utf8')
|
||||
const upstream = textLayerRules(parseBlocks(readFileSync(upstreamPath, 'utf8')), '')
|
||||
const copy = textLayerRules(parseBlocks(ours), '.cc-pdf-page')
|
||||
|
||||
/**
|
||||
* Upstream text-layer rules for features this app does not have. Each entry says
|
||||
* why it may be missing from the copy; a rule that is in neither list is new, and
|
||||
* someone should look at what it does.
|
||||
*/
|
||||
const NOT_NEEDED: Array<[pattern: RegExp, reason: string]> = [
|
||||
[/highlighting/, 'the annotation editor\'s free-highlight tool'],
|
||||
[/^\.textLayerImages/, 'the images the annotation editor keeps beside the text layer'],
|
||||
[/annotationLayer/, 'the annotation layer, which the viewer does not draw'],
|
||||
[/\.highlight\b/, 'find-in-page highlighting, which the viewer does not have'],
|
||||
[/span\[role="img"\]/, 'images embedded in the text layer by the annotation editor'],
|
||||
[/selectionRendering/, 'the viewer\'s own text-rendering mode for annotation editing'],
|
||||
[/\.editToolbar/, 'the annotation editor\'s toolbar'],
|
||||
[/-moz-/, 'Firefox-only pseudo-elements; the app runs in Chromium and WebKit'],
|
||||
]
|
||||
|
||||
/** Where the copy deliberately differs from upstream. */
|
||||
const DIFFERENCES: Record<string, Record<string, string>> = {
|
||||
// Upstream paints the browser's accent colour through `color-mix()`, which the Safari 15 WebView
|
||||
// the app supports cannot parse; the copy uses a translucent token of the app's own accent.
|
||||
// Translucent is the point: the words being selected are drawn on the canvas beneath.
|
||||
'.textLayer ::selection': { background: 'var(--color-document-selection)' },
|
||||
}
|
||||
|
||||
const ignoredUpstream = (selector: string) => NOT_NEEDED.find(([pattern]) => pattern.test(selector))
|
||||
|
||||
describe('the text-layer styles copied from pdf.js', () => {
|
||||
it('finds the rules it is meant to compare, in both stylesheets', () => {
|
||||
// The parser reading nothing would make every comparison below pass.
|
||||
expect(upstream.get('.textLayer')?.get('position')).toBe('absolute')
|
||||
expect(upstream.get('.textLayer.selecting .endOfContent')?.get('top')).toBe('0')
|
||||
expect(copy.get('.textLayer')?.get('position')).toBe('absolute')
|
||||
expect(copy.size).toBeGreaterThanOrEqual(7)
|
||||
})
|
||||
|
||||
it('has no rule that upstream does not', () => {
|
||||
const invented = [...copy.keys()].filter((selector) => !upstream.has(selector))
|
||||
|
||||
expect(invented).toEqual([])
|
||||
})
|
||||
|
||||
it('has every upstream text-layer rule, except those for features this app does not have', () => {
|
||||
const missing = [...upstream.keys()].filter((selector) => !copy.has(selector) && !ignoredUpstream(selector))
|
||||
|
||||
// A new rule in a pdf.js upgrade: decide whether the copy needs it, and add it
|
||||
// here or to NOT_NEEDED with the reason.
|
||||
expect(missing).toEqual([])
|
||||
})
|
||||
|
||||
it.each([...copy.keys()])('%s: declares what upstream declares, with the same values', (selector) => {
|
||||
const wanted = upstream.get(selector)!
|
||||
const declared = copy.get(selector)!
|
||||
|
||||
for (const [property, value] of declared) {
|
||||
const difference = DIFFERENCES[selector]?.[property]
|
||||
if (difference === undefined) {
|
||||
expect(wanted.get(property), `${selector} { ${property} }`).toBe(value)
|
||||
} else {
|
||||
// A listed difference: the copy has exactly what the list says, and upstream still has the property.
|
||||
expect(value, `${selector} { ${property} }`).toBe(difference)
|
||||
expect(wanted.has(property), `${selector} { ${property} } exists upstream`).toBe(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it.each([...copy.keys()])('%s: leaves out nothing that upstream declares', (selector) => {
|
||||
const declared = copy.get(selector)!
|
||||
const missing = [...upstream.get(selector)!.keys()].filter(
|
||||
(property) => !declared.has(property) && !property.startsWith('-moz-'),
|
||||
)
|
||||
|
||||
expect(missing).toEqual([])
|
||||
})
|
||||
|
||||
it('paints a selection translucent, because the words it covers are drawn on the canvas beneath', () => {
|
||||
// The text being selected is transparent and sits over the canvas. An opaque
|
||||
// highlight hides the very words it marks: it turns a sentence into a coloured bar.
|
||||
const globals = readFileSync(path.join(import.meta.dirname, '../../../../theme/globals.css'), 'utf8')
|
||||
const declared = /--color-document-selection:\s*([^;]+);/.exec(globals)?.[1]
|
||||
// `rgba(var(--cc-ac-rgb), 0.3)`: the alpha is the last argument.
|
||||
const alpha = declared && /,\s*([0-9.]+)\s*\)$/.exec(declared.trim())?.[1]
|
||||
|
||||
expect(copy.get('.textLayer ::selection')?.get('background')).toBe('var(--color-document-selection)')
|
||||
expect(alpha).toBeDefined()
|
||||
expect(Number(alpha)).toBeGreaterThan(0)
|
||||
expect(Number(alpha)).toBeLessThan(0.6)
|
||||
})
|
||||
|
||||
it('keeps the licence notice that goes with copied code', () => {
|
||||
expect(ours).toContain('Apache-2.0')
|
||||
expect(ours).toContain('Mozilla Foundation')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,317 @@
|
||||
import { strToU8, zipSync } from 'fflate'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DEFAULT_OFFICE_ZIP_LIMITS } from '@/lib/workspace/officeZipGuard'
|
||||
import { resultsWorkbook, workbookOf } from '@/test/fixtures/xlsx'
|
||||
import {
|
||||
DEFAULT_SPREADSHEET_LIMITS,
|
||||
SpreadsheetError,
|
||||
createSpreadsheetEngine,
|
||||
defaultSpreadsheetEngine,
|
||||
type SpreadsheetEngine,
|
||||
} from './spreadsheetEngine'
|
||||
|
||||
/** The real SheetJS, reading real workbooks written by it. */
|
||||
const engine: SpreadsheetEngine = createSpreadsheetEngine({ loadSheetJs: () => import('xlsx') })
|
||||
|
||||
async function firstGrid(bytes: Uint8Array, using: SpreadsheetEngine = engine) {
|
||||
const document = await using.open(bytes)
|
||||
return document.readSheet(document.sheets[0]!.index)
|
||||
}
|
||||
|
||||
const failureOf = (promise: Promise<unknown>) => promise.catch((error: unknown) => error)
|
||||
|
||||
describe('the sheets of a workbook', () => {
|
||||
it('lists the visible ones, in order, by their place among all of them', async () => {
|
||||
const document = await engine.open(resultsWorkbook())
|
||||
|
||||
// "Hidden data" is the workbook's second sheet: it is not offered, and "Second" is still the third.
|
||||
expect(document.sheets).toEqual([
|
||||
{ index: 0, name: '结果 Results' },
|
||||
{ index: 2, name: 'Second' },
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves out a very hidden sheet too', async () => {
|
||||
const document = await engine.open(workbookOf([
|
||||
{ name: 'A', rows: [['a']] },
|
||||
{ name: 'B', rows: [['b']], hidden: 2 },
|
||||
]))
|
||||
|
||||
expect(document.sheets.map((sheet) => sheet.name)).toEqual(['A'])
|
||||
})
|
||||
|
||||
it('has no sheets to offer when none is visible', async () => {
|
||||
const document = await engine.open(workbookOf([{ name: 'Only', rows: [['x']], hidden: 1 }]))
|
||||
|
||||
expect(document.sheets).toEqual([])
|
||||
})
|
||||
|
||||
it('reads only the sheet it is asked for', async () => {
|
||||
const document = await engine.open(resultsWorkbook())
|
||||
|
||||
const second = await document.readSheet(2)
|
||||
|
||||
expect(second.cells[0]![0]!.text).toBe('only')
|
||||
expect(second.rows).toBe(2)
|
||||
expect(second.cells.flat().map((cell) => cell?.text)).not.toContain('实验结果 Experimental results')
|
||||
})
|
||||
|
||||
it('does not read a sheet that is not there', async () => {
|
||||
const document = await engine.open(resultsWorkbook())
|
||||
|
||||
expect(await failureOf(document.readSheet(9))).toBeInstanceOf(SpreadsheetError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('what a cell shows', () => {
|
||||
it('is its text as Excel would print it, not the raw number behind it', async () => {
|
||||
const grid = await firstGrid(resultsWorkbook())
|
||||
const formatted = grid.cells[6]!
|
||||
|
||||
expect(formatted.map((cell) => cell?.text)).toEqual(['2023-03-15', '25.6%', '¥1,234.50', '1234.5'])
|
||||
})
|
||||
|
||||
it('shows the value of a formula, not the formula', async () => {
|
||||
const grid = await firstGrid(resultsWorkbook())
|
||||
|
||||
// (The row is four columns wide, like the sheet; its last cell is empty.)
|
||||
expect(grid.cells[4]!.map((cell) => cell?.text)).toEqual(['合计', '60', '14.125', undefined])
|
||||
})
|
||||
|
||||
it('shows booleans and errors as Excel does', async () => {
|
||||
const grid = await firstGrid(resultsWorkbook())
|
||||
|
||||
expect(grid.cells[5]![0]!.text).toBe('TRUE')
|
||||
expect(grid.cells[5]![1]!.text).toBe('#DIV/0!')
|
||||
})
|
||||
|
||||
it('keeps markup in a cell as text, for the viewer to show as text', async () => {
|
||||
const grid = await firstGrid(resultsWorkbook())
|
||||
|
||||
expect(grid.cells[5]![2]!.text).toBe('<img src=x onerror="window.parent.__cellXss=1">')
|
||||
})
|
||||
|
||||
it('reads Chinese', async () => {
|
||||
const grid = await firstGrid(resultsWorkbook())
|
||||
|
||||
expect(grid.cells[0]![0]!.text).toBe('实验结果 Experimental results')
|
||||
expect(grid.cells[2]![0]!.text).toBe('对照组')
|
||||
})
|
||||
|
||||
it('leaves an empty cell a hole rather than an empty string', async () => {
|
||||
const grid = await firstGrid(workbookOf([{ name: 'A', rows: [['a', null, 'c'], [null, 'b']] }]))
|
||||
|
||||
expect(grid.cells[0]![1]).toBeUndefined()
|
||||
expect(grid.cells[1]![0]).toBeUndefined()
|
||||
expect(grid.columns).toBe(3)
|
||||
})
|
||||
|
||||
it('puts numbers to the right, booleans and errors in the middle, and text to the left, as Excel does', async () => {
|
||||
const grid = await firstGrid(resultsWorkbook())
|
||||
|
||||
expect(grid.cells[2]![0]!.align).toBe('left')
|
||||
expect(grid.cells[2]![1]!.align).toBe('right')
|
||||
expect(grid.cells[5]![0]!.align).toBe('center')
|
||||
expect(grid.cells[5]![1]!.align).toBe('center')
|
||||
})
|
||||
|
||||
it('cuts a cell that is absurdly long instead of holding all of it', async () => {
|
||||
const limited = createSpreadsheetEngine({
|
||||
loadSheetJs: () => import('xlsx'),
|
||||
limits: { ...DEFAULT_SPREADSHEET_LIMITS, maxCellChars: 10 },
|
||||
})
|
||||
|
||||
const grid = await firstGrid(workbookOf([{ name: 'A', rows: [['0123456789abcdef']] }]), limited)
|
||||
|
||||
expect(grid.cells[0]![0]!.text).toBe('0123456789…')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the shape of a sheet', () => {
|
||||
it('reports its size', async () => {
|
||||
const grid = await firstGrid(resultsWorkbook())
|
||||
|
||||
expect(grid.rows).toBe(7)
|
||||
expect(grid.columns).toBe(4)
|
||||
expect(grid.truncatedRows).toBe(false)
|
||||
expect(grid.truncatedColumns).toBe(false)
|
||||
})
|
||||
|
||||
it('reads merged cells as spans', async () => {
|
||||
const grid = await firstGrid(resultsWorkbook())
|
||||
|
||||
expect(grid.merges).toEqual([{ row: 0, column: 0, rowSpan: 1, columnSpan: 3 }])
|
||||
})
|
||||
|
||||
it('clips a merge to what was read, and drops one that starts beyond it', async () => {
|
||||
const limited = createSpreadsheetEngine({
|
||||
loadSheetJs: () => import('xlsx'),
|
||||
limits: { ...DEFAULT_SPREADSHEET_LIMITS, maxRows: 2, maxColumns: 2 },
|
||||
})
|
||||
const grid = await firstGrid(workbookOf([{
|
||||
name: 'A',
|
||||
rows: [['a', 'b', 'c'], ['d', 'e', 'f'], ['g', 'h', 'i']],
|
||||
merges: [
|
||||
{ s: { r: 0, c: 0 }, e: { r: 1, c: 3 } }, // reaches past both limits
|
||||
{ s: { r: 2, c: 0 }, e: { r: 2, c: 2 } }, // begins past the rows
|
||||
{ s: { r: 0, c: 2 }, e: { r: 0, c: 4 } }, // begins past the columns
|
||||
],
|
||||
}]), limited)
|
||||
|
||||
expect(grid.merges).toEqual([{ row: 0, column: 0, rowSpan: 2, columnSpan: 2 }])
|
||||
})
|
||||
|
||||
it('does not treat a merge of a single cell as a merge', async () => {
|
||||
const grid = await firstGrid(workbookOf([{ name: 'A', rows: [['a']], merges: [{ s: { r: 0, c: 0 }, e: { r: 0, c: 0 } }] }]))
|
||||
|
||||
expect(grid.merges).toEqual([])
|
||||
})
|
||||
|
||||
it('takes column widths from the sheet, and hides a hidden column by giving it none', async () => {
|
||||
const grid = await firstGrid(resultsWorkbook())
|
||||
|
||||
expect(grid.columnWidths[0]).toBe(140) // set in pixels
|
||||
// Set in characters, and turned into pixels by SheetJS with the workbook's own digit width.
|
||||
expect(grid.columnWidths[1]).toBe(77)
|
||||
expect(grid.columnWidths[2]).toBe(0) // hidden
|
||||
expect(grid.columnWidths[3]).toBe(72) // not said
|
||||
})
|
||||
|
||||
it('keeps column widths within what can be drawn', async () => {
|
||||
const grid = await firstGrid(workbookOf([{ name: 'A', rows: [['a', 'b']], columns: [{ wpx: 2 }, { wpx: 9000 }] }]))
|
||||
|
||||
expect(grid.columnWidths).toEqual([28, 480])
|
||||
})
|
||||
|
||||
it('takes row heights from the sheet, kept in points and drawn in pixels, and hides a hidden row', async () => {
|
||||
const grid = await firstGrid(workbookOf([{
|
||||
name: 'A',
|
||||
rows: [['a'], ['b'], ['c'], ['d']],
|
||||
rowInfo: [{ hpt: 30 }, { hpt: 15 }, { hidden: true }, {}],
|
||||
}]))
|
||||
|
||||
// 30pt = 40px and 15pt = 20px at 96 dpi.
|
||||
expect(grid.rowHeights).toEqual([40, 20, 0, undefined])
|
||||
})
|
||||
|
||||
it('keeps row heights within what can be drawn', async () => {
|
||||
const grid = await firstGrid(workbookOf([{ name: 'A', rows: [['a'], ['b']], rowInfo: [{ hpt: 1 }, { hpt: 900 }] }]))
|
||||
|
||||
expect(grid.rowHeights).toEqual([16, 400])
|
||||
})
|
||||
})
|
||||
|
||||
describe('a sheet longer or wider than can be held', () => {
|
||||
const small = createSpreadsheetEngine({
|
||||
loadSheetJs: () => import('xlsx'),
|
||||
limits: { ...DEFAULT_SPREADSHEET_LIMITS, maxRows: 3, maxColumns: 2 },
|
||||
})
|
||||
|
||||
it('holds the first rows and columns, and says there were more', async () => {
|
||||
const grid = await firstGrid(workbookOf([{
|
||||
name: 'A',
|
||||
rows: [['1a', '1b', '1c'], ['2a', '2b', '2c'], ['3a', '3b', '3c'], ['4a', '4b', '4c'], ['5a', '5b', '5c']],
|
||||
}]), small)
|
||||
|
||||
expect(grid.rows).toBe(3)
|
||||
expect(grid.columns).toBe(2)
|
||||
expect(grid.truncatedRows).toBe(true)
|
||||
expect(grid.truncatedColumns).toBe(true)
|
||||
expect(grid.cells.map((row) => row.map((cell) => cell?.text))).toEqual([['1a', '1b'], ['2a', '2b'], ['3a', '3b']])
|
||||
})
|
||||
|
||||
it('does not say so about a sheet that exactly fits', async () => {
|
||||
const grid = await firstGrid(workbookOf([{ name: 'A', rows: [['1a', '1b'], ['2a', '2b'], ['3a', '3b']] }]), small)
|
||||
|
||||
expect(grid.rows).toBe(3)
|
||||
expect(grid.truncatedRows).toBe(false)
|
||||
expect(grid.truncatedColumns).toBe(false)
|
||||
})
|
||||
|
||||
it('has limits a real thesis dataset clears', () => {
|
||||
expect(DEFAULT_SPREADSHEET_LIMITS.maxRows).toBeGreaterThanOrEqual(5_000)
|
||||
expect(DEFAULT_SPREADSHEET_LIMITS.maxColumns).toBeGreaterThanOrEqual(50)
|
||||
})
|
||||
})
|
||||
|
||||
describe('an older workbook (.xls)', () => {
|
||||
it('reads one, with its Chinese text and its sheets', async () => {
|
||||
const document = await engine.open(workbookOf([
|
||||
{ name: '数据', rows: [['姓名', '成绩'], ['张三', 91.5]] },
|
||||
{ name: 'Two', rows: [['x']] },
|
||||
], 'biff8'))
|
||||
|
||||
expect(document.sheets.map((sheet) => sheet.name)).toEqual(['数据', 'Two'])
|
||||
const grid = await document.readSheet(0)
|
||||
expect(grid.cells.map((row) => row.map((cell) => cell?.text))).toEqual([['姓名', '成绩'], ['张三', '91.5']])
|
||||
})
|
||||
|
||||
it('reads a macro-enabled workbook as a workbook, and runs nothing', async () => {
|
||||
const grid = await firstGrid(workbookOf([{ name: 'A', rows: [['macro', 'enabled']] }], 'xlsm'))
|
||||
|
||||
expect(grid.cells[0]!.map((cell) => cell?.text)).toEqual(['macro', 'enabled'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('what is not a workbook', () => {
|
||||
it.each([
|
||||
['text', strToU8('this is not a spreadsheet')],
|
||||
['nothing', new Uint8Array(0)],
|
||||
['HTML, which SheetJS would happily read as a table', strToU8('<html><body><table><tr><td>cell</td></tr></table></body></html>')],
|
||||
['CSV, which it would read as rows', strToU8('a,b,c\n1,2,3\n')],
|
||||
['SpreadsheetML XML, which it would read too', strToU8('<?xml version="1.0"?><Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"></Workbook>')],
|
||||
])('answers %s with `invalid`, without asking SheetJS to interpret it', async (_label, bytes) => {
|
||||
const loadSheetJs = vi.fn(() => import('xlsx'))
|
||||
const guarded = createSpreadsheetEngine({ loadSheetJs })
|
||||
|
||||
const failure = await failureOf(guarded.open(bytes))
|
||||
|
||||
expect(failure).toBeInstanceOf(SpreadsheetError)
|
||||
expect(failure).toMatchObject({ kind: 'invalid' })
|
||||
expect(loadSheetJs).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('answers a workbook cut off mid-write (an agent still at it) with `invalid`', async () => {
|
||||
const whole = resultsWorkbook()
|
||||
|
||||
const failure = await failureOf(engine.open(whole.slice(0, Math.floor(whole.length / 2))))
|
||||
|
||||
expect(failure).toMatchObject({ kind: 'invalid' })
|
||||
})
|
||||
|
||||
it('answers a zip that is not a workbook with `invalid`', async () => {
|
||||
const failure = await failureOf(engine.open(zipSync({ 'hello.txt': strToU8('hello') })))
|
||||
|
||||
expect(failure).toMatchObject({ kind: 'invalid' })
|
||||
})
|
||||
|
||||
it('answers an archive that promises to inflate past all reason with `tooComplex`, before loading anything', async () => {
|
||||
const loadSheetJs = vi.fn(() => import('xlsx'))
|
||||
const guarded = createSpreadsheetEngine({
|
||||
loadSheetJs,
|
||||
zipLimits: { ...DEFAULT_OFFICE_ZIP_LIMITS, maxEntryBytes: 1024 * 1024 },
|
||||
})
|
||||
const bomb = zipSync({ 'xl/media/huge.bmp': new Uint8Array(4 * 1024 * 1024) })
|
||||
|
||||
const failure = await failureOf(guarded.open(bomb))
|
||||
|
||||
expect(failure).toMatchObject({ kind: 'tooComplex' })
|
||||
expect(loadSheetJs).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports SheetJS failing to load as `unavailable`, not as a broken workbook', async () => {
|
||||
const broken = createSpreadsheetEngine({ loadSheetJs: () => Promise.reject(new Error('Failed to fetch dynamically imported module')) })
|
||||
|
||||
const failure = await failureOf(broken.open(resultsWorkbook()))
|
||||
|
||||
expect(failure).toMatchObject({ kind: 'unavailable' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('the default engine', () => {
|
||||
it('is built on SheetJS, loaded when a workbook is first opened', async () => {
|
||||
const document = await defaultSpreadsheetEngine.open(resultsWorkbook())
|
||||
|
||||
expect(document.sheets.map((sheet) => sheet.name)).toEqual(['结果 Results', 'Second'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,266 @@
|
||||
import type * as SheetJs from 'xlsx'
|
||||
import { OfficeZipError, inspectOfficeZip, type OfficeZipLimits } from '@/lib/workspace/officeZipGuard'
|
||||
|
||||
type SheetJsLibrary = typeof SheetJs
|
||||
|
||||
/**
|
||||
* Why a workbook could not be shown, in the terms the reader can act on.
|
||||
*
|
||||
* - `invalid`: not a workbook, damaged, or half-written by an agent that is still at it.
|
||||
* - `tooComplex`: a well-formed archive that promises to inflate to more than the page can hold.
|
||||
* - `unavailable`: SheetJS itself could not be loaded.
|
||||
*/
|
||||
export type SpreadsheetErrorKind = 'invalid' | 'tooComplex' | 'unavailable'
|
||||
|
||||
export class SpreadsheetError extends Error {
|
||||
readonly kind: SpreadsheetErrorKind
|
||||
readonly reason: unknown
|
||||
|
||||
constructor(kind: SpreadsheetErrorKind, message: string, reason?: unknown) {
|
||||
super(message)
|
||||
this.name = 'SpreadsheetError'
|
||||
this.kind = kind
|
||||
this.reason = reason
|
||||
}
|
||||
}
|
||||
|
||||
/** How much of a sheet is read. The rest is said to be there, not held. */
|
||||
export type SpreadsheetLimits = {
|
||||
maxRows: number
|
||||
maxColumns: number
|
||||
/** A cell longer than this is cut. Excel allows 32,767 characters in one. */
|
||||
maxCellChars: number
|
||||
}
|
||||
|
||||
export const DEFAULT_SPREADSHEET_LIMITS: SpreadsheetLimits = {
|
||||
maxRows: 5_000,
|
||||
maxColumns: 100,
|
||||
maxCellChars: 2_000,
|
||||
}
|
||||
|
||||
export type CellAlign = 'left' | 'right' | 'center'
|
||||
|
||||
/** What a cell shows: its formatted text, as Excel would print it, and where Excel would put it. */
|
||||
export type GridCell = { text: string; align: CellAlign }
|
||||
|
||||
export type Merge = {
|
||||
row: number
|
||||
column: number
|
||||
rowSpan: number
|
||||
columnSpan: number
|
||||
}
|
||||
|
||||
/** One worksheet, as much of it as was read. Coordinates are 0-based. */
|
||||
export type Grid = {
|
||||
/** Rows by columns; a hole is an empty cell. */
|
||||
cells: Array<Array<GridCell | undefined>>
|
||||
rows: number
|
||||
columns: number
|
||||
merges: Merge[]
|
||||
/** Pixels per column; 0 for a hidden one. */
|
||||
columnWidths: number[]
|
||||
/** Pixels per row where the sheet sets one, 0 for a hidden one, `undefined` to fit the text. */
|
||||
rowHeights: Array<number | undefined>
|
||||
truncatedRows: boolean
|
||||
truncatedColumns: boolean
|
||||
}
|
||||
|
||||
export type SheetInfo = {
|
||||
/** Its place among all the workbook's sheets, hidden ones included. */
|
||||
index: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export type SpreadsheetDocument = {
|
||||
/** The visible worksheets, in order. */
|
||||
sheets: SheetInfo[]
|
||||
readSheet(index: number): Promise<Grid>
|
||||
}
|
||||
|
||||
export type SpreadsheetEngine = {
|
||||
open(bytes: Uint8Array): Promise<SpreadsheetDocument>
|
||||
}
|
||||
|
||||
export type SpreadsheetEngineOptions = {
|
||||
loadSheetJs: () => Promise<SheetJsLibrary>
|
||||
limits?: SpreadsheetLimits
|
||||
/** What an archive may promise to inflate to. For tests; the defaults are what the app uses. */
|
||||
zipLimits?: OfficeZipLimits
|
||||
}
|
||||
|
||||
const DEFAULT_COLUMN_WIDTH = 72
|
||||
const MIN_COLUMN_WIDTH = 28
|
||||
const MAX_COLUMN_WIDTH = 480
|
||||
const MIN_ROW_HEIGHT = 16
|
||||
const MAX_ROW_HEIGHT = 400
|
||||
|
||||
/** `PK`, the start of a zip: .xlsx and .xlsm. */
|
||||
function isZip(bytes: Uint8Array): boolean {
|
||||
return bytes.length >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 0x03 && bytes[3] === 0x04
|
||||
}
|
||||
|
||||
/** The start of an OLE compound file: .xls, and also an encrypted .xlsx. */
|
||||
function isCompoundFile(bytes: Uint8Array): boolean {
|
||||
const magic = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]
|
||||
return bytes.length >= magic.length && magic.every((byte, index) => bytes[index] === byte)
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
function cellText(cell: SheetJs.CellObject, maxChars: number): string {
|
||||
const text = cell.w ?? (cell.v === undefined || cell.v === null ? '' : String(cell.v))
|
||||
return text.length > maxChars ? `${text.slice(0, maxChars)}…` : text
|
||||
}
|
||||
|
||||
function cellAlign(cell: SheetJs.CellObject): CellAlign {
|
||||
if (cell.t === 'n') return 'right'
|
||||
if (cell.t === 'b' || cell.t === 'e') return 'center'
|
||||
return 'left'
|
||||
}
|
||||
|
||||
/** Turn a dense SheetJS worksheet into the grid the viewer draws, and let SheetJS's objects go. */
|
||||
function toGrid(sheet: SheetJs.WorkSheet, limits: SpreadsheetLimits): Grid {
|
||||
const data = ((sheet as SheetJs.DenseSheet)['!data'] ?? []) as Array<Array<SheetJs.CellObject | undefined> | undefined>
|
||||
|
||||
// One row more than the limit was asked for: its presence is how a longer sheet is told from one that just fits.
|
||||
const truncatedRows = data.length > limits.maxRows
|
||||
const rows = Math.min(data.length, limits.maxRows)
|
||||
let widest = 0
|
||||
for (let row = 0; row < rows; row += 1) widest = Math.max(widest, data[row]?.length ?? 0)
|
||||
const truncatedColumns = widest > limits.maxColumns
|
||||
const columns = Math.min(widest, limits.maxColumns)
|
||||
|
||||
const cells: Grid['cells'] = []
|
||||
for (let row = 0; row < rows; row += 1) {
|
||||
const source = data[row]
|
||||
const line: Array<GridCell | undefined> = new Array<GridCell | undefined>(columns)
|
||||
for (let column = 0; column < columns; column += 1) {
|
||||
const cell = source?.[column]
|
||||
if (!cell || cell.t === 'z') continue
|
||||
const text = cellText(cell, limits.maxCellChars)
|
||||
if (text !== '') line[column] = { text, align: cellAlign(cell) }
|
||||
}
|
||||
cells.push(line)
|
||||
}
|
||||
|
||||
const columnInfo = sheet['!cols'] ?? []
|
||||
const columnWidths = Array.from({ length: columns }, (_, column) => {
|
||||
const info = columnInfo[column]
|
||||
if (info?.hidden) return 0
|
||||
const width = info?.wpx ?? (info?.wch ? info.wch * 7 + 5 : DEFAULT_COLUMN_WIDTH)
|
||||
return clamp(Math.round(width), MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)
|
||||
})
|
||||
|
||||
const rowInfo = sheet['!rows'] ?? []
|
||||
const rowHeights = Array.from({ length: rows }, (_, row): number | undefined => {
|
||||
const info = rowInfo[row]
|
||||
if (info?.hidden) return 0
|
||||
// A row's height is kept in points. SheetJS' own `hpx` is the same number, not converted.
|
||||
const height = info?.hpt !== undefined ? (info.hpt * 96) / 72 : info?.hpx
|
||||
return height === undefined ? undefined : clamp(Math.round(height), MIN_ROW_HEIGHT, MAX_ROW_HEIGHT)
|
||||
})
|
||||
|
||||
const merges: Merge[] = []
|
||||
for (const range of sheet['!merges'] ?? []) {
|
||||
if (range.s.r >= rows || range.s.c >= columns) continue
|
||||
const rowSpan = Math.min(range.e.r, rows - 1) - range.s.r + 1
|
||||
const columnSpan = Math.min(range.e.c, columns - 1) - range.s.c + 1
|
||||
if (rowSpan > 1 || columnSpan > 1) merges.push({ row: range.s.r, column: range.s.c, rowSpan, columnSpan })
|
||||
}
|
||||
|
||||
return { cells, rows, columns, merges, columnWidths, rowHeights, truncatedRows, truncatedColumns }
|
||||
}
|
||||
|
||||
/**
|
||||
* The spreadsheet engine, built on SheetJS. It reads a workbook's table of contents first —
|
||||
* sheet names and which are hidden, without any sheet's cells — and builds a sheet's grid only
|
||||
* when the reader opens it, capped at a number of rows and columns, so a workbook with a
|
||||
* hundred sheets is a list of tabs and one sheet, not a hundred sheets in memory. Each read
|
||||
* still goes through the file (see `open`), which is what the size limit is for.
|
||||
*
|
||||
* It accepts only what it can name: a zip (.xlsx, .xlsm) or an OLE compound file (.xls).
|
||||
* SheetJS itself would take a file's contents for whatever they look like — HTML, CSV, XML —
|
||||
* whatever the extension says, and a preview has no business parsing those.
|
||||
*/
|
||||
export function createSpreadsheetEngine({
|
||||
loadSheetJs,
|
||||
limits = DEFAULT_SPREADSHEET_LIMITS,
|
||||
zipLimits,
|
||||
}: SpreadsheetEngineOptions): SpreadsheetEngine {
|
||||
return {
|
||||
async open(bytes) {
|
||||
if (isZip(bytes)) {
|
||||
try {
|
||||
inspectOfficeZip(bytes, zipLimits)
|
||||
} catch (error) {
|
||||
if (error instanceof OfficeZipError) {
|
||||
throw new SpreadsheetError(error.reason === 'not-a-zip' ? 'invalid' : 'tooComplex', error.message, error)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
} else if (!isCompoundFile(bytes)) {
|
||||
throw new SpreadsheetError('invalid', 'Not a spreadsheet file')
|
||||
}
|
||||
|
||||
let sheetjs: SheetJsLibrary
|
||||
try {
|
||||
sheetjs = await loadSheetJs()
|
||||
} catch (error) {
|
||||
throw new SpreadsheetError('unavailable', error instanceof Error ? error.message : String(error), error)
|
||||
}
|
||||
|
||||
let names: string[]
|
||||
let hidden: boolean[]
|
||||
try {
|
||||
// Reads the workbook's own part, which lists the sheets and which are hidden, without
|
||||
// asking for any sheet's cells. That is cheaper, not cheap: an .xlsx's shared strings,
|
||||
// which every sheet indexes into, are still parsed whole, and an .xls (one BIFF stream,
|
||||
// with no separate parts) is read in full whatever `sheets` says. Measured near the
|
||||
// size limit that is a second or two on this thread, and `readSheet` below pays the
|
||||
// same again for each sheet the reader visits first. (`bookSheets` looks like the
|
||||
// option for this, but it leaves the hidden flags out.)
|
||||
const contents = sheetjs.read(bytes, { type: 'array', sheets: [] })
|
||||
names = contents.SheetNames
|
||||
hidden = names.map((_, index) => (contents.Workbook?.Sheets?.[index]?.Hidden ?? 0) !== 0)
|
||||
} catch (error) {
|
||||
throw new SpreadsheetError('invalid', error instanceof Error ? error.message : String(error), error)
|
||||
}
|
||||
|
||||
const sheets = names.flatMap((name, index): SheetInfo[] => (hidden[index] ? [] : [{ index, name }]))
|
||||
return {
|
||||
sheets,
|
||||
async readSheet(index) {
|
||||
const name = names[index]
|
||||
if (name === undefined) throw new SpreadsheetError('invalid', `The workbook has no sheet ${index}`)
|
||||
try {
|
||||
const workbook = sheetjs.read(bytes, {
|
||||
type: 'array',
|
||||
dense: true,
|
||||
sheets: index,
|
||||
// One more than is shown: see `toGrid`.
|
||||
sheetRows: limits.maxRows + 1,
|
||||
// Nothing to draw with these, and each costs memory per cell.
|
||||
cellFormula: false,
|
||||
cellHTML: false,
|
||||
// Column widths, row heights and which are hidden are only read with this on. It also
|
||||
// hangs a style object on every cell, which is why the rows and columns are capped.
|
||||
cellStyles: true,
|
||||
})
|
||||
const sheet = workbook.Sheets[name]
|
||||
if (!sheet) throw new Error(`The sheet ${name} was not read`)
|
||||
return toGrid(sheet, limits)
|
||||
} catch (error) {
|
||||
throw new SpreadsheetError('invalid', error instanceof Error ? error.message : String(error), error)
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** The engine the app uses. SheetJS loads on the first workbook, not at startup. */
|
||||
export const defaultSpreadsheetEngine: SpreadsheetEngine = createSpreadsheetEngine({
|
||||
loadSheetJs: () => import('xlsx'),
|
||||
})
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useCallback, useEffect, useRef, useState, type MutableRefObject, type RefObject } from 'react'
|
||||
import { DocxError, type DocxEngine } from './docxEngine'
|
||||
import {
|
||||
attachFrameListeners,
|
||||
createDocxFrame,
|
||||
currentColorScheme,
|
||||
documentPaperColor,
|
||||
naturalPageWidth,
|
||||
neutraliseLinks,
|
||||
presentationCss,
|
||||
type FrameHandlers,
|
||||
} from './docxFrame'
|
||||
|
||||
/** A Word document drawn into its frame. The frame is already in the page; the viewer sizes it. */
|
||||
export type RenderedDocx = {
|
||||
frame: HTMLIFrameElement
|
||||
doc: Document
|
||||
/** The widest page at 100%, in CSS pixels. */
|
||||
naturalWidth: number
|
||||
}
|
||||
|
||||
type Drawn = RenderedDocx & { detach: () => void }
|
||||
|
||||
export type DocxDocumentState = {
|
||||
/**
|
||||
* The document to show. It stays in place while a newer version of the same file is
|
||||
* drawn, and is only replaced once the new one is complete.
|
||||
*/
|
||||
current: RenderedDocx | null
|
||||
/**
|
||||
* Why the newest version could not be drawn. With `current` set, that is the previous
|
||||
* version, still on screen; without, there is nothing to show.
|
||||
*/
|
||||
error: DocxError | null
|
||||
retry: () => void
|
||||
}
|
||||
|
||||
/** Keep a frame out of sight and out of the layout while it is drawn. */
|
||||
function hide(frame: HTMLIFrameElement): void {
|
||||
frame.style.position = 'absolute'
|
||||
frame.style.top = '0'
|
||||
frame.style.left = '0'
|
||||
frame.style.visibility = 'hidden'
|
||||
frame.style.pointerEvents = 'none'
|
||||
}
|
||||
|
||||
function reveal(frame: HTMLIFrameElement): void {
|
||||
for (const property of ['position', 'top', 'left', 'visibility', 'pointer-events']) frame.style.removeProperty(property)
|
||||
}
|
||||
|
||||
function toDocxError(reason: unknown): DocxError {
|
||||
if (reason instanceof DocxError) return reason
|
||||
return new DocxError('invalid', reason instanceof Error ? reason.message : String(reason), reason)
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the Word document in `blob` into a frame under `hostRef`, and keep it there for as
|
||||
* long as it is shown.
|
||||
*
|
||||
* A file an agent is still writing produces a stream of versions, some of them half a
|
||||
* document. Each new `blob` is drawn in a frame off to the side; the reader keeps the
|
||||
* version they are looking at until the next one is complete, and one that will not draw
|
||||
* changes nothing but the note under the page.
|
||||
*
|
||||
* `layoutRef` sizes a frame — its zoom, its width, its height. It is called before a new frame
|
||||
* is put on show, so that the swap does not pass through a frame with no height.
|
||||
*/
|
||||
export function useDocxDocument({
|
||||
engine,
|
||||
blob,
|
||||
hostRef,
|
||||
title,
|
||||
handlersRef,
|
||||
layoutRef,
|
||||
}: {
|
||||
engine: DocxEngine
|
||||
blob: Blob
|
||||
hostRef: RefObject<HTMLElement | null>
|
||||
title: string
|
||||
handlersRef: MutableRefObject<FrameHandlers>
|
||||
layoutRef: MutableRefObject<(rendered: RenderedDocx) => void>
|
||||
}): DocxDocumentState {
|
||||
const [current, setCurrent] = useState<RenderedDocx | null>(null)
|
||||
const [error, setError] = useState<DocxError | null>(null)
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
const onShow = useRef<Drawn | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current
|
||||
if (!host) return
|
||||
let superseded = false
|
||||
setError(null)
|
||||
|
||||
void (async () => {
|
||||
let drawn: Drawn | null = null
|
||||
const discard = () => {
|
||||
drawn?.detach()
|
||||
drawn?.frame.remove()
|
||||
}
|
||||
let frame: HTMLIFrameElement | null = null
|
||||
try {
|
||||
// Our own copy of the bytes: the Blob is held for the next tab switch.
|
||||
const bytes = new Uint8Array(await blob.arrayBuffer())
|
||||
if (superseded) return
|
||||
|
||||
const created = createDocxFrame(host, currentColorScheme())
|
||||
frame = created.frame
|
||||
frame.title = title
|
||||
// While another version is on show, draw the next where it cannot be seen.
|
||||
if (onShow.current) hide(frame)
|
||||
const doc = await created.ready
|
||||
if (superseded) return frame.remove()
|
||||
|
||||
// Three siblings, in this order: docx-preview's stylesheet is written into the first
|
||||
// (it empties whatever it is given), the pages into the second, and ours goes last so
|
||||
// that it wins over docx-preview's where the two say the same thing.
|
||||
const styles = doc.createElement('div')
|
||||
const pages = doc.createElement('div')
|
||||
const presentation = doc.createElement('style')
|
||||
presentation.textContent = presentationCss(documentPaperColor())
|
||||
doc.body.append(styles, pages, presentation)
|
||||
|
||||
await engine.render(bytes, { body: pages, styles })
|
||||
if (superseded) return frame.remove()
|
||||
|
||||
neutraliseLinks(pages)
|
||||
const detach = attachFrameListeners(doc, {
|
||||
onExternalLink: (url) => handlersRef.current.onExternalLink(url),
|
||||
onWheelZoom: (deltaY, at) => handlersRef.current.onWheelZoom(deltaY, at),
|
||||
onZoomKey: (key) => handlersRef.current.onZoomKey(key),
|
||||
})
|
||||
drawn = { frame, doc, naturalWidth: naturalPageWidth(doc), detach }
|
||||
|
||||
// Size it, then swap. The frame is complete and correctly sized before anyone sees it.
|
||||
layoutRef.current(drawn)
|
||||
const previous = onShow.current
|
||||
reveal(frame)
|
||||
previous?.detach()
|
||||
previous?.frame.remove()
|
||||
onShow.current = drawn
|
||||
setCurrent({ frame: drawn.frame, doc: drawn.doc, naturalWidth: drawn.naturalWidth })
|
||||
} catch (reason) {
|
||||
discard()
|
||||
frame?.remove()
|
||||
if (!superseded) setError(toDocxError(reason))
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
superseded = true
|
||||
}
|
||||
}, [engine, blob, attempt, hostRef, title, handlersRef, layoutRef])
|
||||
|
||||
// Whatever is on show goes when the viewer does.
|
||||
useEffect(() => () => {
|
||||
onShow.current?.detach()
|
||||
onShow.current = null
|
||||
}, [])
|
||||
|
||||
const retry = useCallback(() => setAttempt((count) => count + 1), [])
|
||||
|
||||
return { current, error, retry }
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { blobWithBytes } from '@/test/blobs'
|
||||
import {
|
||||
LETTER,
|
||||
createControllablePdfEngine,
|
||||
createFakePdfDocument,
|
||||
type FakePdfDocument,
|
||||
} from '@/test/fakePdfEngine'
|
||||
import { PdfError, type PdfEngine } from './pdfEngine'
|
||||
import { usePdfDocument } from './usePdfDocument'
|
||||
|
||||
const bytes = (...values: number[]) => blobWithBytes(values, 'application/pdf')
|
||||
|
||||
/**
|
||||
* The blob is made once, outside the render: the cache hands a viewer the same
|
||||
* Blob for as long as the version is the same, and a hook handed a new one on every
|
||||
* render would (correctly) open the document again on every render.
|
||||
*/
|
||||
function mount(engine: PdfEngine, blob = bytes(1)) {
|
||||
return renderHook(() => usePdfDocument(engine, blob))
|
||||
}
|
||||
|
||||
describe('usePdfDocument', () => {
|
||||
it('has nothing to show until the document is open', async () => {
|
||||
const { engine, opens } = createControllablePdfEngine()
|
||||
|
||||
const { result } = mount(engine)
|
||||
|
||||
await waitFor(() => expect(opens).toHaveLength(1))
|
||||
expect(result.current.current).toBeNull()
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('opens the bytes of the blob and lays out every page from its own size', async () => {
|
||||
const { engine, opens } = createControllablePdfEngine()
|
||||
const doc = createFakePdfDocument({
|
||||
sizes: [LETTER, { width: 500, height: 700 }, { width: 900, height: 600 }],
|
||||
})
|
||||
|
||||
const { result } = mount(engine, bytes(37, 80, 68, 70))
|
||||
await waitFor(() => expect(opens).toHaveLength(1))
|
||||
expect(Array.from(opens[0]!.data)).toEqual([37, 80, 68, 70])
|
||||
await act(async () => opens[0]!.resolve(doc))
|
||||
|
||||
await waitFor(() => expect(result.current.current).not.toBeNull())
|
||||
expect(result.current.current!.doc).toBe(doc)
|
||||
expect(result.current.current!.sizes).toEqual([LETTER, { width: 500, height: 700 }, { width: 900, height: 600 }])
|
||||
expect(doc.pageSize).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('gives the engine a copy of the bytes: pdf.js detaches what it is given, and the blob is opened again later', async () => {
|
||||
const opened: Uint8Array[] = []
|
||||
const engine: PdfEngine = {
|
||||
open: vi.fn(async (data: Uint8Array) => {
|
||||
opened.push(Uint8Array.from(data))
|
||||
data.fill(0) // as a transfer to the worker would leave it
|
||||
return createFakePdfDocument({ pages: 1 })
|
||||
}),
|
||||
}
|
||||
const blob = bytes(1, 2, 3)
|
||||
|
||||
const first = renderHook(() => usePdfDocument(engine, blob))
|
||||
await waitFor(() => expect(first.result.current.current).not.toBeNull())
|
||||
first.unmount()
|
||||
const second = renderHook(() => usePdfDocument(engine, blob))
|
||||
await waitFor(() => expect(second.result.current.current).not.toBeNull())
|
||||
|
||||
expect(opened.map((data) => Array.from(data))).toEqual([[1, 2, 3], [1, 2, 3]])
|
||||
})
|
||||
|
||||
describe('when it cannot be opened', () => {
|
||||
it('reports why, in the engine’s own terms', async () => {
|
||||
const { engine, opens } = createControllablePdfEngine()
|
||||
const { result } = mount(engine)
|
||||
await waitFor(() => expect(opens).toHaveLength(1))
|
||||
|
||||
await act(async () => opens[0]!.reject(new PdfError('password', 'No password given')))
|
||||
|
||||
await waitFor(() => expect(result.current.error).not.toBeNull())
|
||||
expect(result.current.error!.kind).toBe('password')
|
||||
expect(result.current.current).toBeNull()
|
||||
})
|
||||
|
||||
it('calls anything else that goes wrong a document that cannot be shown', async () => {
|
||||
const { engine, opens } = createControllablePdfEngine()
|
||||
const { result } = mount(engine)
|
||||
await waitFor(() => expect(opens).toHaveLength(1))
|
||||
|
||||
await act(async () => opens[0]!.reject(new TypeError('boom')))
|
||||
|
||||
await waitFor(() => expect(result.current.error?.kind).toBe('invalid'))
|
||||
})
|
||||
|
||||
it('closes a document whose pages cannot be measured instead of leaking it', async () => {
|
||||
const { engine, opens } = createControllablePdfEngine()
|
||||
const doc = createFakePdfDocument({ pages: 2 })
|
||||
vi.mocked(doc.pageSize).mockRejectedValue(new Error('Invalid page request'))
|
||||
const { result } = mount(engine)
|
||||
await waitFor(() => expect(opens).toHaveLength(1))
|
||||
|
||||
await act(async () => opens[0]!.resolve(doc))
|
||||
|
||||
await waitFor(() => expect(result.current.error?.kind).toBe('invalid'))
|
||||
expect(doc.destroy).toHaveBeenCalledTimes(1)
|
||||
expect(result.current.current).toBeNull()
|
||||
})
|
||||
|
||||
it('does not accept a document with no pages', async () => {
|
||||
const { engine, opens } = createControllablePdfEngine()
|
||||
const doc = createFakePdfDocument({ pages: 0 })
|
||||
const { result } = mount(engine)
|
||||
await waitFor(() => expect(opens).toHaveLength(1))
|
||||
|
||||
await act(async () => opens[0]!.resolve(doc))
|
||||
|
||||
await waitFor(() => expect(result.current.error?.kind).toBe('invalid'))
|
||||
expect(doc.destroy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('tries again on request, and clears the failure when it works', async () => {
|
||||
const { engine, opens } = createControllablePdfEngine()
|
||||
const { result } = mount(engine)
|
||||
await waitFor(() => expect(opens).toHaveLength(1))
|
||||
await act(async () => opens[0]!.reject(new PdfError('unavailable', 'worker did not start')))
|
||||
await waitFor(() => expect(result.current.error?.kind).toBe('unavailable'))
|
||||
|
||||
act(() => result.current.retry())
|
||||
await waitFor(() => expect(opens).toHaveLength(2))
|
||||
expect(result.current.error).toBeNull() // no longer showing the last attempt's failure
|
||||
await act(async () => opens[1]!.resolve(createFakePdfDocument({ pages: 1 })))
|
||||
|
||||
await waitFor(() => expect(result.current.current).not.toBeNull())
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the file is rewritten', () => {
|
||||
async function shown() {
|
||||
const { engine, opens } = createControllablePdfEngine()
|
||||
const first = createFakePdfDocument({ pages: 3 })
|
||||
const hook = renderHook(({ blob }) => usePdfDocument(engine, blob), { initialProps: { blob: bytes(1) } })
|
||||
await waitFor(() => expect(opens).toHaveLength(1))
|
||||
await act(async () => opens[0]!.resolve(first))
|
||||
await waitFor(() => expect(hook.result.current.current?.doc).toBe(first))
|
||||
return { ...hook, engine, opens, first }
|
||||
}
|
||||
|
||||
it('keeps showing the version the reader is looking at until the next one is open', async () => {
|
||||
const { result, rerender, opens, first } = await shown()
|
||||
|
||||
rerender({ blob: bytes(2) })
|
||||
await waitFor(() => expect(opens).toHaveLength(2))
|
||||
|
||||
expect(result.current.current!.doc).toBe(first)
|
||||
expect(first.destroy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('swaps to the new version when it is ready, and only then closes the old one', async () => {
|
||||
const { result, rerender, opens, first } = await shown()
|
||||
const second = createFakePdfDocument({ pages: 5 })
|
||||
|
||||
rerender({ blob: bytes(2) })
|
||||
await waitFor(() => expect(opens).toHaveLength(2))
|
||||
await act(async () => opens[1]!.resolve(second))
|
||||
|
||||
await waitFor(() => expect(result.current.current!.doc).toBe(second))
|
||||
expect(result.current.current!.sizes).toHaveLength(5)
|
||||
await waitFor(() => expect(first.destroy).toHaveBeenCalledTimes(1))
|
||||
expect(second.destroy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the previous version, and says why, when the new one will not open', async () => {
|
||||
const { result, rerender, opens, first } = await shown()
|
||||
|
||||
rerender({ blob: bytes(2) }) // a half-written PDF
|
||||
await waitFor(() => expect(opens).toHaveLength(2))
|
||||
await act(async () => opens[1]!.reject(new PdfError('invalid', 'Invalid PDF structure.')))
|
||||
|
||||
await waitFor(() => expect(result.current.error?.kind).toBe('invalid'))
|
||||
expect(result.current.current!.doc).toBe(first)
|
||||
expect(first.destroy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drops the note about a failed version as soon as a good one arrives', async () => {
|
||||
const { result, rerender, opens } = await shown()
|
||||
rerender({ blob: bytes(2) })
|
||||
await waitFor(() => expect(opens).toHaveLength(2))
|
||||
await act(async () => opens[1]!.reject(new PdfError('invalid', 'Invalid PDF structure.')))
|
||||
await waitFor(() => expect(result.current.error).not.toBeNull())
|
||||
|
||||
rerender({ blob: bytes(3) })
|
||||
await waitFor(() => expect(opens).toHaveLength(3))
|
||||
await act(async () => opens[2]!.resolve(createFakePdfDocument({ pages: 2 })))
|
||||
|
||||
await waitFor(() => expect(result.current.current!.sizes).toHaveLength(2))
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('never shows a version that was overtaken while it was still opening', async () => {
|
||||
const { result, rerender, opens, first } = await shown()
|
||||
const slow = createFakePdfDocument({ pages: 4 })
|
||||
const fast = createFakePdfDocument({ pages: 6 })
|
||||
|
||||
rerender({ blob: bytes(2) })
|
||||
await waitFor(() => expect(opens).toHaveLength(2))
|
||||
rerender({ blob: bytes(3) })
|
||||
await waitFor(() => expect(opens).toHaveLength(3))
|
||||
await act(async () => opens[2]!.resolve(fast)) // the newer version finishes first
|
||||
await waitFor(() => expect(result.current.current!.doc).toBe(fast))
|
||||
await act(async () => opens[1]!.resolve(slow)) // the older one limps in afterwards
|
||||
|
||||
await waitFor(() => expect(slow.destroy).toHaveBeenCalledTimes(1))
|
||||
expect(result.current.current!.doc).toBe(fast)
|
||||
expect(fast.destroy).not.toHaveBeenCalled()
|
||||
expect(first.destroy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('drops a version overtaken after it opened but while its pages were still being measured', async () => {
|
||||
const { result, rerender, opens, first } = await shown()
|
||||
const measuring = createFakePdfDocument({ pages: 2 })
|
||||
const finishMeasuring: Array<() => void> = []
|
||||
vi.mocked(measuring.pageSize).mockImplementation(
|
||||
() => new Promise((resolve) => finishMeasuring.push(() => resolve(LETTER))),
|
||||
)
|
||||
|
||||
rerender({ blob: bytes(2) })
|
||||
await waitFor(() => expect(opens).toHaveLength(2))
|
||||
await act(async () => opens[1]!.resolve(measuring))
|
||||
await waitFor(() => expect(finishMeasuring).toHaveLength(2))
|
||||
rerender({ blob: bytes(3) }) // overtaken mid-measure
|
||||
await waitFor(() => expect(opens).toHaveLength(3))
|
||||
await act(async () => finishMeasuring.forEach((finish) => finish()))
|
||||
|
||||
await waitFor(() => expect(measuring.destroy).toHaveBeenCalledTimes(1))
|
||||
expect(result.current.current!.doc).toBe(first)
|
||||
})
|
||||
|
||||
it('does not report the failure of a version that was overtaken', async () => {
|
||||
const { result, rerender, opens } = await shown()
|
||||
rerender({ blob: bytes(2) })
|
||||
await waitFor(() => expect(opens).toHaveLength(2))
|
||||
rerender({ blob: bytes(3) })
|
||||
await waitFor(() => expect(opens).toHaveLength(3))
|
||||
|
||||
await act(async () => opens[1]!.reject(new PdfError('invalid', 'stale')))
|
||||
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the viewer goes away', () => {
|
||||
it('closes the document on screen', async () => {
|
||||
const { engine, opens } = createControllablePdfEngine()
|
||||
const doc: FakePdfDocument = createFakePdfDocument({ pages: 2 })
|
||||
const { result, unmount } = mount(engine)
|
||||
await waitFor(() => expect(opens).toHaveLength(1))
|
||||
await act(async () => opens[0]!.resolve(doc))
|
||||
await waitFor(() => expect(result.current.current).not.toBeNull())
|
||||
|
||||
unmount()
|
||||
|
||||
expect(doc.destroy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('closes a document that finishes opening after it left', async () => {
|
||||
const { engine, opens } = createControllablePdfEngine()
|
||||
const doc = createFakePdfDocument({ pages: 2 })
|
||||
const { unmount } = mount(engine)
|
||||
await waitFor(() => expect(opens).toHaveLength(1))
|
||||
|
||||
unmount()
|
||||
await act(async () => opens[0]!.resolve(doc))
|
||||
|
||||
await waitFor(() => expect(doc.destroy).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it('does not close a document twice', async () => {
|
||||
const { engine, opens } = createControllablePdfEngine()
|
||||
const doc = createFakePdfDocument({ pages: 2 })
|
||||
const { result, unmount, rerender } = renderHook(({ blob }) => usePdfDocument(engine, blob), {
|
||||
initialProps: { blob: bytes(1) },
|
||||
})
|
||||
await waitFor(() => expect(opens).toHaveLength(1))
|
||||
await act(async () => opens[0]!.resolve(doc))
|
||||
await waitFor(() => expect(result.current.current).not.toBeNull())
|
||||
|
||||
rerender({ blob: bytes(2) })
|
||||
unmount()
|
||||
|
||||
expect(doc.destroy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { PdfError, type PdfDocumentHandle, type PdfEngine } from './pdfEngine'
|
||||
import type { PageSize } from './pdfLayout'
|
||||
|
||||
/** A document that is open, with every page's natural size (so the column can be laid out at once). */
|
||||
export type OpenPdf = {
|
||||
doc: PdfDocumentHandle
|
||||
sizes: PageSize[]
|
||||
}
|
||||
|
||||
export type PdfDocumentState = {
|
||||
/**
|
||||
* The document to show. It stays in place while a newer version of the same file
|
||||
* opens, and is only replaced once the new one is ready.
|
||||
*/
|
||||
current: OpenPdf | null
|
||||
/**
|
||||
* Why the newest version could not be opened. With `current` set, that is the
|
||||
* previous version, still on screen; without, there is nothing to show.
|
||||
*/
|
||||
error: PdfError | null
|
||||
retry: () => void
|
||||
}
|
||||
|
||||
function toPdfError(reason: unknown): PdfError {
|
||||
if (reason instanceof PdfError) return reason
|
||||
return new PdfError('invalid', reason instanceof Error ? reason.message : String(reason), reason)
|
||||
}
|
||||
|
||||
function close(doc: PdfDocumentHandle | null): void {
|
||||
void doc?.destroy().catch(() => undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the PDF in `blob` with `engine`, and keep it open for as long as it is shown.
|
||||
*
|
||||
* A file that an agent is still writing produces a stream of versions, some of them
|
||||
* half a PDF. Each new `blob` is opened off to the side: the reader keeps the
|
||||
* version they are looking at until the next one is fully open, and a version that
|
||||
* will not open changes nothing but the note under the page.
|
||||
*/
|
||||
export function usePdfDocument(engine: PdfEngine, blob: Blob): PdfDocumentState {
|
||||
const [current, setCurrent] = useState<OpenPdf | null>(null)
|
||||
const [error, setError] = useState<PdfError | null>(null)
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
let superseded = false
|
||||
setError(null)
|
||||
|
||||
void (async () => {
|
||||
let opened: PdfDocumentHandle | null = null
|
||||
try {
|
||||
// Our own copy: pdf.js transfers the buffer it is given to its worker, and
|
||||
// the Blob is still needed for the next tab switch.
|
||||
const document = await engine.open(new Uint8Array(await blob.arrayBuffer()))
|
||||
opened = document
|
||||
if (superseded) return close(document)
|
||||
if (document.numPages < 1) throw new PdfError('invalid', 'The PDF has no pages')
|
||||
|
||||
const sizes = await Promise.all(Array.from({ length: document.numPages }, (_, index) => document.pageSize(index + 1)))
|
||||
if (superseded) return close(document)
|
||||
setCurrent({ doc: document, sizes })
|
||||
} catch (reason) {
|
||||
close(opened)
|
||||
if (!superseded) setError(toPdfError(reason))
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
superseded = true
|
||||
}
|
||||
}, [engine, blob, attempt])
|
||||
|
||||
// The document on screen is closed when it is replaced or the viewer goes away.
|
||||
// After the commit that swaps it out, so pages still drawing from it are cancelled first.
|
||||
useEffect(() => {
|
||||
if (!current) return
|
||||
return () => close(current.doc)
|
||||
}, [current])
|
||||
|
||||
const retry = useCallback(() => setAttempt((count) => count + 1), [])
|
||||
|
||||
return { current, error, retry }
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
SpreadsheetError,
|
||||
type Grid,
|
||||
type SheetInfo,
|
||||
type SpreadsheetDocument,
|
||||
type SpreadsheetEngine,
|
||||
} from './spreadsheetEngine'
|
||||
|
||||
export type SpreadsheetDocumentState = {
|
||||
/**
|
||||
* The workbook to show. It stays in place while a newer version of the same file opens,
|
||||
* and is only replaced once the new one is ready.
|
||||
*/
|
||||
current: SpreadsheetDocument | null
|
||||
/**
|
||||
* Why the newest version could not be opened. With `current` set, that is the previous
|
||||
* version, still on screen; without, there is nothing to show.
|
||||
*/
|
||||
error: SpreadsheetError | null
|
||||
retry: () => void
|
||||
}
|
||||
|
||||
function toSpreadsheetError(reason: unknown): SpreadsheetError {
|
||||
if (reason instanceof SpreadsheetError) return reason
|
||||
return new SpreadsheetError('invalid', reason instanceof Error ? reason.message : String(reason), reason)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the workbook in `blob` with `engine`, and keep it open for as long as it is shown.
|
||||
*
|
||||
* A file an agent is still writing produces a stream of versions, some of them half a
|
||||
* workbook. Each new `blob` is opened off to the side: the reader keeps the version they are
|
||||
* looking at until the next one is open, and one that will not open changes nothing but the
|
||||
* note under the sheet.
|
||||
*/
|
||||
export function useSpreadsheetDocument(engine: SpreadsheetEngine, blob: Blob): SpreadsheetDocumentState {
|
||||
const [current, setCurrent] = useState<SpreadsheetDocument | null>(null)
|
||||
const [error, setError] = useState<SpreadsheetError | null>(null)
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
let superseded = false
|
||||
setError(null)
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
// Our own copy of the bytes: the Blob is held for the next tab switch.
|
||||
const document = await engine.open(new Uint8Array(await blob.arrayBuffer()))
|
||||
if (!superseded) setCurrent(document)
|
||||
} catch (reason) {
|
||||
if (!superseded) setError(toSpreadsheetError(reason))
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
superseded = true
|
||||
}
|
||||
}, [engine, blob, attempt])
|
||||
|
||||
const retry = useCallback(() => setAttempt((count) => count + 1), [])
|
||||
|
||||
return { current, error, retry }
|
||||
}
|
||||
|
||||
export type SheetGridState = {
|
||||
/**
|
||||
* The sheet the reader is on: the one they picked, if this version of the workbook still has
|
||||
* it; failing that, the one they were looking at; failing that, the first.
|
||||
*/
|
||||
selected: SheetInfo | null
|
||||
/** The grid to show: the selected sheet once it has been read, until then the one before it. */
|
||||
grid: Grid | null
|
||||
/**
|
||||
* Which sheet `grid` is, by name. Names, not positions: a newer version of the workbook may
|
||||
* have a sheet added in front, and the sheet on screen is still the same sheet.
|
||||
*/
|
||||
gridName: string | null
|
||||
/** The selected sheet could not be read. */
|
||||
error: SpreadsheetError | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose a sheet of `document` and read it. Sheets already read are kept, so switching back to
|
||||
* one is instant; and the sheet on screen is not blanked while the next is read, which for a
|
||||
* sheet of any size is a beat during which the panel would otherwise flash empty.
|
||||
*
|
||||
* `preferred` is the sheet the reader picked, by name; before they pick one, the sheet that
|
||||
* happens to be first is the one they are on, and a sheet inserted in front of it by the next
|
||||
* version of the file does not move them.
|
||||
*/
|
||||
export function useSheetGrid(document: SpreadsheetDocument | null, preferred: string | undefined): SheetGridState {
|
||||
const cache = useRef(new WeakMap<SpreadsheetDocument, Map<number, Grid>>())
|
||||
const [shown, setShown] = useState<{ grid: Grid; name: string } | null>(null)
|
||||
const [failure, setFailure] = useState<{ document: SpreadsheetDocument; index: number; error: SpreadsheetError } | null>(null)
|
||||
|
||||
const wanted = preferred ?? shown?.name
|
||||
const selected = document ? (document.sheets.find((sheet) => sheet.name === wanted) ?? document.sheets[0] ?? null) : null
|
||||
const index = selected?.index ?? null
|
||||
const name = selected?.name ?? null
|
||||
|
||||
useEffect(() => {
|
||||
if (!document || index === null || name === null) return
|
||||
let superseded = false
|
||||
const known = cache.current.get(document)?.get(index)
|
||||
if (known) {
|
||||
setShown({ grid: known, name })
|
||||
setFailure(null)
|
||||
return
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
const grid = await document.readSheet(index)
|
||||
if (superseded) return
|
||||
const forDocument = cache.current.get(document) ?? new Map<number, Grid>()
|
||||
forDocument.set(index, grid)
|
||||
cache.current.set(document, forDocument)
|
||||
setShown({ grid, name })
|
||||
setFailure(null)
|
||||
} catch (reason) {
|
||||
if (!superseded) setFailure({ document, index, error: toSpreadsheetError(reason) })
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
superseded = true
|
||||
}
|
||||
}, [document, index, name])
|
||||
|
||||
return {
|
||||
selected,
|
||||
grid: shown?.grid ?? null,
|
||||
gridName: shown?.name ?? null,
|
||||
error: failure && failure.document === document && failure.index === index ? failure.error : null,
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,10 @@ import { Spinner } from '@/components/ui/Spinner'
|
||||
import { Switch } from '@/components/ui/Switch'
|
||||
import { TextArea } from '@/components/ui/TextArea'
|
||||
import { Tooltip } from '@/components/ui/Tooltip'
|
||||
import { ZoomableImage, type ImageZoom } from '@/components/ui/ZoomableImage'
|
||||
import { ZoomControls, type ZoomControlsLabels } from '@/components/ui/ZoomControls'
|
||||
import { BrandSeal } from '@/components/composite/BrandSeal'
|
||||
import { ZOOM_MAX, ZOOM_MIN, stepZoom, zoomPercent } from '@/lib/zoomPan'
|
||||
import { useTeamStore } from '@/stores/teamStore'
|
||||
import { THEME_MODES } from '@/types/settings'
|
||||
import type {
|
||||
@@ -323,6 +326,42 @@ function createAgentTeamsGallerySnapshots(now = Date.now()): TeamWorkbenchSnapsh
|
||||
]
|
||||
}
|
||||
|
||||
const ZOOM_LABELS: ZoomControlsLabels = {
|
||||
group: 'Zoom controls',
|
||||
zoomIn: 'Zoom in',
|
||||
zoomOut: 'Zoom out',
|
||||
fit: 'Fit to window',
|
||||
}
|
||||
|
||||
/** A grid, so that zooming and panning are visible, with named colours to stay clear of hex literals. */
|
||||
const ZOOM_SAMPLE = `data:image/svg+xml;utf8,${encodeURIComponent(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1000" viewBox="0 0 1600 1000">'
|
||||
+ '<defs><pattern id="g" width="100" height="100" patternUnits="userSpaceOnUse">'
|
||||
+ '<path d="M100 0H0V100" fill="none" stroke="gray" stroke-width="2"/></pattern></defs>'
|
||||
+ '<rect width="1600" height="1000" fill="lightsteelblue"/><rect width="1600" height="1000" fill="url(#g)"/>'
|
||||
+ '<text x="800" y="530" font-size="120" text-anchor="middle" fill="darkslategray">1600 x 1000</text></svg>',
|
||||
)}`
|
||||
|
||||
/** The cluster on its own, with a real ladder behind it so the disabled ends can be seen. */
|
||||
function ZoomControlsDemo({ surface, flat }: { surface?: 'default' | 'media'; flat?: boolean }) {
|
||||
const [zoom, setZoom] = useState<ImageZoom>('fit')
|
||||
const scale = zoom === 'fit' ? 1 : zoom
|
||||
return (
|
||||
<ZoomControls
|
||||
percent={zoomPercent(scale)}
|
||||
fitActive={zoom === 'fit'}
|
||||
canZoomIn={scale < ZOOM_MAX}
|
||||
canZoomOut={scale > ZOOM_MIN}
|
||||
onZoomIn={() => setZoom(stepZoom(scale, 1))}
|
||||
onZoomOut={() => setZoom(stepZoom(scale, -1))}
|
||||
onFit={() => setZoom('fit')}
|
||||
labels={ZOOM_LABELS}
|
||||
surface={surface}
|
||||
flat={flat}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({ title, note, children }: { title: string; note?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="flex flex-col gap-3 border-b border-[var(--color-border)] py-6">
|
||||
@@ -657,6 +696,27 @@ export function ComponentGallery() {
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="ZoomControls / ZoomableImage"
|
||||
note="The zoom cluster every viewer shares. In the images: Ctrl/⌘+wheel and pinch zoom about the pointer, drag pans, double-click toggles fit and 100%, and + - 0 1 work from the keyboard. Check the dark lightbox surface as well as the panel."
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<ZoomControlsDemo />
|
||||
<ZoomControlsDemo flat />
|
||||
<div className="flex items-center rounded-[var(--radius-lg)] bg-[var(--color-media-bg)] p-3">
|
||||
<ZoomControlsDemo surface="media" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid h-80 grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div className="flex min-h-0 overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)]">
|
||||
<ZoomableImage src={ZOOM_SAMPLE} alt="Sample grid" labels={ZOOM_LABELS} />
|
||||
</div>
|
||||
<div className="flex min-h-0 overflow-hidden rounded-[var(--radius-lg)] bg-[var(--color-media-bg)]">
|
||||
<ZoomableImage src={ZOOM_SAMPLE} alt="Sample grid on the lightbox" labels={ZOOM_LABELS} surface="media" />
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="BrandSeal" note="The cc-haha mark, a vector rebuild of the app icon. It sheds parts as it shrinks — sparkles only at xl, cursor drops at sm — so check each size against its neighbours.">
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<BrandSeal size="sm" />
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useDebouncedValue } from './useDebouncedValue'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('useDebouncedValue', () => {
|
||||
it('returns the first value at once, so nothing is delayed on mount', () => {
|
||||
const { result } = renderHook(() => useDebouncedValue(1.5, 120))
|
||||
|
||||
expect(result.current).toBe(1.5)
|
||||
})
|
||||
|
||||
it('holds the old value until the new one has stopped changing for the delay', () => {
|
||||
const { result, rerender } = renderHook(({ value }) => useDebouncedValue(value, 120), {
|
||||
initialProps: { value: 1 },
|
||||
})
|
||||
|
||||
rerender({ value: 2 })
|
||||
expect(result.current).toBe(1)
|
||||
|
||||
act(() => { vi.advanceTimersByTime(119) })
|
||||
expect(result.current).toBe(1)
|
||||
|
||||
act(() => { vi.advanceTimersByTime(1) })
|
||||
expect(result.current).toBe(2)
|
||||
})
|
||||
|
||||
it('restarts the wait on every change, so a gesture in progress never commits', () => {
|
||||
// A wheel zoom changes the value many times a second. Committing mid-gesture
|
||||
// is the re-rasterising the delay exists to avoid.
|
||||
const { result, rerender } = renderHook(({ value }) => useDebouncedValue(value, 120), {
|
||||
initialProps: { value: 1 },
|
||||
})
|
||||
|
||||
for (const value of [1.1, 1.25, 1.5, 1.75]) {
|
||||
rerender({ value })
|
||||
act(() => { vi.advanceTimersByTime(100) })
|
||||
}
|
||||
expect(result.current).toBe(1)
|
||||
|
||||
act(() => { vi.advanceTimersByTime(120) })
|
||||
expect(result.current).toBe(1.75)
|
||||
})
|
||||
|
||||
it('skips values that came and went inside the delay', () => {
|
||||
const seen: number[] = []
|
||||
const { rerender } = renderHook(({ value }) => {
|
||||
const settled = useDebouncedValue(value, 120)
|
||||
seen.push(settled)
|
||||
return settled
|
||||
}, { initialProps: { value: 1 } })
|
||||
|
||||
rerender({ value: 2 })
|
||||
act(() => { vi.advanceTimersByTime(50) })
|
||||
rerender({ value: 3 })
|
||||
act(() => { vi.advanceTimersByTime(120) })
|
||||
|
||||
expect(new Set(seen)).toEqual(new Set([1, 3]))
|
||||
})
|
||||
|
||||
it('does nothing when the value comes back to what was already settled', () => {
|
||||
const { result, rerender } = renderHook(({ value }) => useDebouncedValue(value, 120), {
|
||||
initialProps: { value: 1 },
|
||||
})
|
||||
|
||||
rerender({ value: 2 })
|
||||
rerender({ value: 1 })
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
|
||||
expect(result.current).toBe(1)
|
||||
})
|
||||
|
||||
it('leaves no timer running after unmount', () => {
|
||||
const { rerender, unmount } = renderHook(({ value }) => useDebouncedValue(value, 120), {
|
||||
initialProps: { value: 1 },
|
||||
})
|
||||
rerender({ value: 2 })
|
||||
|
||||
unmount()
|
||||
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
/**
|
||||
* `value`, delayed until it has stopped changing for `delayMs`.
|
||||
*
|
||||
* For work that is expensive to redo and pointless to do mid-gesture: a pinch or a
|
||||
* wheel zoom changes a scale dozens of times a second, and re-rasterising a page
|
||||
* for every one of them is what makes zooming a PDF stutter. The layout follows
|
||||
* the live value; the pixels follow this one.
|
||||
*
|
||||
* The first render returns `value` itself, so nothing is delayed on mount.
|
||||
*/
|
||||
export function useDebouncedValue<T>(value: T, delayMs: number): T {
|
||||
const [settled, setSettled] = useState(value)
|
||||
|
||||
useEffect(() => {
|
||||
if (Object.is(settled, value)) return
|
||||
const timer = setTimeout(() => setSettled(value), delayMs)
|
||||
return () => clearTimeout(timer)
|
||||
}, [value, delayMs, settled])
|
||||
|
||||
return settled
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import { StrictMode } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import '@testing-library/jest-dom'
|
||||
import { useElementSize } from './useElementSize'
|
||||
|
||||
const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth')
|
||||
const originalClientHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientHeight')
|
||||
|
||||
function Probe() {
|
||||
const [measuredRef, size] = useElementSize<HTMLDivElement>()
|
||||
return (
|
||||
<div ref={measuredRef}>
|
||||
<span data-testid="size">{size === null ? 'unmeasured' : `${size.width}x${size.height}`}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
describe('useElementSize', () => {
|
||||
const observers = new Set<() => void>()
|
||||
let box = { width: 0, height: 0 }
|
||||
|
||||
function stubLayout(width: number, height: number) {
|
||||
box = { width, height }
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, get: () => box.width })
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientHeight', { configurable: true, get: () => box.height })
|
||||
}
|
||||
|
||||
function stubResizeObserver() {
|
||||
class StubResizeObserver {
|
||||
constructor(private readonly callback: () => void) {}
|
||||
observe() { observers.add(this.callback) }
|
||||
unobserve() { observers.delete(this.callback) }
|
||||
disconnect() { observers.delete(this.callback) }
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', StubResizeObserver)
|
||||
}
|
||||
|
||||
function resizeTo(width: number, height: number) {
|
||||
box = { width, height }
|
||||
act(() => {
|
||||
observers.forEach((notify) => notify())
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
observers.clear()
|
||||
box = { width: 0, height: 0 }
|
||||
vi.unstubAllGlobals()
|
||||
for (const [property, original] of [
|
||||
['clientWidth', originalClientWidth],
|
||||
['clientHeight', originalClientHeight],
|
||||
] as const) {
|
||||
if (original) Object.defineProperty(HTMLElement.prototype, property, original)
|
||||
else Reflect.deleteProperty(HTMLElement.prototype, property)
|
||||
}
|
||||
})
|
||||
|
||||
it('measures the node as soon as it mounts', () => {
|
||||
stubLayout(640, 480)
|
||||
stubResizeObserver()
|
||||
|
||||
render(<Probe />)
|
||||
|
||||
expect(screen.getByTestId('size')).toHaveTextContent('640x480')
|
||||
})
|
||||
|
||||
it('reports null rather than a zero size before layout, so callers keep their fallback', () => {
|
||||
// jsdom lays nothing out: everything measures 0.
|
||||
render(<Probe />)
|
||||
|
||||
expect(screen.getByTestId('size')).toHaveTextContent('unmeasured')
|
||||
})
|
||||
|
||||
it('follows the element as it resizes', () => {
|
||||
stubLayout(640, 480)
|
||||
stubResizeObserver()
|
||||
render(<Probe />)
|
||||
|
||||
resizeTo(320, 200)
|
||||
|
||||
expect(screen.getByTestId('size')).toHaveTextContent('320x200')
|
||||
})
|
||||
|
||||
it('goes back to null when the element collapses to nothing', () => {
|
||||
stubLayout(640, 480)
|
||||
stubResizeObserver()
|
||||
render(<Probe />)
|
||||
|
||||
resizeTo(0, 0)
|
||||
|
||||
expect(screen.getByTestId('size')).toHaveTextContent('unmeasured')
|
||||
})
|
||||
|
||||
it('keeps observing under StrictMode, which replays effects without reattaching the ref', () => {
|
||||
stubLayout(640, 480)
|
||||
stubResizeObserver()
|
||||
render(<StrictMode><Probe /></StrictMode>)
|
||||
|
||||
resizeTo(100, 50)
|
||||
|
||||
expect(screen.getByTestId('size')).toHaveTextContent('100x50')
|
||||
})
|
||||
|
||||
it('stops observing on unmount', () => {
|
||||
stubLayout(640, 480)
|
||||
stubResizeObserver()
|
||||
const { unmount } = render(<Probe />)
|
||||
expect(observers.size).toBe(1)
|
||||
|
||||
unmount()
|
||||
|
||||
expect(observers.size).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
export type ElementSize = { width: number; height: number }
|
||||
|
||||
/**
|
||||
* Tracks an element's rendered content-box size.
|
||||
*
|
||||
* The two-dimensional sibling of {@link useElementWidth}, for a viewer that fits
|
||||
* content to its container. It shares that hook's contract: `null` — not a zero
|
||||
* size — until a real measurement lands, so a caller can tell "not measured yet"
|
||||
* from "measured as empty" and keep its CSS-driven fallback instead of flashing a
|
||||
* layout the first paint would replace. jsdom has no ResizeObserver and lays
|
||||
* nothing out, so under test the size stays `null`.
|
||||
*
|
||||
* Measure a node whose own size does not depend on the decision it feeds: a
|
||||
* container that the resulting scale resizes turns the observer into a feedback
|
||||
* loop.
|
||||
*/
|
||||
export function useElementSize<T extends HTMLElement>(): [(node: T | null) => void, ElementSize | null] {
|
||||
const [size, setSize] = useState<ElementSize | null>(null)
|
||||
const nodeRef = useRef<T | null>(null)
|
||||
const observerRef = useRef<ResizeObserver | null>(null)
|
||||
|
||||
const observeNode = useCallback((node: T | null) => {
|
||||
observerRef.current?.disconnect()
|
||||
observerRef.current = null
|
||||
if (!node) return
|
||||
|
||||
const measure = () => {
|
||||
const width = node.clientWidth
|
||||
const height = node.clientHeight
|
||||
setSize((current) => {
|
||||
if (width <= 0 || height <= 0) return null
|
||||
return current?.width === width && current.height === height ? current : { width, height }
|
||||
})
|
||||
}
|
||||
|
||||
measure()
|
||||
if (typeof ResizeObserver === 'undefined') return
|
||||
const observer = new ResizeObserver(measure)
|
||||
observer.observe(node)
|
||||
observerRef.current = observer
|
||||
}, [])
|
||||
|
||||
const measuredRef = useCallback((node: T | null) => {
|
||||
nodeRef.current = node
|
||||
observeNode(node)
|
||||
}, [observeNode])
|
||||
|
||||
useEffect(() => {
|
||||
// StrictMode replays effect setup after cleanup without reattaching the
|
||||
// callback ref. Reconnect to the retained node so later resizes still arrive.
|
||||
if (!observerRef.current) observeNode(nodeRef.current)
|
||||
return () => {
|
||||
observerRef.current?.disconnect()
|
||||
observerRef.current = null
|
||||
}
|
||||
}, [observeNode])
|
||||
|
||||
return [measuredRef, size]
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
|
||||
import { ApiError } from '@/api/client'
|
||||
import { sessionsApi } from '@/api/sessions'
|
||||
import { resetDocumentBlobCacheForTests } from '@/lib/workspace/documentBlobCache'
|
||||
import { useWorkspaceDocumentBlob } from './useWorkspaceDocumentBlob'
|
||||
|
||||
type Pending = {
|
||||
resolve: (blob: Blob) => void
|
||||
reject: (error: unknown) => void
|
||||
signal: AbortSignal | undefined
|
||||
}
|
||||
|
||||
let pending: Pending[]
|
||||
let getWorkspaceRaw: MockInstance<typeof sessionsApi.getWorkspaceRaw>
|
||||
|
||||
beforeEach(() => {
|
||||
resetDocumentBlobCacheForTests()
|
||||
pending = []
|
||||
getWorkspaceRaw = vi.spyOn(sessionsApi, 'getWorkspaceRaw').mockImplementation(
|
||||
(_sessionId, _path, signal) => new Promise<Blob>((resolve, reject) => {
|
||||
pending.push({ resolve, reject, signal })
|
||||
signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')))
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
getWorkspaceRaw.mockRestore()
|
||||
resetDocumentBlobCacheForTests()
|
||||
})
|
||||
|
||||
const blobNamed = (label: string) => new Blob([label])
|
||||
|
||||
describe('useWorkspaceDocumentBlob', () => {
|
||||
it('fetches the document once its version is known and reports it ready', async () => {
|
||||
const { result } = renderHook(() => useWorkspaceDocumentBlob('s', 'thesis.pdf', 'v1'))
|
||||
|
||||
expect(result.current).toMatchObject({ blob: null, loading: true, error: null })
|
||||
const blob = blobNamed('v1')
|
||||
await act(async () => pending[0]!.resolve(blob))
|
||||
|
||||
expect(result.current).toMatchObject({ blob, blobVersion: 'v1', loading: false, error: null })
|
||||
expect(getWorkspaceRaw).toHaveBeenCalledWith('s', 'thesis.pdf', expect.any(AbortSignal))
|
||||
})
|
||||
|
||||
it('fetches nothing until the file metadata (its version) has loaded', () => {
|
||||
const { result } = renderHook(() => useWorkspaceDocumentBlob('s', 'thesis.pdf', undefined))
|
||||
|
||||
expect(result.current).toMatchObject({ blob: null, loading: false })
|
||||
expect(getWorkspaceRaw).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('serves a remount of the same version from memory: no request, no loading flash', async () => {
|
||||
// The workspace mounts only the active tab, so a tab switch is a remount.
|
||||
const first = renderHook(() => useWorkspaceDocumentBlob('s', 'thesis.pdf', 'v1'))
|
||||
const blob = blobNamed('v1')
|
||||
await act(async () => pending[0]!.resolve(blob))
|
||||
first.unmount()
|
||||
|
||||
const second = renderHook(() => useWorkspaceDocumentBlob('s', 'thesis.pdf', 'v1'))
|
||||
|
||||
expect(second.result.current).toMatchObject({ blob, blobVersion: 'v1', loading: false })
|
||||
expect(getWorkspaceRaw).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps showing the previous bytes while a newer version loads, then swaps', async () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ version }) => useWorkspaceDocumentBlob('s', 'thesis.pdf', version),
|
||||
{ initialProps: { version: 'v1' } },
|
||||
)
|
||||
const v1 = blobNamed('v1')
|
||||
await act(async () => pending[0]!.resolve(v1))
|
||||
|
||||
rerender({ version: 'v2' })
|
||||
|
||||
// The agent rewrote the file: the reader keeps the old render, flagged as refreshing.
|
||||
expect(result.current).toMatchObject({ blob: v1, blobVersion: 'v1', loading: true })
|
||||
const v2 = blobNamed('v2')
|
||||
await act(async () => pending[1]!.resolve(v2))
|
||||
|
||||
expect(result.current).toMatchObject({ blob: v2, blobVersion: 'v2', loading: false })
|
||||
})
|
||||
|
||||
it('keeps the last good bytes when a refresh fails, and reports the status', async () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ version }) => useWorkspaceDocumentBlob('s', 'thesis.pdf', version),
|
||||
{ initialProps: { version: 'v1' } },
|
||||
)
|
||||
const v1 = blobNamed('v1')
|
||||
await act(async () => pending[0]!.resolve(v1))
|
||||
|
||||
rerender({ version: 'v2' })
|
||||
await act(async () => pending[1]!.reject(new ApiError(413, { message: 'too big' })))
|
||||
|
||||
expect(result.current.blob).toBe(v1)
|
||||
expect(result.current.blobVersion).toBe('v1')
|
||||
expect(result.current.loading).toBe(false)
|
||||
expect(result.current.errorStatus).toBe(413)
|
||||
expect(result.current.error).toBeTruthy()
|
||||
})
|
||||
|
||||
it('reports a first-load failure with no bytes, and retry asks again', async () => {
|
||||
const { result } = renderHook(() => useWorkspaceDocumentBlob('s', 'thesis.pdf', 'v1'))
|
||||
await act(async () => pending[0]!.reject(new ApiError(404, { message: 'gone' })))
|
||||
|
||||
expect(result.current).toMatchObject({ blob: null, loading: false, errorStatus: 404 })
|
||||
|
||||
act(() => result.current.retry())
|
||||
const blob = blobNamed('v1')
|
||||
await waitFor(() => expect(pending).toHaveLength(2))
|
||||
await act(async () => pending[1]!.resolve(blob))
|
||||
|
||||
expect(result.current).toMatchObject({ blob, loading: false, error: null, errorStatus: null })
|
||||
})
|
||||
|
||||
it('cancels the request when the viewer unmounts', () => {
|
||||
const { unmount } = renderHook(() => useWorkspaceDocumentBlob('s', 'thesis.pdf', 'v1'))
|
||||
expect(pending[0]!.signal!.aborted).toBe(false)
|
||||
|
||||
unmount()
|
||||
|
||||
expect(pending[0]!.signal!.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it("never offers one file's bytes for another file, not even for a render", async () => {
|
||||
// Keeping the old bytes across *versions* is deliberate; across *files* it
|
||||
// would draw a different document's pages under the new file's name.
|
||||
const { result, rerender } = renderHook(
|
||||
({ path }) => useWorkspaceDocumentBlob('s', path, 'v1'),
|
||||
{ initialProps: { path: 'a.pdf' } },
|
||||
)
|
||||
const a = blobNamed('a')
|
||||
await act(async () => pending[0]!.resolve(a))
|
||||
expect(result.current.blob).toBe(a)
|
||||
|
||||
rerender({ path: 'b.pdf' })
|
||||
|
||||
expect(result.current).toMatchObject({ blob: null, blobVersion: undefined, loading: true })
|
||||
const b = blobNamed('b')
|
||||
await act(async () => pending[1]!.resolve(b))
|
||||
expect(result.current).toMatchObject({ blob: b, blobVersion: 'v1', loading: false })
|
||||
})
|
||||
|
||||
it('does not apply a result that arrives after the version moved on', async () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ version }) => useWorkspaceDocumentBlob('s', 'thesis.pdf', version),
|
||||
{ initialProps: { version: 'v1' } },
|
||||
)
|
||||
rerender({ version: 'v2' })
|
||||
// v1 was cancelled by the version change; a late v1 completion must not win.
|
||||
await act(async () => pending[0]!.resolve(blobNamed('late-v1')))
|
||||
const v2 = blobNamed('v2')
|
||||
await act(async () => pending[1]!.resolve(v2))
|
||||
|
||||
expect(result.current.blob).toBe(v2)
|
||||
expect(result.current.blobVersion).toBe('v2')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { ApiError } from '@/api/client'
|
||||
import { sessionsApi } from '@/api/sessions'
|
||||
import {
|
||||
documentBlobKey,
|
||||
fetchDocumentBlob,
|
||||
peekDocumentBlob,
|
||||
} from '@/lib/workspace/documentBlobCache'
|
||||
import { workspaceFileKey } from '@/stores/workspaceContentStore'
|
||||
|
||||
export type WorkspaceDocumentBlob = {
|
||||
/** The newest bytes held. While a newer version loads, this is still the previous one. */
|
||||
blob: Blob | null
|
||||
/** Which version `blob` is; undefined until the first load completes. */
|
||||
blobVersion: string | undefined
|
||||
/** A version this hook does not hold yet is being fetched. */
|
||||
loading: boolean
|
||||
/** The latest fetch failed. `blob` is still the last good one, if there was one. */
|
||||
error: string | null
|
||||
/** HTTP status of that failure, when it was one (403 outside workspace, 413 too large…). */
|
||||
errorStatus: number | null
|
||||
retry: () => void
|
||||
}
|
||||
|
||||
type State = Omit<WorkspaceDocumentBlob, 'retry'> & {
|
||||
/** Which file this state describes; state for another file is not this file's. */
|
||||
identity: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The bytes of a workspace document, for a viewer to render.
|
||||
*
|
||||
* Keyed by `version`, not by a reload: a watcher reload that finds the same
|
||||
* version costs nothing, and a new version is fetched while the previous bytes
|
||||
* stay available, so the viewer can swap renders instead of blanking. Tab
|
||||
* switches are served from a small shared cache (see `documentBlobCache`).
|
||||
*
|
||||
* `version === undefined` means the file's metadata has not loaded yet; nothing
|
||||
* is fetched until it does.
|
||||
*
|
||||
* "Previous bytes stay available" holds across versions of one file only. Point
|
||||
* the hook at another file and the old file's bytes are gone at once — a viewer
|
||||
* must never draw a different document's pages under the new file's name.
|
||||
*/
|
||||
export function useWorkspaceDocumentBlob(
|
||||
sessionId: string,
|
||||
path: string,
|
||||
version: string | undefined,
|
||||
): WorkspaceDocumentBlob {
|
||||
const identity = workspaceFileKey(sessionId, path)
|
||||
const [state, setState] = useState<State>(() => {
|
||||
const cached = version === undefined
|
||||
? undefined
|
||||
: peekDocumentBlob(documentBlobKey(sessionId, path, version))
|
||||
return {
|
||||
identity,
|
||||
blob: cached ?? null,
|
||||
blobVersion: cached ? version : undefined,
|
||||
loading: !cached && version !== undefined,
|
||||
error: null,
|
||||
errorStatus: null,
|
||||
}
|
||||
})
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (version === undefined) return
|
||||
|
||||
const cached = peekDocumentBlob(documentBlobKey(sessionId, path, version))
|
||||
if (cached) {
|
||||
setState({ identity, blob: cached, blobVersion: version, loading: false, error: null, errorStatus: null })
|
||||
return
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
setState((current) => (
|
||||
current.identity === identity
|
||||
? { ...current, loading: true, error: null, errorStatus: null }
|
||||
: { identity, blob: null, blobVersion: undefined, loading: true, error: null, errorStatus: null }
|
||||
))
|
||||
fetchDocumentBlob(
|
||||
sessionId,
|
||||
path,
|
||||
version,
|
||||
(signal) => sessionsApi.getWorkspaceRaw(sessionId, path, signal),
|
||||
controller.signal,
|
||||
).then(
|
||||
(blob) => {
|
||||
if (controller.signal.aborted) return
|
||||
setState({ identity, blob, blobVersion: version, loading: false, error: null, errorStatus: null })
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (controller.signal.aborted) return
|
||||
setState((current) => ({
|
||||
...current,
|
||||
identity,
|
||||
loading: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
errorStatus: error instanceof ApiError ? error.status : null,
|
||||
}))
|
||||
},
|
||||
)
|
||||
return () => controller.abort()
|
||||
}, [sessionId, path, version, attempt, identity])
|
||||
|
||||
const retry = useCallback(() => setAttempt((count) => count + 1), [])
|
||||
|
||||
// The effect above runs after the render in which the file changed. Until it
|
||||
// does, state still describes the previous file: report it as empty rather than
|
||||
// hand its bytes to a viewer for the new one.
|
||||
if (state.identity !== identity) {
|
||||
return {
|
||||
blob: null,
|
||||
blobVersion: undefined,
|
||||
loading: version !== undefined,
|
||||
error: null,
|
||||
errorStatus: null,
|
||||
retry,
|
||||
}
|
||||
}
|
||||
|
||||
const { identity: _identity, ...visible } = state
|
||||
return { ...visible, retry }
|
||||
}
|
||||
@@ -593,6 +593,9 @@ export const en = {
|
||||
'chat.copyPrompt': 'Copy prompt',
|
||||
'chat.teammateMessage': 'teammate message',
|
||||
'chat.copyPath': 'Copy path',
|
||||
'chat.toolResultImage.open': 'Open image {index} of {count}',
|
||||
'chat.toolResultImage.alt': 'Image {index} of {count}',
|
||||
'chat.toolResultImage.omitted': 'Images not shown: {count}',
|
||||
'common.dismissNotification': 'Dismiss notification',
|
||||
|
||||
// ─── Sidebar ──────────────────────────────────────
|
||||
@@ -919,6 +922,32 @@ export const en = {
|
||||
'workspace.previewState.tooLarge': 'File is too large to preview.',
|
||||
'workspace.previewState.missing': 'File not found.',
|
||||
'workspace.imagePreviewUnavailable': 'Image preview is unavailable.',
|
||||
'workspace.zoom.group': 'Zoom controls',
|
||||
'workspace.zoom.in': 'Zoom in',
|
||||
'workspace.zoom.out': 'Zoom out',
|
||||
'workspace.zoom.fitWindow': 'Fit to window',
|
||||
'workspace.zoom.fitWidth': 'Fit to width',
|
||||
'workspace.openInSystemApp': 'Open in system app',
|
||||
'workspace.document.loading': 'Loading document...',
|
||||
'workspace.document.updating': 'Updating preview...',
|
||||
'workspace.document.parseFailed': 'This document could not be displayed.',
|
||||
'workspace.document.retry': 'Try again',
|
||||
'workspace.document.unsupported': "This file type can't be previewed here.",
|
||||
'workspace.document.outsideWorkspace': "This file is outside the workspace and can't be previewed here.",
|
||||
'workspace.document.passwordProtected': "This PDF is password protected and can't be previewed here.",
|
||||
'workspace.document.engineUnavailable': "The document viewer can't start in this environment.",
|
||||
'workspace.document.openInBrowser': 'Open in browser',
|
||||
'workspace.document.tooComplex': 'This file is too large or too complex to preview safely.',
|
||||
'workspace.document.approximate.docx': 'Approximate preview: equations and some shapes may not appear. Open the original for the exact layout.',
|
||||
'workspace.document.approximate.xlsx': "Shows each cell as formatted text. Charts, images and formulas aren't shown.",
|
||||
'workspace.pdf.pages': 'Pages',
|
||||
'workspace.pdf.pageNumber': 'Page number',
|
||||
'workspace.pdf.pageOfTotal': 'Page {page} of {total}',
|
||||
'workspace.pdf.pageFailed': 'This page could not be drawn.',
|
||||
'workspace.sheet.tabs': 'Worksheets',
|
||||
'workspace.sheet.truncated': 'Showing the first {rows} rows and {columns} columns.',
|
||||
'workspace.sheet.showMoreRows': 'Show more rows',
|
||||
'workspace.sheet.empty': 'This worksheet is empty.',
|
||||
'workspace.previewLineLimit': 'Showing first {count} of {total} loaded lines.',
|
||||
'workspace.previewAllLines': 'Showing all {total} loaded lines.',
|
||||
'workspace.showAllLoadedLines': 'Show all loaded lines',
|
||||
|
||||
@@ -594,6 +594,9 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'chat.copyPrompt': 'プロンプトをコピー',
|
||||
'chat.teammateMessage': 'メンバーからのメッセージ',
|
||||
'chat.copyPath': 'パスをコピー',
|
||||
'chat.toolResultImage.open': '画像 {index}/{count} を開く',
|
||||
'chat.toolResultImage.alt': '画像 {index}/{count}',
|
||||
'chat.toolResultImage.omitted': '表示されていない画像: {count} 件',
|
||||
'common.dismissNotification': '通知を閉じる',
|
||||
|
||||
// ─── Sidebar ──────────────────────────────────────
|
||||
@@ -920,6 +923,32 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'workspace.previewState.tooLarge': 'ファイルが大きすぎてプレビューできません。',
|
||||
'workspace.previewState.missing': 'ファイルが見つかりません。',
|
||||
'workspace.imagePreviewUnavailable': '画像をプレビューできません。',
|
||||
'workspace.zoom.group': 'ズーム操作',
|
||||
'workspace.zoom.in': '拡大',
|
||||
'workspace.zoom.out': '縮小',
|
||||
'workspace.zoom.fitWindow': 'ウィンドウに合わせる',
|
||||
'workspace.zoom.fitWidth': '幅に合わせる',
|
||||
'workspace.openInSystemApp': 'システムのアプリで開く',
|
||||
'workspace.document.loading': 'ドキュメントを読み込み中...',
|
||||
'workspace.document.updating': 'プレビューを更新中...',
|
||||
'workspace.document.parseFailed': 'このドキュメントを表示できませんでした。',
|
||||
'workspace.document.retry': '再試行',
|
||||
'workspace.document.unsupported': 'この種類のファイルはここではプレビューできません。',
|
||||
'workspace.document.outsideWorkspace': 'このファイルはワークスペースの外にあるため、ここではプレビューできません。',
|
||||
'workspace.document.passwordProtected': 'この PDF はパスワードで保護されているため、ここではプレビューできません。',
|
||||
'workspace.document.engineUnavailable': 'この環境ではドキュメントビューアを起動できません。',
|
||||
'workspace.document.openInBrowser': 'ブラウザで開く',
|
||||
'workspace.document.tooComplex': 'ファイルが大きすぎる、または複雑すぎるため、安全にプレビューできません。',
|
||||
'workspace.document.approximate.docx': '簡易プレビュー:数式や一部の図形は表示されない場合があります。正確なレイアウトは元のファイルを開いて確認してください。',
|
||||
'workspace.document.approximate.xlsx': 'セルを書式設定済みのテキストで表示します。グラフ、画像、数式は表示されません。',
|
||||
'workspace.pdf.pages': 'ページ',
|
||||
'workspace.pdf.pageNumber': 'ページ番号',
|
||||
'workspace.pdf.pageOfTotal': '{total} ページ中 {page} ページ目',
|
||||
'workspace.pdf.pageFailed': 'このページを描画できませんでした。',
|
||||
'workspace.sheet.tabs': 'ワークシート',
|
||||
'workspace.sheet.truncated': '最初の {rows} 行、{columns} 列のみ表示しています。',
|
||||
'workspace.sheet.showMoreRows': 'さらに行を表示',
|
||||
'workspace.sheet.empty': 'このワークシートは空です。',
|
||||
'workspace.previewLineLimit': '読み込まれた {total} 行のうち最初の {count} 行を表示しています。',
|
||||
'workspace.previewAllLines': '読み込まれた {total} 行すべてを表示しています。',
|
||||
'workspace.showAllLoadedLines': '読み込まれた行をすべて表示',
|
||||
|
||||
@@ -596,6 +596,9 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'chat.copyPrompt': '프롬프트 복사',
|
||||
'chat.teammateMessage': '팀원 메시지',
|
||||
'chat.copyPath': '경로 복사',
|
||||
'chat.toolResultImage.open': '이미지 {index}/{count} 열기',
|
||||
'chat.toolResultImage.alt': '이미지 {index}/{count}',
|
||||
'chat.toolResultImage.omitted': '표시되지 않은 이미지: {count}개',
|
||||
'common.dismissNotification': '알림 닫기',
|
||||
|
||||
// ─── Sidebar ──────────────────────────────────────
|
||||
@@ -922,6 +925,32 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'workspace.previewState.tooLarge': '파일이 너무 커서 미리 볼 수 없습니다.',
|
||||
'workspace.previewState.missing': '파일을 찾을 수 없습니다.',
|
||||
'workspace.imagePreviewUnavailable': '이미지를 미리 볼 수 없습니다.',
|
||||
'workspace.zoom.group': '확대/축소 컨트롤',
|
||||
'workspace.zoom.in': '확대',
|
||||
'workspace.zoom.out': '축소',
|
||||
'workspace.zoom.fitWindow': '창에 맞춤',
|
||||
'workspace.zoom.fitWidth': '너비에 맞춤',
|
||||
'workspace.openInSystemApp': '시스템 앱으로 열기',
|
||||
'workspace.document.loading': '문서 불러오는 중...',
|
||||
'workspace.document.updating': '미리 보기 업데이트 중...',
|
||||
'workspace.document.parseFailed': '이 문서를 표시할 수 없습니다.',
|
||||
'workspace.document.retry': '다시 시도',
|
||||
'workspace.document.unsupported': '이 파일 형식은 여기서 미리 볼 수 없습니다.',
|
||||
'workspace.document.outsideWorkspace': '이 파일은 작업 공간 밖에 있어 여기서 미리 볼 수 없습니다.',
|
||||
'workspace.document.passwordProtected': '이 PDF는 암호로 보호되어 있어 여기서 미리 볼 수 없습니다.',
|
||||
'workspace.document.engineUnavailable': '이 환경에서는 문서 뷰어를 시작할 수 없습니다.',
|
||||
'workspace.document.openInBrowser': '브라우저에서 열기',
|
||||
'workspace.document.tooComplex': '파일이 너무 크거나 복잡해서 안전하게 미리 볼 수 없습니다.',
|
||||
'workspace.document.approximate.docx': '간이 미리 보기: 수식과 일부 도형은 표시되지 않을 수 있습니다. 정확한 레이아웃은 원본 파일을 열어 확인하세요.',
|
||||
'workspace.document.approximate.xlsx': '셀을 서식이 적용된 텍스트로 표시합니다. 차트, 이미지, 수식은 표시되지 않습니다.',
|
||||
'workspace.pdf.pages': '페이지',
|
||||
'workspace.pdf.pageNumber': '페이지 번호',
|
||||
'workspace.pdf.pageOfTotal': '{total}페이지 중 {page}페이지',
|
||||
'workspace.pdf.pageFailed': '이 페이지를 그릴 수 없습니다.',
|
||||
'workspace.sheet.tabs': '워크시트',
|
||||
'workspace.sheet.truncated': '처음 {rows}행, {columns}열만 표시합니다.',
|
||||
'workspace.sheet.showMoreRows': '행 더 보기',
|
||||
'workspace.sheet.empty': '이 워크시트는 비어 있습니다.',
|
||||
'workspace.previewLineLimit': '불러온 {total}줄 중 처음 {count}줄을 표시합니다.',
|
||||
'workspace.previewAllLines': '불러온 {total}줄을 모두 표시합니다.',
|
||||
'workspace.showAllLoadedLines': '불러온 줄 모두 표시',
|
||||
|
||||
@@ -594,6 +594,9 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'chat.copyPrompt': '複製提示詞',
|
||||
'chat.teammateMessage': '隊友訊息',
|
||||
'chat.copyPath': '複製路徑',
|
||||
'chat.toolResultImage.open': '開啟圖片 {index}/{count}',
|
||||
'chat.toolResultImage.alt': '圖片 {index}/{count}',
|
||||
'chat.toolResultImage.omitted': '另有 {count} 張圖片未顯示',
|
||||
'common.dismissNotification': '關閉通知',
|
||||
|
||||
// ─── Sidebar ──────────────────────────────────────
|
||||
@@ -920,6 +923,32 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'workspace.previewState.tooLarge': '檔案過大,無法預覽。',
|
||||
'workspace.previewState.missing': '檔案不存在。',
|
||||
'workspace.imagePreviewUnavailable': '圖片無法預覽。',
|
||||
'workspace.zoom.group': '縮放控制項',
|
||||
'workspace.zoom.in': '放大',
|
||||
'workspace.zoom.out': '縮小',
|
||||
'workspace.zoom.fitWindow': '符合視窗大小',
|
||||
'workspace.zoom.fitWidth': '符合寬度',
|
||||
'workspace.openInSystemApp': '用系統程式開啟',
|
||||
'workspace.document.loading': '正在載入文件...',
|
||||
'workspace.document.updating': '正在更新預覽...',
|
||||
'workspace.document.parseFailed': '無法顯示此文件。',
|
||||
'workspace.document.retry': '重試',
|
||||
'workspace.document.unsupported': '此檔案類型暫不支援在此預覽。',
|
||||
'workspace.document.outsideWorkspace': '此檔案位於工作區之外,無法在此預覽。',
|
||||
'workspace.document.passwordProtected': '此 PDF 已加密,無法在此預覽。',
|
||||
'workspace.document.engineUnavailable': '目前環境無法啟動文件預覽。',
|
||||
'workspace.document.openInBrowser': '在瀏覽器中開啟',
|
||||
'workspace.document.tooComplex': '檔案過大或結構過於複雜,無法安全預覽。',
|
||||
'workspace.document.approximate.docx': '近似預覽:公式與部分圖形可能無法顯示。如需精確排版,請開啟原始檔案。',
|
||||
'workspace.document.approximate.xlsx': '以格式化文字顯示儲存格,不含圖表、圖片與公式。',
|
||||
'workspace.pdf.pages': '頁面',
|
||||
'workspace.pdf.pageNumber': '頁碼',
|
||||
'workspace.pdf.pageOfTotal': '第 {page} 頁,共 {total} 頁',
|
||||
'workspace.pdf.pageFailed': '此頁無法繪製。',
|
||||
'workspace.sheet.tabs': '工作表',
|
||||
'workspace.sheet.truncated': '僅顯示前 {rows} 列、{columns} 欄。',
|
||||
'workspace.sheet.showMoreRows': '顯示更多列',
|
||||
'workspace.sheet.empty': '此工作表為空。',
|
||||
'workspace.previewLineLimit': '正在顯示已載入內容的前 {count} / {total} 行。',
|
||||
'workspace.previewAllLines': '正在顯示全部 {total} 行已載入內容。',
|
||||
'workspace.showAllLoadedLines': '顯示全部已載入行',
|
||||
|
||||
@@ -593,6 +593,9 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'chat.copyPrompt': '复制提示词',
|
||||
'chat.teammateMessage': '队友消息',
|
||||
'chat.copyPath': '复制路径',
|
||||
'chat.toolResultImage.open': '打开图片 {index}/{count}',
|
||||
'chat.toolResultImage.alt': '图片 {index}/{count}',
|
||||
'chat.toolResultImage.omitted': '另有 {count} 张图片未显示',
|
||||
'common.dismissNotification': '关闭通知',
|
||||
|
||||
// ─── Sidebar ──────────────────────────────────────
|
||||
@@ -919,6 +922,32 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'workspace.previewState.tooLarge': '文件过大,无法预览。',
|
||||
'workspace.previewState.missing': '文件不存在。',
|
||||
'workspace.imagePreviewUnavailable': '图片无法预览。',
|
||||
'workspace.zoom.group': '缩放控件',
|
||||
'workspace.zoom.in': '放大',
|
||||
'workspace.zoom.out': '缩小',
|
||||
'workspace.zoom.fitWindow': '适应窗口',
|
||||
'workspace.zoom.fitWidth': '适应宽度',
|
||||
'workspace.openInSystemApp': '用系统程序打开',
|
||||
'workspace.document.loading': '正在加载文档...',
|
||||
'workspace.document.updating': '正在更新预览...',
|
||||
'workspace.document.parseFailed': '无法显示此文档。',
|
||||
'workspace.document.retry': '重试',
|
||||
'workspace.document.unsupported': '此文件类型暂不支持在此预览。',
|
||||
'workspace.document.outsideWorkspace': '此文件在工作区之外,无法在此预览。',
|
||||
'workspace.document.passwordProtected': '此 PDF 已加密,无法在此预览。',
|
||||
'workspace.document.engineUnavailable': '当前环境无法启动文档预览。',
|
||||
'workspace.document.openInBrowser': '在浏览器中打开',
|
||||
'workspace.document.tooComplex': '文件过大或结构过于复杂,无法安全预览。',
|
||||
'workspace.document.approximate.docx': '近似预览:公式和部分形状可能无法显示。如需精确排版,请打开原文件。',
|
||||
'workspace.document.approximate.xlsx': '以格式化文本显示单元格,不含图表、图片与公式。',
|
||||
'workspace.pdf.pages': '页面',
|
||||
'workspace.pdf.pageNumber': '页码',
|
||||
'workspace.pdf.pageOfTotal': '第 {page} 页,共 {total} 页',
|
||||
'workspace.pdf.pageFailed': '此页无法绘制。',
|
||||
'workspace.sheet.tabs': '工作表',
|
||||
'workspace.sheet.truncated': '仅显示前 {rows} 行、{columns} 列。',
|
||||
'workspace.sheet.showMoreRows': '显示更多行',
|
||||
'workspace.sheet.empty': '此工作表为空。',
|
||||
'workspace.previewLineLimit': '正在显示已加载内容的前 {count} / {total} 行。',
|
||||
'workspace.previewAllLines': '正在显示全部 {total} 行已加载内容。',
|
||||
'workspace.showAllLoadedLines': '显示全部已加载行',
|
||||
|
||||
@@ -532,7 +532,8 @@ function isExternalUrl(candidate: string): boolean {
|
||||
return /^[a-z][a-z0-9+.-]*:\/\//i.test(candidate)
|
||||
}
|
||||
|
||||
function isWithinWorkDir(candidatePath: string, workDir: string): boolean {
|
||||
/** Whether an absolute path sits inside `workDir` (drive-letter and case aware). */
|
||||
export function isWithinWorkDir(candidatePath: string, workDir: string): boolean {
|
||||
const relativePath = relativeFilePath(workDir, candidatePath)
|
||||
|
||||
return relativePath === '' || (!relativePath.startsWith('..') && !isAbsoluteFilePath(relativePath))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getBaseUrl } from '../api/client'
|
||||
import { isAbsoluteLocalPath } from './handlePreviewLink'
|
||||
import { filesystemImageUrl, isAbsoluteLocalPath } from './handlePreviewLink'
|
||||
|
||||
/**
|
||||
* Extensions that count as an inline image attachment.
|
||||
@@ -23,7 +23,7 @@ export function isManagedGeneratedImagePath(filePath: string): boolean {
|
||||
|
||||
/** Serves a local absolute image path through the local server. */
|
||||
export function localImageFileUrl(filePath: string): string {
|
||||
return `${getBaseUrl()}/api/filesystem/file?path=${encodeURIComponent(filePath)}`
|
||||
return filesystemImageUrl(getBaseUrl(), filePath)
|
||||
}
|
||||
|
||||
type ImageSourceCandidate = {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
WORKSPACE_DOCUMENT_EXTENSIONS,
|
||||
fileExtension,
|
||||
isEditorOpenableFile,
|
||||
isGeneratedArtifactFile,
|
||||
isOutputResourceFile,
|
||||
isWorkspaceDocumentFile,
|
||||
isWorkspacePreviewableFile,
|
||||
} from './fileCapabilities'
|
||||
|
||||
describe('the workspace document gate', () => {
|
||||
it('ships viewers for PDF, Word and Excel: the real list routes them, with no test double standing in', () => {
|
||||
// This pins what users get. A format joins the list when its viewer lands, and the
|
||||
// server-side parity test refuses one that has no viewer.
|
||||
expect([...WORKSPACE_DOCUMENT_EXTENSIONS].sort()).toEqual(['docx', 'pdf', 'xls', 'xlsm', 'xlsx'])
|
||||
})
|
||||
|
||||
it('previews a listed document in the workspace, whatever the case of its extension', () => {
|
||||
expect(isWorkspaceDocumentFile('out/thesis.pdf')).toBe(true)
|
||||
expect(isWorkspaceDocumentFile('out/THESIS.PDF')).toBe(true)
|
||||
expect(isWorkspacePreviewableFile('out/thesis.pdf')).toBe(true)
|
||||
})
|
||||
|
||||
it('still keeps a document away from code editors: rendering it is not editing it', () => {
|
||||
// A `.pdf` opened in PyCharm is not a thing anyone asked for, even now that
|
||||
// the panel can draw it. The editor gate must not follow the preview gate.
|
||||
expect(isEditorOpenableFile('out/thesis.pdf')).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves formats the workspace cannot draw with the system application', () => {
|
||||
for (const path of ['deck.pptx', 'legacy.doc', 'archive.zip', 'song.mp3', 'clip.mp4', 'book.pages']) {
|
||||
expect(isWorkspaceDocumentFile(path)).toBe(false)
|
||||
expect(isWorkspacePreviewableFile(path)).toBe(false)
|
||||
expect(isEditorOpenableFile(path)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('does not disturb what was previewable before: source, markdown, images, extensionless files', () => {
|
||||
for (const path of ['src/app.ts', 'README.md', 'logo.png', 'Makefile', 'notes.txt']) {
|
||||
expect(isWorkspacePreviewableFile(path)).toBe(true)
|
||||
}
|
||||
for (const path of ['src/app.ts', 'logo.png', 'notes.txt']) {
|
||||
expect(isWorkspaceDocumentFile(path)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('reads the extension through a line suffix, like every other path helper here', () => {
|
||||
expect(fileExtension('out/thesis.pdf:12')).toBe('pdf')
|
||||
expect(isWorkspaceDocumentFile('out/thesis.pdf:12')).toBe(true)
|
||||
})
|
||||
|
||||
it('still shows a produced document among the turn outputs', () => {
|
||||
expect(isGeneratedArtifactFile('out/thesis.pdf')).toBe(true)
|
||||
expect(isOutputResourceFile('out/thesis.pdf')).toBe(true)
|
||||
})
|
||||
|
||||
it('holds only extensions the editor gate refuses, so previewing a document never opens an IDE menu for it', () => {
|
||||
for (const extension of WORKSPACE_DOCUMENT_EXTENSIONS) {
|
||||
expect(isEditorOpenableFile(`file.${extension}`)).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,18 @@ const SYSTEM_OPEN_EXTENSIONS: ReadonlySet<string> = new Set([
|
||||
'mp4', 'mov', 'm4v', 'webm', 'mkv', 'avi',
|
||||
])
|
||||
|
||||
/**
|
||||
* Documents the workspace panel renders itself, from raw bytes.
|
||||
*
|
||||
* A subset of {@link SYSTEM_OPEN_EXTENSIONS} on purpose. Those extensions stay
|
||||
* "not source" for the editor menus (`isEditorOpenableFile`) — a `.pdf` opened in
|
||||
* an IDE is still not a thing anyone asked for — and only these are also
|
||||
* previewable in the workspace. A format joins this set when its viewer lands,
|
||||
* not before, and the server (`workspaceDocumentPreview.ts`) serves exactly
|
||||
* these: a parity test pins the two lists together.
|
||||
*/
|
||||
export const WORKSPACE_DOCUMENT_EXTENSIONS: ReadonlySet<string> = new Set<string>(['pdf', 'docx', 'xlsx', 'xlsm', 'xls'])
|
||||
|
||||
// These are user-facing deliverables often created without being repeated in the
|
||||
// final prose. Source files stay in the changed-file card unless the assistant
|
||||
// explicitly cites them, so a large code change does not become a wall of cards.
|
||||
@@ -49,11 +61,11 @@ export function fileExtension(path: string): string {
|
||||
/**
|
||||
* Whether offering a code editor for this path makes any sense.
|
||||
*
|
||||
* Same set as the workspace preview gate, for the same reason: an Office package,
|
||||
* an archive or a media file is a binary container, and a `.pdf` opened in PyCharm
|
||||
* is not a thing anyone asked for. Deliberately not a fourth extension table —
|
||||
* a separate list of "editable" extensions would drift from this one within a
|
||||
* release or two.
|
||||
* Driven by {@link SYSTEM_OPEN_EXTENSIONS} alone, not by what the workspace can
|
||||
* preview: an Office package, an archive or a media file is a binary container,
|
||||
* and a `.pdf` opened in PyCharm is not a thing anyone asked for — even now that
|
||||
* the panel renders it. Deliberately not a separate list of "editable"
|
||||
* extensions, which would drift from this one within a release or two.
|
||||
*/
|
||||
export function isEditorOpenableFile(path: string): boolean {
|
||||
return !SYSTEM_OPEN_EXTENSIONS.has(fileExtension(path))
|
||||
@@ -67,10 +79,16 @@ export function isShellProducedDeliverable(path: string): boolean {
|
||||
return SHELL_PRODUCED_DELIVERABLE_EXTENSIONS.has(fileExtension(path))
|
||||
}
|
||||
|
||||
/** Whether the workspace can render this path as text/source or an image. */
|
||||
/** Whether the workspace renders this path as a document (PDF, Word, Excel…) from raw bytes. */
|
||||
export function isWorkspaceDocumentFile(path: string): boolean {
|
||||
return WORKSPACE_DOCUMENT_EXTENSIONS.has(fileExtension(path))
|
||||
}
|
||||
|
||||
/** Whether the workspace can render this path as text/source, an image or a document. */
|
||||
export function isWorkspacePreviewableFile(path: string): boolean {
|
||||
const extension = fileExtension(path)
|
||||
if (!extension) return true
|
||||
if (WORKSPACE_DOCUMENT_EXTENSIONS.has(extension)) return true
|
||||
return LINKABLE_FILE_EXTENSIONS.has(extension) && !SYSTEM_OPEN_EXTENSIONS.has(extension)
|
||||
}
|
||||
|
||||
|
||||
@@ -36,8 +36,8 @@ describe('handlePreviewLink', () => {
|
||||
const deps = makeDeps()
|
||||
expect(handlePreviewLink('~/src/app.ts:42:8', deps)).toBe(true)
|
||||
expect(deps.openFilePreview).toHaveBeenCalledWith('s1', '~/src/app.ts', { line: 42, column: 8 })
|
||||
expect(handlePreviewLink('~/Desktop/brief.docx', deps)).toBe(true)
|
||||
expect(deps.openSystemFile).toHaveBeenCalledWith('~/Desktop/brief.docx')
|
||||
expect(handlePreviewLink('~/Desktop/brief.pptx', deps)).toBe(true)
|
||||
expect(deps.openSystemFile).toHaveBeenCalledWith('~/Desktop/brief.pptx')
|
||||
expect(deps.openBrowser).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -143,13 +143,29 @@ describe('handlePreviewLink', () => {
|
||||
expect(withColumn.openFilePreview).toHaveBeenCalledWith('s1', 'src/app.ts', { line: 42, column: 8 })
|
||||
})
|
||||
|
||||
it('routes an office file to the system opener instead of the workspace preview', () => {
|
||||
it('routes an office file the workspace cannot draw to the system opener instead of the workspace preview', () => {
|
||||
const deps = makeDeps()
|
||||
|
||||
expect(handlePreviewLink('reports/brief.pptx', deps)).toBe(true)
|
||||
expect(deps.openSystemFile).toHaveBeenCalledWith('reports/brief.pptx')
|
||||
expect(deps.openFilePreview).not.toHaveBeenCalled()
|
||||
expect(deps.openBrowser).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes a Word document to the workspace preview, which can draw it', () => {
|
||||
const deps = makeDeps()
|
||||
|
||||
expect(handlePreviewLink('reports/brief.docx', deps)).toBe(true)
|
||||
expect(deps.openSystemFile).toHaveBeenCalledWith('reports/brief.docx')
|
||||
expect(deps.openFilePreview).not.toHaveBeenCalled()
|
||||
expect(deps.openBrowser).not.toHaveBeenCalled()
|
||||
expect(deps.openFilePreview).toHaveBeenCalledWith('s1', 'reports/brief.docx', undefined)
|
||||
expect(deps.openSystemFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['reports/budget.xlsx', 'reports/legacy.xls'])('routes an Excel workbook (%s) to the workspace preview as well', (path) => {
|
||||
const deps = makeDeps()
|
||||
|
||||
expect(handlePreviewLink(path, deps)).toBe(true)
|
||||
expect(deps.openFilePreview).toHaveBeenCalledWith('s1', path, undefined)
|
||||
expect(deps.openSystemFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes remote http(s) to openExternal with the url', () => {
|
||||
@@ -217,10 +233,46 @@ describe('localFileUrl', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('previewFsUrl (unchanged, regression guard)', () => {
|
||||
it('still builds a workspace-scoped preview-fs url for relative paths', () => {
|
||||
expect(previewFsUrl('http://127.0.0.1:8787', 's1', 'out/index.html')).toBe(
|
||||
'http://127.0.0.1:8787/preview-fs/s1/out/index.html',
|
||||
describe('previewFsUrl', () => {
|
||||
const BASE = 'http://127.0.0.1:8787'
|
||||
|
||||
it('builds a workspace-scoped preview-fs url for relative paths', () => {
|
||||
expect(previewFsUrl(BASE, 's1', 'out/index.html')).toBe(`${BASE}/preview-fs/s1/out/index.html`)
|
||||
})
|
||||
|
||||
it('keeps the double slash that marks an absolute path', () => {
|
||||
expect(previewFsUrl(BASE, 's1', '/Users/me/a.png')).toBe(`${BASE}/preview-fs/s1//Users/me/a.png`)
|
||||
expect(previewFsUrl(BASE, 's1', '///Users/me/a.png')).toBe(`${BASE}/preview-fs/s1//Users/me/a.png`)
|
||||
})
|
||||
|
||||
it('escapes each segment, so the server decodes exactly the name that was given', () => {
|
||||
expect(previewFsUrl(BASE, 's1', '/Users/me/My Pics/截图 1.png')).toBe(
|
||||
`${BASE}/preview-fs/s1//Users/me/My%20Pics/%E6%88%AA%E5%9B%BE%201.png`,
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a percent sign', '100%/a.png', '100%25/a.png'],
|
||||
['a hash', 'a#b.png', 'a%23b.png'],
|
||||
['a question mark', 'a?b.png', 'a%3Fb.png'],
|
||||
])('escapes %s in a name instead of letting the URL read it as syntax', (_label, filePath, escaped) => {
|
||||
expect(previewFsUrl(BASE, 's1', filePath)).toBe(`${BASE}/preview-fs/s1/${escaped}`)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'%2e%2e/%2e%2e/api/status',
|
||||
'.%2e/api/status',
|
||||
'%2E%2E/api/status',
|
||||
'a/%2e%2e/%2e%2e/%2e%2e/api/status',
|
||||
])('cannot be made to climb out of the session route by a name that spells a dot segment: %s', (filePath) => {
|
||||
// The URL parser collapses a percent-encoded `..` before the request is sent, so
|
||||
// an unescaped one would ask the server for `/api/status`, not for a file.
|
||||
const url = new URL(previewFsUrl(BASE, 's1', filePath))
|
||||
|
||||
expect(url.pathname.startsWith('/preview-fs/s1/')).toBe(true)
|
||||
})
|
||||
|
||||
it('reads the backslashes of a Windows path as separators, as the browser already did', () => {
|
||||
expect(previewFsUrl(BASE, 's1', 'C:\\proj\\out\\a.png')).toBe(`${BASE}/preview-fs/s1/C%3A/proj/out/a.png`)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,14 +16,23 @@ export type PreviewLinkDeps = {
|
||||
/**
|
||||
* Build a `/preview-fs/<sessionId>/<path>` URL for the local server.
|
||||
*
|
||||
* Absolute file paths (leading slash) are preserved as-is, so the resulting URL
|
||||
* carries a `//` between `<sessionId>` and the path. That double slash is
|
||||
* intentional: the server slices everything after the `<sessionId>` segment and
|
||||
* runs `path.resolve(workDir, relPath)`, so an absolute path is resolved as an
|
||||
* Absolute file paths (leading slash) keep it, so the resulting URL carries a `//`
|
||||
* between `<sessionId>` and the path. That double slash is intentional: the server
|
||||
* slices everything after the `<sessionId>` segment and runs
|
||||
* `path.resolve(workDir, relPath)`, so an absolute path is resolved as an
|
||||
* absolute-within-workspace path and sandbox-checked against the work dir root.
|
||||
*
|
||||
* Each segment is escaped, as {@link localFileUrl} does, so the server decodes
|
||||
* exactly the name it was given. Left raw, a `%` or `#` in a name would be read as
|
||||
* URL syntax, and a name that spells a dot segment (`%2e%2e`) would be collapsed by
|
||||
* the URL parser before the request is sent — a request for `/api/status`, not a
|
||||
* file. A Windows path's backslashes are separators, as the browser already made
|
||||
* them.
|
||||
*/
|
||||
export function previewFsUrl(base: string, sessionId: string, filePath: string): string {
|
||||
return `${base.replace(/\/$/, '')}/preview-fs/${encodeURIComponent(sessionId)}/${filePath.replace(/^\/+/, '/')}`
|
||||
const rooted = filePath.replace(/\\/g, '/').replace(/^\/+/, '/')
|
||||
const escaped = rooted.split('/').map((segment) => encodeURIComponent(segment)).join('/')
|
||||
return `${base.replace(/\/$/, '')}/preview-fs/${encodeURIComponent(sessionId)}/${escaped}`
|
||||
}
|
||||
|
||||
/** True for POSIX absolute (`/...`) or Windows drive (`X:\` / `X:/`) paths. */
|
||||
@@ -60,6 +69,15 @@ export function localFileUrl(base: string, absPath: string): string {
|
||||
return `${base.replace(/\/$/, '')}/local-file${encoded}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `/api/filesystem/file` URL that serves one local image by absolute
|
||||
* (or `~/`) path. The server serves images only, and only from `$HOME`, the
|
||||
* temporary directories and the roots it has registered; anything else is a 403.
|
||||
*/
|
||||
export function filesystemImageUrl(base: string, filePath: string): string {
|
||||
return `${base.replace(/\/$/, '')}/api/filesystem/file?path=${encodeURIComponent(filePath)}`
|
||||
}
|
||||
|
||||
/** Returns true if handled (caller should preventDefault). */
|
||||
export function handlePreviewLink(href: string, deps: PreviewLinkDeps): boolean {
|
||||
const cls = classifyPreviewLink(href)
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
createAssistantMarkdownImageResolver,
|
||||
createWorkspaceMarkdownImageResolver,
|
||||
isSafeMarkdownImageSource,
|
||||
localPathFromMarkdownImageUrl,
|
||||
normalizeMarkdownImageDestination,
|
||||
} from './markdownImages'
|
||||
|
||||
const CONTEXT = {
|
||||
@@ -70,6 +72,10 @@ describe('createWorkspaceMarkdownImageResolver', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('routes the Windows shape the renderer writes for file:///C:/... and C:\\..., through /local-file as a drive path', () => {
|
||||
expect(resolve('/C:/Users/me/pic.png')).toBe('http://127.0.0.1:3456/local-file/C%3A/Users/me/pic.png')
|
||||
})
|
||||
|
||||
it('routes workspace escapes through /local-file when the workDir is known', () => {
|
||||
expect(resolve('../../outside.png')).toBe(
|
||||
'http://127.0.0.1:3456/local-file/outside.png',
|
||||
@@ -81,14 +87,19 @@ describe('createWorkspaceMarkdownImageResolver', () => {
|
||||
expect(noWorkDir('../../outside.png')).toBeNull()
|
||||
})
|
||||
|
||||
it('passes spaces and unicode through for the browser to encode', () => {
|
||||
// previewFsUrl keeps the workspace path raw; the browser percent-encodes it
|
||||
// on request and the server decodes each segment again.
|
||||
it('escapes spaces and unicode, so the server decodes the name that was written', () => {
|
||||
expect(resolve('截图/界面 1.png')).toBe(
|
||||
'http://127.0.0.1:3456/preview-fs/session-1/docs/截图/界面 1.png',
|
||||
'http://127.0.0.1:3456/preview-fs/session-1/docs/%E6%88%AA%E5%9B%BE/%E7%95%8C%E9%9D%A2%201.png',
|
||||
)
|
||||
})
|
||||
|
||||
it('cannot be made to leave the session route by a double-escaped dot segment', () => {
|
||||
const url = resolve('%252e%252e/%252e%252e/api/status.png')
|
||||
|
||||
expect(url).not.toBeNull()
|
||||
expect(new URL(url!).pathname.startsWith('/preview-fs/session-1/')).toBe(true)
|
||||
})
|
||||
|
||||
it('drops query strings and fragments from local paths', () => {
|
||||
expect(resolve('assets/logo.png?v=2#frag')).toBe(
|
||||
'http://127.0.0.1:3456/preview-fs/session-1/docs/assets/logo.png',
|
||||
@@ -133,3 +144,270 @@ describe('createAssistantMarkdownImageResolver', () => {
|
||||
expect(resolve('#fragment')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createAssistantMarkdownImageResolver with the session workdir known', () => {
|
||||
const BASE = 'http://127.0.0.1:3456'
|
||||
const resolve = createAssistantMarkdownImageResolver({ baseUrl: BASE, sessionId: 'session-1', workDir: '/repo' })
|
||||
const filesystem = (path: string) => `${BASE}/api/filesystem/file?path=${encodeURIComponent(path)}`
|
||||
|
||||
it('keeps a picture inside the workdir on the session sandbox route, which needs no registered root', () => {
|
||||
expect(resolve('/repo/output/02.png')).toBe(`${BASE}/preview-fs/session-1//repo/output/02.png`)
|
||||
expect(resolve('/repo/01.png')).toBe(`${BASE}/preview-fs/session-1//repo/01.png`)
|
||||
})
|
||||
|
||||
it('serves a picture outside the workdir from the filesystem route, where it used to be a broken image', () => {
|
||||
expect(resolve('/Users/me/Pictures/chart.png')).toBe(filesystem('/Users/me/Pictures/chart.png'))
|
||||
})
|
||||
|
||||
it('does not take a sibling directory that merely starts with the same letters for the workdir', () => {
|
||||
expect(resolve('/repo-old/a.png')).toBe(filesystem('/repo-old/a.png'))
|
||||
})
|
||||
|
||||
it('reads ../ in an absolute path before deciding where it lands', () => {
|
||||
// Lexically /repo/../etc/a.png is /etc/a.png: outside, and the route decides whether it may be read.
|
||||
expect(resolve('/repo/../etc/a.png')).toBe(filesystem('/etc/a.png'))
|
||||
expect(resolve('/elsewhere/../repo/a.png')).toBe(`${BASE}/preview-fs/session-1//repo/a.png`)
|
||||
})
|
||||
|
||||
it('leaves a home-relative path to the filesystem route, which expands it', () => {
|
||||
expect(resolve('~/Pictures/chart.png')).toBe(filesystem('~/Pictures/chart.png'))
|
||||
})
|
||||
|
||||
it('does so whether or not the workdir is known: the session route could never place a home path', () => {
|
||||
const unknown = createAssistantMarkdownImageResolver({ baseUrl: BASE, sessionId: 'session-1' })
|
||||
|
||||
expect(unknown('~/Pictures/chart.png')).toBe(filesystem('~/Pictures/chart.png'))
|
||||
})
|
||||
|
||||
it('reads ../ under the home directory lexically, and refuses to climb out of it', () => {
|
||||
expect(resolve('~/Pictures/../Desktop/chart.png')).toBe(filesystem('~/Desktop/chart.png'))
|
||||
expect(resolve('~/../chart.png')).toBeNull()
|
||||
expect(resolve('~/../../etc/x.png')).toBeNull()
|
||||
})
|
||||
|
||||
it('takes a bare Windows drive path, as it does the shape the renderer writes', () => {
|
||||
expect(resolve('C:/Users/me/chart.png')).toBe(filesystem('C:/Users/me/chart.png'))
|
||||
expect(resolve('C:\\Users\\me\\chart.png')).toBe(filesystem('C:/Users/me/chart.png'))
|
||||
})
|
||||
|
||||
it('does not stumble over a Windows workdir that is deeper than the picture is', () => {
|
||||
const deep = createAssistantMarkdownImageResolver({ baseUrl: BASE, sessionId: 'session-1', workDir: 'C:\\Proj\\Thesis\\Deep' })
|
||||
|
||||
expect(deep('/C:/Proj/chart.png')).toBe(filesystem('C:/Proj/chart.png'))
|
||||
expect(deep('/C:/chart.png')).toBe(filesystem('C:/chart.png'))
|
||||
// A parent of the workdir: every segment it has matches, and the workdir has one more.
|
||||
expect(deep('/C:/Proj/Thesis')).toBeNull()
|
||||
})
|
||||
|
||||
it('reads the Windows shape the renderer writes for file:///C:/... and C:\\..., as a Windows path', () => {
|
||||
expect(resolve('/C:/Users/me/chart.png')).toBe(filesystem('C:/Users/me/chart.png'))
|
||||
})
|
||||
|
||||
it('compares a Windows workdir without regard to case or slash direction', () => {
|
||||
const windows = createAssistantMarkdownImageResolver({ baseUrl: BASE, sessionId: 'session-1', workDir: 'C:\\Proj\\Thesis' })
|
||||
|
||||
expect(windows('/c:/proj/thesis/fig/a.png')).toBe(`${BASE}/preview-fs/session-1/c%3A/proj/thesis/fig/a.png`)
|
||||
expect(windows('/C:/Users/me/a.png')).toBe(filesystem('C:/Users/me/a.png'))
|
||||
})
|
||||
|
||||
it('takes percent-escapes and drops a query string and fragment, as for any local path', () => {
|
||||
expect(resolve('/Users/me/My%20Pics/chart.png?v=2#top')).toBe(filesystem('/Users/me/My Pics/chart.png'))
|
||||
})
|
||||
|
||||
it('refuses a malformed percent-escape rather than guess which file it meant', () => {
|
||||
expect(resolve('/Users/me/100%.png')).toBeNull()
|
||||
expect(resolve('/repo/output/%E0%A4%A.png')).toBeNull()
|
||||
expect(resolve('output/%zz.png')).toBeNull()
|
||||
})
|
||||
|
||||
it('cannot be made to leave the session route by a double-escaped dot segment', () => {
|
||||
for (const source of ['%252e%252e/%252e%252e/api/status.png', '/repo/%252e%252e/%252e%252e/api/status.png']) {
|
||||
const url = resolve(source)
|
||||
|
||||
expect(url).not.toBeNull()
|
||||
expect(new URL(url!).pathname.startsWith('/preview-fs/session-1/')).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a percent sign that is part of the name, rather than reading it as an escape twice', () => {
|
||||
expect(resolve('/repo/out/a%2523b.png')).toBe(`${BASE}/preview-fs/session-1//repo/out/a%2523b.png`)
|
||||
})
|
||||
|
||||
it('serves only pictures from outside the workdir: another kind of file is not the filesystem route\'s business', () => {
|
||||
expect(resolve('/Users/me/notes.txt')).toBeNull()
|
||||
expect(resolve('/etc/passwd')).toBeNull()
|
||||
expect(resolve('/Users/me/archive.png.zip')).toBeNull()
|
||||
expect(resolve('/Users/me/.png')).toBeNull()
|
||||
})
|
||||
|
||||
it.each(['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'bmp', 'svg', 'ico', 'PNG', 'JpEg'])('serves a .%s from outside the workdir', (extension) => {
|
||||
expect(resolve(`/Users/me/pic.${extension}`)).toBe(filesystem(`/Users/me/pic.${extension}`))
|
||||
})
|
||||
|
||||
it('leaves a relative path on the session sandbox route, whatever it is called', () => {
|
||||
expect(resolve('images/../01.png')).toBe(`${BASE}/preview-fs/session-1/01.png`)
|
||||
expect(resolve('figure')).toBe(`${BASE}/preview-fs/session-1/figure`)
|
||||
})
|
||||
|
||||
it('still refuses what it always refused', () => {
|
||||
expect(resolve('https://attacker.example/track.png')).toBeNull()
|
||||
expect(resolve('//attacker.example/track.png')).toBeNull()
|
||||
expect(resolve('javascript:alert(1)')).toBeNull()
|
||||
expect(resolve('file:///Users/me/a.png')).toBeNull()
|
||||
expect(resolve('../outside.png')).toBeNull()
|
||||
expect(resolve('#fragment')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the in-memory sources', () => {
|
||||
expect(resolve('data:image/png;base64,AAAA')).toBe('data:image/png;base64,AAAA')
|
||||
})
|
||||
|
||||
it('without a workdir, still sends an absolute path through the session sandbox as before', () => {
|
||||
const unknown = createAssistantMarkdownImageResolver({ baseUrl: BASE, sessionId: 'session-1' })
|
||||
|
||||
expect(unknown('/repo/output/02.png')).toBe(`${BASE}/preview-fs/session-1//repo/output/02.png`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeMarkdownImageDestination', () => {
|
||||
it.each([
|
||||
['file:///C:/Users/me/a.png', '/C:/Users/me/a.png'],
|
||||
['file:///Users/me/a.png', '/Users/me/a.png'],
|
||||
['FILE:///Users/me/a.png', '/Users/me/a.png'],
|
||||
['file://localhost/Users/me/a.png', '/Users/me/a.png'],
|
||||
['file:///Users/me/My%20Pics/a.png', '/Users/me/My%20Pics/a.png'],
|
||||
['C:\\Users\\me\\a.png', '/C:/Users/me/a.png'],
|
||||
['c:/Users/me/a.png', '/c:/Users/me/a.png'],
|
||||
])('writes %s as %s, the one shape that survives sanitization', (href, expected) => {
|
||||
expect(normalizeMarkdownImageDestination(href)).toBe(expected)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'images/a.png',
|
||||
'./a.png',
|
||||
'/Users/me/a.png',
|
||||
'~/Pictures/a.png',
|
||||
'https://example.com/a.png',
|
||||
'data:image/png;base64,AAAA',
|
||||
'blob:https://desktop.invalid/1234',
|
||||
'javascript:alert(1)',
|
||||
'file://server/share/a.png',
|
||||
'C:relative.png',
|
||||
'',
|
||||
])('leaves %s alone', (href) => {
|
||||
expect(normalizeMarkdownImageDestination(href)).toBe(href)
|
||||
})
|
||||
|
||||
describe('reading a Windows path back from what the author wrote', () => {
|
||||
// Markdown lets a backslash escape the punctuation after it, so the parser hands
|
||||
// over `C:\Users\me\.claude\a.png` as `C:\Users\me.claude\a.png`.
|
||||
it.each([
|
||||
['a hidden folder', 'C:\\Users\\me\\.claude\\a.png', 'C:\\Users\\me.claude\\a.png', '/C:/Users/me/.claude/a.png'],
|
||||
['an underscored folder', 'C:\\Users\\me\\_out\\a.png', 'C:\\Users\\me_out\\a.png', '/C:/Users/me/_out/a.png'],
|
||||
['a hidden folder straight after the drive', 'C:\\.cache\\a.png', 'C:.cache\\a.png', '/C:/.cache/a.png'],
|
||||
['a folder in parentheses, in angle brackets', '<C:\\Users\\me\\(new) pics\\a.png>', 'C:\\Users\\me(new) pics\\a.png', '/C:/Users/me/(new) pics/a.png'],
|
||||
['a title after the path', 'C:\\Users\\me\\.claude\\a.png "the chart"', 'C:\\Users\\me.claude\\a.png', '/C:/Users/me/.claude/a.png'],
|
||||
])('restores the separators before punctuation: %s', (_label, written, parsed, expected) => {
|
||||
expect(normalizeMarkdownImageDestination(parsed, ``)).toBe(expected)
|
||||
})
|
||||
|
||||
it('leaves alone a path the parser did not change', () => {
|
||||
expect(normalizeMarkdownImageDestination('C:\\Users\\me\\a.png', '')).toBe('/C:/Users/me/a.png')
|
||||
})
|
||||
|
||||
it('trusts the parser when the text it was given does not explain the destination', () => {
|
||||
// Only text that, unescaped, is exactly what the parser produced can replace it.
|
||||
expect(normalizeMarkdownImageDestination('C:\\other\\a.png', '')).toBe('/C:/other/a.png')
|
||||
// An alt text with `](` in it moves the apparent destination.
|
||||
expect(normalizeMarkdownImageDestination('C:\\Users\\me\\a.png', '](C:\\Users\\me\\.x\\a.png)')).toBe('/C:/Users/me/a.png')
|
||||
expect(normalizeMarkdownImageDestination('C:\\Users\\me\\a.png', '')).toBe('/C:/Users/me/a.png')
|
||||
})
|
||||
|
||||
it('does not rewrite anything that is not a drive path', () => {
|
||||
expect(normalizeMarkdownImageDestination('/Users/me/.claude/a.png', '')).toBe('/Users/me/.claude/a.png')
|
||||
expect(normalizeMarkdownImageDestination('figures/a_b.png', '')).toBe('figures/a_b.png')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('localPathFromMarkdownImageUrl', () => {
|
||||
const context = { baseUrl: 'http://127.0.0.1:3456', workDir: '/repo' }
|
||||
|
||||
it('finds the file behind a session sandbox URL for a path inside the workdir', () => {
|
||||
expect(localPathFromMarkdownImageUrl('http://127.0.0.1:3456/preview-fs/session-1/output/02.png', context)).toBe('/repo/output/02.png')
|
||||
})
|
||||
|
||||
it('finds the file behind a session sandbox URL that carries an absolute path', () => {
|
||||
expect(localPathFromMarkdownImageUrl('http://127.0.0.1:3456/preview-fs/session-1//repo/output/02.png', context)).toBe('/repo/output/02.png')
|
||||
expect(localPathFromMarkdownImageUrl('http://127.0.0.1:3456/preview-fs/session-1/C:/proj/a.png', { ...context, workDir: null })).toBe('C:/proj/a.png')
|
||||
})
|
||||
|
||||
it('decodes what the URL builders encoded', () => {
|
||||
expect(localPathFromMarkdownImageUrl('http://127.0.0.1:3456/preview-fs/session-1/%E6%88%AA%E5%9B%BE/a%20b.png', context)).toBe('/repo/截图/a b.png')
|
||||
})
|
||||
|
||||
it('finds the file behind a filesystem-route URL', () => {
|
||||
const url = `http://127.0.0.1:3456/api/filesystem/file?path=${encodeURIComponent('/Users/me/My Pics/a.png')}`
|
||||
expect(localPathFromMarkdownImageUrl(url, context)).toBe('/Users/me/My Pics/a.png')
|
||||
expect(localPathFromMarkdownImageUrl(`http://127.0.0.1:3456/api/filesystem/file?path=${encodeURIComponent('~/a.png')}`, context)).toBe('~/a.png')
|
||||
})
|
||||
|
||||
it('finds the file behind a /local-file URL', () => {
|
||||
expect(localPathFromMarkdownImageUrl('http://127.0.0.1:3456/local-file/Users/me/a%20b.png', context)).toBe('/Users/me/a b.png')
|
||||
})
|
||||
|
||||
// "Open original" hands the path to the system's default application, so it is
|
||||
// only ever for a picture — a link to the same file is not opened at all.
|
||||
it.each([
|
||||
'http://127.0.0.1:3456/preview-fs/session-1/run.terminal',
|
||||
'http://127.0.0.1:3456/preview-fs/session-1/notes.txt',
|
||||
'http://127.0.0.1:3456/preview-fs/session-1/figure',
|
||||
'http://127.0.0.1:3456/preview-fs/session-1/archive.png.zip',
|
||||
'http://127.0.0.1:3456/local-file/Users/me/setup.command',
|
||||
`http://127.0.0.1:3456/api/filesystem/file?path=${encodeURIComponent('/Users/me/run.jar')}`,
|
||||
])('offers no file for %s, which is not a picture', (url) => {
|
||||
expect(localPathFromMarkdownImageUrl(url, context)).toBeNull()
|
||||
})
|
||||
|
||||
// A `..` the URL parser would collapse never reaches this far; what can is one
|
||||
// hidden behind an escaped slash, which the server decodes into a real climb.
|
||||
it.each([
|
||||
'http://127.0.0.1:3456/preview-fs/session-1/src%2F..%2F..%2Fetc%2Fhosts.png',
|
||||
'http://127.0.0.1:3456/preview-fs/session-1/%2E%2E%2Fetc%2Fhosts.png',
|
||||
'http://127.0.0.1:3456/local-file/Users/me/..%2F..%2Fetc%2Fhosts.png',
|
||||
`http://127.0.0.1:3456/api/filesystem/file?path=${encodeURIComponent('/Users/me/../../etc/hosts.png')}`,
|
||||
])('offers no file for %s, which climbs', (url) => {
|
||||
expect(localPathFromMarkdownImageUrl(url, context)).toBeNull()
|
||||
})
|
||||
|
||||
it('knows no file behind a URL whose path has a malformed percent-escape', () => {
|
||||
expect(localPathFromMarkdownImageUrl('http://127.0.0.1:3456/preview-fs/session-1/output/100%.png', context)).toBeNull()
|
||||
expect(localPathFromMarkdownImageUrl('http://127.0.0.1:3456/local-file/Users/me/%E0%A4%A.png', context)).toBeNull()
|
||||
})
|
||||
|
||||
it('cannot place a workdir-relative URL without a workdir', () => {
|
||||
expect(localPathFromMarkdownImageUrl('http://127.0.0.1:3456/preview-fs/session-1/output/02.png', { ...context, workDir: null })).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
'blob:https://desktop.invalid/1234',
|
||||
'data:image/png;base64,AAAA',
|
||||
'https://attacker.example/preview-fs/session-1/a.png',
|
||||
'http://127.0.0.1:9999/preview-fs/session-1/a.png',
|
||||
'http://127.0.0.1:3456/api/status',
|
||||
'not a url',
|
||||
'',
|
||||
])('knows no file behind %s', (url) => {
|
||||
expect(localPathFromMarkdownImageUrl(url, context)).toBeNull()
|
||||
})
|
||||
|
||||
it('agrees with the resolver: what it resolves, this puts back', () => {
|
||||
const resolver = createAssistantMarkdownImageResolver({ baseUrl: context.baseUrl, sessionId: 'session-1', workDir: context.workDir })
|
||||
|
||||
for (const source of ['/repo/output/02.png', '/Users/me/My Pics/chart.png', 'sub/dir/x.png', '~/Pictures/a.png']) {
|
||||
const url = resolver(source)!
|
||||
const expected = source.startsWith('sub/') ? '/repo/sub/dir/x.png' : source
|
||||
expect(localPathFromMarkdownImageUrl(url, context)).toBe(expected)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user