Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions docs/design/spreadsheet-editing.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 `<c>`,
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`.
Expand Down
3 changes: 2 additions & 1 deletion wasm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:

Expand Down
73 changes: 73 additions & 0 deletions wasm/example/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@
<body>
<header>
<input type="file" id="file" />
<button id="edit" hidden>edit</button>
<button id="save" hidden disabled>save…</button>
<span id="status">loading…</span>
<select id="views" hidden></select>
</header>
Expand All @@ -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
Expand All @@ -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;
}
Expand All @@ -96,6 +151,7 @@
}
}

filename = file.name;
const list = doc.listViews();
views.replaceChildren(
...list.map((v) => new Option(`${v.name} (${v.path})`, v.index)),
Expand All @@ -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();
Expand Down
25 changes: 24 additions & 1 deletion wasm/tests/edit.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down
48 changes: 36 additions & 12 deletions wasm/tests/helper.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
{
Expand All @@ -114,19 +114,43 @@ export function minimalOdt(text = 'hello') {
},
{
name: 'content.xml',
data:
'<?xml version="1.0" encoding="UTF-8"?>' +
'<office:document-content' +
' xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"' +
' xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"' +
' office:version="1.2">' +
'<office:body><office:text>' +
`<text:p>${text}</text:p>` +
'</office:text></office:body></office:document-content>',
data: `<?xml version="1.0" encoding="UTF-8"?>${content}`,
},
]);
}

// The smallest odt that renders: one paragraph carrying `text`.
export function minimalOdt(text = 'hello') {
return odf(
'application/vnd.oasis.opendocument.text',
'<office:document-content' +
' xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"' +
' xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"' +
' office:version="1.2">' +
'<office:body><office:text>' +
`<text:p>${text}</text:p>` +
'</office:text></office:body></office:document-content>',
);
}

// The smallest ods that renders: one sheet, one string cell holding `text`.
export function minimalOds(text = 'hello') {
return odf(
'application/vnd.oasis.opendocument.spreadsheet',
'<office:document-content' +
' xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"' +
' xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0"' +
' xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"' +
' office:version="1.2">' +
'<office:body><office:spreadsheet>' +
'<table:table table:name="Sheet1"><table:table-row>' +
'<table:table-cell office:value-type="string">' +
`<text:p>${text}</text:p>` +
'</table:table-cell></table:table-row></table:table>' +
'</office:spreadsheet></office:body></office:document-content>',
);
}

// The smallest pdf that opens: one page, its cross-reference offsets computed.
export function minimalPdf() {
const objects = [
Expand Down
Loading