The viewer treats every place a file can come from or go to as a source: your own cloud, the built-in drives, and the user's machine. A source that can be written to is a save target, and saving is always "write this document to a target" — never a browser download in disguise. The recents history is the one source that owns nothing: each entry belongs to the source it was opened from.
Setting the sources up is Sign-in & Cloud Drives; this page is about what the user sees and which config decides it.
| Action | Where | What it does |
|---|---|---|
| Save | main menu, Ctrl+S, the "Save" button of the unsaved-changes dialog | Writes the document back over the file it was opened from. |
| Save as | main menu, Ctrl+Shift+S | Writes it to a target the user picks. |
| Download | toolbar CTA only | Exports a copy. Never a save: the document keeps its path, its source and its unsaved state. |
Downloading is deliberately outside the save flow — it is the one action that does not ask where anything belongs.
Save optimized, Convert, Extract images and Split produce a new artefact — a compressed copy, a converted file, a zip of images or pages — not the document itself. They go straight out through the export path, never through the save chain: nothing is written back to the document's source and its path and unsaved state are untouched.
onExportFile is therefore the single place a host receives all of them — and, when
deviceConfig implements no writing, the device saves too.
Save and Save as are the same chain, entered with or without permission to reuse the document's source:
Save → chain(saveToOrigin: true)
Save as → chain(saveToOrigin: false)
chain(saveToOrigin):
saveToOrigin and the document has a writable source
→ overwrite that file
otherwise, settle on a destination:
exactly one target → write there, no question asked
more than one target → show the target picker (source · folder · file name)
There is always at least one target, so the chain never dead-ends — but what it is depends on the host, not on a platform check, and it is named after what it does:
deviceConfig.saveOver / saveInto and writes the file itself, so
the target is a real place and is offered as This device: Save afterwards overwrites the
same file, and Document properties shows it under File location;*, and still keeps its own name — the name typed in the
dialog belongs to the copy that left, not to the document.A connected drive or a host source with save methods is a target too, and having more than one is what makes the picker worth showing.
deviceConfig: {
// "Save" — overwrite the file the document came from. item.path is the location you handed
// over when you opened it.
saveOver: async (item, file) => {
await shell.write(item.path!, new Uint8Array(await file.arrayBuffer()));
return item; // saved, still in the same place
},
// "Save as" — no folder to pass: show your own dialog.
saveInto: async (name, file) => {
const path = await shell.pickSavePath(name);
if (!path) return null; // cancelled
await shell.write(path, new Uint8Array(await file.arrayBuffer()));
return { id: path, name: path.split(/[\\/]/).pop()!, isFolder: false, path };
},
// Read a file back by path. Only this puts the machine's files in the recents
// history: the viewer records an open only when it could load the file again.
readPath: async (path) => (await shell.exists(path) ? await readAsFile(path) : null),
// Optional veto in front of either.
isSaveAllowed: async (file, target) => (target ? confirmOverwrite(target.path) : true),
}
The item you return is what the document adopts: its path is the location shown under File
location and the one the next Save overwrites. Return no path and nothing is adopted — the
save is treated like the export it resembles, with no location to point at.
For Save to have something to overwrite from the start, the document needs a path, and only
the shell knows it — pass it when you open the file. That is all it takes: a document with a path
has the device as its source, so Save overwrites that file through your saveOver and
Document properties shows the path under File location. There is no source to declare
separately, and it makes no difference whether the file arrived by double-click, by your own
open command, or from Recents — an entry that came off the machine carries the same path.
await PdfEditor({
/* … */
initialDocument: { file: await readAsFile(openedPath), path: openedPath },
});
// or per document
await sdk.openDocument({ file, path });
* next to the file name).| Source | Becomes a save target when | Who writes the bytes |
|---|---|---|
| This device | always | your deviceConfig.saveOver / saveInto where they exist, otherwise the export |
| OneDrive / Google Drive / Dropbox | connected | the library (see isSaveAllowed to veto) |
Your own source (customProviders) |
it implements saveOver / saveInto |
you |
| Recents | never — it is a history, not a place | whoever owns the entry: opening one adopts that source, so Save writes back there |
.pdf — including a name
typed into Save as.path but no source of its own belongs to the device at that
path — that is what a plain disk open is. It is only a source where your deviceConfig
writes files; in a browser page a path locates nothing, so nothing is inferred from it.| Option | Effect |
|---|---|
deviceConfig.isSaveAllowed(file, target) |
Veto before the document is written to the machine. |
*Config.isSaveAllowed(file, target) (the three drives) |
Veto before the library uploads. |
customProviders[].saveOver / .saveInto |
Turns your own source into a save target. |
onExportFile(file) |
Intercepts every export, including the browser-side device target. |
layoutConfig.navigationBar.controls.downloadButton |
Shows or hides the Download CTA. |
layoutConfig.topBar.controls.mainMenu.options.save / .saveAs |
Shows or hides the menu items. |