Add explicit Save As button for desktop viewer (issue #5928) (#5959)

## 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 <[email protected]>
This commit is contained in:
Achieve3318
2026-03-20 09:32:24 +00:00
committed by GitHub
co-authored by James Brunton
parent a7f2abcb22
commit 55bcb92810
6 changed files with 139 additions and 80 deletions
@@ -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(
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={() => handleExportAll(true)}
disabled={
disableForFullscreen ||
(currentView !== 'viewer' && (totalItems === 0 || allButtonsDisabled))
}
>
<LocalIcon icon={icons.saveAsIconName} width="1.5rem" height="1.5rem" />
</ActionIcon>,
t('rightRail.saveAs', 'Save As'),
tooltipPosition,
tooltipOffset
)}
</div>
<div className="right-rail-spacer" />
@@ -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,
};
}
@@ -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,
};
}