Skip to content

Commit f90be76

Browse files
committed
Save back to the opened file where the browser allows it
Chrome and Edge open files through the File System Access API (picker and drag & drop), so Ctrl+S overwrites the original file like PDF-XChange and Save As shows a save dialog. Other browsers keep downloading. Writing failures fall back to a download. After a save, later saves rebuild the file instead of reusing the bytes from opening. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H1UeohehDf7pyxWyDSZgJg
1 parent 7cfa562 commit f90be76

9 files changed

Lines changed: 254 additions & 20 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ How it works: `src/lib/content/` parses the content stream, interprets it with a
129129

130130
### Document & output
131131
- ✅ New blank document, open (picker or drag & drop), save, save as, save flattened copy, print
132+
- ✅ Save writes back to the opened file in Chrome and Edge (File System Access API; the browser asks once for write access), Save As shows a save dialog there; other browsers download the file
132133
- ✅ Unchanged documents are saved byte-for-byte (signatures and encryption stay intact)
133134
- ✅ Document properties (title, author, subject, keywords, creator, producer)
134135
- ✅ Export the current page as PNG

‎ROADMAP.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ The feature list of PDF-XChange Editor (and Editor Plus), and where PDF-Xploder
1010
|---|---|
1111
| Add Placeholder Tool | ⬜ |
1212
| Enhanced 3D Plugin | ⬜ (3D/PRC rendering is a large separate project) |
13-
| Enhanced Open/Save File Dialog Boxes | 🟡 browser file dialogs; drag & drop to open |
13+
| Enhanced Open/Save File Dialog Boxes | 🟡 system open/save dialogs; Save writes back to the opened file in Chrome/Edge (download elsewhere); drag & drop to open |
1414
| FreeSign Tool | 🟡 signature images (drawn, typed or uploaded) as comments |
1515
| Optimize Document Enhancements | ⬜ |
1616
| Search Feature for Files in Cloud Storage | 🚫 needs cloud accounts (see section 11) |

‎src/App.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ function Welcome() {
124124
className={`welcome ${over ? 'drag-over' : ''}`}
125125
onDragOver={(e) => { e.preventDefault(); setOver(true); }}
126126
onDragLeave={() => setOver(false)}
127-
onDrop={(e) => { e.preventDefault(); setOver(false); cmd.openFiles(Array.from(e.dataTransfer.files)); }}
127+
onDrop={(e) => { e.preventDefault(); setOver(false); cmd.openDropped(e.dataTransfer); }}
128128
>
129129
<div className="welcome-card">
130130
<h1>PDF-Xploder</h1>

‎src/components/Viewer.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { styleFor, uid, useStore } from '../store/store';
33
import { can } from '../store/permissions';
44
import { pdfToView, viewRectToPdf, viewSize, type VRect } from '../lib/geometry';
55
import { PageView } from './PageView';
6-
import { openFiles } from '../store/commands';
6+
import { openDropped } from '../store/commands';
77
import type { TextMarkupKind } from '../lib/types';
88

99
const GAP = 16;
@@ -247,7 +247,7 @@ export function Viewer() {
247247
if (!e.dataTransfer.files.length) return;
248248
e.preventDefault();
249249
setDragOver(false);
250-
openFiles(Array.from(e.dataTransfer.files));
250+
openDropped(e.dataTransfer);
251251
}}
252252
>
253253
{/* A different document gets fresh page views, so nothing of the previous one can linger. */}

