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:
1 parent
554f0f6674
commit
52d9ab5720
6 files changed
+106
No files matched your search
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user