diff --git a/CHANGELOG.md b/CHANGELOG.md index a2b8ae46f..68c7835ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,19 @@ The release run heads these entries with the version and opens a fresh ## Unreleased +- The rendered sheet exposes `odr.editing`: `enable()` / `disable()` turn the + mode on, `lockAt()` answers for a cell, and a refusal reaches the host as + `odr.onEditRefused` / `odr.onEditModeChange`, whose codes share the space + `odr.onError` numbers. Beside it `odr.sheet` addresses the view the way an op + does — `cellAt`, `positionOf`, `pinned` and `pin`, by position rather than by + where a cell happens to sit after a merge or a sort. + +- A sheet states in its markup what the page cannot work out: the sheet an op + names (`data-odr-sheet`), whether the document can be edited at all + (`data-odr-editable`), and the lock on a cell that cannot be — + `data-odr-lock` of `formula`, `rich` or `shapes`, with an `odr-locked` class + beside it. + - A sheet rendered with `HtmlConfig::editable` carries no `contenteditable` and no `data-odr-path`: its editing is an overlay, so the markup states none. A cell's runs fold into the `td` as they do read-only. diff --git a/docs/design/spreadsheet-editing.md b/docs/design/spreadsheet-editing.md index 75036159f..d8c0d08f2 100644 --- a/docs/design/spreadsheet-editing.md +++ b/docs/design/spreadsheet-editing.md @@ -255,6 +255,44 @@ frequent, it carries a position, and a host wants it on a snackbar while a real error goes to a dialog or a log. Sharing the code table keeps one lookup for both. +### 8. The sheet script owns the position map, and publishes it as `odr.sheet` + +The editor is a second script on the page, and the two things it needs first — +which cell a position names, and what is pinned — belong to the first, which +already owns the pin, the raise and the sort: + +```js +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 +``` + +**Why not a copy in the editor:** the map is not a walk over `colspan`. A row +is named by its `` label, because sorting moves the ``s away from +position order; a `rowspan` from an earlier row leaves the positions it covers +unwritten, so a colspan-only walk misreads every cell after them; and it is +built once, which means the script that reorders rows is the one that has to +know. Two copies would also be two owners of the pin classes and the raise +wrapper — an editor whose overlay is open while the other script lowers the +cell underneath it. + +**Why not one script instead:** the read-only view would carry the editor it +never runs, and a raw string literal caps at 16380 bytes on msvc +(`fits_a_literal`), which the two together would reach during step 1. + +**The coordinates are the ones an op names** (decision 1), never a DOM index. +The wash paints through `nth-child`, so the ruler's index stays private to the +script, and a merged sheet still gets no wash and no sort control. A position a +merge covers answers with the cell covering it — the one the file states and an +op names. + +**The cost is a public surface**, which a host keeps once it ships. It is a +small one, and a host gets scroll-to-cell and "what is selected" out of it. What +step 1.3 needs to reflow a row after a commit (`visibleRight`, `cutOff`) sits in +the same closure and joins `odr.sheet` when it is written, rather than being +reached around. + ## Staging Each step ships on its own. "Both" means `.ods` and `.xlsx`. @@ -312,11 +350,11 @@ Each step ships on its own. "Both" means `.ods` and `.xlsx`. 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). + 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, - Tab and blur commit; Escape cancels; arrow keys move the pin. Position - comes from the row `` and a per-row colspan/rowspan walk, cached — never - `cellIndex`, which merged cells and sorting both break. + 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 diff --git a/src/odr/internal/html/common.hpp b/src/odr/internal/html/common.hpp index 1aa81dbe5..63307fb63 100644 --- a/src/odr/internal/html/common.hpp +++ b/src/odr/internal/html/common.hpp @@ -51,6 +51,13 @@ struct WritingState { m_editable_markup = editable; } + /// Whether the document can be edited at all, which a sheet states so its + /// editor can refuse before the user clicks anything. + [[nodiscard]] bool document_editable() const { return m_document_editable; } + void set_document_editable(const bool editable) { + m_document_editable = editable; + } + private: HtmlWriter *m_out; const HtmlConfig *m_config; @@ -59,6 +66,7 @@ struct WritingState { StyleRegistry *m_styles; TextDirection m_direction{TextDirection::left_to_right}; bool m_editable_markup{true}; + bool m_document_editable{false}; }; /// Writes the viewport meta tag. Precedence: `config.viewport_content` (raw, diff --git a/src/odr/internal/html/document.cpp b/src/odr/internal/html/document.cpp index c9c07ee70..4d817dd41 100644 --- a/src/odr/internal/html/document.cpp +++ b/src/odr/internal/html/document.cpp @@ -264,6 +264,7 @@ render(const Document &document, const HtmlConfig &config, const Logger &logger, if (document.document_type() != DocumentType::spreadsheet) { WritingState state(out, config, resources, logger); state.set_direction(document_direction(document)); + state.set_document_editable(document.is_editable()); write_head(document, state, name, content_pixels); body(state); out.write_end(); @@ -275,6 +276,7 @@ render(const Document &document, const HtmlConfig &config, const Logger &logger, StyleRegistry::Digits::base36); WritingState head_state(out, config, resources, logger, &styles); head_state.set_direction(document_direction(document)); + head_state.set_document_editable(document.is_editable()); util::stream::DeferredBuffer buffer( out.out(), static_cast(config.spreadsheet_style_buffer), @@ -287,6 +289,7 @@ render(const Document &document, const HtmlConfig &config, const Logger &logger, HtmlWriter body_out(deferred, config); WritingState state(body_out, config, resources, logger, &styles); state.set_direction(head_state.direction()); + state.set_document_editable(head_state.document_editable()); body(state); } buffer.release(); diff --git a/src/odr/internal/html/document_element.cpp b/src/odr/internal/html/document_element.cpp index ee757657b..7c179d0fc 100644 --- a/src/odr/internal/html/document_element.cpp +++ b/src/odr/internal/html/document_element.cpp @@ -288,6 +288,49 @@ bool is_blank(const SheetCell &cell) { return true; } +/// Empty, or one text run at most - what a write can replace. odf wraps a +/// cell's text in a `text:p`, ooxml hangs it under the `c` directly, so a +/// single paragraph is unwrapped once. +bool holds_one_run(const ElementRange &children, const bool unwrap = true) { + ElementIterator child = children.begin(); + if (child == children.end()) { + return true; + } + const Element only = *child; + if (++child != children.end()) { + return false; + } + if (only.type() == ElementType::text) { + return true; + } + return unwrap && only.type() == ElementType::paragraph && + holds_one_run(only.children(), false); +} + +/// Its place among the document's sheets, which is how an op names one. +std::uint32_t sheet_ordinal(const Sheet &sheet) { + std::uint32_t ordinal = 0; + for (Element previous = sheet.previous_sibling(); previous; + previous = previous.previous_sibling()) { + ++ordinal; + } + return ordinal; +} + +/// Why a cell cannot be edited, or null where it can be. The names the page +/// reports to its host; `spreadsheet-editing.md` decision 3 lists them. +const char *cell_lock(const SheetCell &cell, const bool anchors_shapes) { + if (cell.value().has_formula()) { + return "formula"; + } + // its drawings are what the cell is, and an overlay would cover them + if (anchors_shapes) { + return "shapes"; + } + // a write replaces the cell's one run, so anything richer would be lost + return holds_one_run(cell.children()) ? nullptr : "rich"; +} + /// A shape or picture anchored in a cell reaches past it by design. bool holds_only_text(const SheetCell &cell) { for (const Element child : cell.children()) { @@ -429,17 +472,24 @@ void html::translate_sheet(const Sheet &sheet, const WritingState &state) { const std::optional print_fit = sheet_print_fit(sheet, end_column); state.out().write_element_begin( - "table", HtmlElementOptions() - .set_class("odr-sheet") - .set_style([&]() -> std::optional { - if (!print_fit.has_value()) { - return std::nullopt; - } - // `Measure` renders no exponent form - return "--odr-print-fit:" + - Measure(*print_fit, DynamicUnit()).to_string() + - ";"; - }())); + "table", + HtmlElementOptions() + .set_class("odr-sheet") + .set_attributes([&](const HtmlAttributeWriterCallback &clb) { + // what the editor asks before the user clicks anything + clb("data-odr-editable", + state.document_editable() ? "true" : "readOnly"); + // every op names its sheet, and a view holds only one + clb("data-odr-sheet", std::to_string(sheet_ordinal(sheet))); + }) + .set_style([&]() -> std::optional { + if (!print_fit.has_value()) { + return std::nullopt; + } + // `Measure` renders no exponent form + return "--odr-print-fit:" + + Measure(*print_fit, DynamicUnit()).to_string() + ";"; + }())); state.out().write_element_begin("col", HtmlElementOptions() @@ -608,6 +658,8 @@ void html::translate_sheet(const Sheet &sheet, const WritingState &state) { const std::optional folded = fold_cell( cell, sheet_state, wraps, anchors_shapes, table_row_style.height); + const char *lock = cell_lock(cell, anchors_shapes); + state.out().write_element_begin( "td", HtmlElementOptions() @@ -619,6 +671,9 @@ void html::translate_sheet(const Sheet &sheet, const WritingState &state) { if (cell_span.rows > 1) { clb("rowspan", std::to_string(cell_span.rows)); } + if (lock != nullptr) { + clb("data-odr-lock", lock); + } }) .set_style( translate_table_cell_style(cell_style) + @@ -628,10 +683,16 @@ void html::translate_sheet(const Sheet &sheet, const WritingState &state) { (folded.has_value() ? folded->style : std::string()), state.styles()) .set_class([&]() -> std::optional { - if (cell_value_type == ValueType::float_number) { + const bool number = cell_value_type == ValueType::float_number; + if (number && lock != nullptr) { + return "odr-value-type-float odr-locked"; + } + if (number) { return "odr-value-type-float"; } - return std::nullopt; + return lock != nullptr + ? std::optional("odr-locked") + : std::nullopt; }())); if (column_index == 0 && row_index == 0) { for (const Element shape : sheet.shapes()) { diff --git a/src/odr/internal/html/frontend.cpp b/src/odr/internal/html/frontend.cpp index 4a4dd5eda..84f4e90eb 100644 --- a/src/odr/internal/html/frontend.cpp +++ b/src/odr/internal/html/frontend.cpp @@ -1527,6 +1527,8 @@ constexpr std::string_view spreadsheet_js = R"js( var merged = table.querySelector("td[colspan],td[rowspan]") !== null; + var odr = (window.odr = window.odr || {}); + var style = document.createElement("style"); document.head.appendChild(style); @@ -1562,10 +1564,100 @@ constexpr std::string_view spreadsheet_js = R"js( columnRule(pinnedColumn, "var(--odr-sheet-wash-ruler)", "thead "); } - function columnOf(cell) { + // What the wash paints: the cell's place among the ones written beside it, + // gutter included, which is what `nth-child` counts. Not a position - a + // merge writes nothing for a covered one, and gets no wash either. + function rulerColumn(cell) { return cell !== null && !merged ? cell.cellIndex : -1; } + // The gutter's label, which names the row wherever a sort has put it. + function rowOf(tr) { + return Number(tr.cells[0].textContent) - 1; + } + + var index = null; + + // Whether a cell above still reaches into @p row. + function holds(above, row) { + return above !== undefined && above.last >= row; + } + + // A row by its label and, where the sheet merges, its cells by position. + // Walked once: a merged sheet is offered no sort control, so the rows are + // still in the file's order here and one pass can carry the rowspans down. + function build() { + var index = { rows: new Map(), positions: new Map() }; + var covered = []; + var body = table.tBodies[0]; + for (var i = 0; i < body.rows.length; ++i) { + var tr = body.rows[i]; + var row = rowOf(tr); + var line = []; + index.rows.set(row, { tr: tr, cells: line }); + if (!merged) { + continue; + } + var column = 0; + for (var j = 1; j < tr.cells.length; ++j) { + var td = tr.cells[j]; + while (holds(covered[column], row)) { + line[column] = covered[column].cell; + ++column; + } + var columns = Number(td.getAttribute("colspan") || 1); + var last = row + Number(td.getAttribute("rowspan") || 1) - 1; + for (var k = 0; k < columns; ++k) { + line[column + k] = td; + covered[column + k] = { last: last, cell: td }; + } + index.positions.set(td, { column: column, row: row }); + column += columns; + } + // A rowspan reaching past the row's last cell covers the rest of it. + for (; column < covered.length; ++column) { + if (holds(covered[column], row)) { + line[column] = covered[column].cell; + } + } + } + return index; + } + + function indexed() { + if (index === null) { + index = build(); + } + return index; + } + + // The `td` at a position, or null past the sheet's extent. A position a + // merge covers answers with the cell covering it, which is the one the file + // states and an op names. + function cellAt(column, row) { + var entry = indexed().rows.get(row); + if (entry === undefined || column < 0) { + return null; + } + var cell = merged ? entry.cells[column] : entry.tr.cells[column + 1]; + return cell === undefined ? null : cell; + } + + // Where a `td` sits, the way an op names it. Null for anything else - a + // header, a cell of another table. + function positionOf(cell) { + if (cell === null || cell.tagName !== "TD") { + return null; + } + if (merged) { + var position = indexed().positions.get(cell); + return position === undefined + ? null + : { column: position.column, row: position.row }; + } + return { column: cell.cellIndex - 1, row: rowOf(cell.parentElement) }; + } + var raisedCell = null; var raisedWrapper = null; var raisedContent = null; @@ -1671,8 +1763,51 @@ constexpr std::string_view spreadsheet_js = R"js( paint(); } + // What is pinned: a cell, or a whole column or row where a header is, the + // axis that header does not name being null. Null where nothing is pinned. + function pinnedPosition() { + if (pinnedCell === null) { + return null; + } + var position = positionOf(pinnedCell); + if (position !== null) { + return { column: position.column, row: position.row, cell: pinnedCell }; + } + return { + column: pinnedCell.classList.contains("odr-sheet-column-header") + ? pinnedCell.cellIndex - 1 + : null, + row: pinnedRow === null ? null : rowOf(pinnedRow), + cell: pinnedCell, + }; + } + + // Pins the cell at a position, as a click on it does; null clears the pin. + // False where the sheet holds no such cell. + function pinAt(position) { + if (position === null) { + pin(-1, null, null); + return true; + } + var cell = cellAt(position.column, position.row); + if (cell === null) { + return false; + } + pin(rulerColumn(cell), cell.parentElement, cell); + 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 = { + cellAt: cellAt, + positionOf: positionOf, + pinned: pinnedPosition, + pin: pinAt, + }; + table.addEventListener("mouseover", function (event) { - var column = columnOf(event.target.closest("td,th")); + var column = rulerColumn(event.target.closest("td,th")); if (column !== hovered) { hovered = column; paint(); @@ -1702,13 +1837,13 @@ constexpr std::string_view spreadsheet_js = R"js( } if (cell.classList.contains("odr-sheet-column-header")) { - pin(columnOf(cell), null, cell); + pin(rulerColumn(cell), null, cell); } else if (cell.classList.contains("odr-sheet-row-header")) { pin(-1, cell.parentElement, cell); } else if (cell.classList.contains("odr-sheet-corner")) { pin(-1, null, null); } else { - pin(columnOf(cell), cell.parentElement, cell); + pin(rulerColumn(cell), cell.parentElement, cell); } }); @@ -1854,6 +1989,130 @@ constexpr std::string_view spreadsheet_js = R"js( })(); )js"; +/// `odr.editing`: the mode, and the refusals the page reports to its host. +/// A sheet's editing is an overlay, so the markup states only what the page +/// cannot work out - the document's editability and a locked cell's reason. +constexpr std::string_view sheet_editing_js = R"js( +(function () { + "use strict"; + + var table = document.querySelector(".odr-sheet"); + if (table === null) { + return; + } + + var odr = (window.odr = window.odr || {}); + + var sheet = Number(table.getAttribute("data-odr-sheet") || 0); + var editable = table.getAttribute("data-odr-editable") === "true"; + var editing = false; + var lastRefusal = null; + + // One space with `odr.onError`'s codes, appended and never renumbered - 1 is + // `errorIllegalEditNewLine`. The host maps the code to its own wording; the + // message is for a developer who wires nothing. + var refusals = { + formula: { code: 2, message: "cell holds a formula" }, + 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" }, + }; + + odr.onEditRefused = function (event) { + console.warn("edit refused " + event.code + ": " + event.message); + }; + odr.onEditModeChange = function (event) { + console.log("editing " + (event.editing ? "on" : "off")); + }; + odr.onEditChange = function () {}; + + function fire(name, event) { + if (typeof odr[name] === "function") { + odr[name](event); + } + } + + /// Four taps on a locked cell are one snackbar: the same refusal within two + /// seconds of the last is the page's to drop. + function refuse(reason, column, row) { + var refusal = refusals[reason] || refusals.readOnly; + var key = reason + ":" + column + ":" + row; + var now = Date.now(); + if (lastRefusal && lastRefusal.key === key && now - lastRefusal.at < 2000) { + return; + } + lastRefusal = { key: key, at: now }; + fire("onEditRefused", { + sheet: sheet, + column: column, + row: row, + reason: reason, + code: refusal.code, + message: refusal.message, + }); + } + + function modeChange(reason) { + fire("onEditModeChange", { + editing: editing, + editable: editable, + reason: reason || null, + code: reason ? refusals[reason].code : 0, + message: reason ? refusals[reason].message : "", + }); + } + + odr.editing = { + /// Answers whether the mode is on. A document that cannot be edited + /// refuses and says why, so a host can grey its button before a click. + enable: function () { + if (!editable) { + modeChange("readOnly"); + return false; + } + if (!editing) { + editing = true; + table.classList.add("odr-editing"); + modeChange(null); + } + return true; + }, + disable: function () { + if (editing) { + editing = false; + table.classList.remove("odr-editing"); + modeChange(null); + } + }, + isEnabled: function () { + return editing; + }, + /// Whether `enable` would succeed. + isEditable: function () { + return editable; + }, + /// The lock on the cell at (@p column, @p row), or null where it has none. + lockAt: function (column, row) { + var cell = odr.sheet.cellAt(column, row); + return cell === null ? null : cell.getAttribute("data-odr-lock"); + }, + }; + + odr.editing.refuseAt = function (column, row) { + if (!editable) { + refuse("readOnly", column, row); + return true; + } + var lock = odr.editing.lockAt(column, row); + if (lock !== null) { + refuse(lock, column, row); + return true; + } + return false; + }; +})(); +)js"; + /// Every input is applied to the line `
`s by hand, so the line numbers /// stay in step and undo/redo replay changes instead of the browser's history. constexpr std::string_view text_js = R"js( @@ -2266,6 +2525,8 @@ 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}; +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", "text.js", text_js}; constexpr Asset viewport_js_asset{HtmlResourceType::js, "text/javascript", @@ -2425,6 +2686,7 @@ void html::write_search_script(const WritingState &state) { void html::write_spreadsheet_script(const WritingState &state) { write_script(spreadsheet_js_asset, state); + write_script(sheet_editing_js_asset, state); } void html::write_text_script(const WritingState &state) { diff --git a/test/browser/sheet/.gitignore b/test/browser/sheet/.gitignore index 41e099b75..89ffe58f7 100644 --- a/test/browser/sheet/.gitignore +++ b/test/browser/sheet/.gitignore @@ -1,3 +1,4 @@ document.css spreadsheet.css spreadsheet.js +sheet-editing.js diff --git a/test/browser/sheet/README.md b/test/browser/sheet/README.md index 4caa0b93b..9dc44c3e6 100644 --- a/test/browser/sheet/README.md +++ b/test/browser/sheet/README.md @@ -1,20 +1,34 @@ # sheet checks -What the emitted sheet script does with a cell too narrow for its text can only -be seen in a browser, so these are run by hand rather than by `odr_test`. +What the emitted sheet scripts do with a cell too narrow for its text, and with +a position no `td` stands at, can only be seen in a browser, so these are run by +hand rather than by `odr_test`. ```bash -test/browser/sheet/serve # extracts the css and the script, serves on :8732 +test/browser/sheet/serve # extracts the css and the scripts, serves on :8732 open http://localhost:8732/tests.html +open http://localhost:8732/positions.html +open http://localhost:8732/sorting.html ``` -`serve` lifts `document_css`, `spreadsheet_css` and `spreadsheet_js` out of -`src/odr/internal/html/frontend.cpp`, so what runs is what ships. The markup is -what `translate_sheet` writes, cut down to the shapes the script has to tell -apart: a cell that spills over an empty neighbour, one that is cut at its edge, -one that keeps the block a stated row height needs, and one that writes its -string straight into the `td`. +`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. -The point of the checks is that raising a cell shows all of it **without moving -anything**: the box goes out of flow, so no row changes height, and a click -inside it is for the text rather than for the cell. +- **`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: + a cell that spills over an empty neighbour, one that is cut at its edge, one + that keeps the block a stated row height needs, and one that writes its string + straight into the `td`. The point is that raising shows all of a cell + **without moving anything**: the box goes out of flow, so no row changes + height, and a click inside it is for the text rather than for the cell. +- **`positions.html`** — `odr.sheet` over a merged sheet. `translate_sheet` + writes no `td` for a position a span covers, so the fixture has a `colspan`, a + `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. +- **`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 + matter. diff --git a/test/browser/sheet/checks.js b/test/browser/sheet/checks.js new file mode 100644 index 000000000..401f46c2e --- /dev/null +++ b/test/browser/sheet/checks.js @@ -0,0 +1,40 @@ +// The report the check pages print, and the summary line to read at a glance. +(function () { + "use strict"; + + var style = document.createElement("style"); + style.textContent = + "#report{font:13px/1.6 monospace;margin:16px}" + + "#report .pass::before{content:'PASS ';color:#2a7}" + + "#report .fail::before{content:'FAIL ';color:#c33}" + + "#summary{font:600 13px/1.6 monospace;margin:16px 16px 0}"; + document.head.appendChild(style); + + var report = document.getElementById("report"); + var failed = 0; + var total = 0; + + window.check = function (name, condition) { + var line = document.createElement("div"); + line.className = condition ? "pass" : "fail"; + line.textContent = name; + report.appendChild(line); + total += 1; + if (!condition) { + failed += 1; + } + }; + + window.click = function (element) { + element.dispatchEvent(new MouseEvent("click", { bubbles: true })); + document.body.offsetHeight; + }; + + window.addEventListener("load", function () { + var summary = document.createElement("div"); + summary.id = "summary"; + summary.textContent = total + " checks, " + failed + " failed"; + summary.style.color = failed === 0 ? "#2a7" : "#c33"; + report.parentNode.insertBefore(summary, report); + }); +})(); diff --git a/test/browser/sheet/positions.html b/test/browser/sheet/positions.html new file mode 100644 index 000000000..b39025dbe --- /dev/null +++ b/test/browser/sheet/positions.html @@ -0,0 +1,179 @@ + + + + + sheet position checks + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ABCD
1a0b0d0
2a1b1c1d1
3b2c2d2
4a3b3c3d3
5a4b4c4
+ +
+ + + + + + diff --git a/test/browser/sheet/serve b/test/browser/sheet/serve index 4b56c4607..2d2465d96 100755 --- a/test/browser/sheet/serve +++ b/test/browser/sheet/serve @@ -14,8 +14,11 @@ 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(', } +PAGES = ("tests.html", "positions.html", "sorting.html") + def extract(source: str, begin: str) -> str: at = source.index(begin) @@ -34,7 +37,8 @@ def main() -> None: ) socketserver.TCPServer.allow_reuse_address = True with socketserver.TCPServer(("127.0.0.1", PORT), handler) as server: - print(f"http://localhost:{PORT}/tests.html") + for page in PAGES: + print(f"http://localhost:{PORT}/{page}") server.serve_forever() diff --git a/test/browser/sheet/sorting.html b/test/browser/sheet/sorting.html new file mode 100644 index 000000000..e3bb92f56 --- /dev/null +++ b/test/browser/sheet/sorting.html @@ -0,0 +1,118 @@ + + + + + sheet position checks under a sort + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ABC
1r00x0
2r12x1
3r24x2
4r31x3
5r43x4
+ +
+ + + + + diff --git a/test/browser/sheet/tests.html b/test/browser/sheet/tests.html index 67e90eb7c..97ee2b4f6 100644 --- a/test/browser/sheet/tests.html +++ b/test/browser/sheet/tests.html @@ -5,20 +5,6 @@ sheet checks -