Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
46 changes: 42 additions & 4 deletions docs/design/spreadsheet-editing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<th>` label, because sorting moves the `<tr>`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`.
Expand Down Expand Up @@ -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 `<th>` 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
Expand Down
8 changes: 8 additions & 0 deletions src/odr/internal/html/common.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions src/odr/internal/html/document.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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<std::size_t>(config.spreadsheet_style_buffer),
Expand All @@ -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();
Expand Down
87 changes: 74 additions & 13 deletions src/odr/internal/html/document_element.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down Expand Up @@ -429,17 +472,24 @@ void html::translate_sheet(const Sheet &sheet, const WritingState &state) {
const std::optional<double> print_fit = sheet_print_fit(sheet, end_column);

state.out().write_element_begin(
"table", HtmlElementOptions()
.set_class("odr-sheet")
.set_style([&]() -> std::optional<HtmlWritable> {
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<HtmlWritable> {
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()
Expand Down Expand Up @@ -608,6 +658,8 @@ void html::translate_sheet(const Sheet &sheet, const WritingState &state) {
const std::optional<FoldedCell> 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()
Expand All @@ -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) +
Expand All @@ -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<HtmlWritable> {
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<HtmlWritable>("odr-locked")
: std::nullopt;
}()));
if (column_index == 0 && row_index == 0) {
for (const Element shape : sheet.shapes()) {
Expand Down
Loading
Loading