‎src/lib/fileAccess.ts‎

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/**
2+
* File System Access API (Chrome, Edge): open files with a handle so Save can write
3+
* back to the original file, like a desktop editor. Other browsers fall back to
4+
* the file input and downloads.
5+
*/
6+
7+
type Mode = { mode: 'read' | 'readwrite' };
8+
type PickerType = { description: string; accept: Record<string, string[]> };
9+
10+
interface Pickers {
11+
showOpenFilePicker?(o: { types: PickerType[]; multiple?: boolean; excludeAcceptAllOption?: boolean }): Promise<FileSystemFileHandle[]>;
12+
showSaveFilePicker?(o: { types: PickerType[]; suggestedName?: string; excludeAcceptAllOption?: boolean }): Promise<FileSystemFileHandle>;
13+
}
14+
15+
interface PermissionHandle {
16+
queryPermission?(o: Mode): Promise<PermissionState>;
17+
requestPermission?(o: Mode): Promise<PermissionState>;
18+
}
19+
20+
const PDF_TYPES: PickerType[] = [{ description: 'PDF', accept: { 'application/pdf': ['.pdf'] } }];
21+
22+
const pickers = () => (typeof window === 'undefined' ? {} : (window as unknown as Pickers));
23+
24+
/** Whether Save can write to the opened file in this browser. */
25+
export const canSaveInPlace = () => typeof pickers().showSaveFilePicker === 'function';
26+
27+
export interface PickedFile {
28+
file: File;
29+
handle: FileSystemFileHandle | null;
30+
}
31+
32+
/** The user cancelled a picker. */
33+
export const isAbort = (e: unknown) => e instanceof DOMException && e.name === 'AbortError';
34+
35+
/** Show the open picker. Returns null when the API is unavailable (use a file input instead), [] on cancel. */
36+
export async function pickPdfHandles(): Promise<PickedFile[] | null> {
37+
const open = pickers().showOpenFilePicker;
38+
if (!open) return null;
39+
try {
40+
const handles = await open.call(window, { types: PDF_TYPES, multiple: true });
41+
return Promise.all(handles.map(async (handle) => ({ file: await handle.getFile(), handle })));
42+
} catch (e) {
43+
if (isAbort(e)) return [];
44+
// Blocked (e.g. in a cross-origin frame): let the caller use the file input.
45+
return null;
46+
}
47+
}
48+
49+
/** Show the save picker. Returns null when unavailable, undefined on cancel. */
50+
export async function pickSaveHandle(suggestedName: string): Promise<FileSystemFileHandle | null | undefined> {
51+
const save = pickers().showSaveFilePicker;
52+
if (!save) return null;
53+
try {
54+
return await save.call(window, { types: PDF_TYPES, suggestedName });
55+
} catch (e) {
56+
if (isAbort(e)) return undefined;
57+
return null;
58+
}
59+
}
60+
61+
/**
62+
* Handles for dropped files, in the order of `dataTransfer.files`.
63+
* Must be called synchronously inside the drop event: the items expire afterwards.
64+
*/
65+
export function droppedHandles(dt: DataTransfer): Promise<(FileSystemFileHandle | null)[]> {
66+
const items = Array.from(dt.items).filter((i) => i.kind === 'file');
67+
type Item = DataTransferItem & { getAsFileSystemHandle?(): Promise<FileSystemHandle | null> };
68+
const pending = items.map((i) =>
69+
(i as Item).getAsFileSystemHandle
70+
? (i as Item).getAsFileSystemHandle!()
71+
.then((h) => (h && h.kind === 'file' ? (h as FileSystemFileHandle) : null))
72+
.catch(() => null)
73+
: Promise.resolve(null),
74+
);
75+
return Promise.all(pending);
76+
}
77+
78+
/** Ask for write access (prompts the user once per file and session). Call early, while the click still counts as a user gesture. */
79+
export async function ensureWritable(handle: FileSystemFileHandle): Promise<boolean> {
80+
const h = handle as FileSystemFileHandle & PermissionHandle;
81+
try {
82+
if ((await h.queryPermission?.({ mode: 'readwrite' })) === 'granted') return true;
83+
if (!h.requestPermission) return true; // Let createWritable decide.
84+
return (await h.requestPermission({ mode: 'readwrite' })) === 'granted';
85+
} catch {
86+
return false;
87+
}
88+
}
89+
90+
export async function writeToHandle(handle: FileSystemFileHandle, data: Uint8Array): Promise<void> {
91+
const w = await handle.createWritable();
92+
try {
93+
await w.write(data);
94+
} catch (e) {
95+
await w.abort().catch(() => {});
96+
throw e;
97+
}
98+
await w.close();
99+
}

