From 99bcb53172531d401c5c65ae05767ac2b102cb4d Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Thu, 10 Sep 2026 16:21:48 +0200 Subject: [PATCH] feat(text): a plain file joins the editing mode and can be saved `text.js` was the one editor that answered to nobody: its own `beforeinput` gate, its own undo, and no `odr.editing` on the page at all. So a host that wired the mode once and opened a `.txt` found `odr.generateDiff` missing - which decision 11 of `editing.md` said could not happen, and which was true of every view except this one. It attaches now. The view writes `editing.js` and the page-level state, and `text.js` is an editor on the mode rather than beside it. Its undo needed nothing new: it is the one editor that recorded an inverse per change before any of this work started. A plain file has no element tree, so none of the document operations reach it. What it has instead is one operation, `setContent {text}`, carrying the file's whole text. Not a line at a time: a line number is a path, and a plain file has no registry to hang an id on, so per-line operations would walk back into what decision 1 removed. `TextFile::write_edited` is the write path, shaped like `PdfFile::annotate` rather than `Document::save`, because a `.txt` is a `TextFile` and neither `Document::edit` nor the element adapters reach it. `is_savable()` refuses an encoding we cannot decode, since the view hands those bytes to the browser as they are. What it writes is UTF-8 whatever the source was - `encoding/transcode.hpp` decodes and does not encode. `HtmlConfig::editable` no longer writes `contenteditable` on the lines; `odr.editing.enable()` does, so one render serves both modes as it does for a document. `docs/design/txt-editing.md` holds its decisions, beside the two documents the other editors already have. It is not a document view, so the document one is the wrong place for any of it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KKFKbUVCYF2VhujdmjhhPW --- CHANGELOG.md | 8 + docs/design/document-editing.md | 11 +- docs/design/editing.md | 23 +-- docs/design/txt-editing.md | 117 +++++++++++++ src/odr/file.cpp | 36 ++++ src/odr/file.hpp | 15 +- src/odr/internal/file_type_table.cpp | 4 +- src/odr/internal/html/common.cpp | 14 ++ src/odr/internal/html/common.hpp | 3 + src/odr/internal/html/document.cpp | 15 -- src/odr/internal/html/frontend.cpp | 6 +- src/odr/internal/html/frontend/text.js | 74 ++++++++- src/odr/internal/html/text_file.cpp | 25 +-- test/browser/plaintext/README.md | 35 ++++ test/browser/plaintext/serve | 12 ++ test/browser/plaintext/tests.html | 193 ++++++++++++++++++++++ test/src/internal/text/text_file_test.cpp | 84 ++++++++++ 17 files changed, 624 insertions(+), 51 deletions(-) create mode 100644 docs/design/txt-editing.md create mode 100644 test/browser/plaintext/README.md create mode 100755 test/browser/plaintext/serve create mode 100644 test/browser/plaintext/tests.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 21614cd70..f8031125d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,14 @@ The release run heads these entries with the version and opens a fresh `"version": 1` while `Document::edit` takes 2, so every save the browser produced was refused. A check page now asserts the version. +- A `.txt` view carries `odr.editing` like every other view, rather than the + second API `text.js` was. **Breaking**: `HtmlConfig::editable` no longer + writes `contenteditable` — `odr.editing.enable()` does. + +- `TextFile::write_edited` saves an edited plain file and `is_savable()` says + whether it can; `txt` states `edit` and `save`. The output is UTF-8 whatever + the source encoding was, because there is no encoder back. + - A `.pptx` can be edited and saved: every text operation a `.docx` takes, and a save that writes the slide parts back into the package. Its `FileTypeCapabilities` now states `edit` and `save`. diff --git a/docs/design/document-editing.md b/docs/design/document-editing.md index 48f85daad..85330817b 100644 --- a/docs/design/document-editing.md +++ b/docs/design/document-editing.md @@ -3,8 +3,9 @@ The editor of the **document view** — `frontend/document.js` — built on the mode frame in [`editing.md`](editing.md), and the decisions that are its own. [`spreadsheet-editing.md`](spreadsheet-editing.md) is the sibling document for -the sheet view. Text documents, presentations and drawings share this one: -what it edits is runs and paragraphs, wherever the format puts them. +the sheet view and [`txt-editing.md`](txt-editing.md) for the plain-text one. +Text documents, presentations and drawings share this one: what it edits is +runs and paragraphs, wherever the format puts them. Status: **landed.** The schema, the replay, the browser editor and the pptx write side are all in the code; each section says what is in and what is not. @@ -274,6 +275,7 @@ reading forward. | `.odt`, `.odp`, `.ods`, `.odg` | `odf` | edits and saves today; the new ops land here | | `.docx` | `ooxml/text` | edits and saves today; the new ops land here | | `.pptx` | `ooxml/presentation` | edits and saves; the same operations over `a:p` / `a:r` | +| `.txt` | `text` | not a document at all; see [`txt-editing.md`](txt-editing.md) | | everything else | — | read-only, and says so by decision 7 | `.odp` needs nothing of its own: a presentation is the same odf `Document` as a @@ -350,6 +352,5 @@ Two details the checks pin down: - **A list item** is a paragraph in a list. Enter at the end of one should make a new list item, not a bare paragraph. `splitParagraph` splits what the element tree says is a paragraph; the list case is not covered. -- The **plain-text view** (`html/text_file.cpp`) is still its own editor and - still answers to nobody. Unchanged by this work, and still the open question - at the end of [`editing.md`](editing.md). +- The **plain-text view** is a `TextFile` rather than a document, so none of + this reaches it; [`txt-editing.md`](txt-editing.md) is its own. diff --git a/docs/design/editing.md b/docs/design/editing.md index 73a73a742..e2da09a2c 100644 --- a/docs/design/editing.md +++ b/docs/design/editing.md @@ -6,11 +6,11 @@ editing of ODF and OOXML documents, the alternatives we weighed, and *why* we took each decision. Decisions 9 to 12 are the frame every format shares, and they are in the code. -[`spreadsheet-editing.md`](spreadsheet-editing.md) is the first editor built on -the frame, and it is where a sheet's own decisions live. -[`document-editing.md`](document-editing.md) is the second, and it is where the phases -below are being carried out — an edit across runs, a new paragraph, and the -delete and replace that reach across both. +One document per editor, each holding its own decisions: +[`spreadsheet-editing.md`](spreadsheet-editing.md) for the sheet view, +[`document-editing.md`](document-editing.md) for the document view — where the +phases below are carried out — and [`txt-editing.md`](txt-editing.md) for the +plain-text one. This builds on the existing principle in [`README.md`](README.md): @@ -561,9 +561,10 @@ session-scoped, decision 4). [`spreadsheet-editing.md`](spreadsheet-editing.md)). Does the adapter hook grow into `element_edit_lock(id) -> reason`, or does the renderer keep deciding the reason from the element it is over? -- The plain-text view (`html/text_file.cpp`) is outside the mode: `text.js` is - its own editor, with its own `beforeinput` interception and its own undo, and - `config.editable` writes the `contenteditable` it needs. Nothing replays those - edits into a file, because `txt` declares no `edit` capability. Does that view - attach to `odr.editing` — which would need an editable-but-not-savable state — - or stay the one editor that answers to nobody? +- ~~The plain-text view is outside the mode.~~ **Answered: it attaches.** + See [`txt-editing.md`](txt-editing.md). +- The **pdf annotator** is now the one editor that answers to nobody: + `odr.annotation` is its own API and `PdfFile::annotate` its own write path. + It is a different gesture from editing text, so whether it should share the + mode is a real question rather than an oversight + ([`txt-editing.md`](txt-editing.md) carries it too). diff --git a/docs/design/txt-editing.md b/docs/design/txt-editing.md new file mode 100644 index 000000000..f07006c21 --- /dev/null +++ b/docs/design/txt-editing.md @@ -0,0 +1,117 @@ +# Plain-text editing design + +The editor of the **plain-text view** — `frontend/text.js` — built on the mode +frame in [`editing.md`](editing.md), and the decisions that are its own. Its +siblings are [`document-editing.md`](document-editing.md) for a document view +and [`spreadsheet-editing.md`](spreadsheet-editing.md) for a sheet. + +Status: **landed.** It answers the question `editing.md` carried from the day +the mode was built — whether this view attaches to `odr.editing` or stays the +one editor that answers to nobody. It attaches. + +## What makes it different + +A `.txt` is a `TextFile`, **not a `DocumentFile`**. There is no element tree +behind it: no runs, no paragraphs, no ids, no registry, and no adapters. So +none of the document operations reach it, `Document::edit` and `Document::save` +do not apply to it, and the whole write side of the engines is somewhere it +cannot go. + +What it has instead is a flat string of lines and, in the browser, an editor +that predates all of this — `text.js` had its own `beforeinput` gate and its +own inverse-recording undo before the mode existed. + +## Decisions + +### 1. It attaches to the mode rather than keeping its own API + +`text.js` is an editor on `odr.editing` like every format's. The view writes +`editing.js` and the page-level `data-odr-editable` / `data-odr-keyboard`. + +**Why:** decision 9 of [`editing.md`](editing.md) is that a host wires the mode +once for every file it opens and must not learn a second API because of what +the file turned out to be. Decision 11 states that `odr.editing` is on every +view either way and `odr.generateDiff()` never goes missing. Both were true of +every view **except this one**: `text_file.cpp` wrote `text.js` and not +`editing.js`, so a `.txt` page had no mode at all and `odr.generateDiff` came +back `undefined`. + +**What it cost:** nothing in the editor. `text.js` already recorded an inverse +per change, so `undo`, `redo`, `canUndo` and `canRedo` answered honestly the +moment they were wired up. It is the only editor of the three that did not need +an undo built for it. + +### 2. One operation, `setContent {text}`, carrying the whole file + +```json +{"version": 2, "ops": [{"op": "setContent", "text": "…"}]} +``` + +Coalescing makes the log exactly one operation however long the session runs, +and an envelope stating none writes the file back as it was. + +**Why not a line at a time**, which is the obvious alternative: **a line number +is a path**, and decision 1 of [`document-editing.md`](document-editing.md) is +that an operation must not address by one — inserting a line shifts every line +after it, so a log of more than one structural operation cannot be replayed. A +document escapes that with ids from its registry. A plain file has no registry +to hang an id on, because lines are not elements; they are where the newlines +happen to be. Per-line operations would mean inventing an identity the format +does not have. + +**Why not finer:** addressing inside a line wants offsets, and decision 2 of +[`document-editing.md`](document-editing.md) refused those — JavaScript counts +UTF-16 code units and `std::string` counts bytes. + +**What it costs:** the whole file crosses the bridge on every save. Less than +it looks, because `write_edited` produces the complete bytes either way, so a +finer log would only be reassembled before writing; the saving would be one +hop. Where it does bite is a large file, and the answer there is **one** +`replaceLines {from, to, text}` computed as a single diff hunk at emit time — +still one operation, still applied to the file as it was, so still nothing +positional to go stale. That needs no schema change to reach. + +### 3. The write path is `PdfFile::annotate`'s shape, not `Document::save`'s + +```cpp +[[nodiscard]] bool TextFile::is_savable() const noexcept; +void TextFile::write_edited(std::string_view operations, std::ostream &out, + const Logger & = Logger::null()) const; +``` + +One call taking the envelope and a stream, leaving the handle unchanged. + +**Why:** a `TextFile` is an immutable handle over bytes, and there is no +document to mutate and later serialise. `PdfFile::annotate` is the precedent — +the other non-document file with a write path of its own — and the shape suits +for the same reason: nothing is held between the edit and the write. + +### 4. What it writes is UTF-8, whatever the source was + +`is_savable()` refuses only an encoding we cannot **decode**: the view hands +those bytes to the browser as they are, so what comes back could not be put +back. Everything else saves — and saves as UTF-8. + +**Why:** `encoding/transcode.hpp` has `to_utf8` and nothing in the other +direction. A Shift-JIS file therefore opens, edits, saves, and is UTF-8 +afterwards. + +**Why it is stated rather than hidden:** the API doc says so, so a host can +warn the reader. Silently changing a property of someone's file that nothing +told them about is the failure mode worth avoiding here, more than the change +itself. + +## Open questions + +- **`from_utf8`** would let a file round-trip in its own encoding and close + decision 4. The tables in `encoding/encoding_data` are there to reverse, so + the work is real but bounded — except for the question it brings with it: + what to do with a character the target encoding has no room for. +- **Undo granularity.** Typing over a selection is two steps, because + `insertTextAction` calls `removeTextAction` and each pushes its own change. + The document editor is one `beforeinput`, one step. It predates this work and + is only visible now that a host can drive undo. +- **The pdf annotator** is now the one editor that answers to nobody: + `odr.annotation` is its own API and `PdfFile::annotate` its own write path. + That is a different gesture from editing text, so whether it should share the + mode is a real question rather than an oversight. diff --git a/src/odr/file.cpp b/src/odr/file.cpp index 90726737f..eedb3cfb7 100644 --- a/src/odr/file.cpp +++ b/src/odr/file.cpp @@ -14,7 +14,11 @@ #include #include +#include + #include +#include +#include namespace odr { @@ -286,6 +290,38 @@ std::string TextFile::text() const { return internal::encoding::to_utf8(bytes, encoding); } +bool TextFile::is_savable() const noexcept { + const TextEncoding encoding = this->encoding(); + return encoding == TextEncoding::unknown || + text_encoding_is_decodable(encoding); +} + +void TextFile::write_edited(const std::string_view operations, + std::ostream &out, + const Logger & /*logger*/) const { + if (!is_savable()) { + throw UnsupportedOperation(); + } + + const nlohmann::json json = nlohmann::json::parse(operations); + if (json.value("version", 0) != 2) { + throw std::invalid_argument("unsupported edit version"); + } + + std::optional content; + for (const nlohmann::json &operation : json.at("ops")) { + const auto name = operation.at("op").get(); + if (name != "setContent") { + throw std::invalid_argument("unknown operation " + name); + } + content = operation.at("text").get(); + } + + // an envelope stating any operation replaces every byte, so the file is + // only read where it states none + out << (content.has_value() ? *content : text()); +} + std::shared_ptr TextFile::impl() const { return m_impl; } diff --git a/src/odr/file.hpp b/src/odr/file.hpp index 4ff7e08e5..d425d69c6 100644 --- a/src/odr/file.hpp +++ b/src/odr/file.hpp @@ -2,6 +2,7 @@ #include +#include #include #include #include @@ -202,7 +203,8 @@ struct FileTypeCapabilities final { ///< paints bool color_scheme{}; ///< the view honors @ref HtmlConfig::color_scheme bool edit{}; ///< @ref Document::is_editable can be `true` - bool save{}; ///< @ref Document::save is supported + bool save{}; ///< @ref Document::save, or for a plain file + ///< @ref TextFile::write_edited bool encrypt{}; ///< @ref Document::save with a password is supported bool annotate{}; ///< @ref PdfFile::annotate is supported; a concrete file ///< still answers for itself with @@ -436,6 +438,17 @@ class TextFile final : public DecodedFile { /// the raw bytes where it is not. [[nodiscard]] std::string text() const; + /// False where @ref encoding cannot be decoded: the view hands those bytes + /// to the browser as they are, so what comes back cannot be put back. + [[nodiscard]] bool is_savable() const noexcept; + + /// Applies @p operations - `{"version": 2, "ops": [{"op": "setContent", + /// "text": "…"}]}` - and writes the result to @p out, as UTF-8 whatever + /// @ref encoding the source was. See `docs/design/txt-editing.md`. + /// @throws UnsupportedOperation where @ref is_savable is false. + void write_edited(std::string_view operations, std::ostream &out, + const Logger &logger = Logger::null()) const; + [[nodiscard]] std::shared_ptr impl() const; private: diff --git a/src/odr/internal/file_type_table.cpp b/src/odr/internal/file_type_table.cpp index 0da05d0f6..435a0c6d1 100644 --- a/src/odr/internal/file_type_table.cpp +++ b/src/odr/internal/file_type_table.cpp @@ -461,7 +461,9 @@ constexpr std::array table{ {.detect_by_content = true, .open = true, .translate_html = true, - .color_scheme = true}}, + .color_scheme = true, + .edit = true, + .save = true}}, Row{FileType::comma_separated_values, "csv"sv, csv_extensions, diff --git a/src/odr/internal/html/common.cpp b/src/odr/internal/html/common.cpp index a5a3a7e38..eebac278c 100644 --- a/src/odr/internal/html/common.cpp +++ b/src/odr/internal/html/common.cpp @@ -345,4 +345,18 @@ std::string html::file_to_url(const abstract::File &file, return file_to_url(*file.stream(), mime_type); } +std::string html::keyboard_classes(const HtmlConfig &config) { + std::string classes; + if (config.keyboard_navigation) { + classes += "navigation"; + } + if (config.keyboard_shortcuts) { + if (!classes.empty()) { + classes += " "; + } + classes += "shortcuts"; + } + return classes; +} + } // namespace odr::internal diff --git a/src/odr/internal/html/common.hpp b/src/odr/internal/html/common.hpp index 63307fb63..b4eba4111 100644 --- a/src/odr/internal/html/common.hpp +++ b/src/odr/internal/html/common.hpp @@ -146,4 +146,7 @@ std::string file_to_url(std::istream &file, const std::string &mime_type); std::string file_to_url(const abstract::File &file, const std::string &mime_type); +/// The key classes a view's scripts may take; `editing.md` decision 12. +[[nodiscard]] std::string keyboard_classes(const HtmlConfig &config); + } // namespace odr::internal::html diff --git a/src/odr/internal/html/document.cpp b/src/odr/internal/html/document.cpp index 7baf195d4..d7271f6e4 100644 --- a/src/odr/internal/html/document.cpp +++ b/src/odr/internal/html/document.cpp @@ -198,21 +198,6 @@ void write_head(const Document &document, const WritingState &state, out.write_header_end(); } -/// The key classes the view's scripts may take; `editing.md` decision 12. -std::string keyboard_classes(const HtmlConfig &config) { - std::string classes; - if (config.keyboard_navigation) { - classes += "navigation"; - } - if (config.keyboard_shortcuts) { - if (!classes.empty()) { - classes += " "; - } - classes += "shortcuts"; - } - return classes; -} - void write_body_begin(const Document &document, const WritingState &state) { HtmlWriter &out = state.out(); diff --git a/src/odr/internal/html/frontend.cpp b/src/odr/internal/html/frontend.cpp index 608d6ea64..49ef12a7f 100644 --- a/src/odr/internal/html/frontend.cpp +++ b/src/odr/internal/html/frontend.cpp @@ -296,9 +296,9 @@ void html::write_viewport_script(const WritingState &state) { } HtmlResources html::locate_text_resources(const HtmlConfig &config) { - static constexpr std::array assets{text_css_asset, search_css_asset, - search_js_asset, text_js_asset, - viewport_js_asset}; + static constexpr std::array assets{text_css_asset, search_css_asset, + search_js_asset, editing_js_asset, + text_js_asset, viewport_js_asset}; static constexpr std::array dark{text_dark_css_asset, search_dark_css_asset}; return locate_all(assets, dark, config); } diff --git a/src/odr/internal/html/frontend/text.js b/src/odr/internal/html/frontend/text.js index 4f7508554..057f3d4a0 100644 --- a/src/odr/internal/html/frontend/text.js +++ b/src/odr/internal/html/frontend/text.js @@ -1,6 +1,11 @@ +// The plain-text editor: a line-number gutter that runs whatever the mode +// says, and editing that attaches to `odr.editing` like every other format's. +// See `docs/design/txt-editing.md`. (function () { "use strict"; + var odr = (window.odr = window.odr || {}); + function TextEditor(textNr, textBody) { this.textNr = textNr; this.textBody = textBody; @@ -30,6 +35,11 @@ this.textBody.addEventListener("beforeinput", function (event) { event.preventDefault(); + if (!odr.editing.isEnabled()) { + odr.editing.refuse("readOnly", null); + return; + } + if (event.inputType === "historyUndo") { self.undo(); } else if (event.inputType === "historyRedo") { @@ -42,11 +52,19 @@ self.removeTextAction("backward"); } else if (event.inputType === "deleteContentForward") { self.removeTextAction("forward"); + } else { + // a closed list, as the document editor's is: what we cannot record + // is refused rather than swallowed + odr.editing.refuse("unsupportedEdit", null); } }); this.textBody.addEventListener("paste", function (event) { event.preventDefault(); + if (!odr.editing.isEnabled()) { + odr.editing.refuse("readOnly", null); + return; + } self.insertTextAction(event.clipboardData.getData("text/plain")); }); @@ -262,24 +280,39 @@ TextEditor.prototype.pushChange = function (change) { this.past.push(change); this.future = []; + odr.editing.changed(); }; TextEditor.prototype.undo = function () { if (this.past.length === 0) { - return; + return false; } var change = this.past.pop(); this.future.push(change); this.doChange(this.invertChange(change)); + odr.editing.changed(); + return true; }; TextEditor.prototype.redo = function () { if (this.future.length === 0) { - return; + return false; } var change = this.future.pop(); this.past.push(change); this.doChange(change); + odr.editing.changed(); + return true; + }; + + /// The file's whole text, which for a plain file is its whole document. + TextEditor.prototype.text = function () { + var lines = this.textBody.children; + var result = []; + for (var i = 0; i < lines.length; ++i) { + result.push(this.getLineText(lines[i])); + } + return result.join("\n"); }; TextEditor.prototype.insertTextAction = function (text) { @@ -344,7 +377,40 @@ var textNr = document.querySelector(".odr-text-nr"); var textBody = document.querySelector(".odr-text-body"); - if (textNr && textBody) { - new TextEditor(textNr, textBody); + if (!textNr || !textBody) { + return; } + var editor = new TextEditor(textNr, textBody); + + odr.editing.attach({ + enable: function () { + textBody.setAttribute("contenteditable", "true"); + }, + disable: function () { + textBody.removeAttribute("contenteditable"); + }, + /// One operation or none: a plain file's content is the whole of it. + operations: function () { + if (editor.past.length === 0) { + return []; + } + return [{ op: "setContent", text: editor.text() }]; + }, + canUndo: function () { + return editor.past.length > 0; + }, + canRedo: function () { + return editor.future.length > 0; + }, + undo: function () { + return editor.undo(); + }, + redo: function () { + return editor.redo(); + }, + committed: function () { + editor.past = []; + editor.future = []; + }, + }); })(); diff --git a/src/odr/internal/html/text_file.cpp b/src/odr/internal/html/text_file.cpp index 04f52766b..3adda5102 100644 --- a/src/odr/internal/html/text_file.cpp +++ b/src/odr/internal/html/text_file.cpp @@ -108,7 +108,15 @@ class HtmlServiceImpl final : public HtmlService { out.write_header_end(); - out.write_body_begin(); + out.write_body_begin(HtmlElementOptions().set_attributes( + [&](const HtmlAttributeWriterCallback &clb) { + // what `enable()` answers, stated only by a render that edits + if (config().editable) { + clb("data-odr-editable", + m_text_file.is_savable() ? "true" : "readOnly"); + } + clb("data-odr-keyboard", keyboard_classes(config())); + })); out.write_element_begin("div", HtmlElementOptions().set_class("odr-text")); @@ -128,16 +136,10 @@ class HtmlServiceImpl final : public HtmlService { } out.write_element_end("div"); - // `text.js` is this view's own editor and needs the browser to edit the - // lines. Not the mode: `txt` declares no `edit`, so nothing replays these. - out.write_element_begin("div", - HtmlElementOptions().set_attributes( - [&](const HtmlAttributeWriterCallback &clb) { - clb("class", "odr-text-body odr-text-wrap"); - if (config().editable) { - clb("contenteditable", "true"); - } - })); + // `contenteditable` is not written: `odr.editing.enable()` puts it on + // these lines, so one render serves both modes. + out.write_element_begin( + "div", HtmlElementOptions().set_class("odr-text-body odr-text-wrap")); in = std::istringstream(text); while (!in.eof()) { out.write_element_begin("div", HtmlElementOptions().set_inline(true)); @@ -158,6 +160,7 @@ class HtmlServiceImpl final : public HtmlService { out.write_element_end("div"); write_search_script(state); + write_editing_script(state); write_text_script(state); write_viewport_script(state); diff --git a/test/browser/plaintext/README.md b/test/browser/plaintext/README.md new file mode 100644 index 000000000..5faa6719c --- /dev/null +++ b/test/browser/plaintext/README.md @@ -0,0 +1,35 @@ +# plain-text editing checks + +The `.txt` view's editor, which is `text.js` attached to `odr.editing`; see +[`txt-editing.md`](../../../docs/design/txt-editing.md). Run by hand, like the +rest of `test/browser/`. + +```bash +test/browser/plaintext/serve # serves on :8735 +open http://localhost:8735/tests.html +``` + +`serve` serves `text.css`, `editing.js` and `text.js` straight out of +`src/odr/internal/html/frontend/`, so what runs is the file the library embeds. +`editing.js` goes first, as the library writes it. + +- **`tests.html`** — a plain-text view carries the same mode every other view + does, and `text.js` is its editor rather than a second API. The fixture is + three lines and the gutter beside them, without `contenteditable`: the mode + puts that on. + +Why the checks look the way they do: + +- **A plain file's whole content is its document**, so the log is one + `setContent` operation however long the session — no ids, because there are + no elements to name. +- **The gutter is checked alongside the lines.** `text.js` does double duty: + the line numbers track the lines whether or not editing is on, and a split or + a join that left them behind would be invisible in the text alone. +- **A refusal repeats.** Two identical refusals within two seconds are one + event ([`editing.md`](../../docs/design/editing.md) decision 9), so the + second read-only check asserts the edit did not land rather than a second + event. +- **The envelope's version is asserted.** It is the one thing the browser and + `Document::edit` have to agree on that neither side would notice drifting — + and it *did* drift once. diff --git a/test/browser/plaintext/serve b/test/browser/plaintext/serve new file mode 100755 index 000000000..9fd87d4f6 --- /dev/null +++ b/test/browser/plaintext/serve @@ -0,0 +1,12 @@ +#!/usr/bin/env python3 +"""Serves the plain-text editing checks.""" + +import pathlib +import sys + +HERE = pathlib.Path(__file__).resolve().parent +sys.path.insert(0, str(HERE.parent)) + +from serve import serve # noqa: E402 + +serve(8735, HERE, ("tests.html",)) diff --git a/test/browser/plaintext/tests.html b/test/browser/plaintext/tests.html new file mode 100644 index 000000000..698019b38 --- /dev/null +++ b/test/browser/plaintext/tests.html @@ -0,0 +1,193 @@ + + + + + plain-text editing checks + + + + +
+ +
+
first line
+
second line
+
third line
+
+
+ + +
+ + + + + + diff --git a/test/src/internal/text/text_file_test.cpp b/test/src/internal/text/text_file_test.cpp index 9fb0da64e..39e5c42bf 100644 --- a/test/src/internal/text/text_file_test.cpp +++ b/test/src/internal/text/text_file_test.cpp @@ -10,8 +10,13 @@ #include #include +#include +#include #include #include +#include + +#include using namespace odr; using namespace odr::test; @@ -96,3 +101,82 @@ TEST(TextFile, an_undecodable_encoding_yields_its_bytes) { EXPECT_FALSE(text_encoding_is_decodable(TextEncoding::shift_jis)); EXPECT_EQ(file.text(), content); } + +namespace { + +/// The public handle over @p content, which is what the edit surface sits on. +odr::TextFile opened(const std::string &content) { + return odr::TextFile(std::make_shared( + File::from_memory(content).impl())); +} + +std::string edited(const odr::TextFile &file, const std::string &operations) { + std::ostringstream out; + file.write_edited(operations, out); + return std::move(out).str(); +} + +std::string set_content(const std::string &text) { + return nlohmann::json{{"version", 2}, + {"ops", {{{"op", "setContent"}, {"text", text}}}}} + .dump(); +} + +} // namespace + +TEST(TextFile, an_edit_writes_the_text_it_states) { + EXPECT_EQ(edited(opened("one\ntwo\n"), set_content("one\nTWO\n")), + "one\nTWO\n"); +} + +/// A host that saves without an edit gets the file back as it was. +TEST(TextFile, an_envelope_of_no_ops_writes_the_file_back) { + EXPECT_EQ(edited(opened("one\ntwo\n"), R"({"version":2,"ops":[]})"), + "one\ntwo\n"); +} + +TEST(TextFile, the_last_op_wins) { + const std::string ops = + nlohmann::json{{"version", 2}, + {"ops", + {{{"op", "setContent"}, {"text", "first"}}, + {{"op", "setContent"}, {"text", "second"}}}}} + .dump(); + EXPECT_EQ(edited(opened("one"), ops), "second"); +} + +TEST(TextFile, an_unknown_version_or_op_refuses) { + const odr::TextFile file = opened("one"); + + EXPECT_THROW((void)edited(file, R"({"version":1,"ops":[]})"), + std::invalid_argument); + EXPECT_THROW((void)edited(file, R"({"version":2,"ops":[{"op":"setText"}]})"), + std::invalid_argument); +} + +/// The view hands undecodable bytes to the browser as they are, so what comes +/// back could not be put back. +TEST(TextFile, a_file_we_cannot_decode_is_not_savable) { + EXPECT_TRUE(opened("plain ascii").is_savable()); + + const odr::TextFile shift_jis(std::make_shared( + File::from_memory(std::string("\x82\xa0\x82\xa2")).impl(), + TextEncoding::shift_jis)); + EXPECT_FALSE(shift_jis.is_savable()); + std::ostringstream out; + EXPECT_THROW(shift_jis.write_edited(set_content("x"), out), + UnsupportedOperation); +} + +/// A decodable encoding that is not utf-8 saves, and saves as utf-8. +TEST(TextFile, a_decodable_encoding_saves_as_utf8) { + const odr::TextFile latin1(std::make_shared( + File::from_memory(std::string("caf\xe9")).impl(), + TextEncoding::iso_8859_1)); + ASSERT_TRUE(latin1.is_savable()); + EXPECT_EQ(latin1.text(), "caf\u00e9"); + + std::ostringstream out; + latin1.write_edited(R"({"version":2,"ops":[]})", out); + EXPECT_EQ(std::move(out).str(), "caf\u00e9"); +}