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.