‎src/locales/de/ui.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,8 @@ const DE: Record<string, string> = {
328328
Documents: 'Dokumente', 'Close others': 'Andere schließen', 'Close tabs to the right': 'Rechte Tabs schließen', 'Close all': 'Alle schließen',
329329
'Middle click': 'Mittelklick', 'Unsaved changes': 'Ungespeicherte Änderungen', 'Open…': 'Öffnen…',
330330
'“{name}” has unsaved changes. Close it anyway?': '„{name}“ hat ungespeicherte Änderungen. Trotzdem schließen?',
331+
'Saved to {name}': 'Gespeichert in {name}',
332+
'Could not write to the file, so a copy was downloaded instead.': 'Die Datei konnte nicht geschrieben werden, daher wurde stattdessen eine Kopie heruntergeladen.',
331333
};
332334

333335
export default DE;

‎src/store/commands.ts‎

Lines changed: 62 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { viewRectToPdf, viewSize } from '../lib/geometry';
77
import { encrypt, reencryptLike } from '../lib/security';
88
import { requirePermission } from './permissions';
99
import { tf, tr } from '../lib/i18n';
10+
import { droppedHandles, ensureWritable, pickPdfHandles, pickSaveHandle, writeToHandle } from '../lib/fileAccess';
1011
import { signPdf, type SignOptions } from '../lib/signature';
1112
import { openPdf, renderToCanvas } from '../lib/pdfjs';
1213
import type { Annotation, Rect } from '../lib/types';
@@ -20,15 +21,26 @@ export function targetPages(): number[] {
2021
}
2122

