diff --git a/CHANGELOG.md b/CHANGELOG.md index 2738481c1..d836447ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,12 @@ The release run heads these entries with the version and opens a fresh ## Unreleased +- An editable sheet view states each formula cell's expression + (`data-odr-formula`, `odr.sheet.formulaAt`) and the cells it reads. A commit + marks the formula cells reading what it wrote with `odr-sheet-stale`, and + raises the new `odr.onCellsStale` callback, `{sheet, cells}`; an undo takes + the marks back. Nothing recomputes a formula yet. + - `Document::dependents(position)` answers which cells' formulas read a position, directly or through another, and `unresolved_formulas()` those whose references could not all be read. A position is the new diff --git a/docs/design/spreadsheet-editing.md b/docs/design/spreadsheet-editing.md index ba909bb25..a832f2a17 100644 --- a/docs/design/spreadsheet-editing.md +++ b/docs/design/spreadsheet-editing.md @@ -225,8 +225,13 @@ odr.onEditRefused = function (event) {}; odr.onEditChange = function (event) {}; // {editing, editable, reason, code, message} odr.onEditModeChange = function (event) {}; +// {sheet, cells} - the formula cells an edit left computing an old input +odr.onCellsStale = function (event) {}; ``` +`onCellsStale` carries no code, because it reports no refusal, and the page +marks the cells itself, so a host that wires nothing still shows something. + **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` @@ -489,9 +494,21 @@ Each step ships on its own. "Both" means `.ods` and `.xlsx`. the cells the file *spells* rather than the positions they cover: a repeated ODS row of 1024 columns over 1048576 rows is a handful of nodes and a billion positions, and only the first is walked. -3. View: a commit marks dependents stale (a class, the host is told); the - locked formula cell exposes its text (`data-odr-formula`, formula cells - only) so a formula bar or a tooltip can show it. +3. **Landed.** View: a formula cell states its expression + (`data-odr-formula`, which `odr.sheet.formulaAt` hands a formula bar) and + the rectangles it reads (`data-odr-reads`: sheet, columns, rows, `*` for an + axis a reference leaves open). Both are editing scaffolding, so a read-only + render carries neither. + + A commit then marks the cells reading what it wrote — and the cells reading + those — with `odr-sheet-stale`, and raises `odr.onCellsStale`. The marks + follow the **log**, not the last write: `repaintStale` recomputes them off + the coalesced log, so an undo takes back what it made stale and a save + clears them with the log. Nothing recomputes a value; step 4 is what does. + + The page carries the graph rather than asking the host for it, because a + mark has to keep up with typing. The engine's own graph (item 2) is what + the file side uses. 4. File: the `.ods` answer from the spike — drop the cached value of dirty dependents, or whatever LibreOffice needs to recompute. diff --git a/src/odr/internal/common/sheet_dependencies.cpp b/src/odr/internal/common/sheet_dependencies.cpp index ed75cf845..60dcac9ca 100644 --- a/src/odr/internal/common/sheet_dependencies.cpp +++ b/src/odr/internal/common/sheet_dependencies.cpp @@ -13,23 +13,6 @@ namespace odr::internal { -namespace { - -/// The syntax the engine behind @p file_type writes a formula in. Nothing -/// where it states none at all. -std::optional syntax_of(const FileType file_type) { - switch (file_type) { - case FileType::opendocument_spreadsheet: - return formula::Syntax::opendocument; - case FileType::office_open_xml_workbook: - return formula::Syntax::ooxml; - default: - return {}; - } -} - -} // namespace - bool SheetDependencies::Read::contains(const SheetPosition &position) const { return position.sheet == sheet && range.contains(position.cell); } @@ -37,7 +20,8 @@ bool SheetDependencies::Read::contains(const SheetPosition &position) const { SheetDependencies SheetDependencies::of(const abstract::Document &document) { SheetDependencies result; - const std::optional syntax = syntax_of(document.file_type()); + const std::optional syntax = + formula::syntax_of(document.file_type()); const abstract::ElementAdapter *adapter = document.element_adapter(); if (!syntax.has_value() || adapter == nullptr) { return result; diff --git a/src/odr/internal/formula/formula_parser.cpp b/src/odr/internal/formula/formula_parser.cpp index 07ab53a8c..2923ad998 100644 --- a/src/odr/internal/formula/formula_parser.cpp +++ b/src/odr/internal/formula/formula_parser.cpp @@ -684,6 +684,17 @@ std::string_view strip_prefix(std::string_view formula) { namespace odr::internal { +std::optional formula::syntax_of(const FileType file_type) { + switch (file_type) { + case FileType::opendocument_spreadsheet: + return formula::Syntax::opendocument; + case FileType::office_open_xml_workbook: + return formula::Syntax::ooxml; + default: + return {}; + } +} + std::optional formula::parse(const std::string_view formula, const formula::Syntax syntax) { return formula::Parser(formula::strip_prefix(formula), syntax).parse(); diff --git a/src/odr/internal/formula/formula_parser.hpp b/src/odr/internal/formula/formula_parser.hpp index f55d80038..d720886fd 100644 --- a/src/odr/internal/formula/formula_parser.hpp +++ b/src/odr/internal/formula/formula_parser.hpp @@ -1,5 +1,7 @@ #pragma once +#include + #include #include @@ -14,6 +16,10 @@ enum class Syntax { ooxml, ///< the expression an `` holds }; +/// The syntax the engine behind @p file_type writes a formula in. Nothing +/// where it states none, or drops the expression at parse time (`.xls`). +[[nodiscard]] std::optional syntax_of(FileType file_type); + /// Parses @p formula, with or without the `of:=` prefix. Nothing where it does /// not parse, so a caller reads no reference out of a formula it cannot read. [[nodiscard]] std::optional parse(std::string_view formula, diff --git a/src/odr/internal/html/common.hpp b/src/odr/internal/html/common.hpp index b4eba4111..916badd59 100644 --- a/src/odr/internal/html/common.hpp +++ b/src/odr/internal/html/common.hpp @@ -8,6 +8,7 @@ #include #include +#include #include #include @@ -58,6 +59,15 @@ struct WritingState { m_document_editable = editable; } + /// The syntax a formula of this document is written in, or nothing where + /// the engine states none. + [[nodiscard]] std::optional formula_syntax() const { + return m_formula_syntax; + } + void set_formula_syntax(const std::optional syntax) { + m_formula_syntax = syntax; + } + private: HtmlWriter *m_out; const HtmlConfig *m_config; @@ -67,6 +77,7 @@ struct WritingState { TextDirection m_direction{TextDirection::left_to_right}; bool m_editable_markup{true}; bool m_document_editable{false}; + std::optional m_formula_syntax; }; /// 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 d7271f6e4..ee9ae9076 100644 --- a/src/odr/internal/html/document.cpp +++ b/src/odr/internal/html/document.cpp @@ -9,6 +9,7 @@ #include #include +#include #include #include #include @@ -282,6 +283,7 @@ render(const Document &document, const HtmlConfig &config, const Logger &logger, WritingState state(out, config, resources, logger); state.set_direction(document_direction(document)); state.set_document_editable(document.is_editable()); + state.set_formula_syntax(formula::syntax_of(document.file_type())); write_head(document, state, name, content_pixels); body(state); out.write_end(); @@ -294,6 +296,7 @@ render(const Document &document, const HtmlConfig &config, const Logger &logger, WritingState head_state(out, config, resources, logger, &styles); head_state.set_direction(document_direction(document)); head_state.set_document_editable(document.is_editable()); + head_state.set_formula_syntax(formula::syntax_of(document.file_type())); util::stream::DeferredBuffer buffer( out.out(), static_cast(config.spreadsheet_style_buffer), @@ -307,6 +310,7 @@ render(const Document &document, const HtmlConfig &config, const Logger &logger, WritingState state(body_out, config, resources, logger, &styles); state.set_direction(head_state.direction()); state.set_document_editable(head_state.document_editable()); + state.set_formula_syntax(head_state.formula_syntax()); body(state); } buffer.release(); diff --git a/src/odr/internal/html/document_element.cpp b/src/odr/internal/html/document_element.cpp index 24e32d0ac..0519de1ab 100644 --- a/src/odr/internal/html/document_element.cpp +++ b/src/odr/internal/html/document_element.cpp @@ -8,6 +8,8 @@ #include #include +#include +#include #include #include #include @@ -15,9 +17,14 @@ #include #include #include +#include #include #include +#include +#include +#include +#include #include namespace odr::internal { @@ -346,10 +353,78 @@ std::uint32_t sheet_ordinal(const Sheet &sheet) { return ordinal; } +/// Every sheet by its name without case, as a formula names one, so a +/// reference into another sheet resolves to the ordinal an op uses. +std::unordered_map +sheet_ordinals_by_name(const Sheet &sheet) { + Element first = sheet; + for (Element previous = sheet.previous_sibling(); previous; + previous = previous.previous_sibling()) { + first = previous; + } + + std::unordered_map result; + std::uint32_t ordinal = 0; + for (Element current = first; current; current = current.next_sibling()) { + if (current.type() == ElementType::sheet) { + result.emplace(util::string::to_lower(current.as_sheet().name()), + ordinal); + } + ++ordinal; + } + return result; +} + +/// The open end of an axis a reference leaves out. +std::string spell_bound(const std::uint32_t index) { + return index == std::numeric_limits::max() + ? "*" + : std::to_string(index); +} + +/// What @p formula reads, as the rectangles the page tests a written position +/// against: `sheet,first column,last column,first row,last row`, space +/// separated, with `*` for an axis the reference leaves open. Empty where the +/// formula does not parse, or reads nothing this document holds. +std::string +cell_reads(const std::string &formula, const formula::Syntax syntax, + const std::uint32_t own_sheet, + const std::unordered_map &by_name) { + const std::optional node = formula::parse(formula, syntax); + if (!node.has_value()) { + return {}; + } + + std::string result; + for (const formula::Extent &extent : formula::references(*node).extents) { + if (extent.document.has_value()) { + continue; // another file, which no edit here reaches + } + std::uint32_t sheet = own_sheet; + if (extent.sheet.has_value()) { + const auto named = by_name.find(util::string::to_lower(*extent.sheet)); + if (named == by_name.end()) { + continue; + } + sheet = named->second; + } + if (!result.empty()) { + result += " "; + } + result += std::to_string(sheet) + "," + + spell_bound(extent.range.from().column) + "," + + spell_bound(extent.range.to().column) + "," + + spell_bound(extent.range.from().row) + "," + + spell_bound(extent.range.to().row); + } + return result; +} + /// 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()) { +const char *cell_lock(const CellValue &value, const SheetCell &cell, + const bool anchors_shapes) { + if (value.has_formula()) { return "formula"; } // its drawings are what the cell is, and an overlay would cover them @@ -501,22 +576,30 @@ void html::translate_sheet(const Sheet &sheet, const WritingState &state) { const std::optional print_fit = sheet_print_fit(sheet, end_column); + // scaffolding: a read-only render states neither a lock nor a dependency + const std::optional syntax = + state.config().editable ? state.formula_syntax() : std::nullopt; + const std::uint32_t ordinal = sheet_ordinal(sheet); + const std::unordered_map ordinals_by_name = + syntax.has_value() ? sheet_ordinals_by_name(sheet) + : std::unordered_map(); + state.out().write_element_begin( - "table", - HtmlElementOptions() - .set_class("odr-sheet") - .set_attributes([&](const HtmlAttributeWriterCallback &clb) { - // 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() + ";"; - }())); + "table", HtmlElementOptions() + .set_class("odr-sheet") + .set_attributes([&](const HtmlAttributeWriterCallback &clb) { + // every op names its sheet, and a view holds only one + clb("data-odr-sheet", std::to_string(ordinal)); + }) + .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() @@ -685,9 +768,18 @@ 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); - // scaffolding: a read-only render states no lock - const char *lock = - state.config().editable ? cell_lock(cell, anchors_shapes) : nullptr; + const CellValue cell_value = + state.config().editable ? cell.value() : CellValue(); + const char *lock = state.config().editable + ? cell_lock(cell_value, cell, anchors_shapes) + : nullptr; + // what a formula bar shows, and the rectangles the page marks against + const bool computes = cell_value.has_formula() && + !cell_value.formula().empty() && syntax.has_value(); + const std::string reads = computes + ? cell_reads(cell_value.formula(), *syntax, + ordinal, ordinals_by_name) + : std::string(); state.out().write_element_begin( "td", @@ -703,6 +795,13 @@ void html::translate_sheet(const Sheet &sheet, const WritingState &state) { if (lock != nullptr) { clb("data-odr-lock", lock); } + if (computes) { + clb("data-odr-formula", + xml::escape_attribute(cell_value.formula())); + } + if (!reads.empty()) { + clb("data-odr-reads", reads); + } }) .set_style( translate_table_cell_style(cell_style) + diff --git a/src/odr/internal/html/frontend/editing.js b/src/odr/internal/html/frontend/editing.js index 2c718e51e..7a0512f6a 100644 --- a/src/odr/internal/html/frontend/editing.js +++ b/src/odr/internal/html/frontend/editing.js @@ -49,6 +49,7 @@ console.log("editing " + (event.editing ? "on" : "off")); }; odr.onEditChange = function () {}; + odr.onCellsStale = function () {}; function fire(name, event) { if (typeof odr[name] === "function") { @@ -162,6 +163,12 @@ fire("onEditRefused", event); }, + /// The cells the edits so far left computing an old input. Raised + /// whenever the set changes, which an undo does too. + stale: function (detail) { + fire("onCellsStale", detail); + }, + /// The log a host's save button reads; an editor calls it when its log /// moved. changed: function () { diff --git a/src/odr/internal/html/frontend/sheet-editing.js b/src/odr/internal/html/frontend/sheet-editing.js index c9055e9d5..ad3c58eac 100644 --- a/src/odr/internal/html/frontend/sheet-editing.js +++ b/src/odr/internal/html/frontend/sheet-editing.js @@ -97,6 +97,7 @@ before: before, }); undone = []; + repaintStale(); odr.editing.changed(); return true; } @@ -106,9 +107,126 @@ function replay(entry, value) { close(); odr.sheet.showValue(entry.op.column, entry.op.row, value); + repaintStale(); odr.editing.changed(); } + /// What each formula cell reads, off `data-odr-reads`. Only the rectangles + /// in this sheet, because an edit here names a position in it. + var readers = null; + + function bound(text) { + return text === "*" ? Infinity : Number(text); + } + + function readersOf() { + if (readers !== null) { + return readers; + } + readers = []; + var cells = table.querySelectorAll("td[data-odr-reads]"); + for (var i = 0; i < cells.length; ++i) { + var at = odr.sheet.positionOf(cells[i]); + if (at === null) { + continue; + } + var boxes = []; + var groups = cells[i].getAttribute("data-odr-reads").split(" "); + for (var j = 0; j < groups.length; ++j) { + var parts = groups[j].split(","); + if (parts.length === 5 && Number(parts[0]) === sheet) { + boxes.push([ + bound(parts[1]), + bound(parts[2]), + bound(parts[3]), + bound(parts[4]), + ]); + } + } + if (boxes.length > 0) { + readers.push({ cell: cells[i], at: at, reads: boxes }); + } + } + return readers; + } + + function readsAny(entry, positions) { + for (var i = 0; i < entry.reads.length; ++i) { + var box = entry.reads[i]; + for (var j = 0; j < positions.length; ++j) { + var at = positions[j]; + if ( + at.column >= box[0] && + at.column <= box[1] && + at.row >= box[2] && + at.row <= box[3] + ) { + return true; + } + } + } + return false; + } + + /// Every cell reading one of @p positions, and every cell reading one of + /// those: a formula whose input went stale is stale itself. + function staleFrom(positions) { + var entries = readersOf(); + var taken = []; + var frontier = positions; + var result = []; + while (frontier.length > 0) { + var next = []; + for (var i = 0; i < entries.length; ++i) { + if (taken[i] || !readsAny(entries[i], frontier)) { + continue; + } + taken[i] = true; + result.push(entries[i]); + next.push(entries[i].at); + } + frontier = next; + } + return result; + } + + var stale = []; + var staleKey = ""; + + /// The marks follow the log, not the last write, so an undo takes back what + /// it made stale and a save clears them with the log. + function repaintStale() { + var written = []; + var ops = coalesced(); + for (var i = 0; i < ops.length; ++i) { + written.push({ column: ops[i].column, row: ops[i].row }); + } + + for (var j = 0; j < stale.length; ++j) { + stale[j].classList.remove("odr-sheet-stale"); + } + stale = []; + + var cells = []; + var entries = staleFrom(written); + for (var k = 0; k < entries.length; ++k) { + entries[k].cell.classList.add("odr-sheet-stale"); + stale.push(entries[k].cell); + cells.push({ column: entries[k].at.column, row: entries[k].at.row }); + } + + // A host hears when the set moved, not on every keystroke. + var key = cells + .map(function (at) { + return at.column + ":" + at.row; + }) + .join(" "); + if (key !== staleKey) { + staleKey = key; + odr.editing.stale({ sheet: sheet, cells: cells }); + } + } + // 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) { @@ -354,6 +472,7 @@ committed: function () { history = []; undone = []; + repaintStale(); }, }); })(); diff --git a/src/odr/internal/html/frontend/spreadsheet-dark.css b/src/odr/internal/html/frontend/spreadsheet-dark.css index c53034a8a..666a5c775 100644 --- a/src/odr/internal/html/frontend/spreadsheet-dark.css +++ b/src/odr/internal/html/frontend/spreadsheet-dark.css @@ -9,6 +9,7 @@ --odr-sheet-wash-ruler:rgba(255,255,255,.12); --odr-sheet-focus:#4c8dff; --odr-sheet-refused:#f0665b; +--odr-sheet-stale:#e3b341; --odr-sheet-raised:#1c2128; } .odr-sheet{background-color:#161b22!important} diff --git a/src/odr/internal/html/frontend/spreadsheet.css b/src/odr/internal/html/frontend/spreadsheet.css index acf5206ae..c852fc5e5 100644 --- a/src/odr/internal/html/frontend/spreadsheet.css +++ b/src/odr/internal/html/frontend/spreadsheet.css @@ -9,6 +9,7 @@ --odr-sheet-wash-ruler:rgba(0,0,0,.10); --odr-sheet-focus:#3c78dc; --odr-sheet-refused:#d1493f; +--odr-sheet-stale:#b8860b; --odr-sheet-raised:#ffffff; --odr-sheet-font:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif; } @@ -50,6 +51,10 @@ body{margin:0;background:var(--odr-sheet-canvas)} .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} +/* A formula whose input an edit changed; the cell still shows the cached + result. Underlined, so it layers over the pin and the hover wash and moves + no geometry. */ +.odr-sheet td.odr-sheet-stale{text-decoration:underline wavy var(--odr-sheet-stale);text-decoration-skip-ink:none} /* 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} diff --git a/src/odr/internal/html/frontend/spreadsheet.js b/src/odr/internal/html/frontend/spreadsheet.js index 2e5c8dca7..5b736904d 100644 --- a/src/odr/internal/html/frontend/spreadsheet.js +++ b/src/odr/internal/html/frontend/spreadsheet.js @@ -403,6 +403,13 @@ return { type: "string", text: text }; } + // The expression a formula cell computes, as the file spells it. Null for a + // cell holding none, and for a read-only render, which writes no scaffolding. + function formulaAt(column, row) { + var cell = cellAt(column, row); + return cell === null ? null : cell.getAttribute("data-odr-formula"); + } + // Shows @p value at a position, as a write leaves the cell, and reflows // the row around it. function showValue(column, row, value) { @@ -430,6 +437,7 @@ pin: pinAt, lower: lower, valueAt: valueAt, + formulaAt: formulaAt, showValue: showValue, reflow: reflow, }; diff --git a/test/browser/sheet/README.md b/test/browser/sheet/README.md index 131baa902..2f4269db0 100644 --- a/test/browser/sheet/README.md +++ b/test/browser/sheet/README.md @@ -40,7 +40,8 @@ that is where `translate` writes it. 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. Undo, redo and - the log a save resets follow. + the log a save resets follow, and last the marks a commit leaves on the + formula cells reading what it wrote. - **`keyboard.html`** — a page whose config took both key classes away. The arrows, Escape, a printable key and the undo chord are all the host's, while the commands (`editAt`, `undo`) and the open editor's own keys still work. diff --git a/test/browser/sheet/editing.html b/test/browser/sheet/editing.html index 8559533d4..f83729dc6 100644 --- a/test/browser/sheet/editing.html +++ b/test/browser/sheet/editing.html @@ -9,8 +9,9 @@ + where the next cell shows something. B3 holds a formula over A1:A2, + C3 a run of its own, D3 a link. B5 holds a formula over B3, so an + edit into A1 reaches B5 through B3. --> @@ -44,7 +45,7 @@ - + @@ -58,7 +59,7 @@ - + @@ -78,6 +79,19 @@ odr.onEditRefused = function (event) { refusals.push(event.reason + " " + event.code + " at " + event.column + "," + event.row); }; + var staleEvents = []; + odr.onCellsStale = function (event) { + staleEvents.push(event); + }; + function staleNow() { + return Array.prototype.slice + .call(document.querySelectorAll(".odr-sheet-stale")) + .map(function (td) { + var at = odr.sheet.positionOf(td); + return at.column + "," + at.row; + }) + .join(" "); + } function editor() { return document.querySelector(".odr-sheet-editor"); @@ -312,6 +326,44 @@ check("a chord in another input is that input's", cell(3, 0).textContent === "one"); box.remove(); + // What an edit leaves stale: the cells reading it, and those reading them. + odr.editing.committed(); + staleEvents = []; + check("a formula cell hands out its expression", odr.sheet.formulaAt(1, 2) === "of:=SUM([.A1:.A2])"); + check("and a cell holding none hands out nothing", odr.sheet.formulaAt(0, 0) === null); + + odr.editing.editAt(0, 1); + editor().value = "99"; + press(editor(), "Enter"); + check("writing an input marks the formula reading it", staleNow() === "1,2 1,4"); + check( + "and the host is told, once", + staleEvents.length === 1 && + staleEvents[0].sheet === 2 && + staleEvents[0].cells.length === 2 && + staleEvents[0].cells[0].column === 1 && + staleEvents[0].cells[0].row === 2 + ); + + odr.editing.editAt(3, 4); + editor().value = "nothing reads this"; + press(editor(), "Enter"); + check("a write nothing reads leaves the set as it was", staleNow() === "1,2 1,4"); + check("so the host hears nothing further", staleEvents.length === 1); + + odr.editing.undo(); + odr.editing.undo(); + check("undoing the edits takes the marks back", staleNow() === ""); + check("and the host is told that too", staleEvents.length === 2 && staleEvents[1].cells.length === 0); + + odr.editing.redo(); + check("a redo puts them back", staleNow() === "1,2 1,4"); + odr.editing.committed(); + check("and a save clears them with the log", staleNow() === ""); + + odr.editing.editAt(3, 0); + editor().value = "one"; + press(editor(), "Enter"); odr.editing.committed(); check( "a save clears both stacks", diff --git a/test/src/html_test.cpp b/test/src/html_test.cpp index d56d46a98..d1b093557 100644 --- a/test/src/html_test.cpp +++ b/test/src/html_test.cpp @@ -616,10 +616,11 @@ DecodedFile csv_file(const std::uint32_t rows, const std::uint32_t columns) { } /// A flat ODF sheet holding @p rows, each a `table:table-row`, under the -/// `table:table-column`s in @p columns. The one cell style, `ce1`, aligns a -/// cell to the top, so every cell that names it writes the same style block. -DecodedFile fods_file(const std::string &rows, - const std::string &columns = "") { +/// `table:table-column`s in @p columns, and the further `table:table`s in +/// @p sheets. The one cell style, `ce1`, aligns a cell to the top, so every +/// cell that names it writes the same style block. +DecodedFile fods_file(const std::string &rows, const std::string &columns = "", + const std::string &sheets = "") { const std::string fods = R"()" R"()" R"()" R"()" + - columns + rows + - R"()" + columns + rows + R"()" + sheets + + R"()" R"()"; return open(File::from_memory(fods), DecodeOptions::as(FileType::opendocument_spreadsheet)); @@ -949,6 +950,92 @@ TEST(html, a_formula_cell_is_locked_with_its_reason) { EXPECT_NE(page.find("odr-locked"), std::string::npos); } +// A formula bar shows the expression, which is not what the cell shows. +TEST(html, a_formula_cell_states_the_expression_it_computes) { + const std::string page = render_sheet( + fods_file(fods_row( + R"xml()" + R"(7)")), + editing_config()); + + const std::string expected = R"xml(data-odr-formula="of:=SUM([.B1:.C1])")xml"; + EXPECT_NE(page.find(expected), std::string::npos); +} + +// The page marks against the rectangles, not against the expression. +TEST(html, a_formula_cell_states_the_cells_it_reads) { + const std::string page = render_sheet( + fods_file(fods_row( + R"xml()" + R"(7)")), + editing_config()); + + const std::string expected = R"xml(data-odr-reads="0,1,2,0,0")xml"; + EXPECT_NE(page.find(expected), std::string::npos); +} + +// A quote in an expression would close the attribute early. +TEST(html, an_expression_is_escaped_into_its_attribute) { + const std::string page = render_sheet( + fods_file(fods_row( + R"xml()" + R"(a)")), + editing_config()); + + const std::string expected = + R"xml(data-odr-formula="of:=IF([.B1];"a";"b")")xml"; + EXPECT_NE(page.find(expected), std::string::npos); +} + +// A read-only render carries no dependency either: it is editing scaffolding. +TEST(html, a_read_only_render_states_no_formula) { + const std::string page = render_sheet( + fods_file(fods_row( + R"xml()" + R"(7)")), + HtmlConfig()); + + EXPECT_EQ(page.find(R"(data-odr-formula=")"), std::string::npos); + EXPECT_EQ(page.find(R"(data-odr-reads=")"), std::string::npos); +} + +// A reference into another sheet resolves to the ordinal an op names it by, +// and a sheet name is matched without case. +TEST(html, a_formula_reading_another_sheet_states_its_ordinal) { + const std::string page = render_sheet( + fods_file( + fods_row(R"xml()" + R"(7)"), + "", + R"()" + R"()" + R"(7)" + R"()"), + editing_config()); + + const std::string expected = R"xml(data-odr-reads="1,1,1,0,0")xml"; + EXPECT_NE(page.find(expected), std::string::npos); +} + +// A formula naming no position states no rectangle, not an empty one. +TEST(html, a_formula_reading_nothing_states_no_rectangle) { + const std::string page = + render_sheet(fods_file(fods_row( + R"xml()" + R"(7)")), + editing_config()); + + const std::string expected = R"xml(data-odr-formula="of:=TODAY()")xml"; + EXPECT_NE(page.find(expected), std::string::npos); + EXPECT_EQ(page.find(R"(data-odr-reads=")"), std::string::npos); +} + // Several runs of one paragraph are one line, which a write replaces. TEST(html, a_cell_of_several_runs_carries_no_lock) { const std::string page = render_sheet( diff --git a/wasm/README.md b/wasm/README.md index f8d052ed6..0aa37083d 100644 --- a/wasm/README.md +++ b/wasm/README.md @@ -85,6 +85,8 @@ page.editing.committed(); `odr.editing` is on every document view, editable or not: `isEditable()` is what greys a host's edit button, and `onEditRefused` says why an edit was refused. +On a sheet, `onCellsStale` names the formula cells an edit left computing an +old input - the page marks them, and nothing recomputes one yet. `keyboardNavigation` and `keyboardShortcuts` in the config decide whether the page takes the arrow keys and the undo chord, for a host that has its own. `example/index.html` wires the whole surface. diff --git a/wasm/example/index.html b/wasm/example/index.html index d6b54a660..320e7ec48 100644 --- a/wasm/example/index.html +++ b/wasm/example/index.html @@ -89,7 +89,7 @@ return true; } - // The three callbacks a host assigns, which on droid/ios go in through + // The callbacks a host assigns, which on droid/ios go in through // `evaluateJavascript` once the WebView has finished loading. function wire() { const page = editing(); @@ -111,6 +111,12 @@ edit.textContent = event.editing ? 'editing' : 'edit'; if (event.reason) status.textContent = event.message; }; + // The cells an edit left computing an old input; the page marks them. + frame.contentWindow.odr.onCellsStale = (event) => { + if (event.cells.length > 0) { + status.textContent = `${event.cells.length} formula cell(s) out of date`; + } + }; } function show(index) {
3 77 bold x
5 14