From b6e6328426013906acb1e653793680dc24a8c2d1 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Tue, 8 Sep 2026 21:16:21 +0200 Subject: [PATCH] feat(html): type into a sheet cell through an overlay Double-clicking a cell, or a key on the pinned one, opens an `` over it: Enter and Tab commit and move the pin, Escape cancels, blur commits, and the arrows walk the pin through `odr.sheet.pin` rather than a pin of the editor's own. The sheet's dom is untouched until the commit patches the cell, so nothing is `contenteditable` and a cell holding shapes or several runs never opens one - a locked cell refuses on the click, ahead of the double click. The type follows what was typed (decision 4): a strict grammar makes a number, a leading `'` forces a string, and `=` is refused as `formulaInput`, since overwriting an input leaves every formula computed from it stale until step 4. The commit records the op beside the value it replaced, patches the cell through its own run so a styled one keeps its style, and hands the log out coalesced per position as `odr.editing.getOperations()` - the envelope `Document::edit` takes. `odr.sheet` gained the half of this the sheet script owns: `valueAt` and `showValue` for what the page shows at a position, `reflow` for the spill `translate_sheet` measured, which goes stale the moment a blank cell fills or a full one empties, and `lower` for the raise the overlay stands in for. The spill is measured again by the same rule, off the geometry the browser has. `spreadsheet_js` outgrew msvc's 16380-byte literal and is written as two, as `pdf_annotation_js` is; `serve` joins them back. Steps 1.2 and 1.3 of `docs/design/spreadsheet-editing.md`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012eNBvra2NSxLjUvVKzuXSY --- CHANGELOG.md | 7 + docs/design/spreadsheet-editing.md | 42 +-- src/odr/internal/html/frontend.cpp | 445 ++++++++++++++++++++++++++++- test/browser/sheet/README.md | 14 +- test/browser/sheet/editing.html | 251 ++++++++++++++++ test/browser/sheet/serve | 18 +- test/src/html_test.cpp | 3 +- 7 files changed, 749 insertions(+), 31 deletions(-) create mode 100644 test/browser/sheet/editing.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e23e55a7..77cefff5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,13 @@ The release run heads these entries with the version and opens a fresh ## Unreleased +- A sheet cell can be typed into: an overlay opens on a double click or a key, + Enter and Tab commit, and `odr.editing.getOperations()` hands the host the + envelope `Document::edit` takes. + +- `odr.sheet` also answers what the page shows: `valueAt`, `showValue`, + `reflow` and `lower`. + - **Fix**: a zip entry name with a leading slash is read relative to the archive root rather than throwing, and one named `/` alone is dropped. An `.odt` carrying such an entry now opens; LibreOffice still refuses it. diff --git a/docs/design/spreadsheet-editing.md b/docs/design/spreadsheet-editing.md index d8c0d08f2..2dc176b19 100644 --- a/docs/design/spreadsheet-editing.md +++ b/docs/design/spreadsheet-editing.md @@ -46,8 +46,9 @@ results go stale the moment an input changes. | Cell value | `SheetCellAdapter` | `sheet_cell_value` reads the number and the formula (step 0.1, landed); `sheet_cell_value_type` stays the cheap question the renderer asks. Dates, booleans and errors still report `string` | | Number formats | — | Not parsed in either engine. ODS shows the producer's cached `text:p`; XLSX shows the raw `` (a date is its serial) | | Formulas | `sheet_cell_value` | The expression is read and handed out as a string (step 0.1, landed); nothing parses or evaluates it. XLSX shows the cached ``, ODS the cached `text:p`. `xls` and `numbers` drop the expression at parse time | -| Browser: sheet script | `frontend.cpp::spreadsheet_js` | Hover/pin, raise a clipped cell over its neighbours, sort rows in the DOM. Sorting reorders ``s, so a row's identity is its `` label, not its index | +| Browser: sheet script | `frontend.cpp::spreadsheet_js` | Hover/pin, raise a clipped cell over its neighbours, sort rows in the DOM. Sorting reorders ``s, so a row's identity is its `` label, not its index. Publishes `odr.sheet` (step 1.1, landed), and the value and reflow half of it (steps 1.2/1.3, landed) | | Browser: editing script | `frontend.cpp::document_js` | A `MutationObserver` over `contenteditable` runs keyed by `data-odr-path`; `odr.generateDiff()` emits the envelope | +| Browser: sheet editor | `frontend.cpp::sheet_editing_js` | `odr.editing` with the mode, the locks and the refusals (step 1.1, landed), and the overlay that types into a cell (steps 1.2/1.3, landed). Undo/redo and `committed()` are step 1.4 | | Wire format | `document.cpp::Document::edit` | The op envelope, `setCell` and `setText` (step 0.4, landed) | | Addressing | `DocumentPath` | Already spells a cell by position: `/child:0/cell:A1/...` | | Capabilities | `file_type_table.cpp` | `ods` and `xlsx` declare `edit` and `save` (step 0.2, landed); `csv` declares neither. `odr_test` checks the declaration against `Document::is_editable` | @@ -209,7 +210,8 @@ odr.onEditModeChange = function (event) {}; **The message is for the console; the code is for the host.** A mobile snackbar is written in the app's own string catalogue, and nothing in this library is localised — so the host maps `code` to its wording, and `reason` -(`"formula"`, `"repeated"`, `"rich"`, `"readOnly"`, `"encrypted"`, `"cut"`) +(`"formula"`, `"formulaInput"`, `"repeated"`, `"rich"`, `"readOnly"`, +`"encrypted"`, `"cut"`) is the same thing spelled for a reader of the log. We still ship an English `message`, so a developer who wires nothing sees it in the console (the `odr.onError` default does exactly this) and a desktop host with no catalogue @@ -266,6 +268,10 @@ odr.sheet.cellAt(column, row); // the `td`, null past the sheet's extent odr.sheet.positionOf(cell); // {column, row}, null for a header odr.sheet.pinned(); // {column, row, cell}, null for none odr.sheet.pin(position); // null clears; false where there is no cell +odr.sheet.lower(); // puts back a cell the pin raised +odr.sheet.valueAt(column, row); // what the page shows, as an op states it +odr.sheet.showValue(column, row, v); // shows it, and reflows the row +odr.sheet.reflow(row); // the spill geometry, measured again ``` **Why not a copy in the editor:** the map is not a walk over `colspan`. A row @@ -348,22 +354,24 @@ Each step ships on its own. "Both" means `.ods` and `.xlsx`. ### Step 1 — The browser editor -1. `odr.editing` mode: enable/disable, lock classes and the document attribute - from `translate_sheet`, and the three `odr.on*` callbacks with their code - table (decision 7). `spreadsheet_js` publishes `odr.sheet` in the same step - (decision 8) — the position map the mode reads a lock through. -2. Overlay editor: double-click / Enter / typing opens it over the cell; Enter, +1. **Landed.** `odr.editing` mode: enable/disable, lock classes and the + document attribute from `translate_sheet`, and the three `odr.on*` callbacks + with their code table (decision 7). `spreadsheet_js` publishes `odr.sheet` in + the same step (decision 8) — the position map the mode reads a lock through. +2. **Landed**, with item 3: an editor that drops what is typed is not one. + Overlay editor: double-click / Enter / typing opens it over the cell; Enter, Tab and blur commit; Escape cancels; arrow keys move the pin, through - `odr.sheet.pin` rather than a pin of its own. -3. Commit: parse per decision 4, record the op with its inverse, patch the - cell — text, `odr-value-type-float` for alignment, keep any shapes in A1 — - and **reflow the row**: the spill and clip `translate_sheet` measured for - the neighbours (`clip-path:inset`, `overflow:hidden`) are stale once a blank - cell fills or a full one empties. The script already has the measuring - half (`visibleRight`, `cutOff`). -4. Undo/redo over the in-memory log; `getOperations()`; `committed()`; both - raise `onEditChange`, which is what a host's save button and back-press - warning read. + `odr.sheet.pin` rather than a pin of its own. A locked cell refuses on the + click rather than on the double click that would have opened it. +3. **Landed.** Commit: parse per decision 4, record the op with its inverse, + patch the cell — text, `odr-value-type-float` for alignment, keep any shapes + in A1 — and **reflow the row**: the spill and clip `translate_sheet` measured + for the neighbours (`clip-path:inset`, `overflow:hidden`) are stale once a + blank cell fills or a full one empties. `odr.sheet` gained `valueAt`, + `showValue` and `reflow` for it, and `getOperations()` came with them: a log + nothing hands out is a log nothing can check. +4. Undo/redo over the in-memory log; `committed()`; both raise `onEditChange`, + which is what a host's save button and back-press warning read. 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. diff --git a/src/odr/internal/html/frontend.cpp b/src/odr/internal/html/frontend.cpp index 84f4e90eb..912c05673 100644 --- a/src/odr/internal/html/frontend.cpp +++ b/src/odr/internal/html/frontend.cpp @@ -81,6 +81,7 @@ constexpr std::string_view spreadsheet_css = R"css( --odr-sheet-wash-pinned:rgba(0,0,0,.09); --odr-sheet-wash-ruler:rgba(0,0,0,.10); --odr-sheet-focus:#3c78dc; +--odr-sheet-refused:#d1493f; --odr-sheet-raised:#ffffff; --odr-sheet-font:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif; } @@ -116,6 +117,12 @@ body{margin:0;background:var(--odr-sheet-canvas)} .odr-sheet td.odr-sheet-raised.odr-sheet-pinned-cell{outline:none} .odr-sheet td.odr-sheet-raised>x-p,.odr-sheet td.odr-sheet-raised>.odr-sheet-raised-box{position:absolute!important;left:0;top:0;z-index:4;height:auto!important;min-width:100%;width:max-content;max-width:60vw;padding:1px 6px;margin:-1px -6px;background:var(--odr-sheet-raised)!important;box-shadow:0 1px 4px rgba(0,0,0,.35);outline:2px solid var(--odr-sheet-focus);outline-offset:-2px;overflow:visible!important;white-space:normal!important} .odr-sheet-raised-box{display:block} +/* The editor is an overlay: over the ruler, which sticks at 3, and over the + raise at 4. */ +.odr-sheet-editor{position:absolute;z-index:5;box-sizing:border-box;margin:0;padding:1px 6px;border:0;outline:2px solid var(--odr-sheet-focus);outline-offset:-2px;border-radius:0;background:var(--odr-sheet-raised)} +.odr-editing td{cursor:cell} +.odr-editing td.odr-locked{cursor:not-allowed} +.odr-sheet td.odr-sheet-refused{outline:2px solid var(--odr-sheet-refused);outline-offset:-2px} /* The header's `position:sticky` already makes it a containing block. */ .odr-sheet-sort{position:absolute;top:1px;right:1px;bottom:1px;width:17px;display:flex;align-items:center;justify-content:center;border-radius:2px;opacity:0;cursor:pointer} .odr-sheet-column-header:hover .odr-sheet-sort,.odr-sheet-sort-asc,.odr-sheet-sort-desc{opacity:1} @@ -146,6 +153,7 @@ constexpr std::string_view spreadsheet_dark_css = R"css( --odr-sheet-wash-pinned:rgba(255,255,255,.10); --odr-sheet-wash-ruler:rgba(255,255,255,.12); --odr-sheet-focus:#4c8dff; +--odr-sheet-refused:#f0665b; --odr-sheet-raised:#1c2128; } .odr-sheet{background-color:#161b22!important} @@ -1797,6 +1805,149 @@ constexpr std::string_view spreadsheet_js = R"js( return true; } + // Nothing a reader would see, so the cell beside it may spill over it. + function isBlank(cell) { + return ( + cell.textContent.trim() === "" && + cell.querySelector(":not(x-p):not(x-s)") === null + ); + } + + // A row's cells by position, a covered one answering with the cell covering + // it. Null past the sheet's last row. + function rowCells(row) { + var entry = indexed().rows.get(row); + if (entry === undefined) { + return null; + } + if (merged) { + return entry.cells; + } + return Array.prototype.slice.call(entry.tr.cells, 1); + } + + // The row's cells once each, with what `translate_sheet` states about them: + // `max-width:0` where the column states a width, which is where it also + // clips, and `nowrap` where the string may run past the cell. + function rowState(row) { + var cells = rowCells(row); + if (cells === null) { + return null; + } + var line = []; + for (var i = 0; i < cells.length; ++i) { + if (cells[i] === cells[i - 1]) { + continue; + } + var style = getComputedStyle(cells[i]); + line.push({ + cell: cells[i], + blank: isBlank(cells[i]), + sized: style.maxWidth === "0px", + nowrap: style.whiteSpace === "nowrap", + }); + } + return line; + } + + // The spill `translate_sheet` measured goes stale the moment a cell fills or + // empties: its rule again, off the geometry the browser has. Offsets, not + // rects: blink scales a rect by the body zoom, a `clip-path` is stated under + // it. + function reflow(row) { + var line = rowState(row); + if (line === null) { + return false; + } + + // What each cell sees to its right: the next one showing something, or + // the column stating no width that stops the spill before one. + var bound = null; + var stopped = false; + for (var i = line.length - 1; i >= 0; --i) { + line[i].bound = bound; + line[i].stopped = stopped; + if (!line[i].blank) { + bound = line[i].cell; + stopped = false; + } else if (!line[i].sized) { + bound = null; + stopped = true; + } + } + + for (var j = 0; j < line.length; ++j) { + var entry = line[j]; + if (!entry.sized || !entry.nowrap) { + continue; + } + var spill = + entry.bound === null + ? 0 + : entry.bound.offsetLeft - + entry.cell.offsetLeft - + entry.cell.offsetWidth; + entry.cell.style.overflow = + spill > 0.5 || (entry.bound === null && !entry.stopped) + ? "visible" + : "hidden"; + entry.cell.style.clipPath = + spill > 0.5 ? "inset(0 " + -spill + "px 0 0)" : "none"; + } + return true; + } + + // The run a write goes through, so its style survives; the cell itself + // where it writes its string without one. + function runOf(cell) { + var box = boxOf(cell); + while ( + box !== null && + box.childElementCount === 1 && + box.firstElementChild.tagName === "X-S" + ) { + box = box.firstElementChild; + } + return box; + } + + // What the page shows at a position, shaped the way an op states a value. + function valueAt(column, row) { + var cell = cellAt(column, row); + if (cell === null) { + return null; + } + var text = cell.textContent.trim(); + if (text === "") { + return { type: "empty" }; + } + if (cell.classList.contains("odr-value-type-float")) { + var number = toNumber(text); + if (!isNaN(number)) { + return { type: "number", number: number, text: text }; + } + } + return { type: "string", text: text }; + } + + // Shows @p value at a position, as a write leaves the cell, and reflows + // the row around it. + function showValue(column, row, value) { + var cell = cellAt(column, row); + if (cell === null) { + return false; + } + lower(); + var run = runOf(cell); + if (run === null) { + return false; + } + run.textContent = value.type === "empty" ? "" : value.text; + cell.classList.toggle("odr-value-type-float", value.type === "number"); + reflow(row); + return true; + } + // What the script beside this one, and a host, ask of the sheet: positions // the way an op names them, and the pin. `spreadsheet-editing.md` decision 8. odr.sheet = { @@ -1804,6 +1955,10 @@ constexpr std::string_view spreadsheet_js = R"js( positionOf: positionOf, pinned: pinnedPosition, pin: pinAt, + lower: lower, + valueAt: valueAt, + showValue: showValue, + reflow: reflow, }; table.addEventListener("mouseover", function (event) { @@ -1860,6 +2015,10 @@ constexpr std::string_view spreadsheet_js = R"js( } }); +)js"; + +/// The rest of `spreadsheet_js`, which one literal cannot hold. +constexpr std::string_view spreadsheet_js_tail = R"js( var body = table.tBodies[0]; var original = null; var sortedColumn = -1; @@ -2016,6 +2175,7 @@ constexpr std::string_view sheet_editing_js = R"js( rich: { code: 3, message: "cell holds more than one plain run" }, shapes: { code: 4, message: "cell holds a drawing" }, readOnly: { code: 5, message: "document cannot be edited" }, + formulaInput: { code: 6, message: "typing a formula is not supported" }, }; odr.onEditRefused = function (event) { @@ -2032,12 +2192,33 @@ constexpr std::string_view sheet_editing_js = R"js( } } + var outlined = null; + var outlinedTimer = 0; + + /// The outline a refusal paints, so a host wiring nothing is not silent. + function outline(cell) { + if (outlined !== null) { + outlined.classList.remove("odr-sheet-refused"); + } + window.clearTimeout(outlinedTimer); + outlined = cell; + if (cell === null) { + return; + } + cell.classList.add("odr-sheet-refused"); + outlinedTimer = window.setTimeout(function () { + cell.classList.remove("odr-sheet-refused"); + outlined = null; + }, 700); + } + /// Four taps on a locked cell are one snackbar: the same refusal within two - /// seconds of the last is the page's to drop. + /// seconds of the last is the page's to drop. The outline answers each. function refuse(reason, column, row) { var refusal = refusals[reason] || refusals.readOnly; var key = reason + ":" + column + ":" + row; var now = Date.now(); + outline(odr.sheet.cellAt(column, row)); if (lastRefusal && lastRefusal.key === key && now - lastRefusal.at < 2000) { return; } @@ -2080,6 +2261,7 @@ constexpr std::string_view sheet_editing_js = R"js( disable: function () { if (editing) { editing = false; + close(); table.classList.remove("odr-editing"); modeChange(null); } @@ -2098,6 +2280,8 @@ constexpr std::string_view sheet_editing_js = R"js( }, }; + /// Whether the cell at (@p column, @p row) refuses a write, which is also + /// what tells the host. odr.editing.refuseAt = function (column, row) { if (!editable) { refuse("readOnly", column, row); @@ -2110,6 +2294,260 @@ constexpr std::string_view sheet_editing_js = R"js( } return false; }; + + var overlay = null; + var editingAt = null; + var history = []; + + var NUMBER = /^[+-]?([0-9]+(\.[0-9]*)?|\.[0-9]+)([eE][+-]?[0-9]+)?$/; + + /// The type follows the string the user typed: a number where the grammar + /// says so, a string otherwise, and a leading `'` forces one. + function parse(text) { + var quoted = text.charAt(0) === "'"; + var content = quoted ? text.slice(1) : text; + if (content === "") { + return { type: "empty" }; + } + if (!quoted && NUMBER.test(content)) { + return { type: "number", number: Number(content), text: content }; + } + return { type: "string", text: content }; + } + + function same(one, other) { + return ( + one.type === other.type && + (one.type === "empty" || one.text === other.text) + ); + } + + /// Writes @p value at a position: the cell shows it, and the op joins the + /// log beside the value it replaced. + function write(column, row, value) { + var before = odr.sheet.valueAt(column, row); + if (before === null || same(before, value)) { + return false; + } + if (!odr.sheet.showValue(column, row, value)) { + return false; + } + history.push({ + op: { + op: "setCell", + sheet: sheet, + column: column, + row: row, + value: value, + }, + before: before, + }); + return true; + } + + // Offsets, not rects: blink scales a rect by the body zoom `viewport_js` + // applies, and the overlay is laid out under that zoom. + function place(cell) { + var left = 0; + var top = 0; + for (var node = cell; node !== null; node = node.offsetParent) { + left += node.offsetLeft; + top += node.offsetTop; + } + var style = getComputedStyle(cell); + overlay.style.left = left + "px"; + overlay.style.top = top + "px"; + overlay.style.width = cell.offsetWidth + "px"; + overlay.style.height = cell.offsetHeight + "px"; + overlay.style.textAlign = style.textAlign; + overlay.style.color = style.color; + overlay.style.fontFamily = style.fontFamily; + overlay.style.fontSize = style.fontSize; + overlay.style.fontStyle = style.fontStyle; + overlay.style.fontWeight = style.fontWeight; + } + + /// Opens the editor over a cell, holding @p typed or the cell's own string. + /// The raise is put back down: the overlay shows what it would have. + function edit(column, row, typed) { + finish(); + if (!editing || odr.editing.refuseAt(column, row)) { + return false; + } + var cell = odr.sheet.cellAt(column, row); + if (cell === null) { + return false; + } + odr.sheet.pin({ column: column, row: row }); + odr.sheet.lower(); + + var value = odr.sheet.valueAt(column, row); + editingAt = { column: column, row: row }; + overlay = document.createElement("input"); + overlay.type = "text"; + overlay.className = "odr-sheet-editor"; + overlay.value = + typed !== null ? typed : value.type === "empty" ? "" : value.text; + place(cell); + document.body.appendChild(overlay); + overlay.addEventListener("keydown", overlayKey); + overlay.addEventListener("blur", finish); + overlay.focus(); + if (typed === null) { + overlay.select(); + } + return true; + } + + function close() { + var input = overlay; + overlay = null; + editingAt = null; + if (input !== null) { + input.remove(); + } + } + + /// Ends an open edit: what it holds is committed, and a refused formula is + /// dropped rather than left in an overlay nothing focuses again. + function finish() { + if (!commit(0, 0)) { + close(); + } + } + + /// Commits what is typed and moves the pin by (@p columns, @p rows). A + /// formula is refused rather than written, and leaves the editor open. + function commit(columns, rows) { + if (overlay === null) { + return false; + } + var text = overlay.value; + var at = editingAt; + if (text.charAt(0) === "=") { + refuse("formulaInput", at.column, at.row); + return false; + } + close(); + write(at.column, at.row, parse(text)); + if (!odr.sheet.pin({ column: at.column + columns, row: at.row + rows })) { + odr.sheet.pin({ column: at.column, row: at.row }); + } + return true; + } + + function overlayKey(event) { + // Typing is the overlay's, not the sheet's underneath it. + event.stopPropagation(); + if (event.key === "Escape") { + close(); + } else if (event.key === "Enter") { + commit(0, event.shiftKey ? -1 : 1); + } else if (event.key === "Tab") { + commit(event.shiftKey ? -1 : 1, 0); + } else { + return; + } + event.preventDefault(); + } + + var arrows = { + ArrowUp: [0, -1], + ArrowDown: [0, 1], + ArrowLeft: [-1, 0], + ArrowRight: [1, 0], + }; + + /// What a pinned cell does with a key when no editor is open. Captured, so + /// the keys taken here never reach the pin and the sort beneath. + function pinnedKey(event) { + if ( + !editing || + overlay !== null || + event.ctrlKey || + event.metaKey || + event.altKey + ) { + return; + } + var target = event.target; + if ( + target && + (target.isContentEditable || + /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)) + ) { + return; + } + var at = odr.sheet.pinned(); + if (at === null || at.column === null || at.row === null) { + return; + } + + var step = + arrows[event.key] || + (event.key === "Tab" ? [event.shiftKey ? -1 : 1, 0] : null); + if (step !== null) { + odr.sheet.pin({ column: at.column + step[0], row: at.row + step[1] }); + } else if (event.key === "Enter" || event.key === "F2") { + edit(at.column, at.row, null); + } else if (event.key === "Delete" || event.key === "Backspace") { + if (!odr.editing.refuseAt(at.column, at.row)) { + write(at.column, at.row, { type: "empty" }); + } + } else if (event.key.length === 1) { + edit(at.column, at.row, event.key); + } else { + return; + } + event.stopPropagation(); + event.preventDefault(); + } + + document.addEventListener("keydown", pinnedKey, true); + + window.addEventListener("resize", function () { + if (overlay !== null) { + place(odr.sheet.cellAt(editingAt.column, editingAt.row)); + } + }); + + function targetPosition(event) { + return odr.sheet.positionOf(event.target.closest("td")); + } + + table.addEventListener("dblclick", function (event) { + var at = editing ? targetPosition(event) : null; + if (at !== null) { + edit(at.column, at.row, null); + } + }); + + // A locked cell says so on the click, not on the double click. + table.addEventListener("click", function (event) { + var at = editing && overlay === null ? targetPosition(event) : null; + if (at !== null && odr.editing.lockAt(at.column, at.row) !== null) { + odr.editing.refuseAt(at.column, at.row); + } + }); + + /// Opens the editor over a cell, as a double click does. + odr.editing.editAt = function (column, row) { + return edit(column, row, null); + }; + + /// What a host hands to `Document::edit` before saving, coalesced per + /// position. + odr.editing.getOperations = function () { + var byPosition = new Map(); + for (var i = 0; i < history.length; ++i) { + var op = history[i].op; + byPosition.set(op.sheet + ":" + op.column + ":" + op.row, op); + } + return JSON.stringify({ + version: 1, + ops: Array.from(byPosition.values()), + }); + }; })(); )js"; @@ -2487,6 +2925,8 @@ consteval bool fits_a_literal(const std::string_view content) { static_assert(fits_a_literal(viewport_js)); static_assert(fits_a_literal(search_js)); static_assert(fits_a_literal(spreadsheet_js)); +static_assert(fits_a_literal(spreadsheet_js_tail)); +static_assert(fits_a_literal(sheet_editing_js)); static_assert(fits_a_literal(text_js)); static_assert(fits_a_literal(pdf_annotation_js)); static_assert(fits_a_literal(pdf_annotation_js_tail)); @@ -2524,7 +2964,8 @@ constexpr Asset document_js_asset{HtmlResourceType::js, "text/javascript", constexpr Asset search_js_asset{HtmlResourceType::js, "text/javascript", "search.js", search_js}; constexpr Asset spreadsheet_js_asset{HtmlResourceType::js, "text/javascript", - "spreadsheet.js", spreadsheet_js}; + "spreadsheet.js", spreadsheet_js, + spreadsheet_js_tail}; constexpr Asset sheet_editing_js_asset{HtmlResourceType::js, "text/javascript", "sheet-editing.js", sheet_editing_js}; constexpr Asset text_js_asset{HtmlResourceType::js, "text/javascript", diff --git a/test/browser/sheet/README.md b/test/browser/sheet/README.md index 9dc44c3e6..16fc7d61e 100644 --- a/test/browser/sheet/README.md +++ b/test/browser/sheet/README.md @@ -9,12 +9,14 @@ test/browser/sheet/serve # extracts the css and the scripts, serves on : open http://localhost:8732/tests.html open http://localhost:8732/positions.html open http://localhost:8732/sorting.html +open http://localhost:8732/editing.html ``` -`serve` lifts `document_css`, `spreadsheet_css`, `spreadsheet_js` and -`sheet_editing_js` out of `src/odr/internal/html/frontend.cpp`, so what runs is -what ships. Each page prints its own report and heads it with a count; a page -holds one `.odr-sheet`, because the script binds to the first one it finds. +`serve` lifts `document_css`, `spreadsheet_css`, `spreadsheet_js` (both +literals) and `sheet_editing_js` out of `src/odr/internal/html/frontend.cpp`, so +what runs is what ships. Each page prints its own report and heads it with a +count; a page holds one `.odr-sheet`, because the script binds to the first one +it finds. - **`tests.html`** — raising a cell whose text is cut off. The markup is what `translate_sheet` writes, cut down to the shapes the script has to tell apart: @@ -28,6 +30,10 @@ holds one `.odr-sheet`, because the script binds to the first one it finds. `rowspan`, and a `rowspan` reaching past the last cell of the row below it: the three shapes a walk over `colspan` alone reads wrong. It also checks that `odr.editing` finds a lock through the same map. +- **`editing.html`** — the overlay editor, driven through `odr.editing` the + way a host drives it, over the shapes a commit has to get right: a string cut + where its neighbour shows something, a formula cell, a cell of several runs, + and one whose single run carries a style a write must keep. - **`sorting.html`** — the same questions after the sort control has moved every row. Nothing here is merged, because a merged sheet is offered no sort control; a row is found by the label it carries, so where it now sits does not diff --git a/test/browser/sheet/editing.html b/test/browser/sheet/editing.html new file mode 100644 index 000000000..65c028ff9 --- /dev/null +++ b/test/browser/sheet/editing.html @@ -0,0 +1,251 @@ + + + + + sheet editing checks + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ABCD
1a string that runs onstop
212.5
37boldtworuns
4edit me
5
+ +
+ + + + + + diff --git a/test/browser/sheet/serve b/test/browser/sheet/serve index 2d2465d96..1d13cc985 100755 --- a/test/browser/sheet/serve +++ b/test/browser/sheet/serve @@ -10,14 +10,18 @@ PORT = 8732 HERE = pathlib.Path(__file__).resolve().parent SOURCE = HERE.parents[2] / "src" / "odr" / "internal" / "html" / "frontend.cpp" +# A script over the 16380 bytes msvc holds in one literal is written as two. PARTS = { - "document.css": 'constexpr std::string_view document_css = R"css(', - "spreadsheet.css": 'constexpr std::string_view spreadsheet_css = R"css(', - "spreadsheet.js": 'constexpr std::string_view spreadsheet_js = R"js(', - "sheet-editing.js": 'constexpr std::string_view sheet_editing_js = R"js(', + "document.css": ('constexpr std::string_view document_css = R"css(',), + "spreadsheet.css": ('constexpr std::string_view spreadsheet_css = R"css(',), + "spreadsheet.js": ( + 'constexpr std::string_view spreadsheet_js = R"js(', + 'constexpr std::string_view spreadsheet_js_tail = R"js(', + ), + "sheet-editing.js": ('constexpr std::string_view sheet_editing_js = R"js(',), } -PAGES = ("tests.html", "positions.html", "sorting.html") +PAGES = ("tests.html", "positions.html", "sorting.html", "editing.html") def extract(source: str, begin: str) -> str: @@ -28,8 +32,8 @@ def extract(source: str, begin: str) -> str: def main() -> None: source = SOURCE.read_text() - for name, begin in PARTS.items(): - (HERE / name).write_text(extract(source, begin)) + for name, parts in PARTS.items(): + (HERE / name).write_text("".join(extract(source, p) for p in parts)) print(f"{SOURCE.name} -> {', '.join(PARTS)}") handler = functools.partial( diff --git a/test/src/html_test.cpp b/test/src/html_test.cpp index b129b7396..c6b9028ad 100644 --- a/test/src/html_test.cpp +++ b/test/src/html_test.cpp @@ -894,7 +894,8 @@ TEST(html, a_plain_cell_carries_no_lock) { render_sheet(fods_file(fods_row(fods_cell("one"))), HtmlConfig()); EXPECT_EQ(page.find(R"(data-odr-lock=")"), std::string::npos); - EXPECT_EQ(page.find("odr-locked"), std::string::npos); + // the stylesheet names the class either way + EXPECT_EQ(page.find(R"(class="odr-locked")"), std::string::npos); } // A text document still says so in the markup: it has no overlay.