2223
/** Open PDF files, each in its own tab (like PDF-XChange). */
23-
export async function openFiles(files: File[]) {
24-
const pdfs = files.filter((f) => f.type === 'application/pdf' || /\.pdf$/i.test(f.name));
24+
export async function openFiles(files: File[], handles: (FileSystemFileHandle | null)[] = []) {
25+
const pdfs = files.map((file, i) => ({ file, handle: handles[i] ?? null })).filter(({ file: f }) => f.type === 'application/pdf' || /\.pdf$/i.test(f.name));
2526
if (!pdfs.length) return S().notify('Please choose a PDF file.', 'error');
26-
for (const f of pdfs) {
27-
await S().run('Opening…', async () => S().openInNewTab(await fileBytes(f), f.name));
27+
for (const { file: f, handle } of pdfs) {
28+
await S().run('Opening…', async () => S().openInNewTab(await fileBytes(f), f.name, { handle }));
2829
}
2930
}
3031

32+
/** Open dropped files, keeping their file handles where the browser provides them. Call from the drop event itself. */
33+
export function openDropped(dt: DataTransfer) {
34+
const files = Array.from(dt.files);
35+
void droppedHandles(dt).then((handles) => openFiles(files, handles.length === files.length ? handles : []));
36+
}
37+
3138
export async function openFromDialog() {
39+
const picked = await pickPdfHandles();
40+
if (picked) {
41+
if (picked.length) await openFiles(picked.map((p) => p.file), picked.map((p) => p.handle));
42+
return;
43+
}
3244
const files = await pickFile('application/pdf,.pdf', true);
3345
if (files.length) await openFiles(files);
3446
}
@@ -55,7 +67,7 @@ export async function buildOutput(flatten = false): Promise<Uint8Array> {
5567
*/
5668
export async function buildFileForSave(): Promise<Uint8Array> {
5769
const s = S();
58-
if (!s.dirty && s.originalBytes) return s.originalBytes;
70+
if (unchangedSinceOpen()) return s.originalBytes!;
5971
const out = await buildOutput();
6072
if (s.securityChanged) return s.security ? encrypt(out, s.security) : out;
6173
// Untouched security: encrypt exactly like the opened file (works without the owner password).
@@ -70,15 +82,43 @@ function confirmBreakingSignatures(): boolean {
7082
return confirm(tr('This document is digitally signed. Saving your changes rewrites the file, which invalidates the existing signatures. Continue?'));
7183
}
7284

73-
export async function save() {
74-
if (!S().bytes || !confirmBreakingSignatures()) return;
85+
/** The opened bytes are still exactly the document (nothing edited or saved since). */
86+
function unchangedSinceOpen(): boolean {
87+
const s = S();
88+
return !s.dirty && !s.savedOnce && !!s.originalBytes;
89+
}
90+
91+
/** Build the file and write it to `handle`, or download it as `name` when there is none or writing fails. */
92+
async function writeOut(handle: FileSystemFileHandle | null, name: string) {
7593
await S().run('Saving…', async () => {
7694
const out = await buildFileForSave();
77-
downloadBlob(out, S().fileName || 'document.pdf');
95+
if (handle) {
96+
try {
97+
await writeToHandle(handle, out);
98+
useStore.setState({ fileHandle: handle, fileName: handle.name });
99+
S().markSaved();
100+
S().notify(tf('Saved to {name}', { name: handle.name }));
101+
return;
102+
} catch (e) {
103+
console.warn('Writing to the file failed', e);
104+
S().notify('Could not write to the file, so a copy was downloaded instead.', 'error');
105+
}
106+
}
107+
useStore.setState({ fileName: name });
108+
downloadBlob(out, name);
78109
S().markSaved();
79110
});
80111
}
81112

113+
/** Save: write back to the opened file where the browser allows it (Chrome, Edge), else download. */
114+
export async function save() {
115+
const s = S();
116+
if (!s.bytes || !confirmBreakingSignatures()) return;
117+
// Ask for write access first, while the key press or click still counts as a user gesture.
118+
const handle = s.fileHandle && (await ensureWritable(s.fileHandle)) ? s.fileHandle : null;
119+
await writeOut(handle, S().fileName || 'document.pdf');
120+
}
121+
82122
/** Save a copy with all comments burnt into the pages (keeps the editing session as is). */
83123
export async function saveFlattenedCopy() {
84124
if (!S().bytes) return;
@@ -87,12 +127,22 @@ export async function saveFlattenedCopy() {
87127
});
88128
}
89129

130+
/** Save As: a save dialog where available (later saves go to the new file), else ask for a name and download. */
90131
export async function saveAs() {
91132
if (!S().bytes) return;
92-
const name = prompt(tr('File name'), S().fileName || 'document.pdf');
133+
const suggested = S().fileName || 'document.pdf';
134+
const handle = await pickSaveHandle(suggested);
135+
if (handle === undefined) return;
136+
if (handle) {
137+
if (!confirmBreakingSignatures()) return;
138+
return writeOut(handle, handle.name);
139+
}
140+
const name = prompt(tr('File name'), suggested);
93141
if (!name) return;
94-
useStore.setState({ fileName: /\.pdf$/i.test(name) ? name : `${name}.pdf` });
95-
await save();
142+
if (!confirmBreakingSignatures()) return;
143+
// A downloaded copy: later saves must not overwrite the file opened before.
144+
useStore.setState({ fileHandle: null });
145+
await writeOut(null, /\.pdf$/i.test(name) ? name : `${name}.pdf`);
96146
}
97147

98148
export async function print() {
@@ -387,7 +437,7 @@ export async function signDocument(o: SignOptions) {
387437
if (willEncrypt) return s.notify('Remove password protection before signing (signing encrypted files is not supported yet).', 'error');
388438
if (!requirePermission('fillForms') || !confirmBreakingSignatures()) return;
389439
await s.run('Signing…', async () => {
390-
const base = !s.dirty && s.originalBytes ? s.originalBytes : await buildOutput();
440+
const base = unchangedSinceOpen() ? s.originalBytes! : await buildOutput();
391441
const signed = await signPdf(base, o);
392442
const name = /-signed\.pdf$/i.test(s.fileName) ? s.fileName : `${baseName(s.fileName || 'document')}-signed.pdf`;
393443
downloadBlob(signed, name);

‎src/store/store.ts‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@ export interface SearchHit {
3333
context: string;
3434
}
3535

36+
export interface OpenOptions {
37+
password?: string;
38+
ownerPassword?: string;
39+
/** The file it was opened from, when the browser gives one (Save writes back to it). */
40+
handle?: FileSystemFileHandle | null;
41+
}
42+
3643
export type SidebarTab = 'pages' | 'outline' | 'comments' | 'attachments' | 'forms' | 'search' | 'signatures';
3744

3845
export interface ScrollRequest {
@@ -109,6 +116,10 @@ interface State {
109116
lock: { userPassword: string; needsUserPassword: boolean } | null;
110117
/** The author's restrictions were lifted for this session without the permissions password. */
111118
restrictionsLifted: boolean;
119+
/** Where the document came from (File System Access API), so Save can write back to it. */
120+
fileHandle: FileSystemFileHandle | null;
121+
/** Saved at least once: the opened bytes no longer match what the user has on disk. */
122+
savedOnce: boolean;
112123
signatures: SignatureInfo[];
113124
past: Snapshot[];
114125
future: Snapshot[];
@@ -149,7 +160,7 @@ export interface TabInfo {
149160
}
150161

151162
interface Actions {
152-
openDocument(bytes: Uint8Array, name: string, opts?: { password?: string; ownerPassword?: string }): Promise<void>;
163+
openDocument(bytes: Uint8Array, name: string, opts?: OpenOptions): Promise<void>;
153164
/** Lift permission restrictions with the owner password; resolves false if it is wrong. */
154165
unlock(ownerPassword: string): Promise<boolean>;
155166
/**
@@ -163,7 +174,7 @@ interface Actions {
163174
/** Close the active tab's document (and the tab, if there are others). */
164175
closeDocument(): void;
165176
/** Open a document in a new tab (or in the active tab if it is empty). */
166-
openInNewTab(bytes: Uint8Array, name: string, opts?: { password?: string; ownerPassword?: string }): Promise<void>;
177+
openInNewTab(bytes: Uint8Array, name: string, opts?: OpenOptions): Promise<void>;
167178
/** Add an empty tab and activate it; returns its id. */
168179
newTab(): string;
169180
switchTab(id: string): void;
@@ -216,6 +227,8 @@ export function anyTabDirty(): boolean {
216227

217228
const initialDoc = {
218229
restrictionsLifted: false,
230+
fileHandle: null as FileSystemFileHandle | null,
231+
savedOnce: false,
219232
fileName: '',
220233
bytes: null,
221234
pdf: null,
@@ -303,7 +316,7 @@ export const useStore = create<Store>()((set, get) => {
303316
set({ currentPage: Math.min(get().currentPage, snap.pageIds.length - 1) });
304317
}
305318

306-
async function openDocumentImpl(bytes: Uint8Array, name: string, opts: { password?: string; ownerPassword?: string } = {}) {
319+
async function openDocumentImpl(bytes: Uint8Array, name: string, opts: OpenOptions = {}) {
307320
const original = bytes;
308321
let loaded;
309322
try {
@@ -382,6 +395,7 @@ export const useStore = create<Store>()((set, get) => {
382395
bytes,
383396
originalBytes: original,
384397
fileName: name,
398+
fileHandle: opts.handle ?? null,
385399
pdf: loaded.pdf,
386400
geoms: loaded.geoms,
387401
pageIds,
@@ -658,7 +672,7 @@ export const useStore = create<Store>()((set, get) => {
658672
await restore(next);
659673
},
660674

661-
markSaved: () => set({ dirty: false }),
675+
markSaved: () => set({ dirty: false, savedOnce: true }),
662676

663677
setTool: (tool) =>
664678
set({

0 commit comments

Comments
 (0)