From d78766829770b99e6f90ca93e1cb0f229d9898ea Mon Sep 17 00:00:00 2001 From: Jason Wang Date: Sun, 26 Jul 2026 21:13:53 +0800 Subject: [PATCH] feat(ui): add app-level zoom --- docs/features/295-app-zoom.md | 63 ++ docs/features/README.md | 1 + package-lock.json | 860 +++++++++++++++++- package.json | 5 +- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/capabilities/default.json | 1 + src-tauri/src/commands/app.rs | 22 +- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/window.rs | 122 +++ src-tauri/src/lib.rs | 1 + src/App.tsx | 67 +- .../settings/AppearanceSettings.tsx | 54 ++ src/i18n/en.json | 5 + src/i18n/zh.json | 5 + src/lib/appZoom.test.ts | 133 +++ src/lib/appZoom.ts | 80 ++ src/lib/settings.ts | 47 + src/pages/Reader.tsx | 44 +- src/utils/openReaderWindow.ts | 2 + 20 files changed, 1478 insertions(+), 37 deletions(-) create mode 100644 docs/features/295-app-zoom.md create mode 100644 src-tauri/src/commands/window.rs create mode 100644 src/lib/appZoom.test.ts create mode 100644 src/lib/appZoom.ts create mode 100644 src/lib/settings.ts diff --git a/docs/features/295-app-zoom.md b/docs/features/295-app-zoom.md new file mode 100644 index 00000000..a9b5c496 --- /dev/null +++ b/docs/features/295-app-zoom.md @@ -0,0 +1,63 @@ +# 295 — App-Level UI Zoom + +GitHub issue: https://github.com/yicheng47/quill/issues/295 + +## Motivation + +Quill has no way to scale its interface. Text, covers, and chrome render at a fixed size, which is cramped on high-DPI displays and hard to read on large monitors at a distance. Every browser and most desktop apps bind Cmd/Ctrl +/-/0 to app zoom; Quill currently swallows those keys in the Reader (PDF content zoom only) and ignores them everywhere else. + +Quill's existing zoom controls are *content* zoom: per-book PDF zoom (`reader-zoom-${bookId}`, 50–300% + fit) and EPUB font size. Neither scales the app's own UI — sidebar, toolbars, library grid, settings, chat panel. App zoom is a separate axis and both must keep working independently. + +The sibling `runner` project ships this end to end (`src/lib/appZoom.ts`, `src/lib/settings.ts`, `src-tauri/src/commands/window.rs`). Quill can port the same design; the architectural preconditions already match. + +## Reference: how runner does it + +| Concern | runner's approach | +|---|---| +| Levels | Discrete `ZOOM_STEPS = [0.8, 0.9, 1.0, 1.1, 1.2, 1.3, 1.4, 1.5]` — not free-form. `readAppZoom()` snaps to the nearest step on read (no write-back), so off-step values from older builds or hand-edited storage still resolve to something the UI can step from. | +| Apply path | One function, `applyAppZoom(next)`: persist → sync titlebar → `getCurrentWebview().setZoom(next)` → notify same-window storage listeners. Shared by the Settings stepper and the keyboard shortcuts so they cannot drift. | +| Stepping | `nudgeAppZoom(1 \| -1 \| "reset")` — index into `ZOOM_STEPS`, clamped at both ends; `"reset"` jumps to 1.0. | +| Boot restore | In the root component's mount effect: read zoom, `setZoom`, sync titlebar, and only then invoke `app_ready` to reveal the window — no flash of unzoomed UI. Explicitly *not* wrapped in `requestAnimationFrame`, because macOS pauses rAF for hidden windows and the callback would never fire. | +| Shortcuts | A single `keydown` listener on `window` in the **capture** phase, so focused embedded content (xterm in runner; the foliate iframe in Quill) can't swallow the keys first. `preventDefault()` only on a match, so other Cmd combos still work. | +| Native titlebar | Rust `window_set_titlebar_zoom(window, zoom)` repositions the macOS traffic lights via `objc2_app_kit` `NSWindow::standardWindowButton`, because webview zoom scales the CSS overlay titlebar but not the native buttons. No-ops while fullscreen. Paired with a CSS var for the zoom-adjusted control gutter. | +| Multi-window | Every window runs the same restore effect and resolves "the invoking window" for both commands; the level itself is global. | +| Tests | `src/lib/appZoom.test.ts` covers the apply path, titlebar sync, and the step table. | + +Preconditions that already hold in Quill: `visible: false` + an `app_ready` reveal command (`src-tauri/src/commands/app.rs`), `titleBarStyle: "Overlay"` + `hiddenTitle: true` on both window types, and a settings-mirrored-to-localStorage precedent (`theme` / `quill-theme`) for synchronous boot reads. + +## Scope + +In scope: + +- **All windows.** App zoom applies to the main library window and every `reader-{bookId}` window. The level is global, not per-window or per-book; each window restores it on mount. +- **Steps.** Port runner's `[0.8 … 1.5]` table with snap-on-read. +- **Shortcuts.** Cmd/Ctrl `+` zoom in, Cmd/Ctrl `-` zoom out, Cmd/Ctrl `0` reset to 100%. Capture-phase listener. +- **PDF content zoom remap.** The Cmd/Ctrl `+`/`-` handler inside the foliate iframe (`Reader.tsx`) moves to Cmd/Ctrl+Shift `+`/`-`. The toolbar zoom panel, fit mode, and per-book persistence are unchanged. +- **Settings row.** A zoom stepper in Appearance settings following the standard row pattern, sharing `applyAppZoom` with the shortcut path. +- **Persistence.** SQLite `settings` table (`app_zoom`) as the source of truth, mirrored to localStorage for the synchronous boot read — the same split the theme setting already uses. +- **macOS titlebar.** `window_set_titlebar_zoom` Tauri command repositioning the traffic lights; no-op while fullscreen and on non-macOS. + +Out of scope: + +- Per-book or per-window zoom levels. +- Pinch-to-zoom / trackpad gestures. +- Changing EPUB font-size or PDF fit behavior. +- Zooming the standalone chat window's message content independently of the app. + +## Implementation Phases + +1. **Zoom module + persistence.** `src/lib/appZoom.ts` with the step table, snap-on-read reader, `applyAppZoom`, and `nudgeAppZoom`. Write to the SQLite setting and mirror to localStorage. +2. **Boot restore.** Apply the stored zoom in the root mount effect of both window types; for the main window, complete it before invoking `app_ready` so the reveal shows already-zoomed UI. No rAF wrapper. +3. **Shortcuts.** Capture-phase `keydown` handler for zoom in/out/reset. Remap the PDF content-zoom branch in `Reader.tsx` to require Shift. +4. **macOS titlebar command.** `window_set_titlebar_zoom` in a Rust window command module, with unit tests for the geometry math and the fullscreen no-op. +5. **Settings + i18n.** Appearance settings stepper row; en/zh strings for the label, hint, and level display. + +## Verification + +- Cmd `+`/`-` in the library window steps the whole UI (sidebar, grid, toolbar) through the step table and stops at 0.8 / 1.5; Cmd `0` returns to 100%. +- Same shortcuts work in a reader window while the foliate iframe has focus — the iframe does not swallow them. +- Cmd+Shift `+`/`-` still zooms PDF content, independent of app zoom; the toolbar zoom panel and fit mode are unaffected; per-book zoom still persists. +- Zoom set in one window applies to windows opened afterward, and survives an app restart with no flash of unzoomed UI on launch. +- macOS: traffic lights stay aligned with the overlay titlebar at every zoom level; entering/leaving fullscreen doesn't misplace them. +- Appearance settings stepper and the keyboard shortcuts stay in sync in both directions. +- Non-macOS builds compile and zoom works with the titlebar command a no-op. diff --git a/docs/features/README.md b/docs/features/README.md index 9d3e3c6b..f70974a4 100644 --- a/docs/features/README.md +++ b/docs/features/README.md @@ -12,3 +12,4 @@ Specs for features that are in progress or planned. Shipped, dropped, or superse - [276 - Reset All App Data](276-reset-all-data.md) - [284 - MCP Batch Library and Collection Tools](284-mcp-batch-library-collection-tools.md) - [294 - Codex Subscription Model Picker](294-codex-model-picker.md) +- [295 - App-Level UI Zoom](295-app-zoom.md) diff --git a/package-lock.json b/package-lock.json index d546dba0..6b63d4c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,11 +34,34 @@ "eslint": "^9.39.4", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", + "jsdom": "26.1.0", "typescript": "~5.8.3", "typescript-eslint": "^8.56.1", - "vite": "^7.0.4" + "vite": "^7.0.4", + "vitest": "^4.1.9" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -330,6 +353,121 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -1319,6 +1457,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", @@ -1951,6 +2096,17 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -1960,6 +2116,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2353,6 +2516,119 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -2376,6 +2652,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -2416,6 +2702,16 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -2532,6 +2828,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2661,12 +2967,40 @@ "node": ">= 8" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2684,6 +3018,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -2755,6 +3096,26 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", @@ -3003,6 +3364,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -3013,6 +3384,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -3231,6 +3612,19 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/html-parse-stringify": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", @@ -3250,6 +3644,34 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/i18next": { "version": "25.10.5", "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.10.5.tgz", @@ -3281,6 +3703,19 @@ } } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3403,6 +3838,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3439,6 +3881,46 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -4726,6 +5208,27 @@ "dev": true, "license": "MIT" }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "dev": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -4814,6 +5317,19 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -4834,6 +5350,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5153,6 +5676,33 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -5198,6 +5748,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -5217,6 +5774,20 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -5275,6 +5846,13 @@ "node": ">=8" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", @@ -5294,6 +5872,23 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -5310,6 +5905,62 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -5633,6 +6284,96 @@ } } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", @@ -5642,6 +6383,67 @@ "node": ">=0.10.0" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5658,6 +6460,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -5668,6 +6487,45 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index 67658966..aecc02fc 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", + "test": "vitest run", "tauri": "tauri", "package": "tauri build", "lint": "eslint src/" @@ -38,8 +39,10 @@ "eslint": "^9.39.4", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", + "jsdom": "26.1.0", "typescript": "~5.8.3", "typescript-eslint": "^8.56.1", - "vite": "^7.0.4" + "vite": "^7.0.4", + "vitest": "^4.1.9" } } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 422acefe..4db8fc0f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3588,6 +3588,7 @@ dependencies = [ "lopdf", "notify", "objc2", + "objc2-app-kit", "objc2-foundation", "pdfium-render", "rand 0.8.5", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c24232ed..42d17a26 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -53,6 +53,7 @@ image = { version = "0.25", default-features = false, features = ["jpeg"] } [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6" +objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSButton", "NSControl", "NSResponder", "NSView", "NSWindow"] } objc2-foundation = { version = "0.3", features = ["NSFileManager", "NSFileCoordinator", "NSString", "NSURL", "NSError", "block2"] } block2 = "0.6" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index b18973cf..b26a7aa8 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -10,6 +10,7 @@ "core:window:allow-set-focus", "core:window:allow-set-title", "core:webview:allow-create-webview-window", + "core:webview:allow-set-webview-zoom", "opener:default", "dialog:default", "fs:default", diff --git a/src-tauri/src/commands/app.rs b/src-tauri/src/commands/app.rs index 3d8cbc7f..e70e594f 100644 --- a/src-tauri/src/commands/app.rs +++ b/src-tauri/src/commands/app.rs @@ -1,23 +1,19 @@ -use tauri::{AppHandle, Manager}; +use tauri::AppHandle; use tauri_plugin_opener::OpenerExt; use crate::error::{AppError, AppResult}; use crate::resolve_log_dir; /// Called by the frontend after React has mounted and painted its first frame. -/// Shows the main window — the window starts hidden so the user sees the dock -/// bounce → fully-rendered window instead of a beach ball over a blank webview. +/// Shows the calling window after its UI and cached zoom have been restored. #[tauri::command] -pub fn app_ready(app: AppHandle) -> AppResult<()> { - let window = app - .get_webview_window("main") - .ok_or_else(|| AppError::Other("main window not found".into()))?; - window - .show() - .map_err(|e| AppError::Other(e.to_string()))?; - window - .set_focus() - .map_err(|e| AppError::Other(e.to_string()))?; +pub fn app_ready(window: tauri::WebviewWindow) -> AppResult<()> { + window.show().map_err(|e| AppError::Other(e.to_string()))?; + if window.label() == "main" { + window + .set_focus() + .map_err(|e| AppError::Other(e.to_string()))?; + } Ok(()) } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 4a7ba468..0a014457 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -10,3 +10,4 @@ pub mod settings; pub mod sync; pub mod translation; pub mod vocab; +pub mod window; diff --git a/src-tauri/src/commands/window.rs b/src-tauri/src/commands/window.rs new file mode 100644 index 00000000..377b66e2 --- /dev/null +++ b/src-tauri/src/commands/window.rs @@ -0,0 +1,122 @@ +#[cfg(target_os = "macos")] +use crate::error::AppError; +use crate::error::AppResult; + +#[cfg(any(target_os = "macos", test))] +const MAIN_TITLEBAR_HEIGHT: f64 = 44.0; +#[cfg(any(target_os = "macos", test))] +const READER_TITLEBAR_HEIGHT: f64 = 32.0; +#[cfg(any(target_os = "macos", test))] +const TRAFFIC_LIGHT_X: f64 = 16.0; + +#[cfg(any(target_os = "macos", test))] +fn scaled_titlebar_height(label: &str, zoom: f64, fullscreen: bool) -> Option { + if fullscreen { + return None; + } + let base_height = if label == "main" { + MAIN_TITLEBAR_HEIGHT + } else { + READER_TITLEBAR_HEIGHT + }; + Some(base_height * zoom) +} + +#[cfg(any(target_os = "macos", test))] +fn traffic_light_x(index: usize, spacing: f64) -> f64 { + TRAFFIC_LIGHT_X + index as f64 * spacing +} + +#[cfg(any(target_os = "macos", test))] +fn traffic_light_y(titlebar_height: f64, button_height: f64) -> f64 { + (titlebar_height - button_height) / 2.0 +} + +#[tauri::command] +pub fn window_set_titlebar_zoom(window: tauri::WebviewWindow, zoom: f64) -> AppResult<()> { + #[cfg(target_os = "macos")] + { + let fullscreen = window + .is_fullscreen() + .map_err(|error| AppError::Other(error.to_string()))?; + let Some(titlebar_height) = scaled_titlebar_height(window.label(), zoom, fullscreen) else { + return Ok(()); + }; + + window + .with_webview(move |webview| { + use objc2_app_kit::{NSWindow, NSWindowButton}; + + let ns_window: &NSWindow = unsafe { &*webview.ns_window().cast() }; + let Some(close) = ns_window.standardWindowButton(NSWindowButton::CloseButton) + else { + return; + }; + let Some(minimize) = + ns_window.standardWindowButton(NSWindowButton::MiniaturizeButton) + else { + return; + }; + let Some(maximize) = ns_window.standardWindowButton(NSWindowButton::ZoomButton) + else { + return; + }; + let Some(button_group) = (unsafe { close.superview() }) else { + return; + }; + let Some(titlebar_container) = (unsafe { button_group.superview() }) else { + return; + }; + + let button_height = close.frame().size.height; + let spacing = minimize.frame().origin.x - close.frame().origin.x; + let mut titlebar_rect = titlebar_container.frame(); + titlebar_rect.size.height = titlebar_height; + titlebar_rect.origin.y = ns_window.frame().size.height - titlebar_height; + titlebar_container.setFrame(titlebar_rect); + + for (index, button) in [close, minimize, maximize].into_iter().enumerate() { + let mut rect = button.frame(); + rect.origin.x = traffic_light_x(index, spacing); + rect.origin.y = traffic_light_y(titlebar_height, button_height); + button.setFrameOrigin(rect.origin); + } + }) + .map_err(|error| AppError::Other(error.to_string()))?; + } + + #[cfg(not(target_os = "macos"))] + let _ = (window, zoom); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{scaled_titlebar_height, traffic_light_x, traffic_light_y}; + + #[test] + fn titlebar_geometry_tracks_app_zoom() { + assert_eq!(scaled_titlebar_height("main", 0.8, false), Some(35.2)); + assert_eq!(scaled_titlebar_height("main", 1.0, false), Some(44.0)); + assert_eq!(scaled_titlebar_height("main", 1.5, false), Some(66.0)); + assert_eq!( + scaled_titlebar_height("reader-book", 1.0, false), + Some(32.0) + ); + assert_eq!( + scaled_titlebar_height("reader-book", 1.5, false), + Some(48.0) + ); + assert_eq!(traffic_light_x(0, 20.0), 16.0); + assert_eq!(traffic_light_x(1, 20.0), 36.0); + assert_eq!(traffic_light_x(2, 20.0), 56.0); + assert_eq!(traffic_light_y(44.0, 14.0), 15.0); + } + + #[test] + fn fullscreen_skips_titlebar_repositioning() { + assert_eq!(scaled_titlebar_height("main", 1.2, true), None); + assert_eq!(scaled_titlebar_height("reader-book", 1.2, true), None); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5a59afd1..cb5828aa 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -611,6 +611,7 @@ pub fn run() { // App lifecycle commands::app::app_ready, commands::app::reveal_logs, + commands::window::window_set_titlebar_zoom, // Books commands::books::import_book, commands::books::list_books, diff --git a/src/App.tsx b/src/App.tsx index 7e6f19a2..a81609d1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,8 +7,20 @@ import Reader from "./pages/Reader"; import { UpdateProvider } from "./contexts/UpdateContext"; import UpdateToast from "./components/UpdateToast"; import { reconcileLanguage } from "./i18n"; +import { + handleAppZoomShortcut, + restoreAppZoom, + syncTitlebarZoom, +} from "./lib/appZoom"; +import { + readAppZoom, + snapAppZoom, + STORAGE_APP_ZOOM, + writeAppZoom, +} from "./lib/settings"; -const isMainWindow = getCurrentWebviewWindow().label === "main"; +const appWindow = getCurrentWebviewWindow(); +const isMainWindow = appWindow.label === "main"; function applyTheme(theme: string) { const root = document.documentElement; @@ -24,11 +36,18 @@ function applyTheme(theme: string) { export default function App() { useEffect(() => { - invoke>("get_all_settings") + const cachedZoom = readAppZoom(); + const cachedZoomReady = restoreAppZoom(cachedZoom); + void invoke>("get_all_settings") .then((settings) => { const theme = settings.theme ?? "system"; applyTheme(theme); localStorage.setItem("quill-theme", theme); + const persistedZoom = snapAppZoom(settings.app_zoom); + writeAppZoom(persistedZoom); + if (persistedZoom !== cachedZoom) { + return restoreAppZoom(persistedZoom); + } }) .catch(() => applyTheme("system")); @@ -36,14 +55,11 @@ export default function App() { // the persisted DB value (and persist to the DB on first launch). reconcileLanguage(); - // Tell the backend the UI has mounted so it can show the (currently - // hidden) main window. We don't wrap this in requestAnimationFrame — - // macOS pauses rAF for hidden windows, so the callback would never fire. - // useEffect runs after React commits the DOM, which is good enough; the - // OS composites the committed tree when window.show() is called. - if (isMainWindow) { - invoke("app_ready").catch(() => {}); - } + // macOS pauses requestAnimationFrame for hidden windows, so reveal only + // after the synchronously cached zoom restore finishes. + void cachedZoomReady.finally(() => { + void invoke("app_ready").catch(() => {}); + }); const mq = window.matchMedia("(prefers-color-scheme: dark)"); const handler = () => { @@ -55,6 +71,37 @@ export default function App() { return () => mq.removeEventListener("change", handler); }, []); + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + handleAppZoomShortcut(event); + }; + window.addEventListener("keydown", handleKeyDown, true); + return () => window.removeEventListener("keydown", handleKeyDown, true); + }, []); + + useEffect(() => { + const handleStorage = (event: StorageEvent) => { + if (event.key !== STORAGE_APP_ZOOM || event.storageArea === null) return; + void restoreAppZoom(readAppZoom()); + }; + window.addEventListener("storage", handleStorage); + return () => window.removeEventListener("storage", handleStorage); + }, []); + + useEffect(() => { + let timer: number | null = null; + const unlisten = appWindow.onResized(() => { + if (timer !== null) window.clearTimeout(timer); + timer = window.setTimeout(() => { + void syncTitlebarZoom(readAppZoom()); + }, 150); + }); + return () => { + if (timer !== null) window.clearTimeout(timer); + void unlisten.then((stop) => stop()).catch(() => {}); + }; + }, []); + const content = ( <> {isMainWindow && } diff --git a/src/components/settings/AppearanceSettings.tsx b/src/components/settings/AppearanceSettings.tsx index 37326d28..6651fa32 100644 --- a/src/components/settings/AppearanceSettings.tsx +++ b/src/components/settings/AppearanceSettings.tsx @@ -1,17 +1,33 @@ import { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; +import { Minus, Plus } from "lucide-react"; import Select from "../ui/Select"; import type { SettingsProps } from "./types"; +import { applyAppZoom } from "../../lib/appZoom"; +import { + readAppZoom, + STORAGE_APP_ZOOM, + ZOOM_STEPS, +} from "../../lib/settings"; export default function AppearanceSettings({ settings, loading, save, showSavedToast }: SettingsProps) { const { t } = useTranslation(); const [theme, setTheme] = useState("system"); + const [zoom, setZoom] = useState(readAppZoom); useEffect(() => { if (loading) return; if (settings.theme) setTheme(settings.theme); }, [settings, loading]); + useEffect(() => { + const handleStorage = (event: StorageEvent) => { + if (event.key === STORAGE_APP_ZOOM) setZoom(readAppZoom()); + }; + window.addEventListener("storage", handleStorage); + return () => window.removeEventListener("storage", handleStorage); + }, []); + const applyTheme = (value: string) => { const root = document.documentElement; if (value === "dark") root.classList.add("dark"); @@ -47,6 +63,44 @@ export default function AppearanceSettings({ settings, loading, save, showSavedT ]} /> + +
+
+

