From 49e485038ebf9ec65a1c97928d3e0b9ead2006b3 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Fri, 14 Aug 2026 08:23:09 +0200 Subject: [PATCH] feat: the document saves as a template, and the File menu remembers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A .ott/.dotx is the built document with one label changed — ODF's package media type, Word's content type for the document part — so the picker offers both and the chosen extension decides which exporter runs. Opening or saving also records the file in a recent list. The name is in localStorage so the menu draws at once; the handle that reopens it is in IndexedDB beside it. A file that has moved, or whose permission the browser drops on reload, is reported and dropped from the list. --- CHANGELOG.md | 4 +- src/App.svelte | 79 +++++++++++++++- src/lib/components/ribbon/Ribbon.svelte | 22 ++++- src/lib/export/CLAUDE.md | 1 + src/lib/export/saveFile.ts | 25 +++++ src/lib/export/template.ts | 36 ++++++++ src/lib/i18n/locales/de.ts | 6 ++ src/lib/i18n/locales/en.ts | 6 ++ src/lib/storage/CLAUDE.md | 1 + src/lib/storage/recentFiles.ts | 116 ++++++++++++++++++++++++ tests/unit/template.test.ts | 33 +++++++ 11 files changed, 326 insertions(+), 3 deletions(-) create mode 100644 src/lib/export/template.ts create mode 100644 src/lib/storage/recentFiles.ts create mode 100644 tests/unit/template.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b5a92b..d7e4acb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -116,6 +116,8 @@ - ODT export via odf-kit with extensive content.xml / styles.xml post-processing (custom node types, tables with borders, images, fonts and colors); filename derived from the first heading (fallback `document.odt`) - ODT import / open existing file: parses content.xml / styles.xml directly, adopts the file's margins and orientation, and reports graceful-degradation warnings - Opens `.ott` templates (OpenDocument Text Template): read like an `.odt`, but as a new untitled document — the first Save writes a fresh `.odt` and never overwrites the template (Word/LibreOffice behavior) +- Saves as a template too (File ▸ Save ▸ Template): the picker offers `.ott` and `.dotx`, and the chosen extension decides which exporter runs. A template is the document with one label changed — the ODF package's media type, Word's content type for the document part — so both word processors open it as an untitled copy +- Recent files in the File menu, as both word processors have: opening or saving a document puts it at the top of the list, and a click reopens the file itself where the browser can hand its handle back (the File System Access API re-asks for permission after a reload). A file that has moved or been declined is reported and dropped from the list rather than left to fail again; a browser without the API keeps the names only - Pictures are autosaved to IndexedDB, not localStorage: the document JSON keeps a short key per picture, so an image-heavy document no longer runs into the ~5 MB quota that used to stop it saving. Each save sweeps the pictures the document has dropped, a picture used twice is stored once, and where IndexedDB is unavailable everything stays inline as before - Embedded font loading: fonts embedded in an opened `.odt`/`.docx` (Word `.odttf` de-obfuscated) are registered via the FontFace API so text renders in its real face even when the font isn't installed; persisted per-document in IndexedDB so it survives a reload. Fonts the document only names but neither embeds nor installs are still flagged as substituted - List of figures and list of tables (References ▸ Table of contents ▸ the two caption indexes): the same generated index the table of contents is, over the captions of one category instead of the headings — single-level, with live page numbers and leader dots. Round-trips as ODF `text:illustration-index` / `text:table-index` (found by the counter's name, as LibreOffice finds them) and Word's `TOC \c "Figure"` field @@ -146,7 +148,7 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-08. - A list level's own hanging indent: the marker sits at the 0.635 cm both exports write (`LIST_HANGING_CM`), so a wider *left*-set marker overflows where Word moves the text to the next list tab (a **right**-set one — `w:lvlJc`, which is what the built-in Roman numberings use — grows into the margin and is fine). Reading the value back naively also moves the markers of our own ODT exports — LibreOffice draws its own flat hanging at exactly the value odf-kit writes for level 2; see `tests/render-parity/README.md` before building on it - Linked / chained text frames - Find & Replace by formatting or by style (the regular-expression half is implemented) -- Save as template (`.ott` / `.dotx`), multi-document management, recent-files list +- Multi-document management: one document is open at a time, so there is no window list and no side-by-side compare - Thesaurus, grammar check, word completion, AutoText / building blocks - Vertical writing modes (ODF `tb-rl`) and Asian typography (ruby) - Password-protected ODT/DOCX; digital signatures diff --git a/src/App.svelte b/src/App.svelte index e9e691a..7e53980 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -10,7 +10,8 @@ import FindReplaceBar from './lib/components/FindReplaceBar.svelte'; import { buildOdt, deriveFilename } from './lib/export/odt'; import { exportPdf, printPdf, printRaster } from './lib/export/pdf'; - import { supportsFsAccess, saveOdt, saveAsOdt, saveAsDocx, openOdt } from './lib/export/saveFile'; + import { supportsFsAccess, saveOdt, saveAsOdt, saveAsDocx, saveAsTemplate, openOdt } from './lib/export/saveFile'; + import { loadRecentFiles, rememberRecentFile, readRecentFile, forgetRecentFiles, type RecentFile } from './lib/storage/recentFiles'; import { importOdt } from './lib/import/odt'; import { importDocx } from './lib/import/docx'; import { convertUnsupportedImages } from './lib/import/imageFormats'; @@ -440,6 +441,9 @@ // The file the document is saved to (File System Access API). Session-only: a // reload restores the doc from localStorage but the first Save re-prompts. let fileHandle: FileSystemFileHandle | null = $state(null); + // Word's and LibreOffice's recent-documents list; a click reopens the file itself + // where the browser can hand back its handle. + let recentFiles: RecentFile[] = $state(loadRecentFiles()); const fsSupported = supportsFsAccess(); let fileInput: HTMLInputElement | null = $state(null); let pdfBusy = $state(false); @@ -604,6 +608,7 @@ footer: result.footerDistanceCm ?? DEFAULT_HF_DISTANCES.footer, }; fileHandle = isTemplate ? null : handle; + if (sourceName) recentFiles = await rememberRecentFile(sourceName, isTemplate ? null : handle); // Warn about fonts the document uses but the browser can't render, so text // silently shown in a substitute (Liberation Serif) is at least flagged. @@ -677,6 +682,7 @@ try { const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering, pageDecor, lineNumbering); fileHandle = await saveOdt(bytes, suggestedFilename(json), fileHandle); + recentFiles = await rememberRecentFile(fileHandle?.name ?? suggestedFilename(json), fileHandle); } catch (err) { if ((err as DOMException)?.name === 'AbortError') return; // A stored handle may have lost permission; re-prompt via Save As. @@ -693,6 +699,7 @@ try { const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering, pageDecor, lineNumbering); fileHandle = await saveAsOdt(bytes, suggestedFilename(json)); + recentFiles = await rememberRecentFile(fileHandle?.name ?? suggestedFilename(json), fileHandle); } catch (err) { if ((err as DOMException)?.name === 'AbortError') return; console.error('[save] Failed to save file:', err); @@ -700,6 +707,57 @@ } } + // 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. + async function handleSaveTemplate() { + if (!editor) return; + exportMenuOpen = false; + const json = editor.getJSON() as Parameters[0]; + try { + await saveAsTemplate(async (kind) => { + const args = [pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering, pageDecor, lineNumbering] 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)); + } + return odtToOtt(await buildOdt(json, ...args)); + }, stripOdtExtension(suggestedFilename(json))); + } catch (err) { + if ((err as DOMException)?.name === 'AbortError') return; + console.error('[save] Failed to save template:', err); + alert(t().dialogs.couldNotSave); + } + } + + // Reopen a file from the recent list. The File System Access API re-prompts for + // permission after a reload, and a file that has moved or been deleted is gone — + // either way the entry is dropped rather than left to fail again. + async function handleOpenRecent(entry: RecentFile) { + exportMenuOpen = false; + try { + const r = await readRecentFile(entry.id); + if (!r) { + alert(t().dialogs.recentUnavailable(entry.name)); + recentFiles = recentFiles.filter((f) => f.id !== entry.id); + localStorage.setItem('edentext-recent-files', JSON.stringify(recentFiles)); + return; + } + await applyImport(r.bytes, r.handle, r.name); + } catch (err) { + if ((err as DOMException)?.name === 'AbortError') return; + console.error('[open] Failed to reopen a recent file:', err); + alert(t().dialogs.couldNotOpen); + } + } + + function handleForgetRecent() { + forgetRecentFiles(); + recentFiles = []; + 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() { @@ -943,6 +1001,10 @@ 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); }} + onForgetRecent={handleForgetRecent} onExportPdf={handleExportPdf} onPrintPdf={handlePrintPdf} onPrint={handlePrint} @@ -1049,6 +1111,21 @@ {t().app.vectorPdf} {t().app.vectorHint} + + {#if recentFiles.length} +
{t().app.recentFiles}
+ {#each recentFiles as f (f.id)} + + {/each} + + {/if}
{t().docProps.title}
+ + {#if recentFiles.length} +
+ {#each recentFiles as f (f.id)} + + {/each} + + {/if}