feat: the OS can hand a document to the installed app

The manifest registers .odt/.ott/.docx/.dotx as file handlers and a
launchQueue consumer opens what the OS passes, keeping the handle so the
first Save writes that file. A .dotx now reads like an .ott: Word's
importer, but never bound as the file to save over.
This commit is contained in:
Steffen Becker committed 2026-08-15 01:00:20 +02:00
1 parent d0d2ea2338
commit e1b2fefa55
4 files changed
+40 -9

No files matched your search

+1 -1
View File
@@ -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 `<text:sequence>` (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)
+11
View File
@@ -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"]
}
}
]
}
+26 -6
View File
@@ -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 @@
<input
bind:this={fileInput}
type="file"
accept=".odt,.ott,.docx,application/vnd.oasis.opendocument.text,application/vnd.oasis.opendocument.text-template,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
accept=".odt,.ott,.docx,.dotx,application/vnd.oasis.opendocument.text,application/vnd.oasis.opendocument.text-template,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.wordprocessingml.template"
class="file-input"
onchange={handleImportFile}
/>
+2 -2
View File
@@ -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.