fix: improve saving where the File System Access API is unavailable

The fallback download shows a one-time hint about the browser's
ask-where-to-save setting, keeps its anchor in the DOM and the blob
URL alive past the click (Gecko). Failed saves and exports now report
the underlying error, with a clear message when a script blocker
prevented a lazy chunk from loading. A full or blocked localStorage
no longer fails the save when recording the recent-files entry.
This commit is contained in:
Steffen Becker committed 2026-08-18 19:56:32 +02:00
1 parent f1b63c722c
commit fc20e0a89c
7 files changed
+46 -10

No files matched your search

+15 -7
View File
@@ -754,6 +754,14 @@
await applyImport(new Uint8Array(await file.arrayBuffer()), null, file.name);
}
// The reason belongs in the message: a failure on someone else's browser is
// otherwise unreportable. A lazy chunk a script blocker ate says so in plain words.
function failed(what: string, err: unknown): void {
const detail = (err as Error)?.message ?? String(err);
const blocked = /dynamically imported module|Importing a module script failed/i.test(detail);
alert(`${what}\n\n${blocked ? t().dialogs.scriptBlocked : detail}`);
}
async function handleSave() {
if (!editor) return;
exportMenuOpen = false;
@@ -776,7 +784,7 @@
// A stored handle may have lost permission; re-prompt via Save As.
if (fileHandle) { fileHandle = null; await handleSaveAs(); return; }
console.error('[save] Failed to save file:', err);
alert(t().dialogs.couldNotSave);
failed(t().dialogs.couldNotSave, err);
}
}
@@ -792,7 +800,7 @@
} catch (err) {
if ((err as DOMException)?.name === 'AbortError') return;
console.error('[save] Failed to save file:', err);
alert(t().dialogs.couldNotSave);
failed(t().dialogs.couldNotSave, err);
}
}
@@ -816,7 +824,7 @@
} catch (err) {
if ((err as DOMException)?.name === 'AbortError') return;
console.error('[save] Failed to save template:', err);
alert(t().dialogs.couldNotSave);
failed(t().dialogs.couldNotSave, err);
}
}
@@ -861,7 +869,7 @@
} catch (err) {
if ((err as DOMException)?.name === 'AbortError') return;
console.error('[docx] Export failed:', err);
alert(t().dialogs.couldNotExportDocx);
failed(t().dialogs.couldNotExportDocx, err);
} finally {
docxBusy = false;
}
@@ -885,7 +893,7 @@
});
} catch (err) {
console.error('[pdf] Export failed:', err);
alert(t().dialogs.couldNotExportPdf);
failed(t().dialogs.couldNotExportPdf, err);
} finally {
pdfBusy = false;
}
@@ -910,7 +918,7 @@
});
} catch (err) {
console.error('[pdf] Print failed:', err);
alert(t().dialogs.couldNotPrint);
failed(t().dialogs.couldNotPrint, err);
} finally {
pdfBusy = false;
}
@@ -940,7 +948,7 @@
});
} catch (err) {
console.error('[pdf] Print failed:', err);
alert(t().dialogs.couldNotPrintPdf);
failed(t().dialogs.couldNotPrintPdf, err);
}
}
+5
View File
@@ -3,6 +3,11 @@
`odt.ts` (ODF), `docx.ts` (Word), `pdf.ts` (html2canvas + jsPDF), `saveFile.ts`.
Frames, tables and pagination have their own deep-dives in `docs/architecture/`.
`saveFile.ts` picks the path: `showSaveFilePicker` where there is one, else a download —
Gecko has none and Brave disables the whole File System Access API by default. The first
fallback download shows a one-time hint (`edentext-download-hint`) that the browser's
"always ask where to save" setting picks the location.
## Heading defaults (`HEADING_STYLE_OVERRIDES`, `export/odt.ts`)
- **Headings** follow LibreOffice too (`HEADING_STYLE_OVERRIDES`): 18 / 16 / 14 / 13 / 12 / 12pt (levels 4 and 6 italic) with
+17 -1
View File
@@ -1,6 +1,12 @@
// File System Access API helpers for saving/opening .odt files. Falls back to a
// plain browser download / no-op where the API is unavailable (Firefox/Safari).
import { t } from '../i18n/i18n.svelte';
// Shown once per browser, at the first save that goes the download route — the
// moment the missing save dialog is actually felt.
const HINT_KEY = 'edentext-download-hint';
const ODT_MIME = 'application/vnd.oasis.opendocument.text';
const OTT_MIME = 'application/vnd.oasis.opendocument.text-template';
const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
@@ -37,14 +43,24 @@ export function supportsFsAccess(): boolean {
return typeof (window as WinFs).showSaveFilePicker === 'function';
}
// Where the browser saves is the browser's business (its download folder, or its own
// dialog where that is switched on). Gecko needs the anchor in the document and the
// blob URL alive past the click.
function download(bytes: Uint8Array, name: string, mime: string = ODT_MIME): void {
// Before the click: the browser's own save dialog would open on top of it.
if (!localStorage.getItem(HINT_KEY)) {
localStorage.setItem(HINT_KEY, '1');
alert(t().ribbon.saveAsHint);
}
const blob = new Blob([bytes as Uint8Array<ArrayBuffer>], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = name;
document.body.append(a);
a.click();
URL.revokeObjectURL(url);
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 30_000);
}
async function writeHandle(handle: FileSystemFileHandle, bytes: Uint8Array): Promise<void> {
+2
View File
@@ -741,6 +741,7 @@ const de: Messages = {
shapeFormat: 'Formformat',
},
saveAs: 'Speichern unter…',
saveAsHint: 'In den Download-Einstellungen des Browsers aktivieren: „Immer nachfragen, wo Dateien gespeichert werden sollen“.',
collapse: 'Menüband minimieren',
expand: 'Menüband erweitern',
export: 'Exportieren',
@@ -910,6 +911,7 @@ const de: Messages = {
openedWithLimitations: (list) => `Mit Einschränkungen geöffnet:\n• ${list}`,
couldNotOpen: 'Diese Datei konnte nicht geöffnet werden.',
couldNotSave: 'Diese Datei konnte nicht gespeichert werden.',
scriptBlocked: 'Ein Teil der App konnte nicht geladen werden — ein Skript-Blocker (uBlock Origin, NoScript, strenger Schutzmodus) verhindert das. Erlaube diese Seite und versuche es erneut.',
couldNotExportDocx: 'Export nach Word (.docx) nicht möglich.',
couldNotExportPdf: 'PDF-Export nicht möglich.',
recentUnavailable: (name) =>
+2
View File
@@ -747,6 +747,7 @@ const en = {
shapeFormat: 'Shape Format',
},
saveAs: 'Save as…',
saveAsHint: 'Turn this on in the browser\'s download settings: "Always ask you where to save files".',
collapse: 'Collapse the ribbon',
expand: 'Expand the ribbon',
export: 'Export',
@@ -916,6 +917,7 @@ const en = {
openedWithLimitations: (list: string) => `Opened with limitations:\n• ${list}`,
couldNotOpen: 'Could not open this file.',
couldNotSave: 'Could not save this file.',
scriptBlocked: 'Part of the app could not be loaded — a script blocker (uBlock Origin, NoScript, strict privacy mode) is blocking it. Allow this site and try again.',
couldNotExportDocx: 'Could not export to Word (.docx).',
couldNotExportPdf: 'Could not export to PDF.',
recentUnavailable: (name: string) =>
+1
View File
@@ -43,6 +43,7 @@ All keys share the `edentext-` prefix — a namespace, not a description: on Git
- **Tab interval:** `edentext-tab-interval` — cm as a decimal string.
- **Spacing model:** `edentext-spacing-model` — `'add' | 'max'`.
- **Recent fonts:** `edentext-recent-fonts` — JSON string array (ToolbarExpanded).
- **Download hint shown:** `edentext-download-hint` — set by the first fallback download (`export/saveFile.ts`); the one-time "always ask where to save" hint.
- **Header/footer:** `edentext-header` / `-footer` (HfDoc), `edentext-hf-distances`.
- **Styles:** `edentext-styles` — the style registry (`styles/sheet.svelte.ts`).
- **Document properties:** `edentext-doc-properties` — title/subject/author/keywords/comments (`docProperties.ts`); the key is removed when every field is empty, so a fresh document writes nothing into `meta.xml`.
+4 -2
View File
@@ -28,8 +28,10 @@ export function loadRecentFiles(): RecentFile[] {
}
function write(list: RecentFile[]): void {
if (list.length) localStorage.setItem(KEY, JSON.stringify(list));
else localStorage.removeItem(KEY);
try {
if (list.length) localStorage.setItem(KEY, JSON.stringify(list));
else localStorage.removeItem(KEY);
} catch { /* a full or blocked localStorage costs the list, not the save */ }
}
/**