From 55bcb9281048ce5ac3a1ea1155d5ef559b8fa233 Mon Sep 17 00:00:00 2001 From: Achieve3318 Date: Fri, 20 Mar 2026 05:32:24 -0400 Subject: [PATCH] Add explicit Save As button for desktop viewer (issue #5928) (#5959) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Adds an explicit **“Save As”** button to the desktop viewer so users can always save a copy of the current PDF to a different location, even if the original file already has a local path. This complements the existing smart **Save/Download** behavior: - The existing download button continues to either save back to the original path (when available) or prompt for a path when needed. - The new **Save As** button always opens a save dialog to choose a location/name for a new copy. ## Changes - **RightRail (viewer controls)** - Added a new **Save As** action icon in the right rail settings section. - The button: - Uses `viewerContext.exportActions.saveAsCopy()` to get the current viewer state as a PDF. - Calls `downloadFile` without a `localPath`, ensuring the desktop app shows a **Save As** dialog. - Picks the first selected file (if any) or the first active file as the source for the filename. - **Desktop / Web behavior** - In the desktop app (Tauri), clicking **Save As**: - Opens a native save dialog so the user can choose a different folder and filename. - Writes a new copy without changing the existing file’s `localFilePath` or dirty state. - In the web app, the button behaves like a standard download of a copy (browser-controlled save dialog / download). ## Motivation - Users often want to apply operations on a PDF while **keeping the original unmodified**. - The existing smart Save behavior chooses between Save and Save As automatically, but there was no way to explicitly request **Save As**. - This change gives desktop users a clear, dedicated **“Save As”** control while preserving the current Save/Download behavior. ## Notes - No backend changes. - No changes to the existing Save / Download button behavior. - The new button uses existing viewer export and download utilities, minimizing new logic. --------- Co-authored-by: James Brunton --- frontend/package-lock.json | 71 +++++++--- frontend/package.json | 4 +- .../public/locales/en-GB/translation.toml | 1 + .../src/core/components/shared/RightRail.tsx | 127 ++++++++++-------- frontend/src/core/hooks/useFileActionIcons.ts | 8 +- .../src/desktop/hooks/useFileActionIcons.ts | 8 +- 6 files changed, 139 insertions(+), 80 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b4da534281..6eb164ba42 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -115,7 +115,7 @@ "postcss-preset-mantine": "^1.18.0", "postcss-simple-vars": "^7.0.1", "puppeteer": "^24.25.0", - "tsx": "^4.19.4", + "tsx": "^4.21.0", "typescript": "^5.9.2", "typescript-eslint": "^8.44.1", "vite": "^7.1.7", @@ -476,6 +476,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -516,6 +517,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -578,6 +580,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-2.7.0.tgz", "integrity": "sha512-dJ9pCWXVJxh6uSJP4sKuJP4v67+6Vlmw4WqJcv+CKJSUPdX9LhOSTIgTjwKLZ5zXo0c3DihNvrUupovb/DrhgQ==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/engines": "2.7.0", "@embedpdf/models": "2.7.0" @@ -673,6 +676,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-annotation/-/plugin-annotation-2.7.0.tgz", "integrity": "sha512-bkn9+91XfcFoKgwZ0kiJLbnvWNyrorhcbX5grsvUw2gJ8mL0RtwHSnCjZVT3WOPleQVwnNkBZUHefJhNT+LEKw==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "2.7.0", "@embedpdf/utils": "2.7.0" @@ -762,6 +766,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-2.7.0.tgz", "integrity": "sha512-k2c5M2Nwey+j6EtZyf5Sw+cuyNT4MTWdqPQGR9NbmZUiNnkSrg/TmPWZCyaXZKGRjLmoJocc0qRdyaZnaaTpqw==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "2.7.0" }, @@ -779,6 +784,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-2.7.0.tgz", "integrity": "sha512-IfJiugr2k9VD8/gryMb22wC5yk4EQwSS3/Co0EdOhzkmkGSgUHA/KcG83WBu8wPEtj5Gwy473xe41fYKsGMlsA==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "2.7.0" }, @@ -854,6 +860,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-2.7.0.tgz", "integrity": "sha512-qJcBxl2Kgqbw/Yb4qiX3qNsBKmzI8ijAKix3PoYL+nlhX6iFbcCkUbJgF0rPCBbhIy59Bpp7eJL+RyY5DhQvUg==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "2.7.0" }, @@ -888,6 +895,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-2.7.0.tgz", "integrity": "sha512-uEs5XdHZ3XqH3OWnM8E2eHen+7+MxK9SrlBnfM8ZcPkzHR9n7hT/Aqy05BiLKxKem585HvtbRgFzNQZqmNIGHw==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "2.7.0" }, @@ -923,6 +931,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-2.7.0.tgz", "integrity": "sha512-MhTUHZ5jBh1jQU66xrtkx2jEtcreCfHy4zpmMY7iCoZtWUxrML+zf4jYNrR/ZP93O4i5HNBSnJNTG+RmRJ5KPw==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "2.7.0", "@embedpdf/utils": "2.7.0" @@ -997,6 +1006,7 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-2.7.0.tgz", "integrity": "sha512-nVDoU86CgHme7f2r7OFhziqHz1Yg8vdE2K+bXzRCmtZaubH+qVo5C+77at9brYCSinA67znQn1yy6zbfxs4FwA==", "license": "MIT", + "peer": true, "dependencies": { "@embedpdf/models": "2.7.0" }, @@ -1099,6 +1109,7 @@ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -1142,6 +1153,7 @@ "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -2000,6 +2012,7 @@ "resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.15.tgz", "integrity": "sha512-wBn/GogB4x7a2Uj7Ztt3amRaApjED+9XqfE4wyCLh88R7KV55k9vnTdCx+irI/GLOOu9tXNUGm3a4t5sTajwkQ==", "license": "MIT", + "peer": true, "dependencies": { "@floating-ui/react": "^0.27.16", "clsx": "^2.1.1", @@ -2050,6 +2063,7 @@ "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.15.tgz", "integrity": "sha512-AUSnpUlzttHzJht3CJ1YWi16iy6NWRwtyWO5RLGHHsmiW05DyG0qOPKF8+R5dLHuOCnl3XOu4roI2Y1ku9U04Q==", "license": "MIT", + "peer": true, "peerDependencies": { "react": "^18.x || ^19.x" } @@ -2126,6 +2140,7 @@ "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.8.tgz", "integrity": "sha512-QKd1RhDXE1hf2sQDNayA9ic9jGkEgvZOf0tTkJxlBPG8ns8aS4rS8WwYURw2x5y3739p0HauUXX9WbH7UufFLw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.6", "@mui/core-downloads-tracker": "^7.3.8", @@ -2573,6 +2588,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -3488,6 +3504,7 @@ "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.9.0.tgz", "integrity": "sha512-ggs5k+/0FUJcIgNY08aZTqpBTtbExkJMYMLSMwyucrhtWexVOEY1KJmhBsxf+E/Q15f5rbwBpj+t0t2AW2oCsQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=12.16" } @@ -3577,7 +3594,6 @@ "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", "license": "MIT", - "peer": true, "peerDependencies": { "acorn": "^8.9.0" } @@ -4369,6 +4385,7 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -4962,6 +4979,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4972,6 +4990,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -5052,6 +5071,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -5501,7 +5521,6 @@ "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.29.tgz", "integrity": "sha512-zcrANcrRdcLtmGZETBxWqIkoQei8HaFpZWx/GHKxx79JZsiZ8j1du0VUJtu4eJjgFvU/iKL5lRXFXksVmI+5DA==", "license": "MIT", - "peer": true, "dependencies": { "@vue/shared": "3.5.29" } @@ -5511,7 +5530,6 @@ "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.29.tgz", "integrity": "sha512-8DpW2QfdwIWOLqtsNcds4s+QgwSaHSJY/SUe04LptianUQ/0xi6KVsu/pYVh+HO3NTVvVJjIPL2t6GdeKbS4Lg==", "license": "MIT", - "peer": true, "dependencies": { "@vue/reactivity": "3.5.29", "@vue/shared": "3.5.29" @@ -5522,7 +5540,6 @@ "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.29.tgz", "integrity": "sha512-AHvvJEtcY9tw/uk+s/YRLSlxxQnqnAkjqvK25ZiM4CllCZWzElRAoQnCM42m9AHRLNJ6oe2kC5DCgD4AUdlvXg==", "license": "MIT", - "peer": true, "dependencies": { "@vue/reactivity": "3.5.29", "@vue/runtime-core": "3.5.29", @@ -5535,7 +5552,6 @@ "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.29.tgz", "integrity": "sha512-G/1k6WK5MusLlbxSE2YTcqAAezS+VuwHhOvLx2KnQU7G2zCH6KIb+5Wyt6UjMq7a3qPzNEjJXs1hvAxDclQH+g==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-ssr": "3.5.29", "@vue/shared": "3.5.29" @@ -5562,6 +5578,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5813,7 +5830,6 @@ "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">= 0.4" } @@ -6100,6 +6116,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -7011,6 +7028,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -7473,15 +7491,15 @@ "version": "5.6.3", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.3.tgz", "integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/devtools-protocol": { "version": "0.0.1566079", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1566079.tgz", "integrity": "sha512-MJfAEA1UfVhSs7fbSQOG4czavUp1ajfg6prlAN0+cmfa2zNjaIbvq8VneP7do1WAQQIvgNJWSMeP6UyI90gIlQ==", "dev": true, - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/dezalgo": { "version": "1.0.4", @@ -7869,6 +7887,7 @@ "integrity": "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -7978,8 +7997,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/espree": { "version": "11.1.1", @@ -8044,7 +8062,6 @@ "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.3.tgz", "integrity": "sha512-8fOS+GIGCQZl/ZIlhl59htOlms6U8NvX6ZYgYHpRU/b6tVSh3uHkOHZikl3D4cMbYM0JlpBe+p/BkZEi8J9XIQ==", "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } @@ -8866,6 +8883,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.4" }, @@ -9146,7 +9164,6 @@ "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "^1.0.6" } @@ -9322,6 +9339,7 @@ "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", @@ -9883,8 +9901,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/locate-path": { "version": "6.0.0", @@ -10904,6 +10921,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -11164,6 +11182,7 @@ "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.354.0.tgz", "integrity": "sha512-qrpToz7mN1PmEfo+Ob4Z8euX4z2p17LA0EAtFeyod3IVnlwnu+Ybea/oxVsPiq5YAPo+p5z73FcjF2yEJ7oZnA==", "license": "SEE LICENSE IN LICENSE", + "peer": true, "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/api-logs": "^0.208.0", @@ -11185,6 +11204,7 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.4.tgz", "integrity": "sha512-uKFfOHWuSNpRFVTnljsCluEFq57OKT+0QdOiQo8XWnQ/pSvg7OpX5eNOejELXJMWy+BwM2nobz0FkvzmnpCNsQ==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -11559,6 +11579,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -11568,6 +11589,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -11659,7 +11681,8 @@ "version": "19.2.4", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-number-format": { "version": "5.4.4", @@ -11676,6 +11699,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -12057,7 +12081,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -12907,7 +12932,6 @@ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">= 0.4" } @@ -13235,6 +13259,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -13428,6 +13453,7 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -13495,6 +13521,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13745,6 +13772,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -13919,6 +13947,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -13932,6 +13961,7 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -14442,8 +14472,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/zod": { "version": "3.25.76", diff --git a/frontend/package.json b/frontend/package.json index b78dafa0b7..3b13d2b173 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -40,6 +40,7 @@ "@mantine/hooks": "^8.3.1", "@mui/icons-material": "^7.3.2", "@mui/material": "^7.3.2", + "@posthog/react": "^1.8.2", "@reactour/tour": "^3.8.0", "@stripe/react-stripe-js": "^4.0.2", "@stripe/stripe-js": "^7.9.0", @@ -63,7 +64,6 @@ "license-report": "^6.8.0", "pdfjs-dist": "^5.4.149", "peerjs": "^1.5.5", - "@posthog/react": "^1.8.2", "posthog-js": "^1.268.0", "qrcode.react": "^4.2.0", "react": "^19.1.1", @@ -174,8 +174,8 @@ "postcss-preset-mantine": "^1.18.0", "postcss-simple-vars": "^7.0.1", "puppeteer": "^24.25.0", + "tsx": "^4.21.0", "typescript": "^5.9.2", - "tsx": "^4.19.4", "typescript-eslint": "^8.44.1", "vite": "^7.1.7", "vite-plugin-static-copy": "^3.1.4", diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index 810c52d03d..66971ce304 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -4475,6 +4475,7 @@ exitRedaction = "Exit Redaction Mode" save = "Save" downloadAll = "Download All" saveAll = "Save All" +saveAs = "Save As" [textAlign] left = "Left" diff --git a/frontend/src/core/components/shared/RightRail.tsx b/frontend/src/core/components/shared/RightRail.tsx index 11ecbb4a90..8001b17f73 100644 --- a/frontend/src/core/components/shared/RightRail.tsx +++ b/frontend/src/core/components/shared/RightRail.tsx @@ -134,68 +134,71 @@ export default function RightRail() { [actions, allButtonsDisabled, disableForFullscreen, tooltipPosition, tooltipOffset] ); - const handleExportAll = useCallback(async () => { - if (currentView === 'viewer') { - const buffer = await viewerContext?.exportActions?.saveAsCopy?.(); - if (!buffer) return; - const fileToExport = selectedFiles.length > 0 ? selectedFiles[0] : activeFiles[0]; - if (!fileToExport) return; - const stub = isStirlingFile(fileToExport) ? selectors.getStirlingFileStub(fileToExport.fileId) : undefined; - try { - const result = await downloadFile({ - data: new Blob([buffer], { type: 'application/pdf' }), - filename: fileToExport.name, - localPath: stub?.localFilePath, - }); - if (!result.cancelled && stub && result.savedPath) { - fileActions.updateStirlingFileStub(stub.id, { - localFilePath: stub.localFilePath ?? result.savedPath, - isDirty: false, - }); - } - } catch (error) { - console.error('[RightRail] Failed to export viewer file:', error); - } - return; - } - - if (currentView === 'pageEditor') { - pageEditorFunctions?.onExportAll?.(); - return; - } - - const filesToExport = selectedFiles.length > 0 ? selectedFiles : activeFiles; - - if (filesToExport.length > 0) { - for (const file of filesToExport) { - const stub = isStirlingFile(file) ? selectors.getStirlingFileStub(file.fileId) : undefined; + const handleExportAll = useCallback( + async (forceNewFile = false) => { + if (currentView === 'viewer') { + const buffer = await viewerContext?.exportActions?.saveAsCopy?.(); + if (!buffer) return; + const fileToExport = selectedFiles.length > 0 ? selectedFiles[0] : activeFiles[0]; + if (!fileToExport) return; + const stub = isStirlingFile(fileToExport) ? selectors.getStirlingFileStub(fileToExport.fileId) : undefined; try { const result = await downloadFile({ - data: file, - filename: file.name, - localPath: stub?.localFilePath + data: new Blob([buffer], { type: 'application/pdf' }), + filename: fileToExport.name, + localPath: forceNewFile ? undefined : stub?.localFilePath, }); - if (result.cancelled) continue; - if (stub && result.savedPath) { + if (!forceNewFile && !result.cancelled && stub && result.savedPath) { fileActions.updateStirlingFileStub(stub.id, { localFilePath: stub.localFilePath ?? result.savedPath, - isDirty: false + isDirty: false, }); } } catch (error) { - console.error('[RightRail] Failed to export file:', file.name, error); + console.error('[RightRail] Failed to export viewer file:', error); + } + return; + } + + if (currentView === 'pageEditor') { + pageEditorFunctions?.onExportAll?.(); + return; + } + + const filesToExport = selectedFiles.length > 0 ? selectedFiles : activeFiles; + + if (filesToExport.length > 0) { + for (const file of filesToExport) { + const stub = isStirlingFile(file) ? selectors.getStirlingFileStub(file.fileId) : undefined; + try { + const result = await downloadFile({ + data: file, + filename: file.name, + localPath: forceNewFile ? undefined : stub?.localFilePath, + }); + if (result.cancelled) continue; + if (!forceNewFile && stub && result.savedPath) { + fileActions.updateStirlingFileStub(stub.id, { + localFilePath: stub.localFilePath ?? result.savedPath, + isDirty: false, + }); + } + } catch (error) { + console.error('[RightRail] Failed to export file:', file.name, error); + } } } - } - }, [ - currentView, - selectedFiles, - activeFiles, - pageEditorFunctions, - viewerContext, - selectors, - fileActions, - ]); + }, + [ + currentView, + selectedFiles, + activeFiles, + pageEditorFunctions, + viewerContext, + selectors, + fileActions, + ] + ); const downloadTooltip = useMemo(() => { if (currentView === 'pageEditor') { @@ -264,7 +267,7 @@ export default function RightRail() { variant="subtle" radius="md" className="right-rail-icon" - onClick={handleExportAll} + onClick={() => handleExportAll()} disabled={ disableForFullscreen || (currentView !== 'viewer' && (totalItems === 0 || allButtonsDisabled)) @@ -276,6 +279,24 @@ export default function RightRail() { tooltipPosition, tooltipOffset )} + {icons.saveAsIconName && + renderWithTooltip( + handleExportAll(true)} + disabled={ + disableForFullscreen || + (currentView !== 'viewer' && (totalItems === 0 || allButtonsDisabled)) + } + > + + , + t('rightRail.saveAs', 'Save As'), + tooltipPosition, + tooltipOffset + )}
diff --git a/frontend/src/core/hooks/useFileActionIcons.ts b/frontend/src/core/hooks/useFileActionIcons.ts index cfb4037c49..ef58b68b7d 100644 --- a/frontend/src/core/hooks/useFileActionIcons.ts +++ b/frontend/src/core/hooks/useFileActionIcons.ts @@ -2,8 +2,9 @@ import UploadIcon from '@mui/icons-material/Upload'; import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined'; /** - * File action icons for web builds - * Desktop builds override this with different icons + * File action icons for web builds. + * Desktop builds override this file via TypeScript path aliases to provide + * different icons (e.g. Save icon instead of Download, and a Save As icon). */ export function useFileActionIcons() { return { @@ -11,5 +12,8 @@ export function useFileActionIcons() { download: DownloadOutlinedIcon, uploadIconName: 'upload' as const, downloadIconName: 'download' as const, + // Web builds do not expose a Save As icon — the button is hidden when this is undefined. + // Desktop builds override this file and return a real icon name. + saveAsIconName: undefined as string | undefined, }; } diff --git a/frontend/src/desktop/hooks/useFileActionIcons.ts b/frontend/src/desktop/hooks/useFileActionIcons.ts index 7113bae02a..5d2cb9951b 100644 --- a/frontend/src/desktop/hooks/useFileActionIcons.ts +++ b/frontend/src/desktop/hooks/useFileActionIcons.ts @@ -2,8 +2,9 @@ import FolderOpenOutlinedIcon from '@mui/icons-material/FolderOpenOutlined'; import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined'; /** - * File action icons for desktop builds - * Overrides core implementation with desktop-appropriate icons + * File action icons for desktop builds. + * Overrides the core implementation with desktop-appropriate icons. + * The presence of `saveAsIconName` signals RightRail to show the Save As button. */ export function useFileActionIcons() { return { @@ -11,5 +12,8 @@ export function useFileActionIcons() { download: SaveOutlinedIcon, uploadIconName: 'folder-rounded' as const, downloadIconName: 'save-rounded' as const, + // Returning this icon name causes RightRail to render the Save As button. + // On desktop, downloadFile() without a localPath shows a native save dialog. + saveAsIconName: 'save-as-rounded' as string | undefined, }; }