diff --git a/docs/design/spreadsheet-editing.md b/docs/design/spreadsheet-editing.md index b9737eab8..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 @@ -375,8 +376,10 @@ 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 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 @@ -470,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`. 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 = [