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.
This commit is contained in:
Steffen Becker committed 2026-09-08 00:14:44 +02:00
1 parent 554f0f6674
commit 52d9ab5720
6 files changed
+106

No files matched your search

+6
View File
@@ -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. -->
<StyleManagerDialog bind:open={styleManagerOpen} family={styleManagerFamily} editor={activeEditor} />
<NoteOptionsDialog bind:open={noteOptionsOpen} />
<SaveFormatDialog bind:open={saveFormatOpen} onPick={handleSaveAs} />
</main>
<style>
@@ -0,0 +1,83 @@
<script lang="ts">
import { t } from '../i18n/i18n.svelte';
// Asked for a document that has no file behind it yet: the format has to be settled
// before the bytes are built, and the download route cannot read one back.
let { open = $bindable(false), onPick }: {
open?: boolean;
onPick: (kind: 'odt' | 'docx') => void;
} = $props();
let dialogEl = $state<HTMLDialogElement | null>(null);
$effect(() => {
const el = dialogEl;
if (!el) return;
if (open && !el.open) el.showModal();
else if (!open && el.open) el.close();
});
function pick(kind: 'odt' | 'docx') {
open = false;
onPick(kind);
}
</script>
<dialog
bind:this={dialogEl}
onclose={() => (open = false)}
onclick={(e) => e.target === dialogEl && (open = false)}
aria-label={t().dialogs.chooseFormat}
>
<div class="body">
<h2>{t().dialogs.chooseFormat}</h2>
<button class="format" onclick={() => pick('odt')}>OpenDocument Text (.odt)</button>
<button class="format" onclick={() => pick('docx')}>Word Document (.docx)</button>
<div class="actions">
<button onclick={() => (open = false)}>{t().common.cancel}</button>
</div>
</div>
</dialog>
<style>
dialog {
/* The global reset zeroes every margin, which also takes the auto centring a
modal <dialog> gets by default. */
margin: auto;
border: none;
border-radius: 8px;
padding: 0;
background: var(--color-surface);
color: var(--color-text);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
dialog::backdrop { background: rgba(0, 0, 0, 0.35); }
.body {
display: flex;
flex-direction: column;
gap: 8px;
width: 300px;
padding: 18px 20px 16px;
font-family: var(--font-sans);
font-size: 0.85rem;
}
h2 { font-size: 1rem; margin-bottom: 4px; }
button {
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
padding: 7px 14px;
font: inherit;
text-align: left;
cursor: pointer;
}
button:hover { background: var(--color-btn-hover); }
.actions { display: flex; justify-content: flex-end; margin-top: 4px; }
.actions button { text-align: center; }
</style>
+2
View File
@@ -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
+1
View File
@@ -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.',
+1
View File
@@ -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.',
+13
View File
@@ -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,