From fc20e0a89cf3653632732b65299f5fae29ea99f3 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Tue, 18 Aug 2026 19:54:23 +0200 Subject: [PATCH] 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. --- src/App.svelte | 22 +++++++++++++++------- src/lib/export/CLAUDE.md | 5 +++++ src/lib/export/saveFile.ts | 18 +++++++++++++++++- src/lib/i18n/locales/de.ts | 2 ++ src/lib/i18n/locales/en.ts | 2 ++ src/lib/storage/CLAUDE.md | 1 + src/lib/storage/recentFiles.ts | 6 ++++-- 7 files changed, 46 insertions(+), 10 deletions(-) diff --git a/src/App.svelte b/src/App.svelte index 9102764..5b10879 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -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); } } diff --git a/src/lib/export/CLAUDE.md b/src/lib/export/CLAUDE.md index 39dd1c0..c2ede58 100644 --- a/src/lib/export/CLAUDE.md +++ b/src/lib/export/CLAUDE.md @@ -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 diff --git a/src/lib/export/saveFile.ts b/src/lib/export/saveFile.ts index 0398dcc..a0b845e 100644 --- a/src/lib/export/saveFile.ts +++ b/src/lib/export/saveFile.ts @@ -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], { 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 { diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts index f16658d..ad3e30a 100644 --- a/src/lib/i18n/locales/de.ts +++ b/src/lib/i18n/locales/de.ts @@ -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) => diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index 0055718..9353890 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -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) => diff --git a/src/lib/storage/CLAUDE.md b/src/lib/storage/CLAUDE.md index 4362d95..4a7e199 100644 --- a/src/lib/storage/CLAUDE.md +++ b/src/lib/storage/CLAUDE.md @@ -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`. diff --git a/src/lib/storage/recentFiles.ts b/src/lib/storage/recentFiles.ts index 5ed637a..ba472ec 100644 --- a/src/lib/storage/recentFiles.ts +++ b/src/lib/storage/recentFiles.ts @@ -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 */ } } /**