diff --git a/.github/workflows/build_test.yml b/.github/workflows/build_test.yml index 6daa2d3..1354022 100644 --- a/.github/workflows/build_test.yml +++ b/.github/workflows/build_test.yml @@ -16,8 +16,11 @@ jobs: matrix: include: - os: macos-latest + app: src-tauri/target/release/opendocument-desktop - os: windows-latest + app: src-tauri/target/release/opendocument-desktop.exe - os: ubuntu-24.04 + app: src-tauri/target/release/opendocument-desktop runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v5 @@ -26,7 +29,7 @@ jobs: if: runner.os == 'Linux' run: | sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libxdo-dev + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libxdo-dev xvfb - uses: actions/setup-node@v5 with: @@ -43,6 +46,14 @@ jobs: - run: npm run check - run: npx tauri build + # Opens every file in test/documents in the built app, and saves a copy. + - name: Smoke test + if: runner.os != 'Linux' + run: node test/smoke.mjs ${{ matrix.app }} + - name: Smoke test + if: runner.os == 'Linux' + run: xvfb-run -a node test/smoke.mjs ${{ matrix.app }} + - uses: actions/upload-artifact@v5 with: name: bundle-${{ matrix.os }} diff --git a/AGENTS.md b/AGENTS.md index 4fdfcf7..f1b4f9a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,50 @@ The frontend owns the user interface, and it runs the core. The renderer is [OpenDocument.core](https://github.com/opendocument-app/OpenDocument.core). Unsupported formats and rendering bugs belong there, not here. +## Architecture + +``` +src/core/backend.ts the Backend interface: the only way to the core +src/core/wasm-*.ts the Backend over the WebAssembly core, in a Web Worker +src/viewer/viewer.ts one window: open, render, tabs, search, zoom, edit, save, print +src/viewer/frame-bridge.js runs inside the rendered document, talks over postMessage +src/platform.ts files, dialogs, menus, recents: everything from Tauri +src-tauri/src/lib.rs file access, window routing, print windows, smoke mode +src-tauri/src/menu.rs the menu bar; a click is a `menu` event to the focused window +``` + +- **The engine is behind `Backend`.** The viewer calls nothing else of the + core. A native backend, for example the core's HTTP server as a sidecar, can + replace the WebAssembly one without a change to the viewer. +- **The viewer comes from the website.** `viewer.ts` and `frame-bridge.js` are + ports of `src/scripts/` in OpenDocument.web. A fix in one is usually a fix in + the other. The rendered document runs in a `sandbox="allow-scripts"` frame + with an opaque origin, so it cannot reach Tauri. +- **One document per window.** A file that arrives while a window holds a + document opens a new window. On macOS the system sends files with + `RunEvent::Opened`, on Windows and Linux a second launch hands its arguments + to the first through the single-instance plugin. +- **A save writes a temporary file beside the target and renames it over the + target**, so a failed save never leaves half a document. +- **Print renders the view again** with every part laid out, and a `print-*` + window shows it as a top-level page. On macOS, Tauri prints only a top-level + page, not a frame. The print windows get no file access. +- **The CSP keeps `'unsafe-inline'`**, because the srcdoc frame takes the + policy of its parent and the renderer writes inline scripts and styles. + `dangerousDisableAssetCspModification` stops Tauri from adding hashes, which + would switch `'unsafe-inline'` off. +- **No Undo item in the menu.** The page owns its edit log and takes the undo + chord itself. +- **No edition gate, no ads, no store.** How the desktop app earns money is + not decided yet. + +## Checks + +- `npm run check` type-checks the frontend. +- `node test/smoke.mjs ` opens every file in `test/documents/` in + the built app and saves a copy. CI runs it on all three systems. A file named + `*-.` opens with that password. + ## Rules - The app compiles no C++. It uses the WebAssembly build of the core from npm, diff --git a/CHANGELOG.md b/CHANGELOG.md index c871018..f56be25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,4 +8,9 @@ Before a release, a pull request changes that heading to the version. ## Unreleased -- The first version of OpenDocument Reader for Windows, macOS and Linux. +- The first version of OpenDocument Reader for Windows, macOS and Linux. It opens + OpenDocument, Word, Excel, PowerPoint, PDF, Pages, Numbers, Keynote and more, + also with a password. +- Edit and save text documents, spreadsheets and presentations, and mark up PDFs. +- Search, zoom, print, a tab for each sheet, recent documents, and new empty + documents. diff --git a/README.md b/README.md index a859d68..91906af 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,9 @@ npm run tauri dev # starts the app with hot reload npm run tauri build # writes the installers to src-tauri/target/release/bundle/ ``` -`npm run check` type-checks the frontend. +`npm run check` type-checks the frontend. After a build, +`node test/smoke.mjs src-tauri/target/release/opendocument-desktop` opens each +document in `test/documents/` in the app and saves a copy. ## Layout @@ -29,6 +31,7 @@ npm run tauri build # writes the installers to src-tauri/target/release/bundle | `src/` | the frontend: TypeScript, Vite and Tailwind | | `src-tauri/` | the Rust shell: windows, menus, file access, bundling | | `scripts/sync-odr.mjs` | copies the core from `node_modules` into `public/odr/` | +| `test/` | the smoke test and its documents | [`AGENTS.md`](AGENTS.md) describes the architecture and the rules. diff --git a/index.html b/index.html index f09733a..cda9883 100644 --- a/index.html +++ b/index.html @@ -5,7 +5,6 @@ OpenDocument Reader -
diff --git a/package-lock.json b/package-lock.json index a6617d5..2186b81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "dependencies": { "@opendocument/odr-core": "^7.7.0", "@tauri-apps/api": "^2.12.4", - "@tauri-apps/plugin-dialog": "^2.8.4" + "@tauri-apps/plugin-dialog": "^2.8.4", + "@tauri-apps/plugin-opener": "^2.7.2" }, "devDependencies": { "@tailwindcss/vite": "^4.3.3", @@ -860,6 +861,15 @@ "@tauri-apps/api": "^2.12.0" } }, + "node_modules/@tauri-apps/plugin-opener": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.7.2.tgz", + "integrity": "sha512-LyybP1VSzTMQiaFYcsbAse47KpivlyXdLZMQzGLEnkgQacGESnpjAfFoQ6h5sqWgAdZDH9szYjPzHMpv0Sz+TQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.12.0" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", diff --git a/package.json b/package.json index faadc1f..c7c720c 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "dependencies": { "@opendocument/odr-core": "^7.7.0", "@tauri-apps/api": "^2.12.4", - "@tauri-apps/plugin-dialog": "^2.8.4" + "@tauri-apps/plugin-dialog": "^2.8.4", + "@tauri-apps/plugin-opener": "^2.7.2" }, "devDependencies": { "@tailwindcss/vite": "^4.3.3", diff --git a/print.html b/print.html new file mode 100644 index 0000000..095f897 --- /dev/null +++ b/print.html @@ -0,0 +1,10 @@ + + + + + Print + + + + + diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f01cd55..2660503 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -47,6 +47,137 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "atk" version = "0.18.2" @@ -142,6 +273,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "brotli" version = "9.1.1" @@ -332,6 +476,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "cookie" version = "0.18.2" @@ -708,6 +861,33 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "equivalent" version = "1.0.3" @@ -725,6 +905,36 @@ dependencies = [ "typeid", ] +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -847,6 +1057,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.34" @@ -1190,6 +1413,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + [[package]] name = "hex" version = "0.4.3" @@ -1470,6 +1699,25 @@ version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1698,6 +1946,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.3" @@ -2079,15 +2333,30 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "open" +version = "5.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d8c8b8e725ad26404d6896eb2f064294a0a7b4f1d9120210d85544684c4ca95" +dependencies = [ + "dunce", + "is-wsl", + "libc", +] + [[package]] name = "opendocument-desktop" version = "0.1.0" dependencies = [ + "percent-encoding", "serde", "serde_json", "tauri", "tauri-build", "tauri-plugin-dialog", + "tauri-plugin-opener", + "tauri-plugin-single-instance", + "tauri-plugin-window-state", ] [[package]] @@ -2096,6 +2365,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "pango" version = "0.18.3" @@ -2121,6 +2400,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2209,6 +2494,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -2241,6 +2537,20 @@ dependencies = [ "miniz_oxide 0.8.9", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "portable-atomic" version = "1.15.0" @@ -2518,6 +2828,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -2795,6 +3118,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -3225,6 +3558,59 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-opener" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53810be1b4fed22bb6ac3f4cab5a2b80d9391d1af436bda145607532bc0bb219" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.21", + "url", + "windows", + "zbus", +] + +[[package]] +name = "tauri-plugin-single-instance" +version = "2.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "726b6da0dd5bd1344f84f4108a622c07d01923baacb5c803a605189672c5352f" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.21", + "tokio", + "tracing", + "windows-sys 0.61.2", + "zbus", +] + +[[package]] +name = "tauri-plugin-window-state" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d6815cab7b212f9cfe16a9d79d8d22f9e14159e4a9460f7dec1467167a008e3" +dependencies = [ + "bitflags 2.13.2", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.21", +] + [[package]] name = "tauri-runtime" version = "2.12.2" @@ -3324,6 +3710,19 @@ dependencies = [ "toml 1.1.8+spec-1.1.0", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "tendril" version = "0.5.1" @@ -3594,9 +3993,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -3646,6 +4057,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unicode-ident" version = "1.0.27" @@ -4322,6 +4744,76 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.9", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "939f186d9ecebe044315a5cb6d6f78568bbc21152adc954d5a0c2f58bdbc9920" +dependencies = [ + "serde", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -4387,3 +4879,44 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.9", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.9", + "winnow 1.0.4", +] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f31bfe8..8621d52 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -19,6 +19,9 @@ tauri = { version = "2", features = [] } tauri-plugin-dialog = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" +tauri-plugin-opener = "2" +percent-encoding = "2" +tauri-plugin-window-state = "2" [profile.release] codegen-units = 1 @@ -26,3 +29,6 @@ lto = true opt-level = "s" panic = "abort" strip = true + +[target.'cfg(any(target_os = "windows", target_os = "linux"))'.dependencies] +tauri-plugin-single-instance = "2" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index c757650..9f3c6f1 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -1,7 +1,14 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", - "description": "What the windows of the app may call.", - "windows": ["*"], - "permissions": ["core:default", "dialog:default"] + "description": "What the document windows may call.", + "windows": ["main", "doc-*"], + "permissions": [ + "core:default", + "core:window:allow-set-title", + "core:window:allow-destroy", + "core:window:allow-close", + "dialog:default", + "opener:default" + ] } diff --git a/src-tauri/capabilities/print.json b/src-tauri/capabilities/print.json new file mode 100644 index 0000000..19f5258 --- /dev/null +++ b/src-tauri/capabilities/print.json @@ -0,0 +1,7 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "print", + "description": "What a print window may call. The app commands refuse these windows too.", + "windows": ["print-*"], + "permissions": ["core:event:default", "core:webview:allow-print", "core:window:allow-set-title"] +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c2630bf..87c3f2f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,7 +1,387 @@ +//! The shell around the frontend: windows, menus, file access and printing. +//! +//! The frontend runs the core and owns the document. This side reads and +//! writes the files, routes the files that the operating system opens to a +//! window, and shows the print window. + +mod menu; + +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::Mutex; + +use percent_encoding::percent_decode_str; +use tauri::ipc::{InvokeBody, Request, Response}; +use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindow, WebviewWindowBuilder}; + +/// The prefix of the windows that only print. They get no file access. +const PRINT_PREFIX: &str = "print-"; + +#[derive(Default)] +struct Shared { + /// The files that wait for a window, by window label. + pending: HashMap>, + /// The windows whose frontend asked for its files, so it hears events. + ready: HashSet, + /// The markup that a print window shows, by window label. + print: HashMap, +} + +#[derive(Clone, serde::Serialize)] +struct PrintJob { + title: String, + html: String, +} + +struct State { + shared: Mutex, + next_window: AtomicU32, +} + +fn refuse_print_window(window: &WebviewWindow) -> Result<(), String> { + if window.label().starts_with(PRINT_PREFIX) { + return Err("a print window cannot do this".into()); + } + Ok(()) +} + +/// The bytes of a file, as an `ArrayBuffer` on the frontend. +#[tauri::command] +fn read_file(window: WebviewWindow, path: String) -> Result { + refuse_print_window(&window)?; + fs::read(&path) + .map(Response::new) + .map_err(|e| e.to_string()) +} + +/// Writes the raw body of the request to the path in the `x-path` header. +#[tauri::command] +fn write_file(window: WebviewWindow, request: Request<'_>) -> Result<(), String> { + refuse_print_window(&window)?; + let InvokeBody::Raw(bytes) = request.body() else { + return Err("the body must be raw bytes".into()); + }; + let header = request + .headers() + .get("x-path") + .ok_or("no x-path header")? + .to_str() + .map_err(|e| e.to_string())?; + let path = percent_decode_str(header) + .decode_utf8() + .map_err(|e| e.to_string())?; + write_atomically(Path::new(path.as_ref()), bytes).map_err(|e| e.to_string()) +} + +/// Writes into a temporary file beside the target, then renames it over the +/// target. So a failed write never leaves half a document. +fn write_atomically(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + let dir = path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let name = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + let temp = dir.join(format!(".{name}.{}.saving", std::process::id())); + + let result = (|| { + let mut file = fs::File::create(&temp)?; + file.write_all(bytes)?; + file.sync_all()?; + drop(file); + if let Ok(meta) = fs::metadata(path) { + fs::set_permissions(&temp, meta.permissions())?; + } + fs::rename(&temp, path) + })(); + if result.is_err() { + let _ = fs::remove_file(&temp); + } + result +} + +/// The files that wait for the calling window. After this call, the window +/// gets later files as `open-paths` events. +#[tauri::command] +fn take_launch(window: WebviewWindow, state: tauri::State<'_, State>) -> Vec { + let mut shared = state.shared.lock().unwrap(); + shared.ready.insert(window.label().to_string()); + shared.pending.remove(window.label()).unwrap_or_default() +} + +/// Opens one new window for each path, or one empty window for no path. +#[tauri::command] +fn open_window(app: AppHandle, window: WebviewWindow, paths: Vec) -> Result<(), String> { + refuse_print_window(&window)?; + if paths.is_empty() { + return new_document_window(&app, Vec::new()).map(|_| ()); + } + for path in paths { + new_document_window(&app, vec![path])?; + } + Ok(()) +} + +fn new_document_window(app: &AppHandle, paths: Vec) -> Result { + let state = app.state::(); + let label = format!("doc-{}", state.next_window.fetch_add(1, Ordering::Relaxed)); + if !paths.is_empty() { + state + .shared + .lock() + .unwrap() + .pending + .insert(label.clone(), paths); + } + WebviewWindowBuilder::new(app, &label, WebviewUrl::App("index.html".into())) + .title("OpenDocument Reader") + .inner_size(1100.0, 800.0) + .min_inner_size(480.0, 360.0) + .build() + .map_err(|e| e.to_string()) +} + +/// Opens a print window for the markup. That window prints itself. +#[tauri::command] +fn start_print( + app: AppHandle, + window: WebviewWindow, + title: String, + html: String, +) -> Result<(), String> { + refuse_print_window(&window)?; + let state = app.state::(); + let label = format!( + "{PRINT_PREFIX}{}", + state.next_window.fetch_add(1, Ordering::Relaxed) + ); + state.shared.lock().unwrap().print.insert( + label.clone(), + PrintJob { + title: title.clone(), + html, + }, + ); + WebviewWindowBuilder::new(&app, &label, WebviewUrl::App("print.html".into())) + .title(format!("Print — {title}")) + .inner_size(900.0, 1000.0) + .build() + .map(|_| ()) + .map_err(|e| e.to_string()) +} + +/// The markup of the calling print window, once. +#[tauri::command] +fn take_print(window: WebviewWindow, state: tauri::State<'_, State>) -> Option { + state.shared.lock().unwrap().print.remove(window.label()) +} + +/// Sets the check mark of a check item in the menu. +#[tauri::command] +fn set_menu_check(app: AppHandle, id: String, checked: bool) -> Result<(), String> { + menu::set_check(&app, &id, checked) +} + +/// What the smoke mode does: `ODR_DESKTOP_SMOKE` names a file to open, and +/// `ODR_DESKTOP_SMOKE_SAVE` names a path to save a copy to, and +/// `ODR_DESKTOP_SMOKE_PASSWORD` is the password of an encrypted file. The frontend +/// reports, and the app exits. CI uses this to check a build. +#[derive(serde::Serialize)] +struct Smoke { + path: String, + save_to: Option, + password: Option, +} + +#[tauri::command] +fn smoke_path(window: WebviewWindow) -> Option { + if window.label() != "main" { + return None; + } + let path = std::env::var("ODR_DESKTOP_SMOKE") + .ok() + .filter(|p| !p.is_empty())?; + let save_to = std::env::var("ODR_DESKTOP_SMOKE_SAVE") + .ok() + .filter(|p| !p.is_empty()); + let password = std::env::var("ODR_DESKTOP_SMOKE_PASSWORD").ok(); + Some(Smoke { + path, + save_to, + password, + }) +} + +#[tauri::command] +fn smoke_report(app: AppHandle, ok: bool, detail: String) { + println!("smoke: {} {detail}", if ok { "ok" } else { "FAILED" }); + app.exit(if ok { 0 } else { 1 }); +} + +/// Sends files to a window that can take them. A window that is not ready +/// keeps them until its frontend asks. With no window, a new one opens. +fn route_paths(app: &AppHandle, paths: Vec) { + if paths.is_empty() { + return; + } + let windows: Vec = app + .webview_windows() + .into_values() + .filter(|w| !w.label().starts_with(PRINT_PREFIX)) + .collect(); + let state = app.state::(); + let mut shared = state.shared.lock().unwrap(); + + let ready: Vec<&WebviewWindow> = windows + .iter() + .filter(|w| shared.ready.contains(w.label())) + .collect(); + let target = ready + .iter() + .find(|w| w.is_focused().unwrap_or(false)) + .or_else(|| ready.first()) + .map(|w| w.label().to_string()); + + if let Some(label) = target { + drop(shared); + let _ = app.emit_to(&label, "open-paths", paths); + if let Some(w) = app.get_webview_window(&label) { + let _ = w.set_focus(); + } + } else if let Some(w) = windows.first() { + shared + .pending + .entry(w.label().to_string()) + .or_default() + .extend(paths); + } else { + drop(shared); + let _ = new_document_window(app, paths); + } +} + +/// Asks every window to close. A window with unsaved changes asks the user, +/// and the app exits when the last window is gone. +fn close_all_windows(app: &AppHandle) { + for window in app.webview_windows().into_values() { + let _ = window.close(); + } +} + +/// The paths among the arguments of a launch. A relative path is relative to +/// the directory that the launch came from. +fn paths_from_args(args: &[String], cwd: &Path) -> Vec { + args.iter() + .skip(1) + .filter(|a| !a.starts_with('-')) + .map(|a| { + let p = PathBuf::from(a); + if p.is_absolute() { + p + } else { + cwd.join(p) + } + }) + .map(|p| p.to_string_lossy().into_owned()) + .collect() +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - tauri::Builder::default() + #[allow(unused_mut)] + let mut builder = tauri::Builder::default(); + + // A second launch on Windows and Linux hands its files to this one. On + // macOS the system does that, with `RunEvent::Opened`. + #[cfg(any(target_os = "windows", target_os = "linux"))] + { + builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, cwd| { + let paths = paths_from_args(&args, Path::new(&cwd)); + if paths.is_empty() { + if let Some(w) = app.webview_windows().into_values().next() { + let _ = w.set_focus(); + } + } else { + route_paths(app, paths); + } + })); + } + + builder .plugin(tauri_plugin_dialog::init()) - .run(tauri::generate_context!()) - .expect("error while running the application"); + .plugin(tauri_plugin_opener::init()) + .plugin( + tauri_plugin_window_state::Builder::new() + .with_filter(|label| label == "main") + .build(), + ) + .manage(State { + shared: Mutex::default(), + next_window: AtomicU32::new(1), + }) + .setup(|app| { + let cwd = std::env::current_dir().unwrap_or_default(); + let args: Vec = std::env::args().collect(); + let paths = paths_from_args(&args, &cwd); + if !paths.is_empty() { + app.state::() + .shared + .lock() + .unwrap() + .pending + .insert("main".into(), paths); + } + menu::install(app.handle())?; + Ok(()) + }) + .on_window_event(|window, event| { + if let tauri::WindowEvent::Destroyed = event { + let state = window.state::(); + let mut shared = state.shared.lock().unwrap(); + shared.ready.remove(window.label()); + shared.pending.remove(window.label()); + shared.print.remove(window.label()); + } + }) + .invoke_handler(tauri::generate_handler![ + read_file, + write_file, + take_launch, + open_window, + start_print, + take_print, + set_menu_check, + smoke_path, + smoke_report, + ]) + .build(tauri::generate_context!()) + .expect("error while building the application") + .run(|_app, _event| { + // A quit from outside the menu, such as the Dock on macOS, closes + // the windows first, so each window asks about unsaved changes. + // With no window left, the app exits. + if let tauri::RunEvent::ExitRequested { + code: None, api, .. + } = &_event + { + if !_app.webview_windows().is_empty() { + api.prevent_exit(); + close_all_windows(_app); + } + } + #[cfg(target_os = "macos")] + if let tauri::RunEvent::Opened { urls } = &_event { + let paths = urls + .iter() + .filter_map(|u| u.to_file_path().ok()) + .map(|p| p.to_string_lossy().into_owned()) + .collect(); + route_paths(_app, paths); + } + }); } diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs new file mode 100644 index 0000000..9107e3e --- /dev/null +++ b/src-tauri/src/menu.rs @@ -0,0 +1,202 @@ +//! The menu bar. A click goes to the focused window as a `menu` event with +//! the id of the item, and the frontend acts on it. +//! +//! There is no Undo or Redo item. The rendered page owns its edit log and +//! takes the undo chord itself. A native Undo item would take the key first +//! and send it to the text field of the web view. + +use tauri::menu::{ + AboutMetadata, CheckMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu, HELP_SUBMENU_ID, + WINDOW_SUBMENU_ID, +}; +use tauri::{AppHandle, Emitter, Manager, Wry}; +use tauri_plugin_opener::OpenerExt; + +const WEBSITE: &str = "https://opendocument.app"; +const ISSUES: &str = "https://github.com/opendocument-app/OpenDocument.desktop/issues"; + +struct Checks { + darken: CheckMenuItem, +} + +pub fn install(app: &AppHandle) -> tauri::Result<()> { + let item = |id: &str, text: &str, accelerator: Option<&str>| { + MenuItem::with_id(app, id, text, true, accelerator) + }; + let separator = || PredefinedMenuItem::separator(app); + + let new = Submenu::with_items( + app, + "New", + true, + &[ + &item("new-odt", "Text Document (ODT)", None)?, + &item("new-ods", "Spreadsheet (ODS)", None)?, + &item("new-docx", "Word Document (DOCX)", None)?, + &item("new-xlsx", "Excel Spreadsheet (XLSX)", None)?, + ], + )?; + + let file = Submenu::with_items( + app, + "File", + true, + &[ + &item("new-window", "New Window", Some("CmdOrCtrl+N"))?, + &new, + &item("open", "Open…", Some("CmdOrCtrl+O"))?, + &separator()?, + &item("save", "Save", Some("CmdOrCtrl+S"))?, + &item("save-as", "Save As…", Some("CmdOrCtrl+Shift+S"))?, + &separator()?, + &item("print", "Print…", Some("CmdOrCtrl+P"))?, + &separator()?, + &item( + "close-document", + "Close Document", + Some("CmdOrCtrl+Shift+W"), + )?, + &PredefinedMenuItem::close_window(app, None)?, + ], + )?; + + let edit = Submenu::with_items( + app, + "Edit", + true, + &[ + &PredefinedMenuItem::cut(app, None)?, + &PredefinedMenuItem::copy(app, None)?, + &PredefinedMenuItem::paste(app, None)?, + &PredefinedMenuItem::select_all(app, None)?, + &separator()?, + &item("find", "Find…", Some("CmdOrCtrl+F"))?, + &item("find-next", "Find Next", Some("CmdOrCtrl+G"))?, + &item("find-previous", "Find Previous", Some("CmdOrCtrl+Shift+G"))?, + &separator()?, + &item("edit-mode", "Edit Document", Some("CmdOrCtrl+E"))?, + ], + )?; + + let darken = + CheckMenuItem::with_id(app, "darken", "Darken Documents", true, true, None::<&str>)?; + let view = Submenu::with_items( + app, + "View", + true, + &[ + &item("zoom-in", "Zoom In", Some("CmdOrCtrl+="))?, + &item("zoom-out", "Zoom Out", Some("CmdOrCtrl+-"))?, + &item("zoom-fit", "Fit to Window", Some("CmdOrCtrl+0"))?, + &separator()?, + &darken, + &separator()?, + &PredefinedMenuItem::fullscreen(app, None)?, + ], + )?; + + let window = Submenu::with_id_and_items( + app, + WINDOW_SUBMENU_ID, + "Window", + true, + &[ + &PredefinedMenuItem::minimize(app, None)?, + &PredefinedMenuItem::maximize(app, None)?, + ], + )?; + + let help = Submenu::with_id_and_items( + app, + HELP_SUBMENU_ID, + "Help", + true, + &[ + &item("website", "OpenDocument Reader Website", None)?, + &item("report", "Report a Problem", None)?, + ], + )?; + + // Not the predefined Quit, which ends the app at once. This one closes the + // windows one by one, so each window asks about its unsaved changes. + let quit = item("quit", "Quit OpenDocument Reader", Some("CmdOrCtrl+Q"))?; + + let menu = Menu::new(app)?; + + #[cfg(target_os = "macos")] + { + let about = AboutMetadata { + website: Some(WEBSITE.into()), + ..Default::default() + }; + let app_menu = Submenu::with_items( + app, + "OpenDocument Reader", + true, + &[ + &PredefinedMenuItem::about(app, None, Some(about))?, + &separator()?, + &PredefinedMenuItem::services(app, None)?, + &separator()?, + &PredefinedMenuItem::hide(app, None)?, + &PredefinedMenuItem::hide_others(app, None)?, + &PredefinedMenuItem::show_all(app, None)?, + &separator()?, + &quit, + ], + )?; + menu.append(&app_menu)?; + } + #[cfg(not(target_os = "macos"))] + { + let _ = AboutMetadata::default(); + file.append(&separator()?)?; + file.append(&quit)?; + } + + menu.append_items(&[&file, &edit, &view, &window, &help])?; + app.set_menu(menu)?; + app.manage(Checks { darken }); + + app.on_menu_event(|app, event| { + let id = event.id().as_ref(); + match id { + "website" => { + let _ = app.opener().open_url(WEBSITE, None::<&str>); + } + "report" => { + let _ = app.opener().open_url(ISSUES, None::<&str>); + } + "quit" => crate::close_all_windows(app), + _ => { + let target = app + .webview_windows() + .into_values() + .filter(|w| !w.label().starts_with(crate::PRINT_PREFIX)) + .find(|w| w.is_focused().unwrap_or(false)); + match target { + Some(w) => { + let _ = app.emit_to(w.label(), "menu", id); + } + // Only New Window and Open make sense with no window. + None if id == "new-window" || id == "open" => { + let _ = crate::new_document_window(app, Vec::new()); + } + None => {} + } + } + } + }); + Ok(()) +} + +pub fn set_check(app: &AppHandle, id: &str, checked: bool) -> Result<(), String> { + let checks = app.state::(); + match id { + "darken" => checks + .darken + .set_checked(checked) + .map_err(|e| e.to_string()), + _ => Err(format!("no check item {id}")), + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 729816a..329e673 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -21,7 +21,11 @@ } ], "security": { - "csp": null + "csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; media-src 'self' data: blob:; connect-src 'self' ipc: http://ipc.localhost; frame-src 'self' blob:; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'none'", + "dangerousDisableAssetCspModification": [ + "script-src", + "style-src" + ] } }, "bundle": { @@ -39,6 +43,107 @@ "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico" + ], + "fileAssociations": [ + { + "ext": [ + "odt" + ], + "name": "OpenDocument Text", + "description": "OpenDocument Text", + "mimeType": "application/vnd.oasis.opendocument.text", + "role": "Editor" + }, + { + "ext": [ + "ods" + ], + "name": "OpenDocument Spreadsheet", + "description": "OpenDocument Spreadsheet", + "mimeType": "application/vnd.oasis.opendocument.spreadsheet", + "role": "Editor" + }, + { + "ext": [ + "odp" + ], + "name": "OpenDocument Presentation", + "description": "OpenDocument Presentation", + "mimeType": "application/vnd.oasis.opendocument.presentation", + "role": "Editor" + }, + { + "ext": [ + "odg" + ], + "name": "OpenDocument Drawing", + "description": "OpenDocument Drawing", + "mimeType": "application/vnd.oasis.opendocument.graphics", + "role": "Editor" + }, + { + "ext": [ + "fodt" + ], + "name": "Flat OpenDocument Text", + "description": "Flat OpenDocument Text", + "mimeType": "application/vnd.oasis.opendocument.text-flat-xml", + "role": "Editor" + }, + { + "ext": [ + "fods" + ], + "name": "Flat OpenDocument Spreadsheet", + "description": "Flat OpenDocument Spreadsheet", + "mimeType": "application/vnd.oasis.opendocument.spreadsheet-flat-xml", + "role": "Editor" + }, + { + "ext": [ + "fodp" + ], + "name": "Flat OpenDocument Presentation", + "description": "Flat OpenDocument Presentation", + "mimeType": "application/vnd.oasis.opendocument.presentation-flat-xml", + "role": "Editor" + }, + { + "ext": [ + "fodg" + ], + "name": "Flat OpenDocument Drawing", + "description": "Flat OpenDocument Drawing", + "mimeType": "application/vnd.oasis.opendocument.graphics-flat-xml", + "role": "Editor" + }, + { + "ext": [ + "pages" + ], + "name": "Pages Document", + "description": "Pages Document", + "mimeType": "application/vnd.apple.pages", + "role": "Viewer" + }, + { + "ext": [ + "numbers" + ], + "name": "Numbers Spreadsheet", + "description": "Numbers Spreadsheet", + "mimeType": "application/vnd.apple.numbers", + "role": "Viewer" + }, + { + "ext": [ + "key" + ], + "name": "Keynote Presentation", + "description": "Keynote Presentation", + "mimeType": "application/vnd.apple.keynote", + "role": "Viewer" + } ] } } diff --git a/src/core/backend.ts b/src/core/backend.ts new file mode 100644 index 0000000..623db61 --- /dev/null +++ b/src/core/backend.ts @@ -0,0 +1,78 @@ +/* + The one interface between the user interface and the core. The viewer calls + only this, so the engine behind it can change: today the WebAssembly build + in a worker, later perhaps the native core as a sidecar process. + + Every call is async, because no engine answers on the main thread. +*/ +import type { + EnumTables, + FileTypeInfo, + HtmlConfig, + OpenOptions, + PdfPermissions, + Recalculation, + Rendered, + View, +} from '@opendocument/odr-core'; + +export type { + EnumTables, + FileTypeInfo, + HtmlConfig, + OpenOptions, + PdfPermissions, + Recalculation, + Rendered, + View, +}; + +/** An error of the engine. `name` is the C++ exception type, such as `WrongPassword`. */ +export class EngineError extends Error { + constructor( + name: string, + message: string, + readonly code?: number, + ) { + super(message); + this.name = name; + } +} + +export interface Backend { + /** Starts the engine. The other calls wait for it. */ + load(): Promise; + /** The version of the engine. */ + version(): string; + enums(): EnumTables; + fileTypes(): FileTypeInfo[]; + /** The types that the bytes can be, the most specific last. */ + detect(bytes: Uint8Array): Promise; + /** Takes the bytes: the caller must not use them after the call. */ + open(bytes: Uint8Array, options: OpenOptions): Promise; + create(fileType: number, config: HtmlConfig): Promise; +} + +/** A document that the engine holds open. */ +export interface OpenDocument { + readonly fileType: number; + isPasswordEncrypted(): Promise; + /** Throws `WrongPassword` for a wrong password. */ + decrypt(password: string): Promise; + /** What the document is now: after a decrypt, the type inside. Has + `documentType`, such as `spreadsheet`, for a document file. */ + meta(): Promise>; + isEditable(): Promise; + isSavable(encrypted: boolean): Promise; + isAnnotatable(): Promise; + permissions(): Promise; + listViews(): Promise; + render(index: number): Promise; + /** Applies the operation log of the rendered page, a JSON string. */ + edit(operations: string): Promise; + recalculate(): Promise; + save(password?: string): Promise; + /** The pdf with the marks of the rendered page, a JSON string. */ + annotate(annotations: string): Promise; + close(): Promise; +} diff --git a/src/core/wasm-backend.ts b/src/core/wasm-backend.ts new file mode 100644 index 0000000..91f6498 --- /dev/null +++ b/src/core/wasm-backend.ts @@ -0,0 +1,152 @@ +/* + The `Backend` over the WebAssembly core in a worker. See `wasm-worker.ts`. +*/ +import { + EngineError, + type Backend, + type EnumTables, + type FileTypeInfo, + type HtmlConfig, + type OpenDocument, + type OpenOptions, +} from './backend'; + +export class WasmBackend implements Backend { + private worker: Worker | null = null; + private calls = new Map void; reject: (e: Error) => void }>(); + private nextCall = 1; + private loading: Promise | null = null; + private info: { version: string; enums: EnumTables; fileTypes: FileTypeInfo[] } | null = null; + + private start() { + const worker = new Worker(new URL('./wasm-worker.ts', import.meta.url), { type: 'module' }); + worker.onmessage = (event: MessageEvent) => { + const { id, ok, result, error } = event.data; + const call = this.calls.get(id); + this.calls.delete(id); + if (!call) return; + if (ok) call.resolve(result); + else call.reject(new EngineError(error.name, error.message, error.code)); + }; + worker.onerror = (event) => { + // A worker that fails to start answers nothing. Fail every open call. + const error = new EngineError('WorkerError', event.message || 'The engine stopped.'); + for (const call of this.calls.values()) call.reject(error); + this.calls.clear(); + }; + return worker; + } + + call(message: Record, transfer: Transferable[] = []): Promise { + this.worker ??= this.start(); + const id = this.nextCall++; + return new Promise((resolve, reject) => { + this.calls.set(id, { resolve, reject }); + this.worker!.postMessage({ ...message, id }, transfer); + }); + } + + load(): Promise { + this.loading ??= this.call({ op: 'load' }).then( + (info) => { + this.info = info; + }, + (e) => { + // A failed start can be tried again. + this.loading = null; + throw e; + }, + ); + return this.loading; + } + + private loaded() { + if (!this.info) throw new Error('The engine is not loaded.'); + return this.info; + } + + version() { + return this.loaded().version; + } + + enums() { + return this.loaded().enums; + } + + fileTypes() { + return this.loaded().fileTypes; + } + + detect(bytes: Uint8Array) { + // A copy, because the caller keeps its bytes. + return this.call({ op: 'detect', bytes: bytes.slice() }); + } + + async open(bytes: Uint8Array, options: OpenOptions): Promise { + const { handle, fileType } = await this.call<{ handle: number; fileType: number }>( + { op: 'open', bytes, options }, + [bytes.buffer as ArrayBuffer], + ); + return new WasmDocument(this, handle, fileType); + } + + async create(fileType: number, config: HtmlConfig): Promise { + const created = await this.call<{ handle: number; fileType: number }>({ op: 'create', fileType, config }); + return new WasmDocument(this, created.handle, created.fileType); + } +} + +class WasmDocument implements OpenDocument { + constructor( + private backend: WasmBackend, + private handle: number, + readonly fileType: number, + ) {} + + private call(method: string, ...args: unknown[]) { + return this.backend.call({ op: 'call', handle: this.handle, method, args }); + } + + isPasswordEncrypted() { + return this.call('isPasswordEncrypted'); + } + decrypt(password: string) { + return this.call('decrypt', password); + } + meta() { + return this.call>('meta'); + } + isEditable() { + return this.call('isEditable'); + } + isSavable(encrypted: boolean) { + return this.call('isSavable', encrypted); + } + isAnnotatable() { + return this.call('isAnnotatable'); + } + permissions() { + return this.call('permissions'); + } + listViews() { + return this.call('listViews'); + } + render(index: number) { + return this.call('render', index); + } + edit(operations: string) { + return this.call('edit', operations); + } + recalculate() { + return this.call('recalculate'); + } + save(password?: string) { + return password === undefined ? this.call('save') : this.call('save', password); + } + annotate(annotations: string) { + return this.call('annotate', annotations); + } + close() { + return this.backend.call({ op: 'close', handle: this.handle }); + } +} diff --git a/src/core/wasm-worker.ts b/src/core/wasm-worker.ts new file mode 100644 index 0000000..fe39d07 --- /dev/null +++ b/src/core/wasm-worker.ts @@ -0,0 +1,88 @@ +/* + Runs the WebAssembly core off the main thread. A large pdf takes seconds to + render, and the window must stay responsive meanwhile. + + The worker holds the open documents by a number. The main thread sends + `{id, op, ...}` and gets `{id, ok, result}` or `{id, ok: false, error}`. +*/ +/// + +// Imported by URL, from the copy that `scripts/sync-odr.mjs` makes. The glue +// finds `odr-core.wasm` beside itself. +const ODR_ENTRY = `/odr/${__ODR_VERSION__}/index.js`; + +/** The calls that a document takes over the worker boundary. */ +const DOCUMENT_CALLS = new Set([ + 'isPasswordEncrypted', + 'decrypt', + 'meta', + 'isEditable', + 'isSavable', + 'isAnnotatable', + 'permissions', + 'listViews', + 'render', + 'edit', + 'recalculate', + 'save', + 'annotate', +]); + +let odr: any = null; +const documents = new Map(); +let nextHandle = 1; + +function keep(document: any) { + const handle = nextHandle++; + documents.set(handle, document); + return { handle, fileType: document.fileType as number }; +} + +async function run(message: any): Promise { + switch (message.op) { + case 'load': { + if (!odr) { + const module = await import(/* @vite-ignore */ new URL(ODR_ENTRY, self.location.origin).href); + odr = await module.Odr.load(); + } + return { version: odr.version(), enums: odr.enums, fileTypes: odr.fileTypes() }; + } + case 'detect': + return odr.detect(message.bytes).fileTypes; + case 'open': + return keep(odr.open(message.bytes, message.options)); + case 'create': + return keep(odr.create(message.fileType, message.config)); + case 'close': { + documents.get(message.handle)?.close(); + documents.delete(message.handle); + return undefined; + } + case 'call': { + const document = documents.get(message.handle); + if (!document) throw Object.assign(new Error('The document is closed.'), { name: 'Closed' }); + if (!DOCUMENT_CALLS.has(message.method)) throw new Error(`No call ${message.method}.`); + const result = document[message.method](...message.args); + // A call that returns the document itself has nothing to send back. + return result === document ? undefined : result; + } + default: + throw new Error(`No operation ${message.op}.`); + } +} + +self.onmessage = async (event: MessageEvent) => { + const { id } = event.data; + try { + const result = await run(event.data); + const transfer: Transferable[] = []; + if (result instanceof Uint8Array) transfer.push(result.buffer as ArrayBuffer); + self.postMessage({ id, ok: true, result }, transfer); + } catch (e: any) { + self.postMessage({ + id, + ok: false, + error: { name: e?.name ?? 'Error', message: e?.message ?? String(e), code: e?.code }, + }); + } +}; diff --git a/src/main.ts b/src/main.ts index 8702886..4e8be97 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,2 +1,24 @@ -const app = document.getElementById('app'); -if (app) app.textContent = `OpenDocument Reader, core ${__ODR_VERSION__}`; +import './styles.css'; + +import { WasmBackend } from './core/wasm-backend'; +import * as platform from './platform'; +import { mountViewer } from './viewer/viewer'; + +const root = document.getElementById('app'); +if (!root) throw new Error('no #app'); + +const viewer = mountViewer({ root, backend: new WasmBackend() }); + +const smoke = await platform.smokePath(); +if (smoke) { + const errors: string[] = []; + window.addEventListener('error', (e) => errors.push(String(e.message))); + window.addEventListener('unhandledrejection', (e) => errors.push(String(e.reason?.message ?? e.reason))); + try { + await platform.smokeReport(true, await viewer.smoke(smoke.path, smoke.save_to, smoke.password)); + } catch (e: any) { + await platform.smokeReport(false, [String(e?.message ?? e), ...errors].join(' | ')); + } +} else { + await platform.onOpenPaths((paths) => void viewer.openPaths(paths)); +} diff --git a/src/platform.ts b/src/platform.ts new file mode 100644 index 0000000..a67f852 --- /dev/null +++ b/src/platform.ts @@ -0,0 +1,172 @@ +/* + Everything that the viewer needs from the operating system, over Tauri. + The viewer does not import Tauri itself. +*/ +import { invoke } from '@tauri-apps/api/core'; +import { listen } from '@tauri-apps/api/event'; +import { getCurrentWebview } from '@tauri-apps/api/webview'; +import { getCurrentWindow } from '@tauri-apps/api/window'; +import { ask, open as openDialog, save as saveDialog } from '@tauri-apps/plugin-dialog'; +import { openUrl } from '@tauri-apps/plugin-opener'; + +export async function readFile(path: string): Promise { + const buffer = await invoke('read_file', { path }); + return new Uint8Array(buffer); +} + +export async function writeFile(path: string, bytes: Uint8Array): Promise { + // A raw body, so the bytes do not go through JSON. The path goes in a + // header, which takes only ASCII. + await invoke('write_file', bytes, { headers: { 'x-path': encodeURIComponent(path) } }); +} + +export function baseName(path: string) { + return path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1) || path; +} + +export function dirName(path: string) { + const cut = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')); + return cut > 0 ? path.slice(0, cut) : ''; +} + +export async function pickFile(extensions: string[]): Promise { + const picked = await openDialog({ + multiple: false, + directory: false, + filters: [ + { name: 'Documents', extensions }, + { name: 'All files', extensions: ['*'] }, + ], + }); + return typeof picked === 'string' ? picked : null; +} + +/** A path to save to. The extension stays the one of the format. */ +export async function pickSaveTarget(suggested: string, extension: string): Promise { + const picked = await saveDialog({ + defaultPath: suggested, + filters: [{ name: extension.toUpperCase(), extensions: [extension] }], + }); + if (!picked) return null; + return /\.[^./\\]+$/.test(baseName(picked)) ? picked : `${picked}.${extension}`; +} + +export function confirmDiscard(name: string): Promise { + return ask(`${name} has changes that are not saved. If you continue, the changes are lost.`, { + title: 'Unsaved changes', + kind: 'warning', + okLabel: 'Discard changes', + cancelLabel: 'Cancel', + }); +} + +export function setTitle(title: string) { + return getCurrentWindow().setTitle(title); +} + +/** Asks before the window closes. The handler returns false to keep it open. */ +export function onCloseRequested(allow: () => Promise) { + return getCurrentWindow().onCloseRequested(async (event) => { + if (!(await allow())) event.preventDefault(); + }); +} + +export function closeWindow() { + return getCurrentWindow().close(); +} + +/** The files that the operating system gave this window, now and later. */ +export async function onOpenPaths(handler: (paths: string[]) => void) { + await listen('open-paths', (event) => handler(event.payload)); + const launch = await invoke('take_launch'); + if (launch.length) handler(launch); +} + +export function openWindows(paths: string[]) { + return invoke('open_window', { paths }); +} + +export function onMenu(handler: (id: string) => void) { + return listen('menu', (event) => handler(event.payload)); +} + +export function setMenuCheck(id: string, checked: boolean) { + return invoke('set_menu_check', { id, checked }); +} + +/** Files that are dragged over the window, by path. */ +export function onDragDrop(handlers: { + enter: () => void; + leave: () => void; + drop: (paths: string[]) => void; +}) { + return getCurrentWebview().onDragDropEvent((event) => { + const p = event.payload; + if (p.type === 'enter') handlers.enter(); + else if (p.type === 'leave') handlers.leave(); + else if (p.type === 'drop') { + handlers.leave(); + if (p.paths.length) handlers.drop(p.paths); + } + }); +} + +export function startPrint(title: string, html: string) { + return invoke('start_print', { title, html }); +} + +const LINK_SCHEMES = /^(https?|mailto):/i; + +/** Opens a link of a document in the browser of the system. */ +export function openLink(href: string) { + if (LINK_SCHEMES.test(href)) void openUrl(href); +} + +export function smokePath() { + return invoke<{ path: string; save_to: string | null; password: string | null } | null>('smoke_path'); +} + +export function smokeReport(ok: boolean, detail: string) { + return invoke('smoke_report', { ok, detail }); +} + +export interface Recent { + path: string; + name: string; + openedAt: number; +} + +const RECENTS_KEY = 'recents'; +const RECENTS_MAX = 20; + +export function recents(): Recent[] { + try { + const list = JSON.parse(localStorage.getItem(RECENTS_KEY) ?? '[]'); + return Array.isArray(list) ? list.filter((r) => typeof r?.path === 'string') : []; + } catch { + return []; + } +} + +export function rememberRecent(path: string) { + const list = recents().filter((r) => r.path !== path); + list.unshift({ path, name: baseName(path), openedAt: Date.now() }); + localStorage.setItem(RECENTS_KEY, JSON.stringify(list.slice(0, RECENTS_MAX))); +} + +export function forgetRecent(path: string) { + localStorage.setItem(RECENTS_KEY, JSON.stringify(recents().filter((r) => r.path !== path))); +} + +export function clearRecents() { + localStorage.removeItem(RECENTS_KEY); +} + +export function setting(key: string, fallback: boolean): boolean { + const value = localStorage.getItem(`setting.${key}`); + return value === null ? fallback : value === 'true'; +} + +export function setSetting(key: string, value: boolean) { + localStorage.setItem(`setting.${key}`, String(value)); +} diff --git a/src/print.ts b/src/print.ts new file mode 100644 index 0000000..d4035d2 --- /dev/null +++ b/src/print.ts @@ -0,0 +1,27 @@ +/* + The print window. It takes the markup that the document window gave the + shell, makes it this page, and opens the print dialog. On macOS, Tauri + replaces `window.print` with the native print of the web view. + + The markup has no scripts: the document window removed them. +*/ +import { invoke } from '@tauri-apps/api/core'; +import { getCurrentWindow } from '@tauri-apps/api/window'; + +interface PrintJob { + title: string; + html: string; +} + +const job = await invoke('take_print'); +if (job) { + document.open(); + document.write(job.html); + document.close(); + document.title = job.title; + await getCurrentWindow().setTitle(`Print — ${job.title}`); + // Images and fonts are inline, but they decode after the parse. + await document.fonts.ready; + await new Promise((resolve) => setTimeout(resolve, 300)); + window.print(); +} diff --git a/src/styles.css b/src/styles.css index d4b5078..45c75c1 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1 +1,122 @@ @import 'tailwindcss'; + +/* + The palette of the apps, as on the website: the Material 3 roles from + OpenDocument.droid `res/values/colors.xml` and its `values-night` file. +*/ +:root { + --md-primary: #00658f; + --md-on-primary: #ffffff; + --md-primary-container: #c7e7ff; + --md-on-primary-container: #001e2f; + + --md-secondary: #4f616e; + --md-secondary-container: #d2e5f5; + --md-on-secondary-container: #0b1d29; + + --md-surface: #fcfcff; + --md-on-surface: #191c1e; + --md-surface-variant: #dde3ea; + --md-on-surface-variant: #41484d; + --md-surface-container: #eceff3; + + --md-outline: #71787e; + --md-outline-variant: #c1c7ce; + + /* The three stacked pages of the launcher icon, sampled from it. They stand + for the three document kinds throughout the page. */ + --brand-text: #4bb3e8; + --brand-sheet: #62bc77; + --brand-slide: #fca110; + + color-scheme: light dark; +} + +@media (prefers-color-scheme: dark) { + :root { + --md-primary: #86cfff; + --md-on-primary: #00344c; + --md-primary-container: #004c6d; + --md-on-primary-container: #c7e7ff; + + --md-secondary: #b6c9d8; + --md-secondary-container: #384955; + --md-on-secondary-container: #d2e5f5; + + --md-surface: #191c1e; + --md-on-surface: #e2e2e5; + --md-surface-variant: #41484d; + --md-on-surface-variant: #c1c7ce; + --md-surface-container: #1d2022; + + --md-outline: #8b9198; + --md-outline-variant: #41484d; + } +} + +/* `inline` so the utilities emit `var(--md-*)` rather than the value frozen at + build time — that is what lets the media query above flip the whole page. */ +@theme inline { + --color-primary: var(--md-primary); + --color-on-primary: var(--md-on-primary); + --color-primary-container: var(--md-primary-container); + --color-on-primary-container: var(--md-on-primary-container); + + --color-secondary: var(--md-secondary); + --color-secondary-container: var(--md-secondary-container); + --color-on-secondary-container: var(--md-on-secondary-container); + + --color-surface: var(--md-surface); + --color-on-surface: var(--md-on-surface); + --color-surface-variant: var(--md-surface-variant); + --color-on-surface-variant: var(--md-on-surface-variant); + --color-surface-container: var(--md-surface-container); + + --color-outline: var(--md-outline); + --color-outline-variant: var(--md-outline-variant); + + --color-brand-text: var(--brand-text); + --color-brand-sheet: var(--brand-sheet); + --color-brand-slide: var(--brand-slide); + + /* No webfont. The app makes no network request. */ + --font-sans: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', + Arial, sans-serif; + --font-mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, + monospace; +} + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} + +body { + background: var(--md-surface); + color: var(--md-on-surface); +} + +:where(a, button, summary, [tabindex]):focus-visible { + outline: 2px solid var(--md-primary); + outline-offset: 2px; + border-radius: 0.25rem; +} + +/* The chrome is an application, not a page: its labels are not text to select. */ +#app { + user-select: none; + -webkit-user-select: none; +} +#app input, +#app [data-viewer-recent-path] { + user-select: text; + -webkit-user-select: text; +} diff --git a/src/viewer/frame-bridge.js b/src/viewer/frame-bridge.js new file mode 100644 index 0000000..28b8a42 --- /dev/null +++ b/src/viewer/frame-bridge.js @@ -0,0 +1,243 @@ +// @ts-check +/* + Taken from the website (`src/scripts/frame-bridge.js`). Keep the two in + step: a fix in one is usually a fix in the other. + + Runs inside the rendered document, appended by the viewer after the + renderer's own scripts, and is the only thing in the frame that knows there + is a page around it. The frame is sandboxed with `allow-scripts` and without + `allow-same-origin`, so it has an opaque origin: the page cannot reach into + it, and nothing in it - a `javascript:` link in a dropped document included - + can reach the page. What crosses is `postMessage`, in both directions, and + this script is one end of it. `viewer.ts` is the other. + + Plain javascript rather than typescript because it is inlined verbatim: the + viewer imports it as a string and writes it into the markup before mounting. + Nothing here may spell a closing script tag, which would end the inline + block early. + + The page's own callbacks do not pass through here. The viewer renders with + `hostMessageHandler: 'odrHost.post'`, and the renderer's `host-bridge.js` + sends every `odr.on*` callback to that function as one JSON string. The + viewer writes `odrHost` into the head, so it exists before the renderer's + scripts run, and no callback is lost before this script runs. +*/ +(function () { + 'use strict'; + + /** @type {any} */ + var odr = /** @type {any} */ (window).odr || {}; + var editing = odr.editing || null; + var annotation = odr.annotation || null; + var root = document.documentElement; + + /* The row and column edits of a sheet, in the names `odr.editing` takes. */ + var STRUCTURE = ['insertRows', 'deleteRows', 'insertColumns', 'deleteColumns']; + + /** @param {Record} message */ + function post(message) { + message.odrViewer = true; + // `*`: the frame has no origin to name, and nothing here is a secret. + window.parent.postMessage(message, '*'); + } + + /* + A `srcdoc` document resolves urls against the page that holds it, so a + pdf's `#p2` points at the page's own url, and a click would navigate the + frame onto the homepage. This script scrolls a fragment in the frame. The + renderer removes the `href` of a relative link, and it gives an external + link `target="_blank"`, which the sandbox keeps inert because it has no + `allow-popups`. + */ + var anchors = document.querySelectorAll('a[href^="#"]'); + for (var i = 0; i < anchors.length; ++i) { + var a = /** @type {HTMLAnchorElement} */ (anchors[i]); + var href = a.getAttribute('href') || ''; + a.removeAttribute('href'); + a.style.cursor = 'pointer'; + a.addEventListener('click', onFragment(href.slice(1))); + } + + /* + The sandbox has no `allow-popups`, so an external link does nothing by + itself. The app opens it in the browser of the system. The app checks the + scheme again. + */ + document.addEventListener('click', function (event) { + var target = /** @type {Element | null} */ (event.target); + var link = target && target.closest ? target.closest('a[href]') : null; + if (!link) return; + var url = link.getAttribute('href') || ''; + if (!/^(https?|mailto):/i.test(url)) return; + event.preventDefault(); + post({ type: 'link', href: url }); + }); + + /** @param {string} id */ + function onFragment(id) { + return function (/** @type {Event} */ event) { + event.preventDefault(); + var target = document.getElementById(id); + if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }; + } + + /* + Escape leaves whatever mode the pen turned on, unless something in the + frame - a sheet's cell editor, say - took the key for itself first. + */ + document.addEventListener('keydown', function (event) { + if (event.key === 'Escape' && !event.defaultPrevented) post({ type: 'escape' }); + }); + + /* + Ctrl+F and Cmd+F open the search strip of the viewer, not the find of the + browser. The page lays out only the parts near the viewport, so the find + of the browser does not reach the rest, but `odr.search` does. + */ + document.addEventListener('keydown', function (event) { + if (typeof odr.search !== 'function') return; + if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === 'f') { + event.preventDefault(); + post({ type: 'find' }); + } + }); + + /* A step goes to the next or the previous hit of the same text, and searches + first if the text changed. The count comes back as `searchChange`. */ + /** @param {string} text @param {number} step */ + function search(text, step) { + if (typeof odr.search !== 'function') return; + if (!text) odr.resetSearch(); + else if (step > 0) odr.searchNext(text); + else if (step < 0) odr.searchPrevious(text); + else odr.search(text); + } + + /* The zoom is the renderer's where its viewport script is present, and a + bare `body{zoom}` where a view carries none. Then this script reports the + zoom in the form `host-bridge.js` uses. */ + /** @param {number} value */ + function setZoom(value) { + if (typeof odr.setZoom === 'function') { + odr.setZoom(value); + return; + } + document.body.style.zoom = String(value); + root.style.setProperty('--odr-zoom', String(value)); + var host = /** @type {any} */ (window).odrHost; + if (host) host.post(JSON.stringify({ type: 'zoomChange', detail: { zoom: value, followsFit: false } })); + } + function resetZoom() { + if (typeof odr.resetZoom === 'function') { + odr.resetZoom(); + return; + } + setZoom(1); + } + + /* + A tool button does one of three things, and only the frame can tell which, + because only the frame can see the selection. The annotator's `press` + decides: with text selected, the tool marks that selection once, and then + no tool stays armed. Without a selection, a press arms the tool, and a + second press disarms it. An armed tool marks every selection as it is + made. The pen sends `null`, so it disarms outright. + + A new colour for a tool goes to `recolor`, which acts as the document + editor's highlight colour does: it marks a selection once, and it + recolours the tool if the tool is armed. Otherwise the page only keeps the + colour. + */ + /** @param {number[] | null} rgb */ + function setTool( + /** @type {string | null} */ tool, + rgb, + /** @type {number} */ width, + /** @type {boolean} */ recolor + ) { + if (!annotation) return; + /** @type {{color?: number[], width?: number}} */ + var style = {}; + if (rgb) style.color = rgb; + if (width) style.width = width; + if (recolor) annotation.recolor(tool, style); + else if (tool) annotation.press(tool, style); + else annotation.setTool(null); + post({ type: 'tool', armed: annotation.getTool() }); + } + + window.addEventListener('message', function (event) { + if (event.source !== window.parent) return; + var m = event.data; + if (!m || m.odrViewer !== true) return; + switch (m.type) { + case 'setZoom': + setZoom(m.value); + break; + case 'resetZoom': + resetZoom(); + break; + case 'edit': + if (editing) m.on ? editing.enable() : editing.disable(); + // A sheet computes its formula cells in the page. The module loads + // now, so that the first edit does not wait for it. + if (editing && m.on && typeof editing.loadFormulas === 'function') editing.loadFormulas(); + break; + case 'search': + search(String(m.text || ''), Number(m.step) || 0); + break; + case 'undo': + if (annotation) { + annotation.undo(); + } else if (editing) { + editing.undo(); + } + break; + case 'redo': + if (editing) editing.redo(); + break; + case 'format': + if (editing) editing.format(m.style); + break; + case 'toggle': + if (editing) editing.toggle(m.property); + break; + case 'structure': + // Only the four row and column calls. The page refuses a call that + // the open document does not have, so a text document says why. + if (editing && STRUCTURE.indexOf(m.call) >= 0) editing[m.call](m.where || undefined); + break; + case 'getOperations': + post({ type: 'operations', id: m.id, payload: editing ? editing.getOperations() : null }); + break; + case 'committed': + if (editing) editing.committed(); + break; + case 'tool': + setTool(m.tool || null, m.color, m.width, m.recolor === true); + break; + case 'getAnnotations': + post({ + type: 'annotations', + id: m.id, + payload: annotation ? annotation.getAnnotations() : null, + }); + break; + } + }); + + post({ + type: 'ready', + editable: editing ? editing.isEditable() === true : false, + sheet: !!odr.sheet, + // A csv states no style, so its cells take no format. + sheetStyles: document.body.getAttribute('data-odr-sheet-styles') !== 'false', + annotatable: !!annotation, + searchable: typeof odr.search === 'function', + // The canvas the renderer painted on the body, for the frame to wear on + // the overscroll; see the viewer. + background: getComputedStyle(document.body).backgroundColor, + zoom: typeof odr.getZoom === 'function' ? odr.getZoom() : 1, + }); +})(); diff --git a/src/viewer/icons.ts b/src/viewer/icons.ts new file mode 100644 index 0000000..3dd5db4 --- /dev/null +++ b/src/viewer/icons.ts @@ -0,0 +1,50 @@ +/* Stroke icons, from the website (`src/components/Icon.astro`). */ +export const ICONS = { + lock: 'M5 11V8a7 7 0 0 1 14 0v3M5 11h14a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-6a2 2 0 0 1 2-2Z', + search: 'M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16ZM21 21l-4.35-4.35', + pencil: 'M12 20h9M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5Z', + save: 'M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2ZM17 21v-8H7v8M7 3v5h8', + printer: 'M6 9V2h12v7M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2M6 14h12v8H6v-8Z', + upload: 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12', + close: 'M18 6 6 18M6 6l12 12', + chevron: 'm6 9 6 6 6-6', + undo: 'M3 7v6h6M21 17a9 9 0 0 0-15-6.7L3 13', + redo: 'M21 7v6h-6M3 17a9 9 0 0 1 15-6.7L21 13', + highlighter: 'm9 11-6 6v3h9l3-3M22 12l-4.6 4.6a2 2 0 0 1-2.8 0l-5.2-5.2a2 2 0 0 1 0-2.8L14 4l8 8Z', + underline: 'M7 3v6a5 5 0 0 0 10 0V3M4 20h16', + strike: 'M16 4H9a3 3 0 0 0-2.83 4M14 12a4 4 0 0 1 0 8H6M4 12h16', + squiggle: 'M7 3v6a5 5 0 0 0 10 0V3M3 20c1.5-1.5 3-1.5 4.5 0s3 1.5 4.5 0 3-1.5 4.5 0 3 1.5 4.5 0', + scribble: 'M3 17c2-4 4.5-9 6.5-8.5S8 16 10.5 16.5 15 10 17 10.5 15 17 21 15', + alignLeft: 'M21 6H3M15 12H3M17 18H3', + alignCenter: 'M21 6H3M17 12H7M19 18H5', + alignRight: 'M21 6H3M21 12H9M21 18H7', + alignJustify: 'M3 6h18M3 12h18M3 18h18', + fill: 'm19 11-8-8-8.6 8.6a2 2 0 0 0 0 2.8l5.2 5.2c.8.8 2 .8 2.8 0L19 11ZM5 2l5 5M2 13h15M22 20a2 2 0 1 1-4 0c0-1.6 1.7-2.4 2-4 .3 1.6 2 2.4 2 4Z', + rowAbove: 'M3 14h18v6H3zM12 3v7M8.5 6.5h7', + rowBelow: 'M3 4h18v6H3zM12 14v7M8.5 17.5h7', + rowDelete: 'M3 8h18v8H3zM10 10l4 4M14 10l-4 4', + columnLeft: 'M14 3h6v18h-6zM3 12h7M6.5 8.5v7', + columnRight: 'M4 3h6v18H4zM14 12h7M17.5 8.5v7', + columnDelete: 'M8 3h8v18H8zM10 10l4 4M14 10l-4 4', + folder: 'M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z', + file: 'M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8l-5-5ZM14 3v5h5', +} as const; + +export type IconName = keyof typeof ICONS; + +export function icon(name: IconName, className = 'h-4 w-4') { + return ``; +} + +/* The launcher icon as a vector, from the website (`src/components/Logo.astro`). */ +export function logo(className = 'h-9 w-9') { + return ``; +} diff --git a/src/viewer/markup.ts b/src/viewer/markup.ts new file mode 100644 index 0000000..a9a7614 --- /dev/null +++ b/src/viewer/markup.ts @@ -0,0 +1,245 @@ +/* + The markup of the window, ported from the viewer of the website + (`src/components/viewer/Chrome.astro`, `Panel.astro` and `pages/tryit.astro`). + The viewer finds every control by its `data-viewer-*` attribute. + + The desktop has no samples. The landing panel shows the recent documents + instead, and a document can have one tab for each sheet. +*/ +import { icon, logo, type IconName } from './icons'; + +const swatch = + 'relative flex h-7 w-7 shrink-0 items-center justify-center rounded-full transition-colors hover:bg-surface'; +const chevron = + 'relative -ml-0.5 flex h-7 w-4 shrink-0 items-center justify-center rounded-full text-on-surface-variant transition-colors hover:bg-surface'; +const round = + 'flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-outline transition-colors hover:bg-surface disabled:opacity-40 disabled:hover:bg-transparent'; +const tool = + 'flex h-7 min-w-7 shrink-0 items-center justify-center rounded-full border border-transparent px-1.5 text-sm leading-none transition-colors hover:bg-surface aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-on-primary disabled:opacity-40'; + +/* Point sizes that documents often use. */ +const sizes = [8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48]; + +const aligns: { align: string; label: string; icon: IconName }[] = [ + { align: 'left', label: 'Align left', icon: 'alignLeft' }, + { align: 'center', label: 'Centre', icon: 'alignCenter' }, + { align: 'right', label: 'Align right', icon: 'alignRight' }, + { align: 'justify', label: 'Justify', icon: 'alignJustify' }, +]; + +const structure: { call: string; where: string; label: string; icon: IconName }[] = [ + { call: 'insertRows', where: 'above', label: 'Insert rows above', icon: 'rowAbove' }, + { call: 'insertRows', where: 'below', label: 'Insert rows below', icon: 'rowBelow' }, + { call: 'deleteRows', where: '', label: 'Delete rows', icon: 'rowDelete' }, + { call: 'insertColumns', where: 'left', label: 'Insert columns to the left', icon: 'columnLeft' }, + { call: 'insertColumns', where: 'right', label: 'Insert columns to the right', icon: 'columnRight' }, + { call: 'deleteColumns', where: '', label: 'Delete columns', icon: 'columnDelete' }, +]; + +/* The marks of a pdf, in the names of the annotator, with their first colour. */ +const marks: { tool: string; label: string; icon: IconName; color: string }[] = [ + { tool: 'highlight', label: 'Highlight', icon: 'highlighter', color: '#ffe633' }, + { tool: 'underline', label: 'Underline', icon: 'underline', color: '#e53935' }, + { tool: 'strikeOut', label: 'Strike out', icon: 'strike', color: '#e53935' }, + { tool: 'squiggly', label: 'Squiggly underline', icon: 'squiggle', color: '#e53935' }, + { tool: 'ink', label: 'Draw', icon: 'scribble', color: '#1e88e5' }, +]; + +/* The types that a new, empty document can have. */ +export const BLANKS: { ext: string; tone: 'text' | 'sheet' }[] = [ + { ext: 'odt', tone: 'text' }, + { ext: 'ods', tone: 'sheet' }, + { ext: 'docx', tone: 'text' }, + { ext: 'xlsx', tone: 'sheet' }, +]; + +const tones = { text: 'text-brand-text', sheet: 'text-brand-sheet' } as const; + +function bar() { + return ` +
+ +
+ + + + + + + +`; +} + +function panel() { + return ` +
+
+
${logo('h-14 w-14')}
+ + + + +

Open a document

+

Or drop it on this window. ODT, ODS, ODP, DOCX, XLSX, PPTX, PDF and more.

+ + + +
+ + +
+

Or start an empty one:

+ ${BLANKS.map( + (b) => + ``, + ).join('')} +
+ + +
+
+
`; +} + +export function windowMarkup() { + return ` +${bar()} +
+ ${panel()} + +
`; +} + +/* The viewer fills the name and the folder as text, never as markup. */ +export function recentItem() { + return ` +
  • + + +
  • `; +} + +export function tabButton() { + return ``; +} diff --git a/src/viewer/viewer.ts b/src/viewer/viewer.ts new file mode 100644 index 0000000..9963bd3 --- /dev/null +++ b/src/viewer/viewer.ts @@ -0,0 +1,1354 @@ +/* + The viewer of one window, ported from the website (`src/scripts/viewer.ts`). + + The document lives in an iframe that runs the scripts of the renderer and + `frame-bridge.js`, and nothing else of ours. The frame is sandboxed without + `allow-same-origin`, so this side never touches its DOM: every command goes + over `postMessage`, and every answer comes back the same way. The engine + holds the open document. An edit goes into it, and a save comes out of it. + + The differences from the website: + - The engine is a `Backend`, and every call to it is async. + - A save writes the file on disk. "Save As" picks a new path. + - A spreadsheet shows one tab for each sheet, as on Android. + - The landing panel lists the recent documents. + - A document that the system opens while this window holds one goes to a new + window. +*/ +import type { Backend, OpenDocument, OpenOptions, View } from '../core/backend'; +import * as platform from '../platform'; +import bridgeSource from './frame-bridge.js?raw'; +import { recentItem, tabButton, windowMarkup } from './markup'; + +/* + The most cells that one sheet writes. The markup of a sheet goes into + `srcdoc` as one string, so a big sheet costs memory and time before + anything paints. A styled cell is about 226 bytes, so this is about 45 MB. + Android caps at 100,000 rows and 500 columns, and that cap applies too. +*/ +const SHEET_CELL_BUDGET = 200_000; +const SHEET_LIMIT = { rows: 100_000, columns: 500 }; + +const ZOOM_MIN = 0.25; +const ZOOM_MAX = 4; +const ZOOM_STEP = 1.25; + +const DROP_ACTIVE = ['border-primary', 'bg-primary-container/30']; + +const IDLE_TITLE = 'Open a document'; +const IDLE_HINT = 'Or drop it on this window. ODT, ODS, ODP, DOCX, XLSX, PPTX, PDF and more.'; + +const APP_NAME = 'OpenDocument Reader'; + +/* A "path" that asks a new window for an empty document of a type, such as + `odr-new:ods`. No file path starts with it. */ +const NEW_PREFIX = 'odr-new:'; + +/* The width of a line that the Draw tool makes. */ +const INK_WIDTH = 2; + +/* + What the window says when the page refuses an edit. The page reports a + reason and an English message for a console. The host chooses the words. +*/ +const REFUSALS: Record = { + newLine: 'A line break inside a paragraph is more than an edit can hand back.', + formula: 'That cell holds a formula that is shared with other cells, so it stays as it is.', + formulaInput: 'This page cannot take a formula; a number or some text it can.', + formulaInvalid: 'That formula does not parse, so the cell keeps what it had.', + array: + 'That cell is part of an array formula, so it stays as it is, and rows and columns cannot be added or removed through it.', + rich: 'That cell holds more than plain text, so it stays as it is.', + shapes: 'That cell holds a drawing, so it stays as it is.', + readOnly: 'This document cannot be edited.', + unsupportedEdit: 'That kind of edit is not supported here.', + range: 'An edit cannot reach over a picture or a table.', + unnameableEdit: 'That edit landed where the engine cannot name it, so it was not taken.', + outOfScope: 'That edit reaches past what this page offers.', + sheetCut: 'That part of the sheet is past what the app shows, so it cannot be edited here.', +}; + +/* `#rrggbb` to the 0..1 rgb triple that the annotator takes. */ +function rgbOf(hex: string): number[] { + const n = parseInt(hex.slice(1), 16); + return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]; +} + +function extensionOf(name: string) { + const dot = name.lastIndexOf('.'); + return dot < 0 ? '' : name.slice(dot + 1).toLowerCase(); +} + +/* + The function that `hostMessageHandler` names. The `host-bridge.js` of the + renderer calls it with every `odr.on*` callback of the page as one JSON + string, and it posts that string to this page unchanged. +*/ +const HOST_HANDLER = 'odrHost.post'; +const HOST_SCRIPT = + ""; + +/* The host function goes first in the head, and the bridge goes after the + scripts of the renderer, at the end of the body. */ +function withBridge(html: string) { + const head = html.indexOf(''); + const at = head < 0 ? 0 : head + ''.length; + html = html.slice(0, at) + HOST_SCRIPT + html.slice(at); + const script = ``; + const end = html.lastIndexOf(''); + return end < 0 ? html + script : html.slice(0, end) + script + html.slice(end); +} + +/* The print window shows the markup as a top-level page. It needs no script: + the print render lays out everything, and the scripts are the editor. */ +function withoutScripts(html: string) { + const doc = new DOMParser().parseFromString(html, 'text/html'); + for (const s of doc.querySelectorAll('script')) s.remove(); + return '\n' + doc.documentElement.outerHTML; +} + +/* A file to open: the bytes and the path they came from. A blank document + has no bytes, and a document that was never saved has no path. */ +interface Source { + bytes: Uint8Array | null; + name: string; + path: string | null; +} + +export interface ViewerOptions { + root: HTMLElement; + backend: Backend; +} + +export function mountViewer({ root, backend }: ViewerOptions) { + root.innerHTML = windowMarkup(); + + const find = (name: string) => { + const el = root.querySelector(`[data-viewer-${name}]`); + if (!el) throw new Error(`viewer: no [data-viewer-${name}]`); + return el; + }; + const all = (name: string) => [...root.querySelectorAll(`[data-viewer-${name}]`)]; + + const panel = find('panel'); + const zone = find('zone'); + const spinner = find('spinner'); + const idleIcon = find('idle-icon'); + const status = find('status'); + const hint = find('hint'); + const actions = find('actions'); + const passwordForm = find('password'); + const passwordInput = find('password-input'); + const passwordCancelBtn = find('password-cancel'); + const uploadIcon = find('icon-upload'); + const lockIcon = find('icon-lock'); + const pickBtn = find('pick'); + const blankBtns = all('blank'); + const recentsBox = find('recents'); + const recentsList = find('recents-list'); + const recentsClearBtn = find('recents-clear'); + const result = find('result'); + const frameHost = find('frame-host'); + const tabsBar = find('tabs'); + const filename = find('filename'); + const resetBtn = find('reset'); + const zoomBar = find('zoom'); + const zoomOutBtn = find('zoom-out'); + const zoomInBtn = find('zoom-in'); + const zoomLabel = find('zoom-level'); + const cutNote = find('cut'); + const staleNote = find('stale'); + const alertNote = find('alert'); + const penBtn = find('pen'); + const penEdit = find('pen-edit'); + const penMark = find('pen-mark'); + const saveBtn = find('save'); + const printBtn = find('print'); + const tools = find('tools'); + const toolsHint = find('tools-hint'); + const formatGroup = find('format'); + const styleBtns = all('style'); + const highlightBtn = find('highlight'); + const colorInput = find('color'); + const colorBar = find('color-bar'); + const highlightColorInput = find('highlight-color'); + const highlightBar = find('highlight-bar'); + const highlightPick = find('highlight-pick'); + const fillBtn = find('fill'); + const fillPick = find('fill-pick'); + const fillColorInput = find('fill-color'); + const fillBar = find('fill-bar'); + const alignBtns = all('align'); + const structureGroup = find('structure'); + const structureBtns = all('structure-call'); + const sizeSelect = find('size'); + const markGroup = find('mark'); + const toolBtns = all('tool'); + const toolColors = all('tool-color'); + const toolBars = all('tool-bar'); + const undoBtn = find('undo'); + const redoBtn = find('redo'); + const findOpenBtn = find('find-open'); + const findStrip = find('find'); + const findText = find('find-text'); + const findCount = find('find-count'); + const findPreviousBtn = find('find-previous'); + const findNextBtn = find('find-next'); + const findCloseBtn = find('find-close'); + const openOnly = all('open'); + + let currentFrame: HTMLIFrameElement | null = null; + let currentDoc: OpenDocument | null = null; + /** The source of the open document. A save replaces the bytes and the path. */ + let source: Source | null = null; + /** True from the start of an open until a document shows or the panel is back. */ + let busy = false; + + /** The views that the window shows, and the one on screen. */ + let views: View[] = []; + let viewIndex = 0; + + let canEdit = false; + let canFormat = false; + let canMark = false; + let plainText = false; + let sheet = false; + let editOnReady = false; + let modeOn = false; + let armedTool: string | null = null; + /** What the log of the page holds, as the page reports it. */ + let editDirty = false; + /** Edits that went into the engine but not into the file: from a sheet that + the tabs left, or before a print. */ + let appliedUnsaved = false; + let canUndo = false; + let canRedo = false; + let marks = 0; + let savedMarks = 0; + let currentZoom = 1; + let canFind = false; + + let passwordAnswer: ((password: string | null) => void) | null = null; + let encrypted = false; + let savePassword: string | null = null; + + const asks = new Map void>(); + let nextAsk = 1; + + const dirty = () => editDirty || appliedUnsaved || marks !== savedMarks; + const hasDocument = () => currentDoc !== null || busy; + + function send(message: Record) { + currentFrame?.contentWindow?.postMessage({ ...message, odrViewer: true }, '*'); + } + + /* A question with an answer: the frame replies with the same id. */ + function ask(type: string): Promise { + return new Promise((resolve, reject) => { + const id = nextAsk++; + const timer = window.setTimeout(() => { + asks.delete(id); + reject(new Error('The document did not answer.')); + }, 5000); + asks.set(id, (payload) => { + window.clearTimeout(timer); + resolve(payload); + }); + send({ type, id }); + }); + } + + function paintTitle() { + if (!source || !currentDoc) { + void platform.setTitle(APP_NAME); + return; + } + void platform.setTitle(dirty() ? `${source.name} — Edited` : source.name); + } + + function showResult(on: boolean) { + panel.hidden = on; + result.hidden = !on; + for (const el of openOnly) el.hidden = !on; + if (!on) paintRecents(); + } + + /** What the panel said, in order. The smoke mode reports it on a failure. */ + const trail: string[] = []; + + function setBusy(message: string) { + trail.push(message); + passwordForm.hidden = true; + spinner.hidden = false; + idleIcon.hidden = true; + actions.hidden = true; + hint.hidden = true; + status.textContent = message; + status.classList.remove('text-red-600', 'dark:text-red-400'); + } + + function setIdle(message: string, detail: string, isError = false) { + trail.push(`${message} (${detail})`); + passwordForm.hidden = true; + uploadIcon.hidden = false; + lockIcon.hidden = true; + spinner.hidden = true; + idleIcon.hidden = !isError; + actions.hidden = false; + hint.hidden = false; + status.textContent = message; + hint.textContent = detail; + status.classList.toggle('text-red-600', isError); + status.classList.toggle('dark:text-red-400', isError); + paintRecents(); + } + + function paintRecents() { + const list = platform.recents(); + recentsBox.hidden = list.length === 0; + recentsList.replaceChildren(); + for (const r of list) { + const holder = document.createElement('template'); + holder.innerHTML = recentItem().trim(); + const item = holder.content.firstElementChild as HTMLElement; + item.querySelector('[data-viewer-recent-name]')!.textContent = r.name; + item.querySelector('[data-viewer-recent-path]')!.textContent = platform.dirName(r.path); + item.title = r.path; + item.querySelector('[data-viewer-recent-open]')!.addEventListener('click', () => void openPath(r.path)); + item.querySelector('[data-viewer-recent-forget]')!.addEventListener('click', () => { + platform.forgetRecent(r.path); + paintRecents(); + }); + recentsList.append(item); + } + } + + /* Lets the busy message paint before the engine starts a long call. */ + const nextFrame = () => + new Promise((resolve) => { + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + resolve(); + }; + requestAnimationFrame(() => requestAnimationFrame(finish)); + setTimeout(finish, 50); + }); + + function askPassword(name: string, wrong: boolean): Promise { + spinner.hidden = true; + idleIcon.hidden = false; + actions.hidden = true; + hint.hidden = false; + status.textContent = wrong ? 'That password is not correct' : `${name} is password-protected`; + hint.textContent = wrong + ? `Type the password of ${name} again.` + : 'Type its password. The document is decrypted on this computer.'; + status.classList.toggle('text-red-600', wrong); + status.classList.toggle('dark:text-red-400', wrong); + uploadIcon.hidden = true; + lockIcon.hidden = false; + passwordForm.hidden = false; + passwordInput.value = ''; + passwordInput.focus(); + return new Promise((resolve) => { + passwordAnswer = resolve; + }); + } + + function friendlyError(e: any, name: string): [string, string] { + switch (e?.name) { + case 'UnsupportedFileType': + case 'UnknownFileType': + return [`${name} is not a format we can open`, 'Try an ODT, DOCX, XLSX, PPTX or PDF file.']; + case 'FileEncrypted': + return [`${name} is password-protected`, 'Open it again, and type its password.']; + case 'FileNotFound': + case 'NoDocumentFile': + return [`${name} could not be read`, 'The file looks empty or truncated.']; + default: + return [`${name} could not be opened`, e?.message ? String(e.message) : 'Unknown error.']; + } + } + + function showCut(cut: View['sheetCut']) { + if (!cut) { + cutNote.hidden = true; + return; + } + const n = (value: number) => value.toLocaleString(); + const parts: string[] = []; + if (cut.renderedRows < cut.contentRows) parts.push(`the first ${n(cut.renderedRows)} of ${n(cut.contentRows)} rows`); + if (cut.renderedColumns < cut.contentColumns) + parts.push(`the first ${n(cut.renderedColumns)} of ${n(cut.contentColumns)} columns`); + if (!parts.length) { + cutNote.hidden = true; + return; + } + cutNote.textContent = `This sheet is too large to show completely. The app shows ${parts.join(' and ')}. The rest stays in the file.`; + cutNote.hidden = false; + } + + const ALERT_TONES = { + error: ['bg-red-50', 'text-red-700', 'dark:bg-red-950/40', 'dark:text-red-300'], + warn: ['bg-amber-50', 'text-amber-800', 'dark:bg-amber-950/40', 'dark:text-amber-200'], + }; + let alertTimer: number | undefined; + function showAlert(message: string | null, tone: keyof typeof ALERT_TONES = 'error') { + window.clearTimeout(alertTimer); + alertNote.textContent = message ?? ''; + alertNote.hidden = !message; + for (const t of Object.values(ALERT_TONES)) alertNote.classList.remove(...t); + if (message) alertNote.classList.add(...ALERT_TONES[tone]); + if (message && tone === 'warn') alertTimer = window.setTimeout(() => showAlert(null), 6000); + } + + let staleCount = 0; + function showStale(count: number) { + staleCount = count; + if (!count) { + staleNote.hidden = true; + return; + } + staleNote.textContent = `${count} formula ${count === 1 ? 'cell shows a result' : 'cells show results'} that the app could not compute after your edit. A save computes ${count === 1 ? 'it' : 'them'} again.`; + staleNote.hidden = false; + } + + function showUncomputed(recalc: any) { + const count = (recalc?.unevaluated?.length ?? 0) + (recalc?.circular?.length ?? 0); + if (!count) return; + staleNote.textContent = `${count} formula ${count === 1 ? 'cell' : 'cells'} could not be computed here. A spreadsheet app computes ${count === 1 ? 'it' : 'them'} when it opens the saved file.`; + staleNote.hidden = false; + } + + function paintChrome() { + const changeable = canEdit || canMark; + penBtn.hidden = !changeable; + saveBtn.hidden = !changeable; + findOpenBtn.hidden = !canFind; + penEdit.hidden = canMark; + penMark.hidden = !canMark; + const penLabel = canMark + ? modeOn + ? 'Put the marker down' + : 'Mark up this PDF' + : modeOn + ? 'Stop editing' + : 'Edit this document'; + penBtn.title = penLabel; + penBtn.setAttribute('aria-label', penLabel); + penBtn.setAttribute('aria-pressed', String(modeOn)); + penBtn.classList.toggle('bg-primary', modeOn); + penBtn.classList.toggle('text-on-primary', modeOn); + penBtn.classList.toggle('border-transparent', modeOn); + penBtn.classList.toggle('border-outline', !modeOn); + penBtn.classList.toggle('hover:bg-surface', !modeOn); + + // A document that was never saved has no file yet, so its first save is a + // "Save As". That works also without changes. + saveBtn.disabled = !(dirty() || (source !== null && source.path === null)); + + tools.hidden = !modeOn; + formatGroup.hidden = !canFormat; + highlightBtn.hidden = sheet; + highlightPick.hidden = sheet; + fillBtn.hidden = !sheet; + fillPick.hidden = !sheet; + for (const b of alignBtns) b.hidden = sheet && b.dataset.viewerAlign === 'justify'; + structureGroup.hidden = !(canEdit && sheet); + markGroup.hidden = !canMark; + undoBtn.disabled = canMark ? marks === 0 : !canUndo; + redoBtn.hidden = canMark; + redoBtn.disabled = !canRedo; + for (const b of toolBtns) b.setAttribute('aria-pressed', String(b.dataset.viewerTool === armedTool)); + toolsHint.textContent = canMark + ? 'Select text, then a tool, to mark it once. A pressed tool marks every selection; Draw draws on the page.' + : sheet + ? canFormat + ? 'Type a value or a formula such as =SUM(B2:B4) into a cell. Drag over cells, or click a header, to format them or to add and remove rows and columns.' + : 'Type into a cell; Enter keeps the value, Escape drops it. Click a header to add and remove rows and columns. A csv has no styles.' + : canFormat + ? 'Type into the document. Select some text for the buttons, or use the keyboard shortcuts for bold, italic and underline.' + : 'Type into the file. It is plain text, so it saves as UTF-8 with no styles.'; + paintTitle(); + } + + function paintSelection(style: Record) { + for (const b of styleBtns) b.setAttribute('aria-pressed', String(style[b.dataset.viewerStyle ?? ''] === true)); + highlightBtn.setAttribute('aria-pressed', String(typeof style.highlight === 'string')); + if (typeof style.color === 'string' && /^#[0-9a-f]{6}$/i.test(style.color)) colorInput.value = style.color; + if (typeof style.highlight === 'string' && /^#[0-9a-f]{6}$/i.test(style.highlight)) + highlightColorInput.value = style.highlight; + fillBtn.setAttribute('aria-pressed', String(typeof style.fill === 'string')); + if (typeof style.fill === 'string' && /^#[0-9a-f]{6}$/i.test(style.fill)) fillColorInput.value = style.fill; + for (const b of alignBtns) b.setAttribute('aria-pressed', String(style.align === b.dataset.viewerAlign)); + paintColor(); + paintHighlight(); + paintFill(); + const size = typeof style.size === 'string' ? style.size : ''; + sizeSelect.value = [...sizeSelect.options].some((o) => o.value === size) ? size : ''; + } + + function setMode(on: boolean) { + showAlert(null); + if (canMark) { + modeOn = on; + if (!on) sendTool(null); + paintChrome(); + } else if (canEdit) { + send({ type: 'edit', on }); + } + if (on) currentFrame?.focus(); + } + + function colorOf(tool: string): string { + return toolColors.find((i) => i.dataset.viewerToolColor === tool)?.value ?? '#000000'; + } + + function sendTool(tool: string | null, recolor = false) { + const color = tool ? rgbOf(colorOf(tool)) : null; + send({ type: 'tool', tool, color, width: INK_WIDTH, recolor }); + } + + /* + Puts the edits of the page into the engine. After this, the page and the + engine agree, so the log of the page starts again. The file on disk does + not have the edits yet. + */ + async function applyPageEdits() { + if (!currentDoc || canMark || !editDirty) return; + const payload = await ask('getOperations'); + if (!payload) throw new Error('The edits could not be read back.'); + if (JSON.parse(payload).ops.length) await currentDoc.edit(payload); + appliedUnsaved = true; + send({ type: 'committed' }); + editDirty = false; + } + + /* The bytes of the document with every change of the page. */ + async function currentBytes(): Promise<{ bytes: Uint8Array; recalc: any }> { + if (!currentDoc) throw new Error('No document is open.'); + if (canMark) { + const payload = await ask('getAnnotations'); + if (!payload) throw new Error('The marks could not be read back.'); + return { bytes: await currentDoc.annotate(payload), recalc: null }; + } + await applyPageEdits(); + const recalc = sheet ? await currentDoc.recalculate() : null; + const bytes = encrypted ? await currentDoc.save(savePassword ?? '') : await currentDoc.save(); + return { bytes, recalc }; + } + + /* + Writes the document to its file, or to a new file for "Save As" and for a + document that was never saved. The file keeps the format it was opened + as, so "Save As" offers only that extension. + + A sheet is recalculated before the save. If the page left formula cells + stale, and the recalculation changed any cell, the sheet renders again, + so the page shows what the file holds. + */ + async function saveDocument(as = false) { + if (!currentDoc || !source || !(canEdit || canMark)) return; + showAlert(null); + const extension = extensionOf(source.name); + let target = source.path; + if (as || !target) { + target = await platform.pickSaveTarget(source.path ?? source.name, extension); + if (!target) return; + } + saveBtn.disabled = true; + await nextFrame(); + try { + const { bytes, recalc } = await currentBytes(); + await platform.writeFile(target, bytes); + source = { bytes, name: platform.baseName(target), path: target }; + platform.rememberRecent(target); + filename.textContent = source.name; + appliedUnsaved = false; + savedMarks = marks; + if (!canMark) { + send({ type: 'committed' }); + if (staleCount && recalc && Object.values(recalc).some((cells: any) => cells.length)) await renderView(viewIndex); + showUncomputed(recalc); + } + } catch (e: any) { + const detail = e?.message || e?.name || String(e) || 'Unknown error.'; + showAlert(`${source.name} could not be saved: ${detail} Your changes are still here.`); + } finally { + paintChrome(); + } + } + + /* True if the changes may go: there are none, or the user said so. */ + async function mayDiscard() { + if (!currentDoc || !dirty()) return true; + return platform.confirmDiscard(source?.name ?? 'This document'); + } + + function teardown() { + passwordAnswer?.(null); + passwordAnswer = null; + encrypted = false; + savePassword = null; + for (const [, resolve] of asks) resolve(null); + asks.clear(); + modeOn = false; + canEdit = false; + canFormat = false; + canMark = false; + plainText = false; + sheet = false; + editOnReady = false; + editDirty = false; + appliedUnsaved = false; + canUndo = false; + canRedo = false; + marks = 0; + savedMarks = 0; + armedTool = null; + canFind = false; + views = []; + viewIndex = 0; + showFind(false); + showAlert(null); + showStale(0); + void currentDoc?.close(); + currentDoc = null; + source = null; + frameHost.replaceChildren(); + tabsBar.replaceChildren(); + tabsBar.hidden = true; + currentFrame = null; + zoomBar.hidden = true; + cutNote.hidden = true; + paintChrome(); + } + + /* A new iframe for each render, in a container that is visible already. */ + function mountFrame(html: string) { + const frame = document.createElement('iframe'); + frame.title = 'Rendered document'; + // `allow-scripts` without `allow-same-origin`: the scripts of the renderer + // run, and the document gets an opaque origin. See the website viewer. + frame.setAttribute('sandbox', 'allow-scripts'); + frame.className = 'h-full w-full border-0'; + frameHost.replaceChildren(frame); + currentFrame = frame; + frame.srcdoc = withBridge(html); + } + + /* + Spreadsheets show one tab for each sheet. Every other format shows only + its "document" view, if it has one. This is what Android does + (`CoreLoader.selectViews`). + */ + async function selectViews(doc: OpenDocument): Promise { + const all = await doc.listViews(); + const meta = await doc.meta().catch(() => ({}) as Record); + if (meta.documentType === 'spreadsheet') { + const sheets = all.filter((v) => v.name !== 'document'); + if (sheets.length) return sheets; + } + const whole = all.filter((v) => v.name === 'document'); + return whole.length ? whole : all.slice(0, 1); + } + + function paintTabs() { + tabsBar.hidden = views.length < 2; + tabsBar.replaceChildren(); + if (views.length < 2) return; + views.forEach((view, i) => { + const holder = document.createElement('template'); + holder.innerHTML = tabButton(); + const btn = holder.content.firstElementChild as HTMLButtonElement; + btn.textContent = view.name; + btn.setAttribute('aria-selected', String(i === viewIndex)); + btn.addEventListener('click', () => void switchView(i)); + tabsBar.append(btn); + }); + } + + async function renderView(i: number) { + if (!currentDoc) return; + const html = (await currentDoc.render(views[i].index)).html; + viewIndex = i; + editOnReady = modeOn; + modeOn = false; + editDirty = false; + canUndo = false; + canRedo = false; + showStale(0); + showCut(views[i].sheetCut); + paintTabs(); + paintChrome(); + mountFrame(html); + } + + /* The edits of the sheet on screen go into the engine before another + sheet renders, so no edit is lost. */ + async function switchView(i: number) { + if (i === viewIndex || !currentDoc) return; + try { + await applyPageEdits(); + await renderView(i); + } catch (e: any) { + showAlert(`That sheet could not be shown: ${e?.message ?? e}`); + } + } + + window.addEventListener('message', (event) => { + if (!currentFrame || event.source !== currentFrame.contentWindow) return; + const m = event.data; + if (!m || m.odrViewer !== true) return; + switch (m.type) { + case 'ready': + if (m.background && !/^rgba\(0, 0, 0, 0\)$|^transparent$/.test(m.background)) { + currentFrame.style.background = m.background; + } + canEdit = canEdit && m.editable === true; + sheet = m.sheet === true; + canFormat = canEdit && !plainText && !(sheet && m.sheetStyles === false); + canMark = canMark && m.annotatable === true; + canFind = m.searchable === true; + if (canFind && !findStrip.hidden && findText.value) send({ type: 'search', text: findText.value, step: 0 }); + else if (!canFind) showFind(false); + paintZoom(m.zoom); + zoomBar.hidden = false; + paintChrome(); + if (editOnReady && canEdit) setMode(true); + editOnReady = false; + onReady?.(); + break; + case 'page': + onPage(m.message); + break; + case 'tool': + armedTool = typeof m.armed === 'string' ? m.armed : null; + paintChrome(); + break; + case 'escape': + if (!findStrip.hidden) showFind(false); + else if (modeOn) setMode(false); + break; + case 'find': + if (canFind) showFind(true); + break; + case 'link': + if (typeof m.href === 'string') platform.openLink(m.href); + break; + case 'operations': + case 'annotations': { + const resolve = asks.get(m.id); + asks.delete(m.id); + resolve?.(typeof m.payload === 'string' ? m.payload : null); + break; + } + } + }); + + function onPage(message: unknown) { + if (typeof message !== 'string') return; + let type: unknown; + let detail: any; + try { + ({ type, detail } = JSON.parse(message)); + } catch { + return; + } + switch (type) { + case 'zoomChange': + paintZoom(Number(detail?.zoom) || 1); + break; + case 'editModeChange': + modeOn = detail?.editing === true; + if (detail?.reason) showAlert(REFUSALS[detail.reason] ?? detail.message, 'warn'); + paintChrome(); + break; + case 'editChange': + editDirty = detail?.dirty === true; + canUndo = detail?.canUndo === true; + canRedo = detail?.canRedo === true; + paintChrome(); + break; + case 'editRefused': + showAlert(REFUSALS[detail?.reason] ?? detail?.message ?? 'That edit was not taken.', 'warn'); + break; + case 'cellsStale': + showStale(Array.isArray(detail?.cells) ? detail.cells.length : 0); + break; + case 'selectionChange': + paintSelection(detail ?? {}); + break; + case 'searchChange': + paintFind(detail ?? {}); + break; + case 'annotationChange': + marks = Number(detail?.count) || 0; + paintChrome(); + break; + case 'error': + console.warn(`renderer error ${detail?.code}: ${detail?.message}`); + break; + } + } + + function paintZoom(zoom: number) { + currentZoom = zoom; + zoomLabel.textContent = `${Math.round(zoom * 100)}%`; + zoomOutBtn.disabled = zoom <= ZOOM_MIN; + zoomInBtn.disabled = zoom >= ZOOM_MAX; + } + + function nudgeZoom(factor: number) { + const value = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, currentZoom * factor)); + send({ type: 'setZoom', value }); + } + + let findTimer: number | undefined; + function showFind(on: boolean) { + window.clearTimeout(findTimer); + findStrip.hidden = !on; + findOpenBtn.setAttribute('aria-expanded', String(on)); + if (on) { + findText.focus(); + findText.select(); + return; + } + if (findText.value) send({ type: 'search', text: '', step: 0 }); + findText.value = ''; + paintFind({}); + } + + function paintFind(state: { count?: number; current?: number; complete?: boolean }) { + const count = Number(state.count) || 0; + const more = state.complete === false; + findPreviousBtn.disabled = count === 0; + findNextBtn.disabled = count === 0; + if (!findText.value) findCount.textContent = ''; + else if (count === 0) findCount.textContent = more ? 'Searching…' : 'No hits'; + else findCount.textContent = `${(state.current ?? 0) + 1} of ${count.toLocaleString()}${more ? '…' : ''}`; + } + + function search(step: number) { + window.clearTimeout(findTimer); + send({ type: 'search', text: findText.value, step }); + } + + /* The types that a name asks for, for a format with no signature, such as + markdown. Only types that render. */ + let askable: Map | null = null; + function askedType(name: string): number | undefined { + if (!askable) { + askable = new Map(); + for (const t of backend.fileTypes()) { + if (t.capabilities?.detectByContent || !t.capabilities?.translateHtml) continue; + for (const ext of t.extensions ?? []) askable.set(ext.toLowerCase(), t.fileType); + } + } + return askable.get(extensionOf(name)); + } + + function typeInfo(fileType: number | undefined) { + return fileType === undefined ? undefined : backend.fileTypes().find((t) => t.fileType === fileType); + } + + function blankType(name: string): number | undefined { + const ext = extensionOf(name); + return backend.fileTypes().find((t) => t.capabilities?.create && t.extensions?.includes(ext))?.fileType; + } + + /** Whether documents follow the dark mode of the system. */ + let darken = platform.setting('darken', true); + + /* + Opens a source in this window. `knownPassword` is the password from an + earlier open of the same file, for a render again. + */ + async function open(src: Source, knownPassword: string | null = null) { + teardown(); + busy = true; + setBusy('Starting the engine…'); + + try { + await backend.load(); + } catch (e: any) { + const detail = [e?.name, e?.message].filter(Boolean).join(': '); + setIdle('The engine could not start', detail || 'Unknown error.', true); + return; + } + + setBusy(`Opening ${src.name}…`); + await nextFrame(); + + const enums = backend.enums(); + const asked = src.bytes ? askedType(src.name) : undefined; + const blank = src.bytes ? undefined : blankType(src.name); + let detected: number | undefined; + if (src.bytes && asked === undefined) { + try { + detected = (await backend.detect(src.bytes)).at(-1); + } catch { + detected = undefined; + } + } + const info = typeInfo(src.bytes ? (asked ?? detected) : blank); + const editableFormat = Boolean(info?.capabilities?.edit && info?.capabilities?.save); + const markableFormat = Boolean(info?.capabilities?.annotate); + plainText = info?.category === enums.FileCategory.text; + + const scheme = darken && info?.capabilities?.colorScheme ? enums.HtmlColorScheme.system : enums.HtmlColorScheme.light; + const options: OpenOptions = { + lazyLayout: true, + editable: editableFormat, + editingScope: enums.HtmlEditingScope.document, + spreadsheetCellLimit: SHEET_CELL_BUDGET, + spreadsheetLimit: SHEET_LIMIT, + textDocumentMargin: true, + hostMessageHandler: HOST_HANDLER, + pdfAnnotationMarkOnSelection: true, + pdfEnforcePermissions: true, + colorScheme: scheme, + name: src.name, + }; + if (asked !== undefined) options.fileType = asked; + + let doc: OpenDocument; + try { + if (src.bytes) { + // The engine takes its own copy, and the window keeps these bytes for + // a print and for a render again. + doc = await backend.open(src.bytes.slice(), options); + } else if (blank !== undefined) { + doc = await backend.create(blank, options); + editOnReady = true; + } else { + throw new Error('This engine cannot make that type of document.'); + } + } catch (e) { + const [msg, detail] = friendlyError(e, src.name); + busy = false; + setIdle(msg, detail, true); + return; + } + currentDoc = doc; + source = src; + + let password: string | null = null; + if (await doc.isPasswordEncrypted()) { + encrypted = true; + let wrong = false; + for (;;) { + password = knownPassword ?? (await askPassword(src.name, wrong)); + knownPassword = null; + if (password === null || currentDoc !== doc) { + busy = false; + return; + } + setBusy(`Decrypting ${src.name}…`); + await nextFrame(); + if (currentDoc !== doc) return; + try { + await doc.decrypt(password); + break; + } catch (e: any) { + if (e?.name !== 'WrongPassword') { + const [msg, detail] = friendlyError(e, src.name); + teardown(); + busy = false; + setIdle(msg, detail, true); + return; + } + wrong = true; + } + } + } + savePassword = password; + + const answers = async (question: () => Promise) => { + try { + return (await question()) === true; + } catch { + return false; + } + }; + canEdit = editableFormat && (await answers(async () => (await doc.isEditable()) && (await doc.isSavable(encrypted)))); + canMark = + markableFormat && + (await answers(async () => (await doc.isAnnotatable()) && (await doc.permissions())?.modifyAnnotations !== false)); + + setBusy(`Rendering ${src.name}…`); + await nextFrame(); + + try { + views = await selectViews(doc); + if (!views.length) throw new Error('The document has nothing to show.'); + filename.textContent = src.name; + showResult(true); + await renderView(0); + } catch (e) { + const [msg, detail] = friendlyError(e, src.name); + teardown(); + showResult(false); + busy = false; + setIdle(msg, detail, true); + return; + } + busy = false; + if (src.path) platform.rememberRecent(src.path); + } + + async function openSafely(src: Source, knownPassword: string | null = null) { + try { + await open(src, knownPassword); + } catch (e: any) { + teardown(); + showResult(false); + busy = false; + setIdle(`${src.name} could not be opened`, e?.message ? String(e.message) : 'Something went wrong in the viewer.', true); + } + } + + /* Opens a file here if this window is empty, else in a new window. */ + async function openPath(path: string) { + if (hasDocument()) { + await platform.openWindows([path]); + return; + } + const name = platform.baseName(path); + showResult(false); + setBusy(`Reading ${name}…`); + let bytes: Uint8Array; + try { + bytes = await platform.readFile(path); + } catch (e: any) { + platform.forgetRecent(path); + setIdle(`${name} could not be read`, String(e?.message ?? e), true); + return; + } + await openSafely({ bytes, name, path }); + } + + async function openPaths(paths: string[]) { + if (!paths.length) return; + const [first, ...rest] = paths; + if (rest.length) void platform.openWindows(rest); + if (first.startsWith(NEW_PREFIX)) await newBlank(first.slice(NEW_PREFIX.length)); + else await openPath(first); + } + + async function pickAndOpen() { + const extensions = [...new Set(backend.fileTypes().flatMap((t) => (t.capabilities?.translateHtml ? t.extensions : [])))]; + const path = await platform.pickFile(extensions.length ? extensions : ['*']); + if (path) await openPath(path); + } + + async function newBlank(ext: string) { + if (hasDocument()) { + // A new window makes it, so the open document stays. + await platform.openWindows([`${NEW_PREFIX}${ext}`]); + return; + } + await openSafely({ bytes: null, name: `Untitled.${ext}`, path: null }); + } + + /* + Prints the view on screen. The engine renders it once more, with every + part laid out and with light colours, and a print window shows it as a + top-level page. Tauri prints a top-level page on all three systems, but on + macOS not a frame inside one. + */ + async function print() { + if (!currentDoc || !source) return; + showAlert(null); + let printDoc: OpenDocument | null = null; + try { + const bytes = dirty() || !source.bytes ? (await currentBytes()).bytes : source.bytes.slice(); + const enums = backend.enums(); + printDoc = await backend.open(bytes, { + lazyLayout: false, + editable: false, + textDocumentMargin: true, + spreadsheetCellLimit: SHEET_CELL_BUDGET, + spreadsheetLimit: SHEET_LIMIT, + colorScheme: enums.HtmlColorScheme.light, + name: source.name, + }); + if (encrypted && (await printDoc.isPasswordEncrypted())) await printDoc.decrypt(savePassword ?? ''); + const printViews = await selectViews(printDoc); + const view = printViews.find((v) => v.name === views[viewIndex]?.name) ?? printViews[0]; + const html = (await printDoc.render(view.index)).html; + await platform.startPrint(source.name, withoutScripts(html)); + } catch (e: any) { + showAlert(`${source.name} could not be printed: ${e?.message ?? e}`); + } finally { + void printDoc?.close(); + paintChrome(); + } + } + + /* Renders the open document again, for a new colour scheme. */ + async function reload() { + if (!source || !currentDoc) return; + if (dirty()) { + showAlert('The new setting applies to the next document. Save this one first to see it here.', 'warn'); + return; + } + const again = source; + const password = savePassword; + await openSafely(again.bytes ? { ...again, bytes: again.bytes } : again, password); + } + + async function closeDocument() { + if (!(await mayDiscard())) return; + teardown(); + showResult(false); + setIdle(IDLE_TITLE, IDLE_HINT); + } + + // The landing panel. + pickBtn.addEventListener('click', () => void pickAndOpen()); + for (const btn of blankBtns) btn.addEventListener('click', () => void newBlank(btn.dataset.viewerBlank ?? 'odt')); + recentsClearBtn.addEventListener('click', () => { + platform.clearRecents(); + paintRecents(); + }); + + passwordForm.addEventListener('submit', (e) => { + e.preventDefault(); + const answer = passwordAnswer; + passwordAnswer = null; + const value = passwordInput.value; + passwordInput.value = ''; + answer?.(value); + }); + function cancelPassword() { + teardown(); + busy = false; + setIdle(IDLE_TITLE, IDLE_HINT); + } + passwordCancelBtn.addEventListener('click', cancelPassword); + passwordInput.addEventListener('keydown', (e) => { + if (e.key === 'Escape') cancelPassword(); + }); + + resetBtn.addEventListener('click', () => void closeDocument()); + + // Files dragged on the window. Tauri gives the paths, so a save can go back + // to the same file. + function highlight(on: boolean) { + zone.classList.toggle('border-dashed', !on); + for (const c of DROP_ACTIVE) zone.classList.toggle(c, on); + } + void platform.onDragDrop({ + enter: () => highlight(true), + leave: () => highlight(false), + drop: (paths) => void openPaths(paths), + }); + + penBtn.addEventListener('click', () => setMode(!modeOn)); + saveBtn.addEventListener('click', () => void saveDocument()); + printBtn.addEventListener('click', () => void print()); + + /* A button in the strip must not take the focus from the frame, where the + selection is. */ + const keepFocus = (e: Event) => e.preventDefault(); + for (const b of [...styleBtns, highlightBtn, fillBtn, ...alignBtns, ...structureBtns, ...toolBtns, undoBtn, redoBtn]) { + b.addEventListener('mousedown', keepFocus); + } + for (const b of styleBtns) b.addEventListener('click', () => send({ type: 'toggle', property: b.dataset.viewerStyle })); + highlightBtn.addEventListener('click', () => { + const on = highlightBtn.getAttribute('aria-pressed') === 'true'; + send({ type: 'format', style: { highlight: on ? null : highlightColorInput.value } }); + }); + fillBtn.addEventListener('click', () => { + const on = fillBtn.getAttribute('aria-pressed') === 'true'; + send({ type: 'format', style: { fill: on ? null : fillColorInput.value } }); + }); + for (const b of alignBtns) { + b.addEventListener('click', () => { + const off = sheet && b.getAttribute('aria-pressed') === 'true'; + send({ type: 'format', style: { align: off ? null : b.dataset.viewerAlign } }); + }); + } + for (const b of structureBtns) { + b.addEventListener('click', () => + send({ type: 'structure', call: b.dataset.viewerStructureCall, where: b.dataset.viewerStructureWhere }), + ); + } + sizeSelect.addEventListener('change', () => { + if (sizeSelect.value) send({ type: 'format', style: { size: sizeSelect.value } }); + }); + for (const b of toolBtns) b.addEventListener('click', () => sendTool(b.dataset.viewerTool ?? 'highlight')); + + /* A colour control: a picker over a bar in its colour. See the website + viewer for why a second press closes the picker this way. */ + function colourControl(input: HTMLInputElement, bar: HTMLElement | undefined, apply: () => void) { + let isOpen = false; + let applied = input.value; + const paint = () => { + if (bar) bar.style.background = input.value; + }; + paint(); + input.addEventListener('input', paint); + input.addEventListener('change', () => { + if (input.value === applied) return; + applied = input.value; + apply(); + }); + input.addEventListener('click', (e) => { + if (!isOpen) { + isOpen = true; + applied = input.value; + return; + } + e.preventDefault(); + isOpen = false; + input.type = 'text'; + input.type = 'color'; + input.blur(); + input.dispatchEvent(new Event('change')); + }); + input.addEventListener('blur', () => { + isOpen = false; + }); + return paint; + } + + const paintColor = colourControl(colorInput, colorBar, () => send({ type: 'format', style: { color: colorInput.value } })); + const paintHighlight = colourControl(highlightColorInput, highlightBar, () => + send({ type: 'format', style: { highlight: highlightColorInput.value } }), + ); + const paintFill = colourControl(fillColorInput, fillBar, () => + send({ type: 'format', style: { fill: fillColorInput.value } }), + ); + for (const input of toolColors) { + const tool = input.dataset.viewerToolColor ?? ''; + const bar = toolBars.find((b) => b.dataset.viewerToolBar === tool); + colourControl(input, bar, () => sendTool(tool, true)); + } + undoBtn.addEventListener('click', () => send({ type: 'undo' })); + redoBtn.addEventListener('click', () => send({ type: 'redo' })); + + findOpenBtn.addEventListener('click', () => showFind(findStrip.hidden === true)); + findCloseBtn.addEventListener('click', () => showFind(false)); + findPreviousBtn.addEventListener('click', () => search(-1)); + findNextBtn.addEventListener('click', () => search(1)); + findText.addEventListener('input', () => { + window.clearTimeout(findTimer); + findTimer = window.setTimeout(() => search(0), 200); + }); + findText.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + search(e.shiftKey ? -1 : 1); + } else if (e.key === 'Escape') { + e.preventDefault(); + showFind(false); + currentFrame?.focus(); + } + }); + + zoomInBtn.addEventListener('click', () => nudgeZoom(ZOOM_STEP)); + zoomOutBtn.addEventListener('click', () => nudgeZoom(1 / ZOOM_STEP)); + zoomLabel.addEventListener('click', () => send({ type: 'resetZoom' })); + + // The menu bar. + void platform.setMenuCheck('darken', darken); + void platform.onMenu((id) => { + switch (id) { + case 'new-window': + void platform.openWindows([]); + break; + case 'new-odt': + case 'new-ods': + case 'new-docx': + case 'new-xlsx': + void newBlank(id.slice(4)); + break; + case 'open': + void pickAndOpen(); + break; + case 'save': + void saveDocument(false); + break; + case 'save-as': + void saveDocument(true); + break; + case 'print': + void print(); + break; + case 'close-document': + void closeDocument(); + break; + case 'find': + if (canFind) showFind(true); + break; + case 'find-next': + if (canFind && findText.value) search(1); + break; + case 'find-previous': + if (canFind && findText.value) search(-1); + break; + case 'edit-mode': + if (canEdit || canMark) setMode(!modeOn); + break; + case 'zoom-in': + nudgeZoom(ZOOM_STEP); + break; + case 'zoom-out': + nudgeZoom(1 / ZOOM_STEP); + break; + case 'zoom-fit': + send({ type: 'resetZoom' }); + break; + case 'darken': + // The check item toggles itself. The window keeps the setting. + darken = !darken; + platform.setSetting('darken', darken); + void platform.setMenuCheck('darken', darken); + void reload(); + break; + } + }); + + void platform.onCloseRequested(mayDiscard); + + showResult(false); + setIdle(IDLE_TITLE, IDLE_HINT); + paintTitle(); + + /* Called once each time a frame reports that it is ready. */ + let onReady: (() => void) | null = null; + + return { + openPaths, + hasDocument, + /** Opens the file, waits for the frame, and gives what the window shows. */ + async smoke(path: string, saveTo: string | null, password: string | null): Promise { + const ready = new Promise((resolve) => { + onReady = resolve; + }); + await openSafely({ bytes: await platform.readFile(path), name: platform.baseName(path), path }, password); + if (!currentDoc) throw new Error(trail.join(' > ')); + await Promise.race([ + ready, + new Promise((_, reject) => setTimeout(() => reject(new Error('the frame did not report')), 30000)), + ]); + onReady = null; + const names = views.map((v) => v.name).join('|'); + let saved = ''; + // Only a document that the window would save. A password-protected + // file cannot be saved with its password, so the window offers no save. + if (saveTo && (canEdit || canMark)) { + const { bytes } = await currentBytes(); + await platform.writeFile(saveTo, bytes); + const back = await platform.readFile(saveTo); + if (back.length !== bytes.length) throw new Error(`wrote ${bytes.length} bytes, read ${back.length}`); + saved = ` saved=${bytes.length}B`; + } + return `${source?.name}: views=[${names}] editable=${canEdit} markable=${canMark} searchable=${canFind} engine=${backend.version() || __ODR_VERSION__}${saved}`; + }, + }; +} diff --git a/test/documents/encrypted-password.xlsx b/test/documents/encrypted-password.xlsx new file mode 100644 index 0000000..88410be Binary files /dev/null and b/test/documents/encrypted-password.xlsx differ diff --git a/test/documents/sample.docx b/test/documents/sample.docx new file mode 100644 index 0000000..e6fd1b3 Binary files /dev/null and b/test/documents/sample.docx differ diff --git a/test/documents/sample.md b/test/documents/sample.md new file mode 100644 index 0000000..a5f8d0c --- /dev/null +++ b/test/documents/sample.md @@ -0,0 +1,26 @@ +# A sample Markdown file + +Opened in your browser by **OpenDocument Reader** — nothing was uploaded. + +Markdown is plain text with a convention on top, so a `.md` file carries no +signature. The demo asks the engine for it *by name*, and the engine renders +the prose: headings, emphasis, lists, links and tables. + +## What survives + +- **Bold** and *italic* text +- `inline code` and fenced blocks +- [Links](https://opendocument.app), drawn but not followed here +- Tables: + +| Application | Open format | Also opens | +|-------------|-------------|-----------------| +| Writer | ODT | DOC, DOCX, RTF | +| Calc | ODS | XLS, XLSX, CSV | +| Impress | ODP | PPT, PPTX | + +``` +No pen for this one: a rendered Markdown file is read, not written. +``` + +Now drop a Markdown file of your own onto the page. diff --git a/test/documents/sample.ods b/test/documents/sample.ods new file mode 100644 index 0000000..904d8af Binary files /dev/null and b/test/documents/sample.ods differ diff --git a/test/documents/sample.odt b/test/documents/sample.odt new file mode 100644 index 0000000..9caf5c1 Binary files /dev/null and b/test/documents/sample.odt differ diff --git a/test/documents/sample.pdf b/test/documents/sample.pdf new file mode 100644 index 0000000..66b84e4 Binary files /dev/null and b/test/documents/sample.pdf differ diff --git a/test/documents/sample.pptx b/test/documents/sample.pptx new file mode 100644 index 0000000..6a6d006 Binary files /dev/null and b/test/documents/sample.pptx differ diff --git a/test/documents/sample.txt b/test/documents/sample.txt new file mode 100644 index 0000000..d842e27 --- /dev/null +++ b/test/documents/sample.txt @@ -0,0 +1,14 @@ +A sample text file +================== + +Opened in your browser by OpenDocument Reader. Nothing was uploaded: the +file was read, decoded and laid out on your own machine. + +Plain text has no formatting to keep, so this view keeps the one thing it +has - the lines, numbered, in a fixed-width face. + +Press the pen above this file. You can then type into it, and the disc +beside the pen downloads what you typed as a new text file. Encoding is +detected on the way in; what comes out is UTF-8. + +Now drop a text file of your own onto the page. diff --git a/test/documents/sample.xlsx b/test/documents/sample.xlsx new file mode 100644 index 0000000..c0dd7af Binary files /dev/null and b/test/documents/sample.xlsx differ diff --git a/test/documents/two-sheets.ods b/test/documents/two-sheets.ods new file mode 100644 index 0000000..ef16d5b Binary files /dev/null and b/test/documents/two-sheets.ods differ diff --git a/test/smoke.mjs b/test/smoke.mjs new file mode 100644 index 0000000..f07fb86 --- /dev/null +++ b/test/smoke.mjs @@ -0,0 +1,51 @@ +/* + Runs the built app in its smoke mode on every file in `test/documents/`. + The app opens the file, waits for the rendered page, saves a copy where the + window would offer a save, reports one line, and exits. + + node test/smoke.mjs + + A file named `*-.` is opened with that password. +*/ +import { spawn } from 'node:child_process'; +import { mkdtempSync, readdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +const binary = resolve(process.argv[2] ?? ''); +const documents = resolve(import.meta.dirname, 'documents'); +const out = mkdtempSync(join(tmpdir(), 'odr-smoke-')); +const TIMEOUT_MS = 90_000; + +function run(file) { + const password = file.match(/-([^-.]+)\.[^.]+$/)?.[1]; + const env = { + ...process.env, + ODR_DESKTOP_SMOKE: join(documents, file), + ODR_DESKTOP_SMOKE_SAVE: join(out, file), + }; + if (password) env.ODR_DESKTOP_SMOKE_PASSWORD = password; + return new Promise((done) => { + const child = spawn(binary, [], { env, stdio: ['ignore', 'pipe', 'pipe'] }); + let output = ''; + child.stdout.on('data', (d) => (output += d)); + child.stderr.on('data', (d) => (output += d)); + const timer = setTimeout(() => child.kill(), TIMEOUT_MS); + child.on('exit', (code) => { + clearTimeout(timer); + const line = output.split('\n').find((l) => l.startsWith('smoke: ')) ?? `no report (exit ${code})`; + done({ ok: code === 0 && line.startsWith('smoke: ok'), line, output }); + }); + }); +} + +let failed = 0; +for (const file of readdirSync(documents).sort()) { + const { ok, line, output } = await run(file); + console.log(`${ok ? 'ok ' : 'FAIL'} ${file}: ${line}`); + if (!ok) { + failed++; + console.log(output.trim().split('\n').slice(-20).join('\n')); + } +} +process.exit(failed ? 1 : 0); diff --git a/vite.config.ts b/vite.config.ts index 5710f9d..e548b5b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,25 +1,58 @@ -import { readFileSync } from 'node:fs'; +import { createReadStream, existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; import { createRequire } from 'node:module'; import tailwindcss from '@tailwindcss/vite'; -import { defineConfig } from 'vite'; +import { defineConfig, type Plugin } from 'vite'; // The worker imports the core by URL, from the directory that // `scripts/sync-odr.mjs` fills. The bundle needs the version for that URL. const manifest = createRequire(import.meta.url).resolve('@opendocument/odr-core/package.json'); const { version: odrVersion } = JSON.parse(readFileSync(manifest, 'utf8')); +/* + In dev, Vite answers a module import of a `public/` file with an error, and + it adds `?import` to dynamic imports at runtime. This middleware serves + `/odr/` before Vite sees the request, as the files are. A build has no such + problem, because Tauri serves `dist/` as it is. +*/ +function serveOdrRaw(): Plugin { + const types: Record = { + '.js': 'text/javascript', + '.mjs': 'text/javascript', + '.wasm': 'application/wasm', + }; + return { + name: 'odr-raw', + configureServer(server) { + server.middlewares.use((req, res, next) => { + const url = (req.url ?? '').split('?')[0]; + if (!url.startsWith('/odr/')) return next(); + const base = join(process.cwd(), 'public', 'odr'); + const file = join(process.cwd(), 'public', decodeURIComponent(url)); + if (!file.startsWith(base) || !existsSync(file)) return next(); + const ext = file.slice(file.lastIndexOf('.')); + res.setHeader('Content-Type', types[ext] ?? 'application/octet-stream'); + createReadStream(file).pipe(res); + }); + }, + }; +} + export default defineConfig({ // Tauri expects a fixed port, and it shows the Rust errors itself. clearScreen: false, server: { port: 1420, strictPort: true }, envPrefix: ['VITE_', 'TAURI_ENV_'], - plugins: [tailwindcss()], + plugins: [tailwindcss(), serveOdrRaw()], define: { __ODR_VERSION__: JSON.stringify(odrVersion) }, worker: { format: 'es' }, build: { // WebKitGTK on Linux is the oldest engine that we support. target: 'es2022', sourcemap: true, + rollupOptions: { + input: { main: 'index.html', print: 'print.html' }, + }, }, });