fix: Save As is one entry per format, and the template follows the document's
Brave ships with the File System Access API off, so a save is a download and the browser's dialog cannot hand a changed name back: the bytes must be built in the format chosen beforehand, or a renamed file carries the other format. One entry per format settles that on every route, the DOCX export entry goes as its duplicate, and the DOM run covers the download route.
This commit is contained in:
1 parent
6693600f3b
commit
abb4870544
8 files changed
+105
-205
No files matched your search
+24
-53
@@ -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, saveToHandle, saveAsDocument, saveAsDocx, saveAsTemplate, openOdt } from './lib/export/saveFile';
|
||||
import { supportsFsAccess, saveDocument, 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';
|
||||
@@ -568,7 +568,6 @@
|
||||
if (fsSupported) void pruneRecentFiles().then((list) => (recentFiles = list));
|
||||
let fileInput: HTMLInputElement | null = $state(null);
|
||||
let pdfBusy = $state(false);
|
||||
let docxBusy = $state(false);
|
||||
let exportMenuOpen = $state(false);
|
||||
|
||||
// The editable zones as one section — section 1 of the export.
|
||||
@@ -979,42 +978,40 @@
|
||||
|
||||
async function handleSave() {
|
||||
if (!editor) return;
|
||||
// The first save is Save As: the location, and with it the format, is still open.
|
||||
const handle = fileHandle;
|
||||
if (!handle) return handleSaveAs();
|
||||
exportMenuOpen = false;
|
||||
if (!(await ensurePassword())) return;
|
||||
const json = editor.getJSON() as TiptapNode;
|
||||
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.
|
||||
await saveToHandle(await buildBytes(documentFormat, editor.getJSON() as TiptapNode), handle, docPassword);
|
||||
recentFiles = await rememberRecentFile(handle.name, handle);
|
||||
const name = documentFormat === 'docx' ? suggestedFilenameDocx(json) : suggestedFilename(json);
|
||||
fileHandle = await saveDocument(await buildBytes(documentFormat, json), name, documentFormat, fileHandle, docPassword);
|
||||
recentFiles = await rememberRecentFile(fileHandle?.name ?? name, fileHandle);
|
||||
documentHasFile = true;
|
||||
markSaved();
|
||||
} 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.
|
||||
// Every other error is reported, not papered over.
|
||||
// A stored handle may have lost its permission or its file: prompt for a new one,
|
||||
// in the document's own format. Every other error is reported, not papered over.
|
||||
const name = (err as DOMException)?.name;
|
||||
if (name === 'NotAllowedError' || name === 'NotFoundError') { fileHandle = null; return handleSave(); }
|
||||
if (fileHandle && (name === 'NotAllowedError' || name === 'NotFoundError')) { fileHandle = null; return handleSave(); }
|
||||
console.error('[save] Failed to save file:', err);
|
||||
failed(t().dialogs.couldNotSave, err);
|
||||
}
|
||||
}
|
||||
|
||||
// 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() {
|
||||
// Save As is one entry per format: the format has to be settled before the bytes are
|
||||
// built (a download cannot read a name back), and it is the document's from then on.
|
||||
async function handleSaveAs(kind: DocumentFormat) {
|
||||
if (!editor) return;
|
||||
exportMenuOpen = false;
|
||||
if (!(await ensurePassword())) return;
|
||||
const json = editor.getJSON() as TiptapNode;
|
||||
const suggested = documentFormat === 'docx' ? suggestedFilenameDocx(json) : suggestedFilename(json);
|
||||
const name = kind === 'docx' ? suggestedFilenameDocx(json) : suggestedFilename(json);
|
||||
try {
|
||||
const { handle, kind } = await saveAsDocument((k) => buildBytes(k, json), suggested, documentFormat, docPassword);
|
||||
fileHandle = handle;
|
||||
fileHandle = await saveDocument(await buildBytes(kind, json), name, kind, null, docPassword);
|
||||
documentFormat = kind;
|
||||
recentFiles = await rememberRecentFile(handle?.name ?? suggested, handle);
|
||||
recentFiles = await rememberRecentFile(fileHandle?.name ?? name, fileHandle);
|
||||
documentHasFile = true;
|
||||
markSaved();
|
||||
} catch (err) {
|
||||
@@ -1024,21 +1021,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Save the document as a template. The picker offers .ott and .dotx, so which
|
||||
// exporter runs is only known once a name is chosen; a template is never bound as
|
||||
// the current file, exactly as opening one isn't.
|
||||
// A template in the document's own format (.ott, or .dotx for a .docx document). It
|
||||
// is never bound as the current file, exactly as opening one isn't.
|
||||
async function handleSaveTemplate() {
|
||||
if (!editor) return;
|
||||
exportMenuOpen = false;
|
||||
if (!(await ensurePassword())) return;
|
||||
const json = editor.getJSON() as TiptapNode;
|
||||
try {
|
||||
await saveAsTemplate(async (kind) => {
|
||||
const { odtToOtt, docxToDotx } = await import('./lib/export/template');
|
||||
return kind === 'dotx'
|
||||
? docxToDotx(await buildBytes('docx', json))
|
||||
: odtToOtt(await buildBytes('odt', json));
|
||||
}, stripOdtExtension(suggestedFilename(json)), docPassword);
|
||||
const { odtToOtt, docxToDotx } = await import('./lib/export/template');
|
||||
const kind = documentFormat === 'docx' ? 'dotx' : 'ott';
|
||||
const bytes = kind === 'dotx' ? docxToDotx(await buildBytes('docx', json)) : odtToOtt(await buildBytes('odt', json));
|
||||
await saveDocument(bytes, `${stripOdtExtension(suggestedFilename(json))}.${kind}`, kind, null, docPassword);
|
||||
} catch (err) {
|
||||
if ((err as DOMException)?.name === 'AbortError') return;
|
||||
console.error('[save] Failed to save template:', err);
|
||||
@@ -1075,25 +1069,6 @@
|
||||
exportMenuOpen = false;
|
||||
}
|
||||
|
||||
// Export to Word .docx. The exporter (and the `docx` library) is lazy-loaded so it
|
||||
// never enters the initial bundle. Export-style like PDF: always prompt, no handle.
|
||||
async function handleSaveDocx() {
|
||||
if (!editor || docxBusy) return;
|
||||
exportMenuOpen = false;
|
||||
if (!(await ensurePassword())) return;
|
||||
docxBusy = true;
|
||||
try {
|
||||
const json = editor.getJSON() as TiptapNode;
|
||||
await saveAsDocx(await buildBytes('docx', json), suggestedFilenameDocx(json), docPassword);
|
||||
} catch (err) {
|
||||
if ((err as DOMException)?.name === 'AbortError') return;
|
||||
console.error('[docx] Export failed:', err);
|
||||
failed(t().dialogs.couldNotExportDocx, err);
|
||||
} finally {
|
||||
docxBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Lay the document out into A4 pages (Paged.js) and open the print dialog so the
|
||||
// user can "Save as PDF" — vector text matching the editor, with header/footer.
|
||||
async function handleExportPdf() {
|
||||
@@ -1334,14 +1309,12 @@
|
||||
{namePlaceholder}
|
||||
{themeMode}
|
||||
onSelectTheme={selectTheme}
|
||||
{docxBusy}
|
||||
{pdfBusy}
|
||||
onNew={handleNew}
|
||||
onNewFromTemplate={() => (templateGalleryOpen = true)}
|
||||
onOpen={handleOpen}
|
||||
onSave={handleSave}
|
||||
onSaveAs={handleSaveAs}
|
||||
onSaveDocx={handleSaveDocx}
|
||||
onSaveTemplate={handleSaveTemplate}
|
||||
recentFiles={recentFiles}
|
||||
onOpenRecent={(id) => { const f = recentFiles.find((r) => r.id === id); if (f) void handleOpenRecent(f); }}
|
||||
@@ -1452,13 +1425,11 @@
|
||||
<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 class="theme-option" onclick={() => handleSaveAs('odt')} role="menuitem">
|
||||
<span>{t().ribbon.saveAs} (.odt)</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>
|
||||
<button class="theme-option" onclick={() => handleSaveAs('docx')} role="menuitem">
|
||||
<span>{t().ribbon.saveAs} (.docx)</span>
|
||||
</button>
|
||||
<button class="theme-option" onclick={handleExportPdf} disabled={pdfBusy} role="menuitem">
|
||||
<span>{pdfBusy ? t().app.exporting : t().app.rasterPdf}</span>
|
||||
|
||||
@@ -67,10 +67,9 @@
|
||||
namePlaceholder = '',
|
||||
themeMode = 'auto',
|
||||
onSelectTheme,
|
||||
docxBusy = false,
|
||||
pdfBusy = false,
|
||||
hasPassword = false,
|
||||
onNew, onNewFromTemplate, onOpen, onSave, onSaveAs, onSaveDocx, onSaveTemplate, onExportPdf, onPrintPdf, onPrint, onAbout, onDocProperties, onProtect, onAutoCorrect, onAutoText, onNewComment,
|
||||
onNew, onNewFromTemplate, onOpen, onSave, onSaveAs, onSaveTemplate, onExportPdf, onPrintPdf, onPrint, onAbout, onDocProperties, onProtect, onAutoCorrect, onAutoText, onNewComment,
|
||||
navigatorOpen = false, onToggleNavigator,
|
||||
recentFiles = [], onOpenRecent, onForgetRecent,
|
||||
}: {
|
||||
@@ -108,14 +107,12 @@
|
||||
namePlaceholder?: string;
|
||||
themeMode?: ThemeMode;
|
||||
onSelectTheme?: (mode: ThemeMode) => void;
|
||||
docxBusy?: boolean;
|
||||
pdfBusy?: boolean;
|
||||
onNew?: () => void;
|
||||
onNewFromTemplate?: () => void;
|
||||
onOpen?: () => void;
|
||||
onSave?: () => void;
|
||||
onSaveAs?: () => void;
|
||||
onSaveDocx?: () => void;
|
||||
onSaveAs?: (kind: 'odt' | 'docx') => void;
|
||||
onSaveTemplate?: () => void;
|
||||
recentFiles?: { id: string; name: string }[];
|
||||
onOpenRecent?: (id: string) => void;
|
||||
@@ -216,15 +213,13 @@
|
||||
<Icon name="save" size={16} />{t().app.save}
|
||||
<span class="menu-key">{withShortcut('Ctrl+S')}</span>
|
||||
</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 onclick={() => run(() => onSaveAs?.('odt'))} disabled={!editor || pdfBusy}>
|
||||
<Icon name="save" size={16} />{t().ribbon.saveAs} (.odt)
|
||||
</button>
|
||||
<button onclick={() => run(() => onSaveAs?.('docx'))} disabled={!editor || pdfBusy}>
|
||||
<Icon name="save" size={16} />{t().ribbon.saveAs} (.docx)
|
||||
</button>
|
||||
<hr />
|
||||
<button onclick={() => run(onSaveDocx)} disabled={docxBusy}>
|
||||
<Icon name="export" size={16} />{docxBusy ? t().app.exporting : t().app.wordDocx}
|
||||
<span class="menu-sub">{t().app.microsoftWord}</span>
|
||||
</button>
|
||||
<button onclick={() => run(onExportPdf)} disabled={pdfBusy}>
|
||||
<Icon name="export" size={16} />{pdfBusy ? t().app.exporting : t().app.rasterPdf}
|
||||
<span class="menu-sub">{t().app.rasterHint}</span>
|
||||
|
||||
@@ -6,12 +6,11 @@ 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. Both
|
||||
extensions sit in **one** picker type: Chrome's macOS save panel shows no format popup and
|
||||
admits only the first type's extensions, so a second type can never be chosen there. A
|
||||
document without a file saves through this picker too — the first save is Save As.
|
||||
**Save As is one entry per format** (`saveDocument`): the format is settled before the bytes
|
||||
are built, because the download route (Brave ships with the File System Access API off) cannot
|
||||
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`).
|
||||
|
||||
`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
|
||||
@@ -44,7 +43,7 @@ fallback download shows a one-time hint (`edentext-download-hint`) that the brow
|
||||
- **Text boxes / shapes** (`replaceTextBoxes`/`applyTextBoxes`): a box is inline, so it becomes **two** things. `replaceTextBoxes` (after `replacePageBreaks`, before the inline passes) recurses over every node's content like `replaceImages` and swaps each box for a `TBX`-sentinel `P{i}` run **where it sits in its paragraph** — which rides every odf-kit path, table cells included — while its own blocks are **staged** at top level between `S{i}`…`E{i}` marker paragraphs (U+E008), so every existing pass serializes them unchanged (custom attrs, list styles, inline sentinels, images) and document-order-indexed passes stay aligned. The staging is only a place to serialize in: where a box sits and where its content is written out are no longer the same spot, which is what lets a box in a cell work with no change to the cell machinery. `applyTextBoxes` (after `applyImages`) cuts each `S…E` region out, keeps the serialized blocks, and writes `<draw:frame><draw:text-box fo:min-height>` (plain box; `svg:height` also emitted for non-auto-grow consumers) or `<draw:custom-shape>` + preset `<draw:enhanced-geometry>` (`utils/shapes.ts`; see `docs/architecture/frames.md`) over the `P{i}` sentinel, in whatever `<text:p>` the box really sits in. It mints a `TbxFr{n}` graphic style (fill/stroke/padding + the image wrap props; `draw:auto-grow-height="true"` for boxes, both auto-grows explicitly **false** for shapes or LibreOffice's autofit shrinks them to their text). A plain box's style chains to **`style:parent-style-name="Frame"`** — that parent is what makes LibreOffice read it as a Writer text *frame*; without it it is a drawing object whose text loses lists, nested images and borders (probed on a re-save). It is also why an **as-char** box's style spells out `style:vertical-pos="top" style:vertical-rel="baseline"`: under that parent a frame naming no vertical position hangs below the line, where a style-less as-char frame (an image) stands on it. Frames take `fo:background-color` (written beside `draw:fill`) and `fo:border` — `draw:stroke` is ignored there — and vertical text as `style:writing-mode` in the graphic properties; a *shape* instead needs `<style:paragraph-properties style:writing-mode="tb-rl"/>` (both probed). `rewriteStylesXml` declares a minimal `Frame` base style (zeroed margins/padding/border, or the built-in leaks 2mm margins and a hairline). An in-line box has **no horizontal position of its own** — the paragraph it sits in aligns it — so nothing is minted for that; a band-wrapped one carries `wrapAlign` in its graphic style. DOCX is the same pair: `inlineToRuns` drops a marker **run** where the box sits, and `applyTextBoxesDocx` swaps only that run, leaving the paragraph and the text beside it alone.
|
||||
|
||||
- **A text box's own parts, DOCX** (`applyTextBoxesDocx`): the `docx` package can't emit a DrawingML shape, so the box XML is a hand-built string — and its content needs parts the package never hears about. The pass holds the whole unzipped package, so it mints them itself: a picture becomes `word/media/tbx{n}.{ext}` plus a `Relationship` above the package's highest `rId` (Content_Types already declares the raster defaults), a list an `<w:abstractNum>`/`<w:num>` pair above its highest id, appended **before** the first `<w:num>` because every abstract definition has to precede them. The numbering ids only exist once the packed file is in hand, so the paragraphs carry a `TXBX_NUM` sentinel that the same pass substitutes.
|
||||
- **Templates** (`template.ts`): a `.ott`/`.dotx` is the built document with one label changed — ODF's package media type (in `mimetype` **and** in the manifest, the mimetype entry staying first and stored) and OOXML's content type for `/word/document.xml`. `saveAsTemplate` offers both in one picker, so the bytes are only built once the extension is known.
|
||||
- **Templates** (`template.ts`): a `.ott`/`.dotx` is the built document with one label changed — ODF's package media type (in `mimetype` **and** in the manifest, the mimetype entry staying first and stored) and OOXML's content type for `/word/document.xml`. The template's format follows the document's (`handleSaveTemplate`).
|
||||
- **Formulas** (`replaceFormulas`/`applyFormulas`): an `MTH`-sentinel run becomes an as-char `<draw:frame><draw:object xlink:href="./Formula{n}"/>` plus a `Formula{n}/content.xml` sub-document (MathML root) and its two manifest entries. **No `svg:width`/`svg:height`** — a sized frame makes LibreOffice scale the object instead of typesetting it. DOCX emits `<m:oMath>` (or `<m:oMathPara>` when displayed) from the same post-pack pass as the text boxes. See `docs/architecture/formulas.md`.
|
||||
- **Right-to-left pages**: ODF gets `style:writing-mode="rl-tb"` on the page layout (`rewriteStylesXml`), DOCX a `<w:bidi/>` in every `w:sectPr` from a post-pack pass (`applyBidiDocx`) — the package exposes only the paragraph-level flag. A **paragraph's** own direction (`textDirection.ts`) rides that same flag in DOCX and the `ParaStyle`/PBX-spec pipe in ODF, so it reaches cells and list items as well as top-level blocks.
|
||||
- **Mirror margins** (`applyMirrorMarginsDocx`, `export/docx.ts`): the `docx` package writes only the per-section `w:pgMar`, so Word's document-level `w:mirrorMargins` goes into `word/settings.xml` in the same post-pack pass; ODF's flag rides `rewriteStylesXml`. Both formats already hold left/right as the inner/outer pair, so only the flag has to travel.
|
||||
|
||||
+24
-68
@@ -11,23 +11,19 @@ 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';
|
||||
|
||||
const DOCX_PICKER_TYPES = [
|
||||
{ description: 'Word Document', accept: { [DOCX_MIME]: ['.docx'] } },
|
||||
];
|
||||
|
||||
// Both document formats as ONE picker type: Chrome's macOS save panel shows no format
|
||||
// popup and admits only the first type's extensions, so a second type is unreachable.
|
||||
// The extension typed decides which is written.
|
||||
const DOCUMENT_PICKER_TYPES = [
|
||||
{ description: 'Document', accept: { [ODT_MIME]: ['.odt'], [DOCX_MIME]: ['.docx'] } },
|
||||
];
|
||||
|
||||
const DOTX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.template';
|
||||
|
||||
// Both template formats in one picker type, for the same reason as the documents.
|
||||
const TEMPLATE_PICKER_TYPES = [
|
||||
{ description: 'Template', accept: { [OTT_MIME]: ['.ott'], [DOTX_MIME]: ['.dotx'] } },
|
||||
];
|
||||
type Kind = 'odt' | 'docx' | 'ott' | 'dotx';
|
||||
|
||||
// One picker type per format, and the format is settled before the bytes are built: the
|
||||
// download route cannot read a changed name back from the browser's dialog, and Chrome's
|
||||
// macOS panel has no format popup, so a second type in one picker is unreachable anyway.
|
||||
const FORMATS: Record<Kind, { mime: string; type: { description: string; accept: Record<string, string[]> } }> = {
|
||||
odt: { mime: ODT_MIME, type: { description: 'OpenDocument Text', accept: { [ODT_MIME]: ['.odt'] } } },
|
||||
docx: { mime: DOCX_MIME, type: { description: 'Word Document', accept: { [DOCX_MIME]: ['.docx'] } } },
|
||||
ott: { mime: OTT_MIME, type: { description: 'OpenDocument Text Template', accept: { [OTT_MIME]: ['.ott'] } } },
|
||||
dotx: { mime: DOTX_MIME, type: { description: 'Word Template', accept: { [DOTX_MIME]: ['.dotx'] } } },
|
||||
};
|
||||
|
||||
// The open picker also accepts templates; opening one never binds it as the file.
|
||||
const OPEN_PICKER_TYPES = [
|
||||
@@ -48,7 +44,7 @@ export function supportsFsAccess(): boolean {
|
||||
// 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 {
|
||||
function download(bytes: Uint8Array, name: string, mime: string): 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');
|
||||
@@ -80,64 +76,24 @@ async function protect(bytes: Uint8Array, password: string | null): Promise<Uint
|
||||
return encryptPackage(bytes, password);
|
||||
}
|
||||
|
||||
// Write into the file the document already has. A document without one saves through
|
||||
// saveAsDocument, so no location is ever asked for here.
|
||||
export async function saveToHandle(
|
||||
bytes: Uint8Array,
|
||||
handle: FileSystemFileHandle,
|
||||
password: string | null = null,
|
||||
): Promise<void> {
|
||||
await writeHandle(handle, await protect(bytes, password));
|
||||
}
|
||||
|
||||
// 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<{ handle: FileSystemFileHandle | null; kind: 'odt' | 'docx' }> {
|
||||
if (!supportsFsAccess()) {
|
||||
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: DOCUMENT_PICKER_TYPES });
|
||||
const kind = handle.name.toLowerCase().endsWith('.docx') ? 'docx' : 'odt';
|
||||
await writeHandle(handle, await protect(await build(kind), password));
|
||||
return { handle, kind };
|
||||
}
|
||||
|
||||
// Export a .docx copy: always prompt for a location, no handle is tracked (this is
|
||||
// the explicit "Export" action, like PDF). Throws AbortError if cancelled.
|
||||
export async function saveAsDocx(
|
||||
// Save in the given format: into the handle where there is one, else to a location asked
|
||||
// for in that format alone, or a download where there is no picker. Returns the handle
|
||||
// written to (null for a download). Throws AbortError if the user cancels.
|
||||
export async function saveDocument(
|
||||
bytes: Uint8Array,
|
||||
suggestedName: string,
|
||||
kind: Kind,
|
||||
handle: FileSystemFileHandle | null = null,
|
||||
password: string | null = null,
|
||||
): Promise<void> {
|
||||
): Promise<FileSystemFileHandle | null> {
|
||||
const out = await protect(bytes, password);
|
||||
if (!supportsFsAccess()) return download(out, suggestedName, DOCX_MIME);
|
||||
await writeHandle(await (window as WinFs).showSaveFilePicker!({ suggestedName, types: DOCX_PICKER_TYPES }), out);
|
||||
}
|
||||
|
||||
// Save a template. The picker offers both formats, so the bytes can only be built
|
||||
// once the user has picked one: `build` is called with the chosen extension. Falls
|
||||
// back to a plain .ott download where there is no picker.
|
||||
export async function saveAsTemplate(
|
||||
build: (kind: 'ott' | 'dotx') => Promise<Uint8Array>,
|
||||
baseName: string,
|
||||
password: string | null = null,
|
||||
): Promise<void> {
|
||||
if (!supportsFsAccess()) {
|
||||
download(await protect(await build('ott'), password), `${baseName}.ott`, OTT_MIME);
|
||||
return;
|
||||
download(out, suggestedName, FORMATS[kind].mime);
|
||||
return null;
|
||||
}
|
||||
const handle = await (window as WinFs).showSaveFilePicker!({
|
||||
suggestedName: `${baseName}.ott`, types: TEMPLATE_PICKER_TYPES,
|
||||
});
|
||||
const kind = handle.name.toLowerCase().endsWith('.dotx') ? 'dotx' : 'ott';
|
||||
await writeHandle(handle, await protect(await build(kind), password));
|
||||
const target = handle ?? (await (window as WinFs).showSaveFilePicker!({ suggestedName, types: [FORMATS[kind].type] }));
|
||||
await writeHandle(target, out);
|
||||
return target;
|
||||
}
|
||||
|
||||
// Prompt for an .odt/.ott/.docx to open, capturing its handle so a later save can
|
||||
|
||||
@@ -1041,18 +1041,15 @@ const de: Messages = {
|
||||
save: 'Speichern',
|
||||
saveExport: 'Speichern / Exportieren',
|
||||
unsavedChanges: 'Nicht gespeichert',
|
||||
saveAsFormats: '.odt oder .docx',
|
||||
odt: 'ODT',
|
||||
openDocument: 'OpenDocument',
|
||||
wordDocx: 'Word (.docx)',
|
||||
exporting: 'Wird exportiert…',
|
||||
microsoftWord: 'Microsoft Word',
|
||||
rasterPdf: 'Raster-PDF',
|
||||
rasterHint: 'Exakte Kopie des Editors',
|
||||
vectorPdf: 'Vektor-PDF (Beta)',
|
||||
vectorHint: 'Scharf, aber Probleme mit Tabellen',
|
||||
template: 'Vorlage',
|
||||
templateHint: '.ott / .dotx — öffnet sich als neues Dokument',
|
||||
templateHint: 'wie das Dokument: .ott oder .dotx — öffnet sich als neues Dokument',
|
||||
recentFiles: 'Zuletzt verwendet',
|
||||
clearRecentFiles: 'Liste leeren',
|
||||
print: 'Drucken',
|
||||
@@ -1086,7 +1083,6 @@ const de: Messages = {
|
||||
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) =>
|
||||
`„${name}“ konnte nicht erneut geöffnet werden — die Datei wurde offenbar verschoben oder gelöscht. Öffne sie stattdessen über den Dateidialog.`,
|
||||
|
||||
@@ -1047,18 +1047,15 @@ const en = {
|
||||
save: 'Save',
|
||||
saveExport: 'Save / Export',
|
||||
unsavedChanges: 'Not saved',
|
||||
saveAsFormats: '.odt or .docx',
|
||||
odt: 'ODT',
|
||||
openDocument: 'OpenDocument',
|
||||
wordDocx: 'Word (.docx)',
|
||||
exporting: 'Exporting…',
|
||||
microsoftWord: 'Microsoft Word',
|
||||
rasterPdf: 'Raster PDF',
|
||||
rasterHint: 'Exact copy of the editor',
|
||||
vectorPdf: 'Vector PDF (beta)',
|
||||
vectorHint: 'Sharp, but problems with tables',
|
||||
template: 'Template',
|
||||
templateHint: '.ott / .dotx — opens as a new document',
|
||||
templateHint: 'as the document: .ott or .dotx — opens as a new document',
|
||||
recentFiles: 'Recent files',
|
||||
clearRecentFiles: 'Clear the list',
|
||||
print: 'Print',
|
||||
@@ -1092,7 +1089,6 @@ const en = {
|
||||
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) =>
|
||||
`"${name}" could not be opened again — the file seems to have been moved or deleted. Open it from the file picker instead.`,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// the page count is what every layer on the page is measured against. Fails on any
|
||||
// uncaught page error.
|
||||
import { join } from 'node:path';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { ROOT, MOD, checker, previewServer, openApp } from '../browser.mjs';
|
||||
|
||||
const PORT = +(process.env.DOM_PORT ?? 4185);
|
||||
@@ -117,6 +118,19 @@ try {
|
||||
await page.fill('.doc-name-input', 'Umbenannt');
|
||||
const afterRename = await dot(true);
|
||||
check(!reopened && afterRename, `a rename marks the document unsaved (${afterRename})`);
|
||||
|
||||
// Without the File System Access API (Brave ships with it off) a save is a download, so
|
||||
// the format is settled before the bytes are built: Save As (.docx) hands over a DOCX
|
||||
// whatever the browser's dialog does with the name.
|
||||
await page.evaluate(() => { window.showSaveFilePicker = undefined; });
|
||||
await page.click('.ribbon-tab-file');
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent('download', { timeout: 30_000 }),
|
||||
page.locator('.ribbon-menu button', { hasText: '(.docx)' }).first().click(),
|
||||
]);
|
||||
const saved = await readFile(await download.path());
|
||||
const isDocx = saved[0] === 0x50 && saved[1] === 0x4b && saved.includes('word/document.xml');
|
||||
check(isDocx, `Save As (.docx) without a picker downloads a DOCX (${download.suggestedFilename()}, ${saved.length} bytes)`);
|
||||
} catch (err) {
|
||||
check(false, `dom run threw: ${err.message ?? err}`);
|
||||
} finally {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { saveAsDocument, saveAsTemplate } from '../../src/lib/export/saveFile';
|
||||
import { saveDocument } from '../../src/lib/export/saveFile';
|
||||
|
||||
// A handle that records what was written to it, like the picker hands back.
|
||||
function handleFor(name: string) {
|
||||
@@ -9,63 +9,36 @@ function handleFor(name: string) {
|
||||
written,
|
||||
};
|
||||
}
|
||||
const win = window as unknown as Record<string, unknown>;
|
||||
const bytes = new Uint8Array([1, 2]);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
// Chrome's macOS save panel has no format popup and admits only the first type's
|
||||
// extensions, so both must live in that one type or the second is unreachable.
|
||||
describe('the save pickers keep both extensions in one type', () => {
|
||||
const typesOf = async (run: () => Promise<unknown>) => {
|
||||
describe('saveDocument', () => {
|
||||
it('asks for a location in the chosen format alone', async () => {
|
||||
const picked = handleFor('report.docx');
|
||||
let types: { accept: Record<string, string[]> }[] = [];
|
||||
(window as unknown as Record<string, unknown>).showSaveFilePicker = async (o: { types: typeof types }) => {
|
||||
types = o.types;
|
||||
return handleFor('x.odt').handle;
|
||||
};
|
||||
await run();
|
||||
return { count: types.length, exts: types.flatMap((t) => Object.values(t.accept).flat()) };
|
||||
};
|
||||
|
||||
it('for documents', async () => {
|
||||
const r = await typesOf(() => saveAsDocument(async () => new Uint8Array(), 'doc.odt', 'odt'));
|
||||
expect(r).toEqual({ count: 1, exts: ['.odt', '.docx'] });
|
||||
win.showSaveFilePicker = async (o: { types: typeof types }) => { types = o.types; return picked.handle; };
|
||||
const handle = await saveDocument(bytes, 'report.docx', 'docx');
|
||||
expect(types.flatMap((t) => Object.values(t.accept).flat())).toEqual(['.docx']);
|
||||
expect(handle).toBe(picked.handle);
|
||||
expect(picked.written[0]).toEqual(bytes);
|
||||
});
|
||||
|
||||
it('for templates', async () => {
|
||||
const r = await typesOf(() => saveAsTemplate(async () => new Uint8Array(), 'doc'));
|
||||
expect(r).toEqual({ count: 1, exts: ['.ott', '.dotx'] });
|
||||
it('writes into the handle it is given without asking', async () => {
|
||||
const own = handleFor('mine.odt');
|
||||
win.showSaveFilePicker = async () => { throw new Error('asked'); };
|
||||
await saveDocument(bytes, 'mine.odt', 'odt', own.handle as unknown as FileSystemFileHandle);
|
||||
expect(own.written[0]).toEqual(bytes);
|
||||
});
|
||||
|
||||
// Brave ships without the File System Access API: a download in the format's own MIME
|
||||
// type, since the browser's dialog cannot hand a changed name back.
|
||||
it('downloads in the chosen format where there is no picker', async () => {
|
||||
delete win.showSaveFilePicker;
|
||||
vi.stubGlobal('alert', () => {});
|
||||
let blob: Blob | undefined;
|
||||
URL.createObjectURL = (b: Blob) => { blob = b; return 'blob:x'; };
|
||||
URL.revokeObjectURL = () => {};
|
||||
expect(await saveDocument(bytes, 'report.docx', 'docx')).toBeNull();
|
||||
expect(blob?.type).toBe('application/vnd.openxmlformats-officedocument.wordprocessingml.document');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user