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
+
+
+
+
+
+