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:
1 parent
6a313e5692
commit
49e485038e
11 files changed
+326
-3
No files matched your search
+3
-1
@@ -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
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user