feat: the document saves as a template, and the File menu remembers

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.
This commit is contained in:
Steffen Becker committed 2026-08-14 08:23:09 +02:00
1 parent 6a313e5692
commit 49e485038e
11 files changed
+326 -3

No files matched your search

+3 -1
View File
@@ -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
+78 -1
View File
@@ -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<typeof buildOdt>[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 @@
<span>{t().app.vectorPdf}</span>
<span class="theme-option-hint">{t().app.vectorHint}</span>
</button>
<button class="theme-option" onclick={handleSaveTemplate} role="menuitem">
<span>{t().app.template}</span>
<span class="theme-option-hint">{t().app.templateHint}</span>
</button>
{#if recentFiles.length}
<div class="theme-heading">{t().app.recentFiles}</div>
{#each recentFiles as f (f.id)}
<button class="theme-option" onclick={() => handleOpenRecent(f)} role="menuitem">
<span class="recent-name">{f.name}</span>
</button>
{/each}
<button class="theme-option" onclick={handleForgetRecent} role="menuitem">
<span class="theme-option-hint">{t().app.clearRecentFiles}</span>
</button>
{/if}
<div class="theme-heading">{t().docProps.title}</div>
<button class="theme-option" onclick={() => { exportMenuOpen = false; docPropsOpen = true; }} role="menuitem">
<span>{t().docProps.title}</span>
+21 -1
View File
@@ -62,7 +62,8 @@
onSelectTheme,
docxBusy = false,
pdfBusy = false,
onNew, onOpen, onSave, onSaveAs, onSaveDocx, onExportPdf, onPrintPdf, onPrint, onAbout, onDocProperties, onAutoCorrect, onNewComment, commentsOpen = false, onToggleComments,
onNew, onOpen, onSave, onSaveAs, onSaveDocx, onSaveTemplate, onExportPdf, onPrintPdf, onPrint, onAbout, onDocProperties, onAutoCorrect, onNewComment, commentsOpen = false, onToggleComments,
recentFiles = [], onOpenRecent, onForgetRecent,
}: {
editor: Editor | null;
tick: number;
@@ -99,6 +100,10 @@
onSave?: () => void;
onSaveAs?: () => void;
onSaveDocx?: () => void;
onSaveTemplate?: () => void;
recentFiles?: { id: string; name: string }[];
onOpenRecent?: (id: string) => void;
onForgetRecent?: () => void;
onExportPdf?: () => void;
onPrintPdf?: () => void;
onPrint?: () => void;
@@ -199,6 +204,21 @@
<Icon name="export" size={16} />{t().app.vectorPdf}
<span class="menu-sub">{t().app.vectorHint}</span>
</button>
<button onclick={() => run(onSaveTemplate)}>
<Icon name="export" size={16} />{t().app.template}
<span class="menu-sub">{t().app.templateHint}</span>
</button>
{#if recentFiles.length}
<hr />
{#each recentFiles as f (f.id)}
<button onclick={() => { closeMenu(); onOpenRecent?.(f.id); }}>
<Icon name="folder" size={16} />{f.name}
</button>
{/each}
<button onclick={() => run(onForgetRecent)}>
<span class="menu-sub">{t().app.clearRecentFiles}</span>
</button>
{/if}
<hr />
<button onclick={() => run(onPrint)} disabled={!editor || pdfBusy}>
<Icon name="print" size={16} />{t().app.print}
+1
View File
@@ -24,6 +24,7 @@ Frames, tables and pagination have their own deep-dives in `docs/architecture/`.
- **Tables:** column widths come from the editor's per-column weights (`tableColumnWidthsCm`, summing exactly to the text width, with `table:align="margins"`). **Table margins:** a table's `marginLeft`/`marginRight` shrink that width and are written by `applyTableMargins` into the table's automatic style (`style:width` + `fo:margin-left`/`-right`; odf-kit names table styles `Table1`, `Table2`, … in document order, matching the descriptor list `exportTable` fills). odf-kit serializes each cell to a single `<text:p>` of runs; `exportTable` emits all of a cell's content into that paragraph separated by a `SEG` sentinel and records a `CellBlock[]` descriptor, then `applyCellBlocks` splits on `SEG` and rebuilds real `<text:h>`/`<text:p>`/`<text:list>` (incl. nested lists) with minted automatic paragraph styles. **Merged cells:** `exportTable` passes each cell's `colspan`/`rowspan` as odf-kit `CellOptions.colSpan`/`rowSpan`; odf-kit emits `table:number-columns/rows-spanned` and auto-generates the `<table:covered-table-cell>` placeholders (which never match the `applyCellBlocks` cell regex, so `cellBlocks` stays one-per-real-cell aligned). Import already reads spans/covered cells in `convertTable`. **Cell shading:** a cell's `backgroundColor` attr is passed as `CellOptions.backgroundColor` (normalized), which odf-kit mints into the cell style as `fo:background-color`. **Cell borders:** the per-side border attrs (tableCellBorders.ts) are passed as `CellOptions.borderTop/Right/Bottom/Left` (`'none'` or `'<W>pt solid #RRGGBB'`) overriding the table-level default border per side.
- **Text boxes / shapes** (`replaceTextBoxes`/`applyTextBoxes`): a top-level `textBox` is **hoisted** — `replaceTextBoxes` (after `replacePageBreaks`, before the inline passes) swaps it for `TBX`-sentinel marker paragraphs (`S{i}`…`E{i}`, U+E008) bracketing its child blocks at top level, so every existing pass (custom attrs, list styles, inline sentinels, images) serializes them unchanged and document-order-indexed passes stay aligned. `applyTextBoxes` (after `applyImages`) wraps each serialized S…E region into `<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`) inside a fresh anchor paragraph, minting 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 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.
- **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.
+25
View File
@@ -13,6 +13,14 @@ const DOCX_PICKER_TYPES = [
{ description: 'Word Document', accept: { [DOCX_MIME]: ['.docx'] } },
];
const DOTX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.template';
// Both template formats in one picker: the chosen extension decides which is written.
const TEMPLATE_PICKER_TYPES = [
{ description: 'OpenDocument Text Template', accept: { [OTT_MIME]: ['.ott'] } },
{ description: 'Word Template', accept: { [DOTX_MIME]: ['.dotx'] } },
];
// The open picker also accepts .ott templates (read-only; saving stays .odt-only).
const OPEN_PICKER_TYPES = [
{ description: 'OpenDocument Text', accept: { [ODT_MIME]: ['.odt'], [OTT_MIME]: ['.ott'] } },
@@ -88,6 +96,23 @@ export async function saveAsDocx(bytes: Uint8Array, suggestedName: string): Prom
await writeHandle(handle, bytes);
}
// 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,
): Promise<void> {
if (!supportsFsAccess()) {
download(await build('ott'), `${baseName}.ott`, OTT_MIME);
return;
}
const handle = await (window as WinFs).showSaveFilePicker!({
suggestedName: `${baseName}.ott`, types: TEMPLATE_PICKER_TYPES,
});
await writeHandle(handle, await build(handle.name.toLowerCase().endsWith('.dotx') ? 'dotx' : 'ott'));
}
// Prompt for an .odt/.ott/.docx to open, capturing its handle so a later save can
// overwrite the same file. Returns null if cancelled. Only call when supportsFsAccess().
export async function openOdt(): Promise<{ bytes: Uint8Array; handle: FileSystemFileHandle; name: string } | null> {
+36
View File
@@ -0,0 +1,36 @@
import { unzipSync, zipSync, strFromU8, strToU8 } from 'fflate';
// A template is the document it was built from with one label changed: ODF swaps the
// package's media type (in `mimetype` and in the manifest), OOXML the content type of
// the document part. Both word processors then open it as an untitled copy instead of
// the file itself — which is all a .ott/.dotx is.
const ODT_MIME = 'application/vnd.oasis.opendocument.text';
const OTT_MIME = 'application/vnd.oasis.opendocument.text-template';
const DOCX_MAIN = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml';
const DOTX_MAIN = 'application/vnd.openxmlformats-officedocument.wordprocessingml.template.main+xml';
export function odtToOtt(bytes: Uint8Array): Uint8Array {
const files = unzipSync(bytes);
files['mimetype'] = strToU8(OTT_MIME);
const manifest = files['META-INF/manifest.xml'];
if (manifest) {
files['META-INF/manifest.xml'] = strToU8(strFromU8(manifest).replace(
new RegExp(`manifest:media-type="${ODT_MIME}"`, 'g'), `manifest:media-type="${OTT_MIME}"`));
}
// The mimetype entry stays first and uncompressed, as in every ODF package.
const out: Record<string, [Uint8Array, { level: 0 | 6 }]> = { mimetype: [files['mimetype'], { level: 0 }] };
for (const [path, data] of Object.entries(files)) {
if (path !== 'mimetype') out[path] = [data, { level: 6 }];
}
return zipSync(out);
}
export function docxToDotx(bytes: Uint8Array): Uint8Array {
const files = unzipSync(bytes);
const ct = files['[Content_Types].xml'];
if (ct) files['[Content_Types].xml'] = strToU8(strFromU8(ct).replace(DOCX_MAIN, DOTX_MAIN));
const out: Record<string, [Uint8Array, { level: 6 }]> = {};
for (const [path, data] of Object.entries(files)) out[path] = [data, { level: 6 }];
return zipSync(out);
}
+6
View File
@@ -739,6 +739,10 @@ const de: Messages = {
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',
recentFiles: 'Zuletzt verwendet',
clearRecentFiles: 'Liste leeren',
print: 'Drucken',
tools: 'Werkzeuge',
showExtraTools: 'Weitere Werkzeuge anzeigen',
@@ -771,6 +775,8 @@ const de: Messages = {
couldNotSave: 'Diese Datei konnte nicht gespeichert werden.',
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 vielleicht verschoben, oder der Browser hat keine Berechtigung mehr dafür. Öffne sie stattdessen über den Dateidialog.`,
couldNotPrint: 'Drucken nicht möglich.',
couldNotPrintPdf: 'Druck als PDF nicht möglich.',
autosaveQuota:
+6
View File
@@ -743,6 +743,10 @@ const en = {
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',
recentFiles: 'Recent files',
clearRecentFiles: 'Clear the list',
print: 'Print',
tools: 'Tools',
showExtraTools: 'Show extra tools',
@@ -775,6 +779,8 @@ const en = {
couldNotSave: 'Could not save this file.',
couldNotExportDocx: 'Could not export to Word (.docx).',
couldNotExportPdf: 'Could not export to PDF.',
recentUnavailable: (name: string) =>
`"${name}" could not be opened again — it may have moved, or the browser no longer has permission for it. Open it from the file picker instead.`,
couldNotPrint: 'Could not print.',
couldNotPrintPdf: 'Could not print to PDF.',
autosaveQuota:
+1
View File
@@ -25,6 +25,7 @@ Both margins and orientation are passed into the ODT export so the exported docu
All keys share the `edentext-` prefix — a namespace, not a description: on GitHub Pages every repo of an account shares one origin, so a generic prefix would collide.
- **Document:** `edentext-doc` — TipTap JSON, debounced 1 s on every `onUpdate` (`autosave.ts`). 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: it moves to `edentext-doc-broken` and the app starts empty, so a reload escapes the freeze instead of repeating it. **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. Reopening re-asks for permission (the API drops it on reload); a refusal, a moved file or a browser with no picker leaves the entry unopenable, and it is dropped with a message instead of failing again.
- **Theme:** `edentext-theme` — `'light' | 'dark' | 'allBlack' | 'auto'`.
- **Toolbar expanded:** `edentext-toolbar-expanded` — boolean string.
- **Chrome:** `edentext-chrome` — `'classic' | 'ribbon'`; absent = classic.
+116
View File
@@ -0,0 +1,116 @@
import { openDb, idbRequest } from './idb';
// The files this browser has opened or saved, most recent first. The name and time
// go in localStorage so the menu can be drawn synchronously; the FileSystemFileHandle
// that actually reopens one is structured-cloneable but not JSON, so it lives in
// IndexedDB beside it. A browser without the File System Access API keeps the names
// only — there is nothing to reopen from, and the entry says so.
const KEY = 'edentext-recent-files';
const DB_NAME = 'edentext-recent';
const STORE = 'handles';
const MAX = 8;
export type RecentFile = {
/** Stable within this browser: the id of its handle record. */
id: string;
name: string;
/** Epoch ms of the last open or save. */
at: number;
};
export function loadRecentFiles(): RecentFile[] {
try {
const list = JSON.parse(localStorage.getItem(KEY) ?? '[]');
return Array.isArray(list) ? list.filter((f) => f && typeof f.name === 'string') : [];
} catch {
return [];
}
}
function write(list: RecentFile[]): void {
if (list.length) localStorage.setItem(KEY, JSON.stringify(list));
else localStorage.removeItem(KEY);
}
/**
* Record a file as just used. A handle already in the list keeps its id — the same
* file opened twice is one entry, moved to the top — and one without a handle is
* matched on its name, which is all a browser with no picker gives us.
*/
export async function rememberRecentFile(name: string, handle: FileSystemFileHandle | null): Promise<RecentFile[]> {
const list = loadRecentFiles();
const existing = handle ? await findByHandle(list, handle) : list.find((f) => f.name === name);
const id = existing?.id ?? `f${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
const next = [{ id, name, at: Date.now() }, ...list.filter((f) => f.id !== id)].slice(0, MAX);
write(next);
if (handle) await putHandle(id, handle);
await sweepHandles(next);
return next;
}
export function forgetRecentFiles(): void {
write([]);
void sweepHandles([]);
}
// isSameEntry is the only way to compare two handles; it is async, so this walks.
async function findByHandle(list: RecentFile[], handle: FileSystemFileHandle): Promise<RecentFile | undefined> {
for (const f of list) {
const known = await getHandle(f.id);
if (known && (await known.isSameEntry(handle))) return f;
}
return undefined;
}
async function putHandle(id: string, handle: FileSystemFileHandle): Promise<void> {
if (typeof indexedDB === 'undefined') return;
try {
const db = await openDb(DB_NAME, STORE);
await idbRequest(db, STORE, 'readwrite', (s) => s.put(handle, id));
db.close();
} catch { /* no handle stored: the entry is then a name only */ }
}
export async function getHandle(id: string): Promise<FileSystemFileHandle | null> {
if (typeof indexedDB === 'undefined') return null;
try {
const db = await openDb(DB_NAME, STORE);
const handle = await idbRequest<FileSystemFileHandle | undefined>(db, STORE, 'readonly', (s) => s.get(id));
db.close();
return handle ?? null;
} catch {
return null;
}
}
async function sweepHandles(list: RecentFile[]): Promise<void> {
if (typeof indexedDB === 'undefined') return;
try {
const db = await openDb(DB_NAME, STORE);
const keys = await idbRequest<IDBValidKey[]>(db, STORE, 'readonly', (s) => s.getAllKeys());
for (const key of keys) {
if (!list.some((f) => f.id === String(key))) await idbRequest(db, STORE, 'readwrite', (s) => s.delete(key));
}
db.close();
} catch { /* nothing to sweep */ }
}
/**
* The bytes of a recent file, or null when it can't be reopened — the browser has no
* handle for it, the file is gone, or the user declined the permission prompt the
* File System Access API requires after a reload.
*/
export async function readRecentFile(id: string): Promise<{ bytes: Uint8Array; handle: FileSystemFileHandle; name: string } | null> {
const handle = await getHandle(id);
if (!handle) return null;
type Permissioned = FileSystemFileHandle & {
queryPermission?: (d: { mode: string }) => Promise<PermissionState>;
requestPermission?: (d: { mode: string }) => Promise<PermissionState>;
};
const h = handle as Permissioned;
let state = (await h.queryPermission?.({ mode: 'readwrite' })) ?? 'granted';
if (state === 'prompt') state = (await h.requestPermission?.({ mode: 'readwrite' })) ?? 'denied';
if (state !== 'granted') return null;
const file = await handle.getFile();
return { bytes: new Uint8Array(await file.arrayBuffer()), handle, name: file.name };
}
+33
View File
@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest';
import { unzipSync, strFromU8 } from 'fflate';
import { buildOdt } from '../../src/lib/export/odt';
import { buildDocx } from '../../src/lib/export/docx';
import { odtToOtt, docxToDotx } from '../../src/lib/export/template';
import { importOdt } from '../../src/lib/import/odt';
import { importDocx } from '../../src/lib/import/docx';
const margins = { top: 2, bottom: 2, left: 2, right: 2 };
const doc: any = { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Vorlage' }] }] };
describe('saving as a template', () => {
it('.ott relabels the package and keeps the mimetype entry first', async () => {
const ott = odtToOtt(await buildOdt(doc, margins as any, 'portrait'));
const files = unzipSync(ott);
expect(strFromU8(files['mimetype'])).toBe('application/vnd.oasis.opendocument.text-template');
expect(Object.keys(files)[0]).toBe('mimetype');
expect(strFromU8(files['META-INF/manifest.xml'])).toContain(
'manifest:media-type="application/vnd.oasis.opendocument.text-template"');
// The mimetype entry must be stored, not deflated: the local header's
// compression-method field sits at offset 8.
expect(ott[8] | (ott[9] << 8)).toBe(0);
expect(importOdt(ott).content.content?.[0].content?.[0].text).toBe('Vorlage');
});
it('.dotx relabels the document part', async () => {
const dotx = docxToDotx(await buildDocx(doc, margins as any, 'portrait'));
const ct = strFromU8(unzipSync(dotx)['[Content_Types].xml']);
expect(ct).toContain('wordprocessingml.template.main+xml');
expect(ct).not.toContain('wordprocessingml.document.main+xml');
expect(importDocx(dotx).content.content?.[0].content?.[0].text).toBe('Vorlage');
});
});