diff --git a/CHANGELOG.md b/CHANGELOG.md index 4da46b6..9cd4731 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -133,6 +133,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` +- 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) @@ -160,7 +161,7 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-08. - Vertical writing modes (ODF `tb-rl`) and Asian typography (ruby) - Password-protected ODT/DOCX; digital signatures - Navigator / outline view, split view -- PWA / offline support +- 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/index.html b/index.html index b1199e5..81930f2 100644 --- a/index.html +++ b/index.html @@ -5,6 +5,10 @@ EdenText + + + +
diff --git a/public/icon-192.png b/public/icon-192.png new file mode 100644 index 0000000..4a27835 Binary files /dev/null and b/public/icon-192.png differ diff --git a/public/icon-512.png b/public/icon-512.png new file mode 100644 index 0000000..9330619 Binary files /dev/null and b/public/icon-512.png differ diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..f12771b --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "EdenText", + "short_name": "EdenText", + "description": "A word processor that runs in the browser and saves .odt and .docx. No account, no server: the document never leaves the machine.", + "id": "/", + "start_url": "/", + "scope": "/", + "display": "standalone", + "orientation": "any", + "background_color": "#e6e9eb", + "theme_color": "#52748b", + "categories": ["productivity", "utilities"], + "icons": [ + { "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" }, + { "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" } + ] +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..6a19174 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,68 @@ +// Offline support. Nothing here talks to a server at runtime — the document lives in +// localStorage/IndexedDB — so a cached shell is the whole working editor: dictionaries, +// fonts and the WASM speller included, each kept the first time it is asked for. + +const CACHE = 'edentext-v1'; +// The entry document under one key, whichever path asked for it. +const SHELL = '/'; + +// The build's asset names carry a content hash, and the entry document is the only +// place they are written down — so reading them out of it precaches the shell with no +// build step. What the app loads later (the speller, a dictionary, a picture decoder) +// is kept as it is asked for, which is from the **second** visit on: on the first the +// worker is still installing while those requests go out. +async function precache(cache) { + const response = await fetch(SHELL, { cache: 'reload' }); + await cache.put(SHELL, response.clone()); + const html = await response.text(); + const assets = [...html.matchAll(/(?:src|href)="(\/assets\/[^"]+)"/g)].map((m) => m[1]); + await cache.addAll([...new Set(assets), '/favicon.svg', '/manifest.webmanifest']); +} + +self.addEventListener('install', (event) => { + event.waitUntil(caches.open(CACHE).then(precache).then(() => self.skipWaiting())); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys() + .then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))) + .then(() => self.clients.claim()), + ); +}); + +// The document is network-first: served from the cache it would keep naming the assets +// of the build it was cached with, and a new one would never be fetched. +async function documentFrom(request) { + try { + const response = await fetch(request); + if (response.ok) (await caches.open(CACHE)).put(SHELL, response.clone()); + return response; + } catch { + const cached = await caches.match(SHELL, { ignoreVary: true }); + if (cached) return cached; + throw new Error('offline and no cached shell'); + } +} + +// Everything else is cache-first: the build's own files carry a content hash, so a +// cached one can never be stale. +// ponytail: the cache is never version-swept, so a redeploy's old assets stay in it +// until the browser evicts them. Sweeping needs the build id, which needs a build step. +async function assetFrom(request) { + // ignoreVary: a precached asset was fetched without the page's `Origin`, and a + // static server that answers `Vary: Origin` would make the two never match. + const cached = await caches.match(request, { ignoreVary: true }); + if (cached) return cached; + const response = await fetch(request); + // An opaque response hides its status, so only a real same-origin one is kept. + if (response.ok && response.type === 'basic') (await caches.open(CACHE)).put(request, response.clone()); + return response; +} + +self.addEventListener('fetch', (event) => { + const { request } = event; + if (request.method !== 'GET') return; + if (new URL(request.url).origin !== self.location.origin) return; + event.respondWith(request.mode === 'navigate' ? documentFrom(request) : assetFrom(request)); +}); diff --git a/src/main.ts b/src/main.ts index b01bddf..f2fd437 100644 --- a/src/main.ts +++ b/src/main.ts @@ -15,6 +15,14 @@ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () if (loadTheme() === 'auto') applyTheme('auto'); }); +// Offline support (public/sw.js). Not in dev, where a worker would serve the cached +// build over the one being edited. +if (import.meta.env.PROD && 'serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('/sw.js').catch(() => {}); + }); +} + const app = mount(App, { target: document.getElementById('app')! }); export default app;