From 362e096a2ce113ab92f0b09c1a8010b9c966f3e8 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Tue, 8 Sep 2026 21:26:46 +0200 Subject: [PATCH 1/2] feat(wasm): edit and save a sheet from the example The example turns `odr.editing` on, lights its save button off `onEditChange` and says a refusal in the status line - the three callbacks a host assigns, here on the frame's `contentWindow` once it has loaded, where droid and ios go in through `evaluateJavascript`. A view holds its own log, so the log is written into the document when the view goes away as well as before a save, which a cell op being idempotent makes safe. `edit.test.mjs` writes a cell by position through the bindings and reopens what was saved, on an `.ods` built in memory beside the `.odt`. Step 1.5 of `docs/design/spreadsheet-editing.md`, which closes step 1. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012eNBvra2NSxLjUvVKzuXSY --- docs/design/spreadsheet-editing.md | 7 ++- wasm/README.md | 3 +- wasm/example/index.html | 73 ++++++++++++++++++++++++++++++ wasm/tests/edit.test.mjs | 25 +++++++++- wasm/tests/helper.mjs | 48 +++++++++++++++----- 5 files changed, 140 insertions(+), 16 deletions(-) diff --git a/docs/design/spreadsheet-editing.md b/docs/design/spreadsheet-editing.md index b9737eab8..6a00bf2d2 100644 --- a/docs/design/spreadsheet-editing.md +++ b/docs/design/spreadsheet-editing.md @@ -375,8 +375,11 @@ Each step ships on its own. "Both" means `.ods` and `.xlsx`. host's save button and back-press warning read. An undo shows the value the op replaced and drops it from the log, so what the log hands out and what the page shows stay the same thing. -5. `test/browser/sheet` grows the editing cases; the wasm example gets an - edit-and-save button, which is also the host-wiring reference for droid/ios. +5. **Landed.** `test/browser/sheet/editing.html` holds the editing cases; the + wasm example turns the mode on, lights its save button off `onEditChange`, + and writes the log into the document before it saves — the host-wiring + reference for droid/ios. A view holds its own log, so the example applies it + when the view goes away as well as on save. ### Step 2 — Materialise the cells that are not there diff --git a/wasm/README.md b/wasm/README.md index 1f80c39df..d29fa994b 100644 --- a/wasm/README.md +++ b/wasm/README.md @@ -187,7 +187,8 @@ cmake --build build-wasm --target odr_wasm The package lands in `build-wasm/wasm/dist` and is directly importable. `wasm/example/index.html` opens it with no bundler; serve the repository over -HTTP and visit it. +HTTP and visit it. Its `edit` and `save` buttons drive a sheet's `odr.editing` +and are the reference for wiring a host to it. Tests run under node, from ctest with `-DODR_TEST=ON`: diff --git a/wasm/example/index.html b/wasm/example/index.html index 6c8c46eb3..1de0e0ef4 100644 --- a/wasm/example/index.html +++ b/wasm/example/index.html @@ -39,6 +39,8 @@
+ + loading…
@@ -52,14 +54,65 @@ const views = document.getElementById('views'); const frame = document.getElementById('view'); const drop = document.getElementById('drop'); + const edit = document.getElementById('edit'); + const save = document.getElementById('save'); const odr = await Odr.load(); status.textContent = odr.identify(); let doc = null; let url = null; + let filename = 'document'; + let framed = null; + + // What the page in the frame publishes, or null for a view that has no + // editor - anything but a sheet, today. + function editing() { + return frame.contentWindow?.odr?.editing ?? null; + } + + // A view holds its own log, so it is applied before the view goes away + // and again before a save; a cell op is idempotent, so both is fine. + // False where the engine refused one, which keeps the log. + function collect() { + const page = framed === doc ? editing() : null; + if (page === null || !page.isEditable()) return true; + const operations = page.getOperations(); + if (JSON.parse(operations).ops.length === 0) return true; + try { + doc.edit(operations); + } catch (e) { + status.textContent = `${e.name} — ${e.message}`; + return false; + } + page.committed(); + return true; + } + + // The three callbacks a host assigns, which on droid/ios go in through + // `evaluateJavascript` once the WebView has finished loading. + function wire() { + const page = editing(); + edit.hidden = page === null; + save.hidden = page === null || !doc.isSavable(); + if (page === null) return; + edit.textContent = 'edit'; + frame.contentWindow.odr.onEditRefused = (event) => { + // the snackbar an app writes in its own string catalogue + status.textContent = `${event.reason} (${event.code}): ${event.message}`; + }; + frame.contentWindow.odr.onEditChange = (event) => { + save.disabled = !event.dirty; + save.textContent = event.dirty ? `save (${event.operations})` : 'save…'; + }; + frame.contentWindow.odr.onEditModeChange = (event) => { + edit.textContent = event.editing ? 'editing' : 'edit'; + if (event.reason) status.textContent = event.message; + }; + } function show(index) { + collect(); const { html, externalResources } = doc.render(index); if (externalResources.length > 0) { // Media is never inlined, so a blob: iframe cannot resolve it. A real @@ -69,7 +122,9 @@ } if (url) URL.revokeObjectURL(url); url = URL.createObjectURL(new Blob([html], { type: 'text/html' })); + frame.onload = wire; frame.src = url; + framed = doc; frame.hidden = false; drop.hidden = true; } @@ -96,6 +151,7 @@ } } + filename = file.name; const list = doc.listViews(); views.replaceChildren( ...list.map((v) => new Option(`${v.name} (${v.path})`, v.index)), @@ -110,6 +166,23 @@ .addEventListener('change', (e) => e.target.files[0] && open(e.target.files[0])); views.addEventListener('change', () => show(Number(views.value))); + edit.addEventListener('click', () => { + const page = editing(); + page.isEnabled() ? page.disable() : page.enable(); + }); + + save.addEventListener('click', () => { + // a file without the edits is not what the button offers + if (!collect()) return; + const saved = URL.createObjectURL(new Blob([doc.save()])); + const link = document.createElement('a'); + link.href = saved; + link.download = filename; + link.click(); + // revoking in the same tick cancels the download in chrome + setTimeout(() => URL.revokeObjectURL(saved), 0); + }); + document.addEventListener('dragover', (e) => e.preventDefault()); document.addEventListener('drop', (e) => { e.preventDefault(); diff --git a/wasm/tests/edit.test.mjs b/wasm/tests/edit.test.mjs index 551988915..1270fe1e4 100644 --- a/wasm/tests/edit.test.mjs +++ b/wasm/tests/edit.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { after, before, describe, it } from 'node:test'; -import { Odr, OdrError, minimalOdt } from './helper.mjs'; +import { Odr, OdrError, minimalOds, minimalOdt } from './helper.mjs'; // Read out of the html rather than spelled, as the browser does. function firstEditablePath(html) { @@ -62,6 +62,29 @@ describe('edit', () => { } }); + it('writes a sheet cell by position and saves it', () => { + const doc = odr.open(minimalOds('hello')); + try { + doc.edit(JSON.stringify({ + version: 1, + ops: [{ + op: 'setCell', sheet: 0, column: 0, row: 0, + value: { type: 'number', number: 12.5, text: '12.5' }, + }], + })); + assert.match(doc.render(0).html, /12\.5/); + + const reopened = odr.open(doc.save()); + try { + assert.match(reopened.render(0).html, /12\.5/); + } finally { + reopened.close(); + } + } finally { + doc.close(); + } + }); + it('saves without a render having happened', () => { const doc = odr.open(minimalOdt('untouched')); try { diff --git a/wasm/tests/helper.mjs b/wasm/tests/helper.mjs index 87d25f7bf..ddc71a10b 100644 --- a/wasm/tests/helper.mjs +++ b/wasm/tests/helper.mjs @@ -98,9 +98,9 @@ function zip(entries) { return new Uint8Array(Buffer.concat([...locals, directory, end])); } -// The smallest odt that renders: one paragraph carrying `text`. -export function minimalOdt(text = 'hello') { - const mimetype = 'application/vnd.oasis.opendocument.text'; +// `mimetype` uncompressed and a manifest naming the one part, which is what +// the documents below share. +function odf(mimetype, content) { return zip([ { name: 'mimetype', data: mimetype, store: true }, { @@ -114,19 +114,43 @@ export function minimalOdt(text = 'hello') { }, { name: 'content.xml', - data: - '' + - '' + - '' + - `${text}` + - '', + data: `${content}`, }, ]); } +// The smallest odt that renders: one paragraph carrying `text`. +export function minimalOdt(text = 'hello') { + return odf( + 'application/vnd.oasis.opendocument.text', + '' + + '' + + `${text}` + + '', + ); +} + +// The smallest ods that renders: one sheet, one string cell holding `text`. +export function minimalOds(text = 'hello') { + return odf( + 'application/vnd.oasis.opendocument.spreadsheet', + '' + + '' + + '' + + '' + + `${text}` + + '' + + '', + ); +} + // The smallest pdf that opens: one page, its cross-reference offsets computed. export function minimalPdf() { const objects = [ From eb8fa4d7b119c1fd6ddb22c624419fb14a808a9f Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Tue, 8 Sep 2026 21:27:23 +0200 Subject: [PATCH 2/2] docs(design): the spreadsheet plan is a record of two landed steps Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012eNBvra2NSxLjUvVKzuXSY --- docs/design/spreadsheet-editing.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/design/spreadsheet-editing.md b/docs/design/spreadsheet-editing.md index 6a00bf2d2..2c3a2ac3d 100644 --- a/docs/design/spreadsheet-editing.md +++ b/docs/design/spreadsheet-editing.md @@ -1,8 +1,9 @@ # Spreadsheet editing design -Status: **proposed; nothing scheduled.** This records why spreadsheet editing -is staged the way it is, what the code already gives us, and the order the -steps go in. It is a plan, not a record — update it as steps land. +Status: **steps 0 and 1 landed, and 2.1 with them; step 2 is next.** This +records why spreadsheet editing is staged the way it is, what the code already +gives us, and the order the steps go in. It is a plan, not a record — update it +as steps land. Related: [`editing.md`](editing.md) is the accepted direction for text documents (op log, ids, browser-side undo). This builds on its decisions and @@ -376,10 +377,9 @@ Each step ships on its own. "Both" means `.ods` and `.xlsx`. op replaced and drops it from the log, so what the log hands out and what the page shows stay the same thing. 5. **Landed.** `test/browser/sheet/editing.html` holds the editing cases; the - wasm example turns the mode on, lights its save button off `onEditChange`, - and writes the log into the document before it saves — the host-wiring - reference for droid/ios. A view holds its own log, so the example applies it - when the view goes away as well as on save. + wasm example is the host-wiring reference for droid/ios. A view holds its own + log, so the example writes it into the document when the view goes away as + well as on save. ### Step 2 — Materialise the cells that are not there @@ -473,6 +473,9 @@ Ordered by value over cost; all in step 0 or 1. translate time from the neighbours; the browser has to redo it for the edited row. Without it an edit into a blank cell shows the left neighbour's overflow painting across the new text. +- **A position the engine cannot write yet** — an `.xlsx` cell with no ``, + an `.ods` one with no element — carries no lock, so the page takes the edit + and `Document::edit` throws it back at the host. Until step 2, it says so. - **Sheets past the cut** (`spreadsheet_limit`, `spreadsheet_cell_limit`) are not in the page and cannot be edited; the mode should say so where a view reports a `sheet_cut`.