diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f80ceb..5f1cc21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -134,6 +134,7 @@ - Captions (References ▸ Caption, LibreOffice's Insert ▸ Caption): a caption paragraph in the `Caption` style with a running number that counts itself — one counter per category (figure / table), in document order, renumbering as the document is edited. Round-trips as an ODF `` (with LibreOffice's own `ooow:Illustration+1` formula) and a Word `SEQ Figure \* ARABIC` field - Word (.docx) export and import — round-trips the editor's formatting (text, fonts, lists, tables, images, headers/footers, page geometry) and opens real Word documents - Document properties (title, subject, author, keywords, comments) in the File menu — LibreOffice's File ▸ Properties / Word's File ▸ Info. Round-trips through ODF `meta.xml` (one `meta:keyword` per keyword) and DOCX `docProps/core.xml` +- File handlers: the installed app registers for `.odt` / `.ott` / `.docx` / `.dotx`, so the OS offers it for a double-clicked document and hands the file over through `launchQueue` — with its handle, so the first Save writes that same file. Word templates (`.dotx`) open like `.ott` does: read for their content, never bound as the file to save over - Installable and offline (`public/manifest.webmanifest`, `public/sw.js`): nothing here talks to a server at runtime, so once the app's files are cached it is the whole working editor with the network gone — spell checker, fonts and all. The service worker reads the build's own hashed asset names out of the entry document, which precaches the shell with no build step; what the app loads lazily (a dictionary, the speller's WASM) is kept as it is asked for, so it joins from the second visit on. The document itself is network-first, or a cached one would keep naming the assets of the build it was cached with. Verified in a headless Chromium with the network cut: the app reloads, styles and all, and the manifest validates clean - PDF export — Raster (pixel-exact copy of the editor with a selectable text layer) and Vector (crisp, fonts embedded, via the browser print dialog) - Print (printer button / Ctrl+P) — opens the browser print dialog with a pixel-exact raster of the document (tables, headers/footers and page breaks intact) @@ -162,7 +163,6 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-14. - Vertical writing modes (ODF `tb-rl`) and Asian typography (ruby) - Password-protected ODT/DOCX; digital signatures - Split view: two views of one document, scrolled independently -- File handlers, so the OS can offer the installed app for a double-clicked `.odt`/`.docx` (Chrome's `file_handlers` + `launchQueue`) - A committed corpus of documents we author ourselves, so CI can test against real files (`.gitignore` excludes all of `tests/render-parity/fixtures/` today) diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest index f12771b..48e88a4 100644 --- a/public/manifest.webmanifest +++ b/public/manifest.webmanifest @@ -15,5 +15,16 @@ { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }, { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ], + "file_handlers": [ + { + "action": "/", + "accept": { + "application/vnd.oasis.opendocument.text": [".odt"], + "application/vnd.oasis.opendocument.text-template": [".ott"], + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": [".docx"], + "application/vnd.openxmlformats-officedocument.wordprocessingml.template": [".dotx"] + } + } ] } diff --git a/src/App.svelte b/src/App.svelte index e33e900..f06da73 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -64,6 +64,11 @@ import { registerEmbeddedFonts, clearEmbeddedFonts } from './lib/fonts/embeddedFonts'; import { saveEmbeddedFonts, loadEmbeddedFonts, clearEmbeddedFontStore } from './lib/storage/embeddedFontStore'; + // launchQueue is not in lib.dom yet; reach it through this shape. + type WithLaunchQueue = Window & { + launchQueue?: { setConsumer: (c: (p: { files: FileSystemFileHandle[] }) => void) => void }; + }; + let editor: Editor | null = $state(null); let tick: number = $state(0); let currentPage: number = $state(1); @@ -552,10 +557,11 @@ async function applyImport(bytes: Uint8Array, handle: FileSystemFileHandle | null, sourceName?: string) { if (!editor) return; try { - const isDocx = sourceName?.toLowerCase().endsWith('.docx'); - // An .ott is a template: load its content but don't bind the handle, so the - // first Save prompts for a new .odt instead of overwriting the template. - const isTemplate = sourceName?.toLowerCase().endsWith('.ott'); + const name = sourceName?.toLowerCase() ?? ''; + const isDocx = name.endsWith('.docx') || name.endsWith('.dotx'); + // A template is loaded for its content but never bound as the handle, so the + // first Save prompts for a new document instead of overwriting the template. + const isTemplate = name.endsWith('.ott') || name.endsWith('.dotx'); // Pre-decode any images in a format the browser can't render (TIFF, …) to PNG. // Lazy: the decoder loads only when such an image is present, else this is a no-op. const converted = await convertUnsupportedImages(bytes); @@ -575,7 +581,7 @@ documentEpoch++; resetHistory(); // Adopt the opened file's name as the document name (drives the save filename). - if (sourceName) documentName = stripOdtExtension(sourceName).replace(/\.docx$/i, ''); + if (sourceName) documentName = stripOdtExtension(sourceName).replace(/\.do[ct]x$/i, ''); // Adopt the document's page geometry; the $effects persist it and // Editor.svelte re-paginates. if (result.margins) pageMargins = result.margins; @@ -656,6 +662,20 @@ return () => window.removeEventListener(OPEN_COMMENT_EVENT, open); }); + // A document double-clicked in the OS reaches the installed app through launchQueue + // (the manifest's `file_handlers`). It fires once at startup, so it waits for the + // editor; the handle comes with write permission, so a later Save writes that file. + $effect(() => { + const queue = (window as WithLaunchQueue).launchQueue; + if (!editor || !queue) return; + queue.setConsumer(async ({ files }) => { + const handle = files?.[0]; + if (!handle) return; + const file = await handle.getFile(); + await applyImport(new Uint8Array(await file.arrayBuffer()), handle, file.name); + }); + }); + async function handleOpen() { if (!editor) return; if (fsSupported) { @@ -1287,7 +1307,7 @@ diff --git a/src/lib/export/saveFile.ts b/src/lib/export/saveFile.ts index d717bb4..4099be2 100644 --- a/src/lib/export/saveFile.ts +++ b/src/lib/export/saveFile.ts @@ -21,10 +21,10 @@ const TEMPLATE_PICKER_TYPES = [ { description: 'Word Template', accept: { [DOTX_MIME]: ['.dotx'] } }, ]; -// The open picker also accepts .ott templates (read-only; saving stays .odt-only). +// The open picker also accepts templates (read-only; saving stays .odt-only). const OPEN_PICKER_TYPES = [ { description: 'OpenDocument Text', accept: { [ODT_MIME]: ['.odt'], [OTT_MIME]: ['.ott'] } }, - ...DOCX_PICKER_TYPES, + { description: 'Word Document', accept: { [DOCX_MIME]: ['.docx'], [DOTX_MIME]: ['.dotx'] } }, ]; // showSaveFilePicker/showOpenFilePicker are not in lib.dom yet; reach them via casts.