feat: Save As offers both document formats in one picker
The picker lists .odt and .docx, and the chosen extension decides which exporter runs and which format the document keeps — no silent switch to .odt for a file that came in as .docx. Both save paths now build their bytes through one helper instead of repeating the argument list.
This commit is contained in:
1 parent
ad7b840662
commit
b8ff3d764f
8 files changed
+108
-39
No files matched your search
+36
-27
@@ -10,7 +10,7 @@
|
||||
import FindReplaceBar from './lib/components/FindReplaceBar.svelte';
|
||||
import type { TiptapNode } from 'odf-kit';
|
||||
import { exportPdf, printPdf, printRaster } from './lib/export/pdf';
|
||||
import { supportsFsAccess, saveOdt, saveAsOdt, saveDocx, saveAsDocx, saveAsTemplate, openOdt } from './lib/export/saveFile';
|
||||
import { supportsFsAccess, saveOdt, saveAsDocument, saveDocx, saveAsDocx, saveAsTemplate, openOdt } from './lib/export/saveFile';
|
||||
import { loadRecentFiles, rememberRecentFile, readRecentFile, forgetRecentFile, forgetRecentFiles, pruneRecentFiles, type RecentFile } from './lib/storage/recentFiles';
|
||||
import { isProtected, decryptPackage, WRONG_PASSWORD } from './lib/crypto/protect';
|
||||
import { convertUnsupportedImages } from './lib/import/imageFormats';
|
||||
@@ -901,6 +901,21 @@
|
||||
alert(`${what}\n\n${blocked ? t().dialogs.scriptBlocked : detail}`);
|
||||
}
|
||||
|
||||
// Both exporters take the same document-wide arguments, and every save path needs
|
||||
// one of them. The exporter module loads on first use.
|
||||
function exportArgs() {
|
||||
return [pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering, pageDecor, lineNumbering, recordChanges(), foldMarks, spacingAtPageStart] as const;
|
||||
}
|
||||
|
||||
async function buildBytes(kind: DocumentFormat, json: TiptapNode): Promise<Uint8Array> {
|
||||
if (kind === 'docx') {
|
||||
const { buildDocx } = await import('./lib/export/docx');
|
||||
return buildDocx(json, ...exportArgs());
|
||||
}
|
||||
const { buildOdt } = await import('./lib/export/odt');
|
||||
return buildOdt(json, ...exportArgs());
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!editor) return;
|
||||
exportMenuOpen = false;
|
||||
@@ -909,17 +924,11 @@
|
||||
try {
|
||||
// A document opened as .docx round-trips through the same format, like both
|
||||
// reference word processors — not silently rewritten to .odt under its old name.
|
||||
if (documentFormat === 'docx') {
|
||||
const { buildDocx } = await import('./lib/export/docx');
|
||||
const bytes = await buildDocx(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering, pageDecor, lineNumbering, recordChanges(), foldMarks, spacingAtPageStart);
|
||||
fileHandle = await saveDocx(bytes, suggestedFilenameDocx(json), fileHandle, docPassword);
|
||||
recentFiles = await rememberRecentFile(fileHandle?.name ?? suggestedFilenameDocx(json), fileHandle);
|
||||
return;
|
||||
}
|
||||
const { buildOdt } = await import('./lib/export/odt');
|
||||
const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering, pageDecor, lineNumbering, recordChanges(), foldMarks, spacingAtPageStart);
|
||||
fileHandle = await saveOdt(bytes, suggestedFilename(json), fileHandle, docPassword);
|
||||
recentFiles = await rememberRecentFile(fileHandle?.name ?? suggestedFilename(json), fileHandle);
|
||||
const name = documentFormat === 'docx' ? suggestedFilenameDocx(json) : suggestedFilename(json);
|
||||
const bytes = await buildBytes(documentFormat, json);
|
||||
const save = documentFormat === 'docx' ? saveDocx : saveOdt;
|
||||
fileHandle = await save(bytes, name, fileHandle, docPassword);
|
||||
recentFiles = await rememberRecentFile(fileHandle?.name ?? name, fileHandle);
|
||||
} catch (err) {
|
||||
if ((err as DOMException)?.name === 'AbortError') return;
|
||||
// A stored handle may have lost its permission or its file: prompt for a new one,
|
||||
@@ -931,17 +940,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Save As offers both formats in one picker, so the chosen extension — not the
|
||||
// format the document arrived in — decides what is written and what it becomes.
|
||||
async function handleSaveAs() {
|
||||
if (!editor) return;
|
||||
exportMenuOpen = false;
|
||||
if (!(await ensurePassword())) return;
|
||||
const json = editor.getJSON() as TiptapNode;
|
||||
const suggested = documentFormat === 'docx' ? suggestedFilenameDocx(json) : suggestedFilename(json);
|
||||
try {
|
||||
const { buildOdt } = await import('./lib/export/odt');
|
||||
const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering, pageDecor, lineNumbering, recordChanges(), foldMarks, spacingAtPageStart);
|
||||
fileHandle = await saveAsOdt(bytes, suggestedFilename(json), docPassword);
|
||||
documentFormat = 'odt'; // Save As is odt-only, so a docx-opened document switches format here.
|
||||
recentFiles = await rememberRecentFile(fileHandle?.name ?? suggestedFilename(json), fileHandle);
|
||||
const { handle, kind } = await saveAsDocument((k) => buildBytes(k, json), suggested, documentFormat, docPassword);
|
||||
fileHandle = handle;
|
||||
documentFormat = kind;
|
||||
recentFiles = await rememberRecentFile(handle?.name ?? suggested, handle);
|
||||
} catch (err) {
|
||||
if ((err as DOMException)?.name === 'AbortError') return;
|
||||
console.error('[save] Failed to save file:', err);
|
||||
@@ -959,14 +970,10 @@
|
||||
const json = editor.getJSON() as TiptapNode;
|
||||
try {
|
||||
await saveAsTemplate(async (kind) => {
|
||||
const args = [pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering, pageDecor, lineNumbering, recordChanges(), foldMarks, spacingAtPageStart] as const;
|
||||
const { odtToOtt, docxToDotx } = await import('./lib/export/template');
|
||||
if (kind === 'dotx') {
|
||||
const { buildDocx } = await import('./lib/export/docx');
|
||||
return docxToDotx(await buildDocx(json, ...args));
|
||||
}
|
||||
const { buildOdt } = await import('./lib/export/odt');
|
||||
return odtToOtt(await buildOdt(json, ...args));
|
||||
return kind === 'dotx'
|
||||
? docxToDotx(await buildBytes('docx', json))
|
||||
: odtToOtt(await buildBytes('odt', json));
|
||||
}, stripOdtExtension(suggestedFilename(json)), docPassword);
|
||||
} catch (err) {
|
||||
if ((err as DOMException)?.name === 'AbortError') return;
|
||||
@@ -1013,9 +1020,7 @@
|
||||
docxBusy = true;
|
||||
try {
|
||||
const json = editor.getJSON() as TiptapNode;
|
||||
const { buildDocx } = await import('./lib/export/docx');
|
||||
const bytes = await buildDocx(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering, pageDecor, lineNumbering, recordChanges(), foldMarks, spacingAtPageStart);
|
||||
await saveAsDocx(bytes, suggestedFilenameDocx(json), docPassword);
|
||||
await saveAsDocx(await buildBytes('docx', json), suggestedFilenameDocx(json), docPassword);
|
||||
} catch (err) {
|
||||
if ((err as DOMException)?.name === 'AbortError') return;
|
||||
console.error('[docx] Export failed:', err);
|
||||
@@ -1381,6 +1386,10 @@
|
||||
<span>{t().app.odt}</span>
|
||||
<span class="theme-option-hint">{t().app.openDocument}</span>
|
||||
</button>
|
||||
<button class="theme-option" onclick={handleSaveAs} role="menuitem">
|
||||
<span>{t().ribbon.saveAs}</span>
|
||||
<span class="theme-option-hint">{t().app.saveAsFormats}</span>
|
||||
</button>
|
||||
<button class="theme-option" onclick={handleSaveDocx} disabled={docxBusy} role="menuitem">
|
||||
<span>{docxBusy ? t().app.exporting : t().app.wordDocx}</span>
|
||||
<span class="theme-option-hint">{t().app.microsoftWord}</span>
|
||||
|
||||
@@ -216,6 +216,7 @@
|
||||
</button>
|
||||
<button onclick={() => run(onSaveAs)} disabled={!editor || pdfBusy}>
|
||||
<Icon name="save" size={16} />{t().ribbon.saveAs}
|
||||
<span class="menu-sub">{t().app.saveAsFormats}</span>
|
||||
</button>
|
||||
<hr />
|
||||
<button onclick={() => run(onSaveDocx)} disabled={docxBusy}>
|
||||
|
||||
@@ -6,6 +6,10 @@ Frames, tables and pagination have their own deep-dives in `docs/architecture/`.
|
||||
A password set for the document is applied in `saveFile.ts`'s two write primitives, after
|
||||
every pass here and after the template conversion — see `docs/architecture/encryption.md`.
|
||||
|
||||
**Save As offers both document formats in one picker** (`saveAsDocument`), like the template
|
||||
one: the bytes are built only once the extension is known, and that extension is what the
|
||||
document round-trips as from then on. Without a picker it keeps the format it had.
|
||||
|
||||
`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
|
||||
|
||||
+17
-11
@@ -19,6 +19,9 @@ const DOCX_PICKER_TYPES = [
|
||||
{ description: 'Word Document', accept: { [DOCX_MIME]: ['.docx'] } },
|
||||
];
|
||||
|
||||
// Both document formats in one picker: the chosen extension decides which is written.
|
||||
const DOCUMENT_PICKER_TYPES = [...PICKER_TYPES, ...DOCX_PICKER_TYPES];
|
||||
|
||||
const DOTX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.template';
|
||||
|
||||
// Both template formats in one picker: the chosen extension decides which is written.
|
||||
@@ -27,7 +30,7 @@ const TEMPLATE_PICKER_TYPES = [
|
||||
{ description: 'Word Template', accept: { [DOTX_MIME]: ['.dotx'] } },
|
||||
];
|
||||
|
||||
// The open picker also accepts templates (read-only; saving stays .odt-only).
|
||||
// The open picker also accepts templates; opening one never binds it as the file.
|
||||
const OPEN_PICKER_TYPES = [
|
||||
{ description: 'OpenDocument Text', accept: { [ODT_MIME]: ['.odt'], [OTT_MIME]: ['.ott'] } },
|
||||
{ description: 'Word Document', accept: { [DOCX_MIME]: ['.docx'], [DOTX_MIME]: ['.dotx'] } },
|
||||
@@ -97,20 +100,23 @@ export async function saveOdt(
|
||||
return target;
|
||||
}
|
||||
|
||||
// Always prompt for a location. Returns the new handle, or null on fallback.
|
||||
export async function saveAsOdt(
|
||||
bytes: Uint8Array,
|
||||
// Always prompt for a location, in either document format. Which exporter runs is
|
||||
// only known once a name is picked, so `build` is called with the chosen extension.
|
||||
// Without a picker the document keeps the format it already has.
|
||||
export async function saveAsDocument(
|
||||
build: (kind: 'odt' | 'docx') => Promise<Uint8Array>,
|
||||
suggestedName: string,
|
||||
fallback: 'odt' | 'docx',
|
||||
password: string | null = null,
|
||||
): Promise<FileSystemFileHandle | null> {
|
||||
const out = await protect(bytes, password);
|
||||
): Promise<{ handle: FileSystemFileHandle | null; kind: 'odt' | 'docx' }> {
|
||||
if (!supportsFsAccess()) {
|
||||
download(out, suggestedName);
|
||||
return null;
|
||||
download(await protect(await build(fallback), password), suggestedName, fallback === 'docx' ? DOCX_MIME : ODT_MIME);
|
||||
return { handle: null, kind: fallback };
|
||||
}
|
||||
const handle = await (window as WinFs).showSaveFilePicker!({ suggestedName, types: PICKER_TYPES });
|
||||
await writeHandle(handle, out);
|
||||
return handle;
|
||||
const handle = await (window as WinFs).showSaveFilePicker!({ suggestedName, types: DOCUMENT_PICKER_TYPES });
|
||||
const kind = handle.name.toLowerCase().endsWith('.docx') ? 'docx' : 'odt';
|
||||
await writeHandle(handle, await protect(await build(kind), password));
|
||||
return { handle, kind };
|
||||
}
|
||||
|
||||
// Save to the given handle if we have one; otherwise prompt for a location. Mirrors
|
||||
|
||||
@@ -1040,6 +1040,7 @@ const de: Messages = {
|
||||
open: 'Öffnen…',
|
||||
save: '.odt speichern',
|
||||
saveExport: 'Speichern / Exportieren',
|
||||
saveAsFormats: '.odt oder .docx',
|
||||
odt: 'ODT',
|
||||
openDocument: 'OpenDocument',
|
||||
wordDocx: 'Word (.docx)',
|
||||
|
||||
@@ -1046,6 +1046,7 @@ const en = {
|
||||
open: 'Open…',
|
||||
save: 'Save .odt',
|
||||
saveExport: 'Save / Export',
|
||||
saveAsFormats: '.odt or .docx',
|
||||
odt: 'ODT',
|
||||
openDocument: 'OpenDocument',
|
||||
wordDocx: 'Word (.docx)',
|
||||
|
||||
@@ -41,7 +41,7 @@ All keys share the `edentext-` prefix — a namespace, not a description: on Git
|
||||
- **Document:** `edentext-doc` — TipTap JSON, debounced 1 s on every `onUpdate` (`autosave.ts`); hiding the page (`visibilitychange`/`pagehide`) writes what is pending at once, and writes queue behind each other so a slow picture round trip never stores an older document over a newer one. Loading it raises `edentext-doc-loading`, which only a completed startup clears (`markDocumentLoaded`, two frames after `Editor.svelte`'s mount). Still set at the next load = that document hung or crashed the editor, or a reload cut its start short: the user is asked whether to try again; declined, it moves to `edentext-doc-broken` and the app starts empty, so a freeze is escaped instead of repeated. **Pictures do not live in it:** `imageStore.ts` swaps every data-URI past 4 KB for an `idb:<key>` before the write and keeps the bytes in the `edentext-images` IndexedDB store (a handful of photos is the whole localStorage quota). The key is a content hash of the data-URI's head, tail and length — sampled, because this runs on every autosave — so a picture used twice is stored once, and each save sweeps the keys the document no longer names. Where IndexedDB is unavailable the data-URIs stay inline, exactly as before; a key the store has lost keeps its `idb:` src and is reported, rather than quietly dropping the reader's content. `loadDocument` is therefore **async**, and `Editor.svelte` awaits it before building the editor.
|
||||
- **Recent files:** `edentext-recent-files` — `{id, name, at}` per entry, newest first, eight kept (`recentFiles.ts`). Only the label lives here: the `FileSystemFileHandle` that reopens one is structured-cloneable but not JSON, so it sits in the `edentext-recent` IndexedDB store under the same id, and each write sweeps the handles no entry names. Two handles compare only through `isSameEntry`, which is async — hence the walk when recording a file already in the list. A file without a handle is never recorded (nothing could reopen it — no File System Access API, or a template), so the list simply stays empty in Firefox/Safari/Brave; a startup prune drops entries whose handle is gone. Reopening asks for `read` permission again (the API drops it on reload) — a declined prompt keeps the entry for the next try, only a moved or deleted file drops it (with a message). Save re-prompts for write on its own (Save-As fallback in `App.svelte`).
|
||||
- **Document protected:** `edentext-doc-protected` — `'1'` while the open document is password-protected; the password itself is never stored, so after a reload the first save asks for it again or lets the protection go (`documentName.ts`).
|
||||
- **Document format:** `edentext-doc-format` — `'docx'` while the open document round-trips as .docx; absent = .odt (`documentName.ts`).
|
||||
- **Document format:** `edentext-doc-format` — `'docx'` while the open document round-trips as .docx; absent = .odt (`documentName.ts`). Set by the format a file was opened in, and by the extension picked in Save As.
|
||||
- **Theme:** `edentext-theme` — `'light' | 'dark' | 'allBlack' | 'auto'`.
|
||||
- **Toolbar expanded:** `edentext-toolbar-expanded` — boolean string.
|
||||
- **Chrome:** `edentext-chrome` — `'modern' | 'ribbon'` ('classic' read as the modern island's legacy stored name); absent = ribbon.
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { saveAsDocument } from '../../src/lib/export/saveFile';
|
||||
|
||||
// A handle that records what was written to it, like the picker hands back.
|
||||
function handleFor(name: string) {
|
||||
const written: Uint8Array[] = [];
|
||||
return {
|
||||
handle: { name, createWritable: async () => ({ write: (b: Uint8Array) => { written.push(b); }, close: async () => {} }) },
|
||||
written,
|
||||
};
|
||||
}
|
||||
|
||||
async function saveAs(pickerName: string | null, fallback: 'odt' | 'docx') {
|
||||
const picked = pickerName ? handleFor(pickerName) : null;
|
||||
const win = window as unknown as Record<string, unknown>;
|
||||
if (picked) win.showSaveFilePicker = async () => picked.handle;
|
||||
else delete win.showSaveFilePicker;
|
||||
const built: string[] = [];
|
||||
const res = await saveAsDocument(async (kind) => { built.push(kind); return new Uint8Array([1, 2]); },
|
||||
`doc.${fallback}`, fallback);
|
||||
return { built, kind: res.kind, handle: res.handle, written: picked?.written };
|
||||
}
|
||||
|
||||
describe('Save As builds the format the picker chose', () => {
|
||||
it('writes .docx when the chosen name says so', async () => {
|
||||
const r = await saveAs('report.docx', 'odt');
|
||||
expect(r.built).toEqual(['docx']);
|
||||
expect(r.kind).toBe('docx');
|
||||
expect(r.written?.[0]).toEqual(new Uint8Array([1, 2]));
|
||||
});
|
||||
|
||||
it('writes .odt for a document that came in as .docx', async () => {
|
||||
const r = await saveAs('report.odt', 'docx');
|
||||
expect(r.built).toEqual(['odt']);
|
||||
expect(r.kind).toBe('odt');
|
||||
});
|
||||
|
||||
it('keeps the document format where there is no picker', async () => {
|
||||
vi.stubGlobal('alert', () => {});
|
||||
URL.createObjectURL = () => 'blob:x';
|
||||
URL.revokeObjectURL = () => {};
|
||||
const r = await saveAs(null, 'docx');
|
||||
expect(r.built).toEqual(['docx']);
|
||||
expect(r.kind).toBe('docx');
|
||||
expect(r.handle).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user