Skip to content

feat(html): an editing mode for a sheet, and the locks it refuses on - #861

Open
andiwand wants to merge 3 commits into
mainfrom
feat/sheet-editing-mode
Open

feat(html): an editing mode for a sheet, and the locks it refuses on#861
andiwand wants to merge 3 commits into
mainfrom
feat/sheet-editing-mode

Conversation

@andiwand

@andiwand andiwand commented Sep 7, 2026

Copy link
Copy Markdown
Member

🤖 Generated with Claude Code

Step 1.1 of docs/design/spreadsheet-editing.md — stage 0 is done, this opens the browser editor.

What the markup states

A sheet's editing is an overlay, so the page works out what it can and the markup carries only what it cannot:

where what why
<table> data-odr-sheet="0" every op names its sheet, and a view holds one
<table> data-odr-editable="true" so enable() can refuse before the user clicks anything
<td> odr-locked + data-odr-lock="…" only on a cell an edit would spoil

Three lock reasons, per decision 3:

  • formula — overwriting it leaves every value computed from it stale.
  • shapes — the cell's drawings are the cell, and an overlay would cover them.
  • rich — a write replaces the cell's one run, so anything richer would be thrown away.

repeated is not among them: #858 made a repeated cell writable, so the lock the plan reserved for it is already gone.

A cell that can be written carries nothing at all — the cost is on the locked cells only, not on the half million others.

What the scripts do

sheet-editing.js holds odr.editing (enable, disable, isEnabled, isEditable, lockAt) and the three flat odr.on* callbacks from decision 7.

  • Codes share one space with odr.onError's, appended and never renumbered — 1 is errorIllegalEditNewLine, so these start at 2.
  • The message is for the console, the code is for the host. Nothing here is localised, so a host maps the code to its own catalogue; the English message is what a developer who wires nothing sees.
  • The page suppresses its own repeats — an identical refusal within two seconds is dropped, so four taps on a locked cell are one snackbar rather than four.

spreadsheet.js publishes odr.sheetcellAt(column, row), positionOf(cell), pinned(), pin(position) — and the editor addresses cells through it (decision 8, added to the design in this PR). It is the map, not a walk:

  • a row is named by the label it carries, never by where it sits: sorting reorders the <tr>s, so body.rows[row] stops being row row the moment a column is sorted;
  • the columns are walked with the rowspans above carried down: translate_sheet writes no <td> for a position a merge covers, so a colspan-only walk misreads every cell to the right of one;
  • it is built once, by the script that owns the pin, the raise wrapper and the row order — a second copy would be a second owner of all three, and an overlay open over a cell the other script has just lowered.

Positions are the ones an op names (decision 1); the ruler's nth-child index stays private to the script, and a position a merge covers answers with the cell covering it — the one the file states. Merged sheets still get no wash and no sort control, as before.

The first two points were live bugs in the cellAt this PR previously carried inside sheet-editing.js: it indexed body.rows and walked colspan only. Neither had shipped.

One thing worth flagging

My first rich classifier assumed a cell wraps its text in a paragraph, which is odf's shape — ooxml hangs the run under the c directly. That locked 92,690 xlsx cells that are perfectly plain. Now a single paragraph is unwrapped once, and the corpus reads: 30,906 formula, 2,300 rich, 36 shapes. The remaining public rich is multiline.xlsx, which genuinely holds several runs.

Reference output

Regenerated and pinned — 355 output files (83 public, 272 private), plus resources/sheet-editing.js, a new file, and the resources/spreadsheet.js that publishes odr.sheet. Every ods page changes, since every <table> gains the two attributes and the page gains a script.

Gates: 0 of 355 changed files differ in visible text, and a fresh run against the new reference is 0 diffs in output/ and resources/.

Checks

  • 1602 tests, 1596 passed, 6 pre-existing skips. 4 new, covering the table attributes, both lock reasons reachable from a fixture, and that a plain cell carries nothing.
  • The position map is not reachable from odr_test, so test/browser/sheet grows two pages beside the raise checks: positions.html (a merged sheet — a colspan, a rowspan, and a rowspan past the row's last cell) and sorting.html (the same questions once the sort control has moved every row). 42 checks over the three pages, 0 failing. As a negative control the walk this PR replaced was run against the same fixtures: it misses 5 of the merged sheet's 20 positions and 4 of the 5 sorted rows, so the fixtures do discriminate.
  • clang-tidy clean (the one deadcode.DeadStores it reports is pre-existing on main, identical code); gcc-15 -Wall -Wextra -Werror over four TUs. No C++ changed since — odr.sheet lives in a string literal, and fits_a_literal still holds at 14580 of 16380.

Next in the stack

1.2 the overlay editor, driving odr.sheet.pin for the arrow keys, 1.3 commit and row reflow (whose visibleRight/cutOff join odr.sheet), 1.4 undo/redo and committed(), 1.5 the browser tests and the wasm example button.

andiwand and others added 2 commits September 8, 2026 07:55
A sheet's editing is an overlay, so the page works out what it can and the
markup states only what it cannot. The `<table>` gains `data-odr-sheet`, which
is what an op names, and `data-odr-editable`, so `odr.editing.enable()` can
refuse before the user clicks anything. A cell an edit would spoil gains
`odr-locked` and `data-odr-lock` with the reason - `formula`, whose dependants
would go stale, `shapes`, which an overlay would cover, and `rich`, whose
markup a one-run write would throw away. A cell that can be written carries
nothing, which is the whole point: the cost is on the locked cells only.

`sheet-editing.js` holds `odr.editing` - `enable`, `disable`, `isEnabled`,
`isEditable`, `lockAt` - and the three flat `odr.on*` callbacks. Codes share
one space with `odr.onError`'s and are appended, never renumbered; the host
maps a code to its own wording, since nothing here is localised, and the
English message is for a developer who wires nothing. An identical refusal
within two seconds is dropped by the page, so four taps on a locked cell are
one snackbar.

`spreadsheet.js` publishes `odr.sheet` beside it - `cellAt`, `positionOf`,
`pinned`, `pin` - and the editor reads positions through it rather than
walking the rows itself. A position is not a `cellIndex`: a sort moves the
rows out of position order, so a row is named by the label it carries, and a
merge writes no cell for the positions it covers, so a row's cells are walked
with the rowspans above carried down. The map is built once, by the script
that owns the pin, the raise and the sort - two copies of it would be two
owners of all three. Decision 8 in the design.

First step of the browser editor in `docs/design/spreadsheet-editing.md`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J325TWocZ4iXBjvKv2ZVZi
Every sheet states its index and editability, the locked cells say why, and
`sheet-editing.js` is a new resource beside a `spreadsheet.js` that publishes
`odr.sheet`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J325TWocZ4iXBjvKv2ZVZi
@andiwand
andiwand force-pushed the feat/sheet-editing-mode branch from 48b24b9 to 4b52a1a Compare September 8, 2026 05:55
`odr.sheet` answers questions `odr_test` cannot ask - what a `td` is at a
position - so two pages join the raise checks beside it.

`positions.html` is a merged sheet as `translate_sheet` writes one, with no
`td` where a span covers a position: a `colspan`, a `rowspan`, and a `rowspan`
reaching past the last cell of the row below it. `sorting.html` asks the same
questions after the sort control has moved every row. Both are shapes the walk
that came before answered wrong - it misses 5 of the merged sheet's 20
positions and 4 of the 5 sorted rows, which is what the fixtures are cut to
show.

The report runner moves into `checks.js` for the three pages to share, and it
now heads the list with a count. `serve` extracts `sheet_editing_js` too, since
the locks are read through the same map.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant