From 52d9ab57204a1426e94f2db723aa08eafb6abbe4 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Tue, 8 Sep 2026 00:14:44 +0200 Subject: [PATCH] feat: saving a document with no file asks for the format The format has to be settled before the bytes are built, so a save that goes the download route cannot become .docx by renaming it in the browser's dialog. Save on a document with no file behind it now opens SaveFormatDialog and hands the choice to Save As; from then on it saves in that format without asking. --- src/App.svelte | 6 ++ src/lib/components/SaveFormatDialog.svelte | 83 ++++++++++++++++++++++ src/lib/export/CLAUDE.md | 2 + src/lib/i18n/locales/de.ts | 1 + src/lib/i18n/locales/en.ts | 1 + tests/dom/run.mjs | 13 ++++ 6 files changed, 106 insertions(+) create mode 100644 src/lib/components/SaveFormatDialog.svelte diff --git a/src/App.svelte b/src/App.svelte index 0fa27ba..9e94c91 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -68,6 +68,7 @@ import { OPEN_THESAURUS_EVENT } from './lib/spell/thesaurus'; import StyleManagerDialog from './lib/components/StyleManagerDialog.svelte'; import NoteOptionsDialog from './lib/components/NoteOptionsDialog.svelte'; + import SaveFormatDialog from './lib/components/SaveFormatDialog.svelte'; import { t, locale } from './lib/i18n/i18n.svelte'; import { fnv1a } from './lib/utils/hash'; import { withShortcut } from './lib/i18n/shortcut'; @@ -569,6 +570,7 @@ let fileInput: HTMLInputElement | null = $state(null); let pdfBusy = $state(false); let exportMenuOpen = $state(false); + let saveFormatOpen = $state(false); // The editable zones as one section — section 1 of the export. function hfSetOfState(): HfSet { @@ -979,6 +981,9 @@ async function handleSave() { if (!editor) return; exportMenuOpen = false; + // A document with no file behind it has no format yet: ask, the way saving an + // untitled document does everywhere else, and let Save As do the rest. + if (!fileHandle && !documentHasFile) { saveFormatOpen = true; return; } if (!(await ensurePassword())) return; const json = editor.getJSON() as TiptapNode; try { @@ -1765,6 +1770,7 @@ callers only say which family to land on. --> + diff --git a/src/lib/export/CLAUDE.md b/src/lib/export/CLAUDE.md index 577f8ad..25b10ba 100644 --- a/src/lib/export/CLAUDE.md +++ b/src/lib/export/CLAUDE.md @@ -11,6 +11,8 @@ are built, because the download route (Brave ships with the File System Access A read a changed name back from the browser's dialog — and Chrome's macOS save panel has no format popup and admits only the first picker type's extensions anyway. The chosen format is the document's from then on; a template follows the document's format (`.ott`, or `.dotx` for `.docx`). +Save on a document with no file behind it therefore asks first (`SaveFormatDialog`, App.svelte) +and hands over to Save As — the same beat as saving an untitled document elsewhere. `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 diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts index f7f904e..c1eb288 100644 --- a/src/lib/i18n/locales/de.ts +++ b/src/lib/i18n/locales/de.ts @@ -1082,6 +1082,7 @@ const de: Messages = { confirmReplace: 'Beim Öffnen dieser Datei wird das aktuelle Dokument ersetzt. Fortfahren?', openedWithLimitations: (list) => `Mit Einschränkungen geöffnet:\n• ${list}`, couldNotOpen: 'Diese Datei konnte nicht geöffnet werden.', + chooseFormat: 'In welchem Format speichern?', 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.', couldNotExportPdf: 'PDF-Export nicht möglich.', diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index a328eb1..c2290b8 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -1088,6 +1088,7 @@ const en = { confirmReplace: 'Opening this file will replace the current document. Continue?', openedWithLimitations: (list: string) => `Opened with limitations:\n• ${list}`, couldNotOpen: 'Could not open this file.', + chooseFormat: 'Save in which format?', 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.', couldNotExportPdf: 'Could not export to PDF.', diff --git a/tests/dom/run.mjs b/tests/dom/run.mjs index 227f22d..361ebb5 100644 --- a/tests/dom/run.mjs +++ b/tests/dom/run.mjs @@ -30,6 +30,19 @@ try { await page.reload({ waitUntil: 'load' }); await page.waitForSelector('.tiptap', { timeout: 15_000 }); + // Save on a document with no file behind it settles the format first: the download + // route cannot read one back from the browser's own dialog. + await page.evaluate(() => { window.showSaveFilePicker = undefined; }); + await page.click('.tiptap'); + await page.keyboard.type('Format first'); + await page.keyboard.press(`${MOD}+s`); + const [firstSave] = await Promise.all([ + page.waitForEvent('download', { timeout: 30_000 }), + page.locator('dialog[open] button', { hasText: '(.docx)' }).first().click(), + ]); + check(firstSave.suggestedFilename().endsWith('.docx'), + `Ctrl+S on an unsaved document asks for the format (${firstSave.suggestedFilename()})`); + // A multi-page corpus document, through the file input (no picker in headless). await page.setInputFiles('input.file-input[accept*=".odt"]', join(ROOT, 'tests/corpus/05-breaks.odt')); await page.waitForFunction(() => (document.querySelector('.tiptap')?.textContent ?? '').length > 200,