{t("settings.appearance.zoom")}

+

{t("settings.appearance.zoomHint")}

+
+
+ + + {t("settings.appearance.zoomLevel", { percent: Math.round(zoom * 100) })} + + +
+
); } diff --git a/src/i18n/en.json b/src/i18n/en.json index bd4d7693..98bad2b7 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -378,6 +378,11 @@ "settings.appearance.system": "System", "settings.appearance.light": "Light", "settings.appearance.dark": "Dark", + "settings.appearance.zoom": "App zoom", + "settings.appearance.zoomHint": "Scale the library, reader, and app controls", + "settings.appearance.zoomLevel": "{{percent}}%", + "settings.appearance.zoomIn": "Zoom in", + "settings.appearance.zoomOut": "Zoom out", "common.cancel": "Cancel", "common.delete": "Delete", diff --git a/src/i18n/zh.json b/src/i18n/zh.json index e979a721..89efdce6 100644 --- a/src/i18n/zh.json +++ b/src/i18n/zh.json @@ -380,6 +380,11 @@ "settings.appearance.system": "跟随系统", "settings.appearance.light": "浅色", "settings.appearance.dark": "深色", + "settings.appearance.zoom": "应用缩放", + "settings.appearance.zoomHint": "缩放书库、阅读器和应用控件", + "settings.appearance.zoomLevel": "{{percent}}%", + "settings.appearance.zoomIn": "放大", + "settings.appearance.zoomOut": "缩小", "common.cancel": "取消", "common.delete": "删除", diff --git a/src/lib/appZoom.test.ts b/src/lib/appZoom.test.ts new file mode 100644 index 00000000..bd13a407 --- /dev/null +++ b/src/lib/appZoom.test.ts @@ -0,0 +1,133 @@ +/** @vitest-environment jsdom */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + setZoom: vi.fn<(zoom: number) => Promise>(), + invoke: vi.fn<(command: string, args?: object) => Promise>(), +})); +const storedValues = new Map(); +const localStorageMock = { + clear: () => storedValues.clear(), + getItem: (key: string) => storedValues.get(key) ?? null, + setItem: (key: string, value: string) => storedValues.set(key, value), +}; + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: mocks.invoke, +})); + +vi.mock("@tauri-apps/api/webview", () => ({ + getCurrentWebview: () => ({ + setZoom: mocks.setZoom, + }), +})); + +import { + appZoomActionForEvent, + applyAppZoom, + handleAppZoomShortcut, + nudgeAppZoom, + syncTitlebarZoom, +} from "./appZoom"; +import { + readAppZoom, + snapAppZoom, + STORAGE_APP_ZOOM, + ZOOM_STEPS, +} from "./settings"; + +describe("app zoom", () => { + beforeEach(() => { + vi.stubGlobal("localStorage", localStorageMock); + localStorage.clear(); + mocks.invoke.mockReset(); + mocks.invoke.mockResolvedValue(); + mocks.setZoom.mockReset(); + mocks.setZoom.mockResolvedValue(); + }); + + it("persists and applies zoom to the invoking window", () => { + applyAppZoom(1.2); + + expect(localStorage.getItem(STORAGE_APP_ZOOM)).toBe("1.2"); + expect(mocks.invoke).toHaveBeenCalledWith("set_setting", { + key: "app_zoom", + value: "1.2", + }); + expect(mocks.invoke).toHaveBeenCalledWith("window_set_titlebar_zoom", { + zoom: 1.2, + }); + expect(mocks.setZoom).toHaveBeenCalledWith(1.2); + }); + + it("syncs the native titlebar for the invoking window", async () => { + await syncTitlebarZoom(0.8); + + expect(mocks.invoke).toHaveBeenCalledWith("window_set_titlebar_zoom", { + zoom: 0.8, + }); + }); + + it("snaps stored values without writing them back", () => { + localStorage.setItem(STORAGE_APP_ZOOM, "1.26"); + + expect(readAppZoom()).toBe(1.3); + expect(localStorage.getItem(STORAGE_APP_ZOOM)).toBe("1.26"); + expect(snapAppZoom("invalid")).toBe(1); + }); + + it("steps through the fixed zoom levels and clamps at the ends", () => { + expect(ZOOM_STEPS).toEqual([0.8, 0.9, 1, 1.1, 1.2, 1.3, 1.4, 1.5]); + + localStorage.setItem(STORAGE_APP_ZOOM, "1.0"); + nudgeAppZoom(1); + expect(readAppZoom()).toBe(1.1); + + localStorage.setItem(STORAGE_APP_ZOOM, "1.5"); + nudgeAppZoom(1); + expect(readAppZoom()).toBe(1.5); + + nudgeAppZoom("reset"); + expect(readAppZoom()).toBe(1); + }); + + it("matches the app zoom shortcuts", () => { + const event = { + metaKey: true, + ctrlKey: false, + altKey: false, + shiftKey: false, + code: "Equal", + }; + + expect(appZoomActionForEvent(event)).toBe(1); + expect(appZoomActionForEvent({ ...event, shiftKey: true })).toBeNull(); + expect(appZoomActionForEvent({ ...event, code: "Minus" })).toBe(-1); + expect(appZoomActionForEvent({ ...event, code: "Digit0" })).toBe("reset"); + expect( + appZoomActionForEvent({ ...event, code: "Minus", shiftKey: true }), + ).toBeNull(); + }); + + it("prevents default only for matching shortcuts", () => { + const preventDefault = vi.fn(); + const stopPropagation = vi.fn(); + const event = { + metaKey: true, + ctrlKey: false, + altKey: false, + shiftKey: false, + code: "KeyA", + preventDefault, + stopPropagation, + } as unknown as KeyboardEvent; + + expect(handleAppZoomShortcut(event)).toBe(false); + expect(preventDefault).not.toHaveBeenCalled(); + + expect(handleAppZoomShortcut({ ...event, code: "Equal" })).toBe(true); + expect(preventDefault).toHaveBeenCalledOnce(); + expect(stopPropagation).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/lib/appZoom.ts b/src/lib/appZoom.ts new file mode 100644 index 00000000..6c9a4479 --- /dev/null +++ b/src/lib/appZoom.ts @@ -0,0 +1,80 @@ +import { invoke } from "@tauri-apps/api/core"; +import { getCurrentWebview } from "@tauri-apps/api/webview"; +import { + notifySameWindowStorage, + readAppZoom, + STORAGE_APP_ZOOM, + writeAppZoom, + ZOOM_STEPS, +} from "./settings"; + +export function syncTitlebarZoom(zoom: number): Promise { + try { + return invoke("window_set_titlebar_zoom", { zoom }).catch(() => {}); + } catch { + return Promise.resolve(); + } +} + +export function restoreAppZoom(zoom: number): Promise { + const titlebarZoom = syncTitlebarZoom(zoom); + let webviewZoom = Promise.resolve(); + try { + webviewZoom = getCurrentWebview().setZoom(zoom).catch(() => {}); + } catch { + // Browser preview has no Tauri webview. + } + return Promise.all([titlebarZoom, webviewZoom]).then(() => {}); +} + +export function applyAppZoom(next: number): void { + writeAppZoom(next); + try { + void invoke("set_setting", { + key: "app_zoom", + value: String(next), + }).catch(() => {}); + } catch { + // Browser preview has no Tauri command runtime. + } + void restoreAppZoom(next); + notifySameWindowStorage(STORAGE_APP_ZOOM, String(next)); +} + +export function nudgeAppZoom(direction: 1 | -1 | "reset"): void { + if (direction === "reset") { + applyAppZoom(1.0); + return; + } + + const index = ZOOM_STEPS.indexOf(readAppZoom()); + const safeIndex = index === -1 ? ZOOM_STEPS.indexOf(1.0) : index; + const nextIndex = + direction === 1 + ? Math.min(ZOOM_STEPS.length - 1, safeIndex + 1) + : Math.max(0, safeIndex - 1); + applyAppZoom(ZOOM_STEPS[nextIndex]); +} + +export function appZoomActionForEvent( + event: Pick< + KeyboardEvent, + "altKey" | "code" | "ctrlKey" | "metaKey" | "shiftKey" + >, +): 1 | -1 | "reset" | null { + if (!(event.metaKey || event.ctrlKey) || event.altKey) return null; + if (event.shiftKey) return null; + if (event.code === "Equal") return 1; + if (event.code === "Minus") return -1; + if (event.code === "Digit0") return "reset"; + return null; +} + +export function handleAppZoomShortcut(event: KeyboardEvent): boolean { + const action = appZoomActionForEvent(event); + if (action === null) return false; + event.preventDefault(); + event.stopPropagation(); + nudgeAppZoom(action); + return true; +} diff --git a/src/lib/settings.ts b/src/lib/settings.ts new file mode 100644 index 00000000..67e8465e --- /dev/null +++ b/src/lib/settings.ts @@ -0,0 +1,47 @@ +export const STORAGE_APP_ZOOM = "quill-app-zoom"; + +export const ZOOM_STEPS: readonly number[] = [ + 0.8, 0.9, 1.0, 1.1, 1.2, 1.3, 1.4, 1.5, +]; + +const DEFAULT_APP_ZOOM = 1.0; + +export function snapAppZoom(value: string | number | null | undefined): number { + const parsed = typeof value === "number" ? value : Number.parseFloat(value ?? ""); + if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_APP_ZOOM; + + let nearest = ZOOM_STEPS[0]; + let best = Math.abs(nearest - parsed); + for (let i = 1; i < ZOOM_STEPS.length; i += 1) { + const distance = Math.abs(ZOOM_STEPS[i] - parsed); + if (distance < best) { + best = distance; + nearest = ZOOM_STEPS[i]; + } + } + return nearest; +} + +export function readAppZoom(): number { + try { + return snapAppZoom(localStorage.getItem(STORAGE_APP_ZOOM)); + } catch { + return DEFAULT_APP_ZOOM; + } +} + +export function writeAppZoom(value: number): void { + try { + localStorage.setItem(STORAGE_APP_ZOOM, String(value)); + } catch { + // Persistence is best-effort when localStorage is unavailable. + } +} + +export function notifySameWindowStorage(key: string, value: string): void { + try { + window.dispatchEvent(new StorageEvent("storage", { key, newValue: value })); + } catch { + // Older webviews may not support constructing StorageEvent. + } +} diff --git a/src/pages/Reader.tsx b/src/pages/Reader.tsx index 56dd24bd..86195f4a 100644 --- a/src/pages/Reader.tsx +++ b/src/pages/Reader.tsx @@ -30,6 +30,7 @@ import TableOfContents from "../components/TableOfContents"; import { getBook, updateReadingProgress, checkBookAvailable, type Book } from "../hooks/useBooks"; import { getAllSettings } from "../hooks/useSettings"; import type { Highlight } from "../hooks/useBookmarks"; +import { handleAppZoomShortcut } from "../lib/appZoom"; // foliate-js web component interface /* eslint-disable @typescript-eslint/no-explicit-any -- foliate-js has no TS definitions */ @@ -644,15 +645,25 @@ export default function Reader() { } else if ((ev.metaKey || ev.ctrlKey) && ev.key === "]") { ev.preventDefault(); view.history.forward(); - } else if (ev.key === "ArrowLeft") view.prev(); - else if (ev.key === "ArrowRight") view.next(); - else if ((ev.metaKey || ev.ctrlKey) && (ev.key === "=" || ev.key === "+")) { + } else if ( + book?.format === "pdf" && + (ev.metaKey || ev.ctrlKey) && + ev.shiftKey && + ev.code === "Equal" + ) { ev.preventDefault(); - if (book?.format === "pdf") handleZoom(10); - } else if ((ev.metaKey || ev.ctrlKey) && ev.key === "-") { + handleZoom(10); + } else if ( + book?.format === "pdf" && + (ev.metaKey || ev.ctrlKey) && + ev.shiftKey && + ev.code === "Minus" + ) { ev.preventDefault(); - if (book?.format === "pdf") handleZoom(-10); - } + handleZoom(-10); + } else if (handleAppZoomShortcut(ev)) return; + else if (ev.key === "ArrowLeft") view.prev(); + else if (ev.key === "ArrowRight") view.next(); }); // Click to dismiss context menu and highlight toolbar @@ -920,13 +931,22 @@ export default function Reader() { if (tag === "INPUT" || tag === "TEXTAREA") return; if (e.key === "ArrowLeft") viewRef.current?.prev(); else if (e.key === "ArrowRight") viewRef.current?.next(); - // Cmd+/Cmd- zoom for PDFs - else if ((e.metaKey || e.ctrlKey) && (e.key === "=" || e.key === "+")) { + else if ( + book?.format === "pdf" && + (e.metaKey || e.ctrlKey) && + e.shiftKey && + e.code === "Equal" + ) { e.preventDefault(); - if (book?.format === "pdf") handleZoom(10); - } else if ((e.metaKey || e.ctrlKey) && e.key === "-") { + handleZoom(10); + } else if ( + book?.format === "pdf" && + (e.metaKey || e.ctrlKey) && + e.shiftKey && + e.code === "Minus" + ) { e.preventDefault(); - if (book?.format === "pdf") handleZoom(-10); + handleZoom(-10); } }; document.addEventListener("keydown", handleKeyDown); diff --git a/src/utils/openReaderWindow.ts b/src/utils/openReaderWindow.ts index d4363135..a1916794 100644 --- a/src/utils/openReaderWindow.ts +++ b/src/utils/openReaderWindow.ts @@ -37,6 +37,7 @@ export async function openReaderWindow( // Focus existing window if already open const existing = await WebviewWindow.getByLabel(label); if (existing) { + await existing.show(); await existing.setFocus(); return; } @@ -64,5 +65,6 @@ export async function openReaderWindow( minHeight: MIN_HEIGHT, titleBarStyle: "overlay", hiddenTitle: true, + visible: false, }); }