From 126d77d8c5db63bbe537ae09c2ecce6a0b8821d2 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sun, 13 Sep 2026 17:52:21 +0200 Subject: [PATCH 1/2] feat(html): the document editor formats text `odr.editing.format(style)` states any of bold, italic, underline, strikethrough, highlight, colour and size on the selection, and ctrl/cmd+B, I and U toggle through the `formatBold` family of input types. A run the selection covers in part is cut first, and each covered run gets one `setTextStyle`. A collapsed caret inside a word marks the word. Formatting sits behind the scope gate whole: `paragraph` refuses it with `outOfScope`. The editor writes what `translate_text_style` writes, so an edited page looks like a fresh render, and `odr.onSelectionChange` reports the computed style the covered runs agree on. Two marks on one run fold into one op unless an operation naming that run lies between them. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0137vd79NGaB8nfLsdPoghM4 --- CHANGELOG.md | 5 + docs/design/document-editing.md | 30 +- docs/design/editing.md | 40 +- src/odr/internal/html/frontend/document.js | 421 ++++++++++++++++++++- src/odr/internal/html/frontend/editing.js | 23 +- test/browser/text/README.md | 8 +- test/browser/text/tests.html | 206 +++++++++- 7 files changed, 693 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 217efb188..1e570720b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,11 @@ The release run heads these entries with the version and opens a fresh - A `.docx` run's shading (`w:shd`) renders as its background where no highlight paints over it. +- The document view's editor formats text under scope `document`: + `odr.editing.format({bold, italic, underline, strikethrough, highlight, + color, size})`, the ctrl/cmd+B, I and U chords, and `odr.onSelectionChange` + for a host's buttons. Scope `paragraph` refuses it with `outOfScope`. + - A run that is both underlined and struck through renders both lines. The page wrote two `text-decoration` declarations, and the second replaced the first. diff --git a/docs/design/document-editing.md b/docs/design/document-editing.md index d812ce602..ce94e8953 100644 --- a/docs/design/document-editing.md +++ b/docs/design/document-editing.md @@ -186,11 +186,11 @@ a path in a version-2 world names the wrong element rather than none. | `splitParagraph` | `paragraph`, `after` (optional), `id` | the children after `after` move into a new paragraph that copies the style; no `after` moves all of them | | `mergeParagraph` | `paragraph` | takes the children of the next sibling paragraph and removes it | | `insertParagraph` | `after`, `id` | a fresh empty paragraph after the named one, copying its style | +| `setTextStyle` | `id`, `style` | states the listed properties on one run; see [Inline formatting](#inline-formatting) | | `setCell` | `sheet`, `column`, `row`, `value` | unchanged; see [`spreadsheet-editing.md`](spreadsheet-editing.md) | Every `id` field on an op that creates an element is negative (decision 4). -Every other id is one the page wrote. [Inline formatting](#inline-formatting) -adds `setTextStyle` to this table. +Every other id is one the page wrote. ### The four reader gestures, as ops @@ -362,9 +362,8 @@ that names the frame rather than a range across text. ## Inline formatting -Status: **in progress.** It is the one step of [`editing.md`](editing.md) -still open; the order of work below says what is in. It covers what a reader -changes on a stretch of text without +Status: **landed**, but for the bindings; the order of work below says what +is in. It covers what a reader changes on a stretch of text without changing the text: bold, italic, underline, strikethrough, highlight, colour and size. Font name, superscript and subscript are not in it; nothing asked for them, and each is the same shape once these seven are in. @@ -515,15 +514,18 @@ second wins, so such a run shows one line. It has to become one declaration, ### 16. The gesture reaches the editor two ways, and both land in one function - **The host asks.** `odr.editing.format(style)` applies a partial style to - the current selection, since a mobile host has buttons and no keyboard. - For the buttons to show state, the editor reports the style of the - selection through `odr.onSelectionChange(style)`, one key per property and - a key left out where the selection is mixed. + the current selection, since a mobile host has buttons and no keyboard; it + answers false where the editor refused, and the refusal channel says why. + For the buttons to show state, the editor reports the computed style of + the selection through `odr.onSelectionChange(style)` as it moves, one key + per property and none where the covered runs differ. - **The browser asks.** `formatBold`, `formatItalic`, `formatUnderline` and `formatStrikeThrough` leave the refused list of decision 13 in [`editing.md`](editing.md) and join the whitelist. Chrome raises them for ctrl/cmd+B, I and U. They are chords, so they are the *shortcuts* class of - decision 12, and a host that keeps that class keeps these too. + decision 12: where a host keeps that class the editor cancels the + browser's own mark and does nothing else. A chord toggles, and a mixed + selection turns on, as Word does. Formatting sits behind the scope gate, decision 14 of [`editing.md`](editing.md): under `paragraph` every formatting gesture @@ -535,6 +537,9 @@ questions hold what it should do instead. Undo needs nothing new. A step already holds its ops and the two halves of taking it back; here the halves are the runs' old and new `style` attributes. +Two marks on one run fold into one op where the later keys win, unless an +operation naming that run lies between them: a run put beside it takes the +style it has at that moment. ### The adapter surface @@ -567,7 +572,7 @@ Each step is a pull request that builds and tests on its own. **Landed.** 4. **The browser.** `format()`, `onSelectionChange`, the four input types, the word rule for a collapsed caret, and a check page in - `test/browser/text` asserting the log of each gesture. + `test/browser/text` asserting the log of each gesture. **Landed.** ### Open questions @@ -584,6 +589,9 @@ Each step is a pull request that builds and tests on its own. - **Where the size list comes from.** A host offers sizes; the editor takes any length. Whether the ODF percentage sizes the reader resolves are ever written back as absolute is a question the fixtures answer. +- **A line under a wrapper.** `text-decoration` is drawn through every + descendant, so an underline taken off a run inside an underlined span + still shows in the page and in a fresh render; the saved file is right. - **`transparent` is read as unstated.** `read_color` answers nothing for `fo:background-color="transparent"`, so a highlight taken away on a run inside a highlighted paragraph still shows in our render, not in diff --git a/docs/design/editing.md b/docs/design/editing.md index f1dd9e7d7..875f286a9 100644 --- a/docs/design/editing.md +++ b/docs/design/editing.md @@ -30,16 +30,16 @@ file — with no live connection between the browser and C++. format's editor attaches one editor to it (decision 9): `frontend/sheet-editing.js`, `frontend/document.js`, `frontend/text.js`. - `Document::edit` replays the envelope: `setCell` for a sheet, and `setText`, - `insertText`, `removeElement`, `splitParagraph`, `mergeParagraph` and - `insertParagraph` for a document. `TextFile::write_edited` is the plain-text - counterpart, since a `.txt` is not a document. + `setTextStyle`, `insertText`, `removeElement`, `splitParagraph`, + `mergeParagraph` and `insertParagraph` for a document. + `TextFile::write_edited` is the plain-text counterpart, since a `.txt` is + not a document. - `back_translate` CLI replays an envelope onto a source document and `save`s it. -What is **not** done is inline formatting — bold, italic, underline, -strikethrough, highlight, colour and size. Decision 5 of -[`document-editing.md`](document-editing.md) says why the schema takes it -without changing, and its [Inline formatting](document-editing.md#inline-formatting) -section is the plan. +Inline formatting — bold, italic, underline, strikethrough, highlight, colour +and size — is `setTextStyle` on the wire and `odr.editing.format` in the page; +the [Inline formatting](document-editing.md#inline-formatting) section of +[`document-editing.md`](document-editing.md) holds its decisions. ## Decisions @@ -189,6 +189,9 @@ runs or paragraphs. It owns: the wording and not the numbers; - the **log** — `getOperations()`, `undo()`, `redo()`, `committed()`, and the `dirty` / `canUndo` / `canRedo` state `odr.onEditChange` reports; +- the **formatting seam** — `format(style)` hands a host's button to the + editor that has one, and `odr.onSelectionChange` is where that editor + reports what the selection shows; - the **keyboard classes** the page may take (decision 12). A format's editor is a second script that **attaches** one editor to the mode: @@ -348,11 +351,12 @@ then intercepts `beforeinput` and takes the edits it can express as operations: | Enter | taken: the paragraph splits where the caret sits | | Backspace at the start of a paragraph | taken: the paragraph merges into the one before it | | a paste of plain text, over as many lines as it holds | taken: each line after the first opens a paragraph | +| a mark - ctrl/cmd+B, I, U, or `odr.editing.format` - under scope `document` | taken: a run covered in part is cut, and the covered runs are restyled | | a composition (CJK, autocorrect, dictation) | let through and reconciled on `compositionend` | | a soft line break (`insertLineBreak`) | refused, reason `newLine` - no operation carries one | | a range reaching over a picture | taken: the frame carries an address, so the picture goes with the text | | a range reaching over a text box or a table | refused, reason `range` - it holds text of its own, which the reader did not mean to lose | -| anything else the browser offers (a mark, a list, a drop) | refused, reason `unsupportedEdit` | +| anything else the browser offers (a list, a rule, a drop) | refused, reason `unsupportedEdit` | | an edit landing outside every run | refused, reason `range` | **Why the whole view rather than a run at a time:** `contenteditable` per run @@ -439,17 +443,13 @@ Each per-editor document holds what its own step decided. | The op envelope and a replay that dispatches over it | landed | | The write side of the engines — odf, ooxml text, ooxml presentation | landed | | The browser editor, owning the edit and its own undo | landed | -| **Inline formatting** — bold, italic, underline, strikethrough, highlight, colour, size | **not started**; planned in [`document-editing.md`](document-editing.md#inline-formatting) | - -Formatting is the one left, and decision 5 of -[`document-editing.md`](document-editing.md) is why the schema takes it without -changing: toggling a mark on part of a run is, in both formats, "split the run, -restyle the middle one", and the split is already two operations we have. What -it needs is one op, `setTextStyle {id, style}`, carrying values rather than -toggles, and two things on the write side: a container of the run's own, -because a run shares its `text:span` / `w:r` / `a:r` with its siblings, and for -ODF the automatic style a mark is reached through. Decisions 8 to 16 there -hold the rest. +| **Inline formatting** — bold, italic, underline, strikethrough, highlight, colour, size | landed; [`document-editing.md`](document-editing.md#inline-formatting) | + +Formatting landed last, and decision 5 of +[`document-editing.md`](document-editing.md) is why the schema took it without +changing: a mark on part of a run is "split the run, restyle the middle one", +and the split was already two operations. It added one op, `setTextStyle +{id, style}`; decisions 8 to 16 there hold the rest. The **conformance corpus** decision 7 asks for is still not built. What stands in for it is that both sides pin the same operation shapes: the browser check diff --git a/src/odr/internal/html/frontend/document.js b/src/odr/internal/html/frontend/document.js index 567508703..20391258e 100644 --- a/src/odr/internal/html/frontend/document.js +++ b/src/odr/internal/html/frontend/document.js @@ -122,9 +122,50 @@ return copy; } + /// Whether @p op names @p id in any of its fields. + function names(op, id) { + return ( + op.id === id || + op.after === id || + op.before === id || + op.parent === id || + op.paragraph === id + ); + } + + /// The earlier `setTextStyle` on @p op's run with no operation naming that + /// run in between: a run put beside it takes the style it has then. + function foldableStyle(result, op) { + for (var i = result.length - 1; i >= 0; --i) { + if (result[i].op === "setTextStyle" && result[i].id === op.id) { + return i; + } + if (names(result[i], op.id)) { + return -1; + } + } + return -1; + } + + function withStyle(op, style) { + var merged = {}; + var key; + for (key in op.style) { + if (Object.prototype.hasOwnProperty.call(op.style, key)) { + merged[key] = op.style[key]; + } + } + for (key in style) { + if (Object.prototype.hasOwnProperty.call(style, key)) { + merged[key] = style[key]; + } + } + return { op: "setTextStyle", id: op.id, style: merged }; + } + /// Folds what a save need not carry: several edits to one run are the text - /// it ends at, and a run created and typed into is one insert. Only adjacent - /// operations fold, so nothing between them can depend on it. + /// it ends at, a run created and typed into is one insert, and two marks on + /// one run are one where the later keys win. function coalesce(ops) { var result = []; for (var i = 0; i < ops.length; ++i) { @@ -139,6 +180,13 @@ result[result.length - 1] = withText(last, op.text); continue; } + if (op.op === "setTextStyle") { + var at = foldableStyle(result, op); + if (at !== -1) { + result[at] = withStyle(result[at], op.style); + continue; + } + } result.push(op); } return result; @@ -349,6 +397,26 @@ }; } + /// States @p style on @p run as the renderer writes it; undo puts the + /// attribute back as it was. + function setRunStyle(run, style) { + var before = run.getAttribute("style"); + return { + ops: [{ op: "setTextStyle", id: idOf(run), style: style }], + apply: function () { + paint(run, style); + }, + revert: function () { + // Chrome serialises a declaration set through `style` into an empty + // attribute after a bare `removeAttribute`, so it is stated first + run.setAttribute("style", before === null ? "" : before); + if (before === null) { + run.removeAttribute("style"); + } + }, + }; + } + function insertParagraph(after) { var id = mint(); var paragraph = shellCopy(after, id); @@ -365,13 +433,327 @@ }; } + // ------------------------------------------------------------- formatting + + // the properties `setTextStyle` carries (`docs/design/document-editing.md`) + var properties = [ + "bold", + "italic", + "underline", + "strikethrough", + "highlight", + "color", + "size", + ]; + + // the input types a chord raises, and the property each one toggles + var toggling = { + formatBold: "bold", + formatItalic: "italic", + formatUnderline: "underline", + formatStrikeThrough: "strikethrough", + }; + + /// `#rrggbb` for a computed `rgb(…)`, null for a transparent one. + function hexOf(computed) { + var match = /^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)$/.exec( + computed + ); + if (match === null || (match[4] !== undefined && Number(match[4]) === 0)) { + return null; + } + var hex = "#"; + for (var i = 1; i <= 3; ++i) { + hex += ("0" + Number(match[i]).toString(16)).slice(-2); + } + return hex; + } + + /// The style @p run shows, as the wire spells it; computed, so what a + /// wrapper or the paragraph gives it counts. + function styleOf(run) { + var computed = window.getComputedStyle(run); + var weight = computed.fontWeight; + var lines = computed.textDecorationLine || computed.textDecoration || ""; + var px = parseFloat(computed.fontSize); + return { + bold: weight === "bold" || weight === "bolder" || Number(weight) >= 600, + italic: computed.fontStyle === "italic" || computed.fontStyle === "oblique", + underline: lines.indexOf("underline") !== -1, + strikethrough: lines.indexOf("line-through") !== -1, + highlight: hexOf(computed.backgroundColor), + color: hexOf(computed.color), + size: isNaN(px) ? null : String(Math.round(px * 75) / 100) + "pt", + }; + } + + /// What @p runs agree on: a key per property with one value across them, + /// and none where they differ. + function summaryOf(runs) { + var result = {}; + if (runs.length === 0) { + return result; + } + var styles = []; + for (var i = 0; i < runs.length; ++i) { + styles.push(styleOf(runs[i])); + } + for (var p = 0; p < properties.length; ++p) { + var key = properties[p]; + var agreed = true; + for (var j = 1; j < styles.length && agreed; ++j) { + agreed = styles[j][key] === styles[0][key]; + } + if (agreed) { + result[key] = styles[0][key]; + } + } + return result; + } + + /// Writes @p style into @p run's `style` attribute as `translate_text_style` + /// does: one `text-decoration` for both lines, none for a highlight removed. + function paint(run, style) { + var css = run.style; + if (style.bold !== undefined) { + css.fontWeight = style.bold ? "bold" : "normal"; + } + if (style.italic !== undefined) { + css.fontStyle = style.italic ? "italic" : "normal"; + } + if (style.underline !== undefined || style.strikethrough !== undefined) { + var shown = styleOf(run); + var underline = + style.underline !== undefined ? style.underline : shown.underline; + var strikethrough = + style.strikethrough !== undefined + ? style.strikethrough + : shown.strikethrough; + var lines = []; + if (underline) { + lines.push("underline"); + } + if (strikethrough) { + lines.push("line-through"); + } + if (lines.length === 0) { + css.removeProperty("text-decoration"); + } else { + css.textDecoration = lines.join(" "); + } + } + if (style.color !== undefined) { + css.color = style.color; + } + if (style.highlight !== undefined) { + if (style.highlight === null) { + css.removeProperty("background-color"); + } else { + css.backgroundColor = style.highlight; + } + } + if (style.size !== undefined) { + css.fontSize = style.size; + } + } + + /// Every run of the view in document order. + function allRuns() { + return Array.prototype.slice.call(root.querySelectorAll("x-s[data-odr-id]")); + } + + function runsBetween(from, to) { + var all = allRuns(); + return all.slice(all.indexOf(from), all.indexOf(to) + 1); + } + + /// Whether @p style names only properties the wire carries. + function knownStyle(style) { + if (style === null || typeof style !== "object") { + return false; + } + var any = false; + for (var key in style) { + if (Object.prototype.hasOwnProperty.call(style, key)) { + if (properties.indexOf(key) === -1) { + return false; + } + any = true; + } + } + return any; + } + + /// A collapsed range grown to the word the caret sits in, as Word does; + /// null at a word boundary. A range that is not collapsed is itself. + function wordAround(at) { + if (at.start.run !== at.end.run || at.start.offset !== at.end.offset) { + return at; + } + var place = at.start; + if (place.run === null) { + return null; + } + var text = place.run.textContent; + var from = place.offset; + var to = place.offset; + if ( + from <= 0 || + to >= text.length || + /\s/.test(text[from - 1]) || + /\s/.test(text[to]) + ) { + return null; + } + while (from > 0 && !/\s/.test(text[from - 1])) { + --from; + } + while (to < text.length && !/\s/.test(text[to])) { + ++to; + } + return { + start: { run: place.run, offset: from, paragraph: place.paragraph }, + end: { run: place.run, offset: to, paragraph: place.paragraph }, + }; + } + + /// States @p style on what @p at covers, cutting a run covered in part so + /// the mark lands on the covered text alone. Answers the range marked, or + /// null where it cannot be named. + function markRange(at, style) { + var start = at.start; + var end = at.end; + if (start.run === null || end.run === null) { + return null; + } + if (start.run !== end.run && !coversOnlyText(start.run, end.run)) { + return null; + } + if ( + start.paragraph !== end.paragraph && + paragraphsBetween(start.paragraph, end.paragraph) === null + ) { + return null; + } + + // an end that covers none of its run steps over to the next one + var all = allRuns(); + var from = all.indexOf(start.run); + var to = all.indexOf(end.run); + var first = start.run; + var last = end.run; + var startOffset = start.offset; + var endOffset = end.offset; + if (startOffset >= first.textContent.length && from < to) { + first = all[++from]; + startOffset = 0; + } + if (endOffset === 0 && to > from) { + last = all[--to]; + endOffset = last.textContent.length; + } + if (from > to || (first === last && startOffset >= endOffset)) { + return null; + } + + if (startOffset > 0) { + var whole = first.textContent; + perform(setRunText(first, whole.slice(0, startOffset))); + var head = first; + first = perform(insertRun(head, "after", whole.slice(startOffset))).run; + if (last === head) { + last = first; + endOffset -= startOffset; + } + } + if (endOffset < last.textContent.length) { + var text = last.textContent; + perform(setRunText(last, text.slice(0, endOffset))); + perform(insertRun(last, "after", text.slice(endOffset))); + } + + var runs = runsBetween(first, last); + for (var i = 0; i < runs.length; ++i) { + perform(setRunStyle(runs[i], style)); + } + return { + start: { run: first, offset: 0, paragraph: paragraphOf(first) }, + end: { + run: last, + offset: last.textContent.length, + paragraph: paragraphOf(last), + }, + }; + } + + /// The runs @p at reaches, for what a toggle or a host's buttons read. + function coveredRuns(at) { + if (at === null || at.start.run === null || at.end.run === null) { + return []; + } + return runsBetween(at.start.run, at.end.run); + } + + /// States @p style on @p at, for a host's `format` and for a chord. + /// Formatting sits behind the scope gate whole. + function format(style, at) { + if (!odr.editing.isEnabled()) { + refuse(null, "readOnly", at); + return false; + } + if (!knownStyle(style)) { + refuse(null, "unsupportedEdit", at); + return false; + } + if (at === null) { + refuse(null, "range", at); + return false; + } + if (odr.editing.scope() === "paragraph") { + refuse(null, "outOfScope", at); + return false; + } + var covering = wordAround(at); + var marked = covering === null ? null : markRange(covering, style); + if (marked === null) { + refuse(null, "range", at); + return false; + } + selectRange(marked); + return true; + } + + // what the selection last reported, so a move that changes nothing is quiet + var lastReported = null; + + function reportSelection() { + if (!odr.editing.isEnabled()) { + return; + } + var summary = summaryOf(coveredRuns(rangeOf({}))); + var key = JSON.stringify(summary); + if (key === lastReported) { + return; + } + lastReported = key; + odr.editing.selectionChanged(summary); + } + + document.addEventListener("selectionchange", reportSelection); + // ------------------------------------------------------------------ caret - function placeCaret(run, offset) { + /// The text node of @p run a range can point into, made where it has none. + function textNodeOf(run) { var node = run.firstChild; if (node === null || node.nodeType !== 3) { node = run.insertBefore(document.createTextNode(""), run.firstChild); } + return node; + } + + function placeCaret(run, offset) { + var node = textNodeOf(run); var range = document.createRange(); range.setStart(node, Math.max(0, Math.min(offset, node.data.length))); range.collapse(true); @@ -380,6 +762,17 @@ selection.addRange(range); } + /// Selects from the start of @p marked's first run to the end of its last. + function selectRange(marked) { + var range = document.createRange(); + range.setStart(textNodeOf(marked.start.run), 0); + var end = textNodeOf(marked.end.run); + range.setEnd(end, end.data.length); + var selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + } + /// The offset into @p run's text that (@p container, @p offset) names. The /// browser counts characters inside a text node and children inside an /// element, either of which may sit under a nested wrapper. @@ -805,7 +1198,7 @@ } function refuse(event, reason, at) { - if (event.cancelable) { + if (event !== null && event.cancelable) { event.preventDefault(); } // the id keeps two refusals apart, so the same key in one run and then in @@ -915,6 +1308,23 @@ return; } + var property = toggling[type]; + if (property !== undefined) { + // the browser's own mark is never taken + event.preventDefault(); + // a chord is the shortcuts class, and a host may keep it + if (!odr.takesKeys("shortcuts")) { + return; + } + // mixed turns on, as Word does + var covering = wordAround(at); + var current = covering === null ? {} : summaryOf(coveredRuns(covering)); + var style = {}; + style[property] = current[property] !== true; + format(style, at); + return; + } + var text = replacing[type]; if (text === undefined) { refuse(event, named[type] || "unsupportedEdit", at); @@ -976,6 +1386,9 @@ root.removeAttribute("contenteditable"); }, operations: operations, + format: function (style) { + return format(style, rangeOf({})); + }, canUndo: function () { return done.length > 0; }, diff --git a/src/odr/internal/html/frontend/editing.js b/src/odr/internal/html/frontend/editing.js index eb26d3962..2338f9f2a 100644 --- a/src/odr/internal/html/frontend/editing.js +++ b/src/odr/internal/html/frontend/editing.js @@ -51,6 +51,7 @@ }; odr.onEditChange = function () {}; odr.onCellsStale = function () {}; + odr.onSelectionChange = function () {}; function fire(name, event) { if (typeof odr[name] === "function") { @@ -146,11 +147,31 @@ }, /// Adds one format's editor. Only `operations` is required; `enable`, - /// `disable`, `undo`, `redo`, `canUndo`, `canRedo` and `committed` default. + /// `disable`, `undo`, `redo`, `canUndo`, `canRedo`, `committed` and + /// `format` default. attach: function (editor) { editors.push(editor); }, + /// States @p style on the selection: `bold`, `italic`, `underline`, + /// `strikethrough` (a bool), `highlight` (`#rrggbb` or null), `color` + /// (`#rrggbb`), `size` (`14pt`). False where refused; the channel says why. + format: function (style) { + for (var i = editors.length - 1; i >= 0; --i) { + if (typeof editors[i].format === "function") { + return editors[i].format(style) === true; + } + } + odr.editing.refuse("unsupportedEdit", null); + return false; + }, + + /// The style the selection shows, a key per property the covered runs + /// agree on; raised by an editor as the selection moves. + selectionChanged: function (style) { + fire("onSelectionChange", style); + }, + /// Reports a refused edit, dropping a repeat of the same one within two /// seconds: four taps on a locked cell are one snackbar. @p detail is how /// the format addresses it. Painting it is the editor's. diff --git a/test/browser/text/README.md b/test/browser/text/README.md index 4a9413aee..6b298c5fe 100644 --- a/test/browser/text/README.md +++ b/test/browser/text/README.md @@ -51,7 +51,13 @@ Why the checks look the way they do: the two sides from drifting apart. - **Scope `paragraph` runs on the same fixture**: the group sets - `data-odr-editing-scope` on ``, which the editor reads per edit. + `data-odr-editing-scope` on ``, which the editor reads per edit. Its + formatting refusals land in runs no other refusal of the group names. +- **Formatting is driven two ways**: `odr.editing.format` for a host's button + and a `formatBold` input for the chord. The checks read the `style` + attribute back and assert the cut. `onSelectionChange` is checked by + dispatching `selectionchange` by hand, since the browser raises it after + the script. **Scripted editing is not the editing a reader does, which is why no check uses `execCommand`.** Chrome's scripted path raises no cancelable `beforeinput`, so diff --git a/test/browser/text/tests.html b/test/browser/text/tests.html index 2be972138..19029481d 100644 --- a/test/browser/text/tests.html +++ b/test/browser/text/tests.html @@ -75,6 +75,10 @@ var refusals = []; var errors = []; var changes = []; + var selections = []; + odr.onSelectionChange = function (style) { + selections.push(style); + }; odr.onEditRefused = function (event) { refusals.push(event.reason + " " + event.code); }; @@ -102,6 +106,7 @@ refusals = []; errors = []; changes = []; + selections = []; } function run(id) { @@ -422,12 +427,12 @@ // A refusal needs a caret: with none the gate cannot name where the edit // lands, and says `range` before it looks at the input type. select(run(11).firstChild, 3); - check("a bold toggle is refused", input("formatBold") === "refused"); + check("a list is refused", input("insertUnorderedList") === "refused"); check("as an edit we cannot replay", refusals.pop() === "unsupportedEdit 1007"); - // The same reason twice within two seconds is one event, so the list has + // The same reason twice within two seconds is one event, so the rule has // to land in another run to be heard. select(run(13).firstChild, 3); - check("so is a list", input("insertOrderedList") === "refused"); + check("so is a rule", input("insertHorizontalRule") === "refused"); check("under the same reason", refusals.pop() === "unsupportedEdit 1007"); refusals = []; @@ -499,6 +504,191 @@ tail.querySelector("x-s[style]") !== null ); + // ------------------------------------------------------ formatting + + reset(); + selectRuns(11, 0, 11, 5); + check("bold over part of a run is taken", odr.editing.format({ bold: true }) === true); + check( + "the run is cut and the head is bold", + run(11).textContent === "first" && + run(11).style.fontWeight === "bold" && + run(11).nextSibling.textContent === " run " && + run(11).nextSibling.style.fontWeight === "" + ); + var cut = ops(); + check( + "as a setText, an insertText and a setTextStyle naming the head", + cut.length === 3 && + cut[0].op === "setText" && + cut[0].id === 11 && + cut[0].text === "first" && + cut[1].op === "insertText" && + cut[1].after === 11 && + cut[1].text === " run " && + cut[2].op === "setTextStyle" && + cut[2].id === 11 && + cut[2].style.bold === true + ); + check( + "the tail carrying the id the log created", + cut[1].id < 0 && + run(11).nextSibling.getAttribute("data-odr-id") === String(cut[1].id) + ); + check( + "and the selection covers the marked text", + caret() === "11:0" && window.getSelection().toString() === "first" + ); + + selectRuns(11, 2, 11, 5); + check("bold off over part of it is taken", odr.editing.format({ bold: false }) === true); + check( + "cutting again, and the middle is normal", + run(11).textContent === "fi" && + run(11).nextSibling.textContent === "rst" && + run(11).nextSibling.style.fontWeight === "normal" && + texts() === "first run a link and a tail|bold|third|" + ); + + // a chord toggles: mixed turns on, all on turns off + reset(); + selectRuns(11, 0, 13, 4); + check("ctrl+B over runs of mixed weight is taken", input("formatBold") === "taken"); + check( + "and turns them bold, the link's run too", + run(11).style.fontWeight === "bold" && + run(12).style.fontWeight === "bold" && + run(13).style.fontWeight === "bold" && + run(13).textContent === " and" + ); + function styleOps() { + return ops().filter(function (op) { + return op.op === "setTextStyle"; + }); + } + check( + "naming each run the selection covered", + styleOps() + .map(function (op) { + return op.id; + }) + .join() === "11,12,13" + ); + check("ctrl+B again is taken", input("formatBold") === "taken"); + check("and turns them back", run(11).style.fontWeight === "normal"); + check( + "the two toggles folding into one op per run", + styleOps().length === 3 && styleOps()[0].style.bold === false + ); + + // a collapsed caret marks the word it sits in + reset(); + select(run(11).firstChild, 2); + check("italic with a caret inside a word is taken", odr.editing.format({ italic: true }) === true); + check( + "and marks the word", + run(11).textContent === "first" && + run(11).style.fontStyle === "italic" && + run(11).nextSibling.textContent === " run " + ); + select(run(11).nextSibling.firstChild, 1); + check("a caret at a word boundary is refused", odr.editing.format({ italic: true }) === false); + check("as a range", refusals.pop() === "range 1008"); + + // the rest of the properties, and what they paint + reset(); + selectRuns(31, 0, 31, 5); + odr.editing.format({ + underline: true, + strikethrough: true, + highlight: "#ffff00", + color: "#ff0000", + size: "14pt", + }); + check( + "every property paints what the renderer writes", + run(31).style.textDecoration === "underline line-through" && + run(31).style.backgroundColor === "rgb(255, 255, 0)" && + run(31).style.color === "rgb(255, 0, 0)" && + run(31).style.fontSize === "14pt" + ); + check( + "as one setTextStyle on the whole run", + ops().length === 1 && + ops()[0].op === "setTextStyle" && + ops()[0].id === 31 && + ops()[0].style.size === "14pt" && + ops()[0].style.highlight === "#ffff00" + ); + odr.editing.format({ highlight: null, underline: false }); + check( + "a highlight taken away leaves no declaration", + run(31).style.backgroundColor === "" && + run(31).style.textDecoration === "line-through" + ); + check( + "and the two ops fold into one, the later keys winning", + ops().length === 1 && + ops()[0].style.highlight === null && + ops()[0].style.underline === false && + ops()[0].style.strikethrough === true + ); + check("an unknown property is refused", odr.editing.format({ blink: true }) === false); + check("as an edit we cannot replay", refusals.pop() === "unsupportedEdit 1007"); + + // what the selection shows, for a host's buttons + reset(); + select(run(21).firstChild, 1); + document.dispatchEvent(new Event("selectionchange")); + check( + "the selection reports what the run shows, wrapper and all", + selections.length === 1 && + selections[0].bold === true && + selections[0].italic === false && + selections[0].highlight === null + ); + selectRuns(11, 0, 21, 2); + document.dispatchEvent(new Event("selectionchange")); + check( + "and leaves out what the runs differ on", + selections.length === 2 && + selections[1].bold === undefined && + selections[1].italic === false + ); + document.dispatchEvent(new Event("selectionchange")); + check("saying nothing when nothing changed", selections.length === 2); + + // undo + reset(); + selectRuns(13, 0, 13, 11); + odr.editing.format({ bold: true }); + check("a mark can be taken back", odr.editing.undo() === true); + check( + "leaving the run as the renderer wrote it", + run(13).getAttribute("style") === null && ops().length === 0, + { style: run(13).getAttribute("style"), ops: ops() } + ); + check( + "and redo paints it again", + odr.editing.redo() === true && run(13).style.fontWeight === "bold" + ); + + // what a mark reaches over + reset(); + selectRuns(51, 1, 53, 3); + check("a mark over a picture is taken", odr.editing.format({ bold: true }) === true); + check( + "marking the text on both sides and leaving the picture", + paragraph(50).querySelector("img") !== null && styleOps().length === 2 + ); + selectRuns(61, 1, 65, 3); + check("a mark over a text box is refused", odr.editing.format({ bold: true }) === false); + check("as a range", refusals.pop() === "range 1008"); + var picture = paragraph(40); + select(picture, 0, picture, picture.childNodes.length); + check("a mark in a paragraph holding no run is refused", odr.editing.format({ bold: true }) === false); + check("as a range too", refusals.pop() === "range 1008"); + // ---------------------------------------------- scope paragraph reset(); @@ -506,6 +696,16 @@ document.body.setAttribute("data-odr-editing-scope", "paragraph"); check("until the host narrows it", odr.editing.scope() === "paragraph"); + // Formatting sits behind the gate whole, so these land in runs no other + // refusal of this group names. + select(run(12).firstChild, 2); + check("a chord is refused", input("formatBold") === "refused"); + check("as out of scope", refusals.pop() === "outOfScope 1010"); + selectRuns(51, 0, 51, 4); + check("and so is the host's format", odr.editing.format({ bold: true }) === false); + check("as out of scope too", refusals.pop() === "outOfScope 1010"); + check("with nothing marked", run(51).getAttribute("style") === null); + select(run(11).firstChild, 5); check("typing inside a run is taken", input("insertText", "X") === "taken"); selectRuns(11, 6, 13, 4); From 65fc96e4369acd43d30744959bd513146ca025a5 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sun, 13 Sep 2026 19:08:01 +0200 Subject: [PATCH 2/2] feat(html): a toggle, and a mark that waits for typing `odr.editing.toggle(property)` is the chord's rule for a host's button: a mixed selection turns on. On a collapsed caret at a word boundary, or in a paragraph holding no run, the mark is pending: nothing changes until the next typed text, which is cut into a run of its own and marked, so what follows stays marked on its own. `onSelectionChange` reports the pending mark meanwhile; a caret that moves away, a commit and a narrowed scope drop it. The text fold steps over a style op on the same run and the style fold over a text op, since the two are independent, so typing after a pending mark still folds into the insert that opened the run. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0137vd79NGaB8nfLsdPoghM4 --- CHANGELOG.md | 5 +- docs/design/document-editing.md | 23 +-- docs/design/editing.md | 6 +- src/odr/internal/html/frontend/document.js | 198 +++++++++++++++++---- src/odr/internal/html/frontend/editing.js | 18 +- test/browser/text/README.md | 11 +- test/browser/text/tests.html | 103 ++++++++++- 7 files changed, 300 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e570720b..2a736fff3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,8 +25,9 @@ The release run heads these entries with the version and opens a fresh - The document view's editor formats text under scope `document`: `odr.editing.format({bold, italic, underline, strikethrough, highlight, - color, size})`, the ctrl/cmd+B, I and U chords, and `odr.onSelectionChange` - for a host's buttons. Scope `paragraph` refuses it with `outOfScope`. + color, size})`, `odr.editing.toggle("bold")`, the ctrl/cmd+B, I and U + chords, and `odr.onSelectionChange` for a host's buttons. A toggle on a + collapsed caret marks the next typed text. Scope `paragraph` refuses it. - A run that is both underlined and struck through renders both lines. The page wrote two `text-decoration` declarations, and the second replaced the diff --git a/docs/design/document-editing.md b/docs/design/document-editing.md index ce94e8953..fcdedbf23 100644 --- a/docs/design/document-editing.md +++ b/docs/design/document-editing.md @@ -531,15 +531,22 @@ Formatting sits behind the scope gate, decision 14 of [`editing.md`](editing.md): under `paragraph` every formatting gesture refuses with `outOfScope`, whatever it covers, and only `document` takes it. A host that offers the narrow scope today keeps offering exactly what it -tested. A collapsed caret inside a word marks the word, as Word does; a -collapsed caret at a word boundary refuses with `range`, and the open -questions hold what it should do instead. +tested. + +`odr.editing.toggle(property)` is the chord's rule for a host's button: a +mixed selection turns on, as Word does. A collapsed caret inside a word marks +the word. At a word boundary, or in a paragraph holding no run, the mark is +**pending**: nothing changes until the next typed text, which is cut into a +run of its own and marked, so what follows stays marked on its own. +`onSelectionChange` reports the pending mark meanwhile, and a caret that moves +away drops it. There is no empty run for the caret to sit in, which is what +Chrome places a caret in unreliably. Undo needs nothing new. A step already holds its ops and the two halves of taking it back; here the halves are the runs' old and new `style` attributes. -Two marks on one run fold into one op where the later keys win, unless an -operation naming that run lies between them: a run put beside it takes the -style it has at that moment. +Two marks on one run fold into one op where the later keys win, and so do +two texts, each stepping over the other's kind; any other operation naming +the run stops the fold, since a run put beside it takes what it holds then. ### The adapter surface @@ -576,10 +583,6 @@ Each step is a pull request that builds and tests on its own. ### Open questions -- **A pending mark.** A caret between words that toggles bold means "what I - type next is bold". That needs an empty run the caret can sit in, and - Chrome places a caret in an empty inline unreliably. Refusing it is - honest and not what a reader expects. - **Colour back to automatic.** docx has `w:color w:val="auto"`; ODF has nothing but removal, which decision 9 forbids. Until this is answered a colour once set can only become another colour. diff --git a/docs/design/editing.md b/docs/design/editing.md index 875f286a9..095f0ce6c 100644 --- a/docs/design/editing.md +++ b/docs/design/editing.md @@ -189,9 +189,9 @@ runs or paragraphs. It owns: the wording and not the numbers; - the **log** — `getOperations()`, `undo()`, `redo()`, `committed()`, and the `dirty` / `canUndo` / `canRedo` state `odr.onEditChange` reports; -- the **formatting seam** — `format(style)` hands a host's button to the - editor that has one, and `odr.onSelectionChange` is where that editor - reports what the selection shows; +- the **formatting seam** — `format(style)` and `toggle(property)` hand a + host's button to the editor that has one, and `odr.onSelectionChange` is + where that editor reports what the selection shows; - the **keyboard classes** the page may take (decision 12). A format's editor is a second script that **attaches** one editor to the mode: diff --git a/src/odr/internal/html/frontend/document.js b/src/odr/internal/html/frontend/document.js index 20391258e..6670c540b 100644 --- a/src/odr/internal/html/frontend/document.js +++ b/src/odr/internal/html/frontend/document.js @@ -133,34 +133,45 @@ ); } - /// The earlier `setTextStyle` on @p op's run with no operation naming that - /// run in between: a run put beside it takes the style it has then. - function foldableStyle(result, op) { + /// The earlier operation of a kind in @p kinds on @p op's run. One of a + /// kind in @p skips on that run lies between them harmlessly - its text and + /// its style are independent - but any other operation naming the run + /// stops the walk: a run put beside it takes what it holds at that moment. + function foldable(result, op, kinds, skips) { for (var i = result.length - 1; i >= 0; --i) { - if (result[i].op === "setTextStyle" && result[i].id === op.id) { + var earlier = result[i]; + if (kinds.indexOf(earlier.op) !== -1 && earlier.id === op.id) { return i; } - if (names(result[i], op.id)) { + if (skips.indexOf(earlier.op) !== -1 && earlier.id === op.id) { + continue; + } + if (names(earlier, op.id)) { return -1; } } return -1; } - function withStyle(op, style) { - var merged = {}; + /// @p over's keys written over @p base's. + function merged(base, over) { + var result = {}; var key; - for (key in op.style) { - if (Object.prototype.hasOwnProperty.call(op.style, key)) { - merged[key] = op.style[key]; + for (key in base) { + if (Object.prototype.hasOwnProperty.call(base, key)) { + result[key] = base[key]; } } - for (key in style) { - if (Object.prototype.hasOwnProperty.call(style, key)) { - merged[key] = style[key]; + for (key in over) { + if (Object.prototype.hasOwnProperty.call(over, key)) { + result[key] = over[key]; } } - return { op: "setTextStyle", id: op.id, style: merged }; + return result; + } + + function withStyle(op, style) { + return { op: "setTextStyle", id: op.id, style: merged(op.style, style) }; } /// Folds what a save need not carry: several edits to one run are the text @@ -170,18 +181,16 @@ var result = []; for (var i = 0; i < ops.length; ++i) { var op = ops[i]; - var last = result.length === 0 ? null : result[result.length - 1]; - if ( - last !== null && - op.op === "setText" && - (last.op === "setText" || last.op === "insertText") && - last.id === op.id - ) { - result[result.length - 1] = withText(last, op.text); - continue; + var at = -1; + if (op.op === "setText") { + at = foldable(result, op, ["setText", "insertText"], ["setTextStyle"]); + if (at !== -1) { + result[at] = withText(result[at], op.text); + continue; + } } if (op.op === "setTextStyle") { - var at = foldableStyle(result, op); + at = foldable(result, op, ["setTextStyle"], ["setText"]); if (at !== -1) { result[at] = withStyle(result[at], op.style); continue; @@ -446,6 +455,9 @@ "size", ]; + // the properties a toggle flips + var toggles = ["bold", "italic", "underline", "strikethrough"]; + // the input types a chord raises, and the property each one toggles var toggling = { formatBold: "bold", @@ -694,7 +706,43 @@ return runsBetween(at.start.run, at.end.run); } - /// States @p style on @p at, for a host's `format` and for a chord. + // A mark a toggle set on a collapsed caret, waiting for the next typed + // text; dropped when the caret moves away from where it was set. + var pending = null; + var pendingAt = null; + + function isCollapsed(at) { + return at.start.run === at.end.run && at.start.offset === at.end.offset; + } + + function samePlace(a, b) { + return ( + a !== null && + b !== null && + a.run === b.run && + a.offset === b.offset && + a.paragraph === b.paragraph + ); + } + + function dropPending() { + pending = null; + pendingAt = null; + } + + /// Whether @p at is a collapsed caret still where a pending mark was set. + function pendingApplies(at) { + return ( + pending !== null && + at !== null && + isCollapsed(at) && + samePlace(pendingAt, at.start) + ); + } + + /// States @p style on @p at, for a host's `format` and for a chord. A + /// collapsed caret marks the word it sits in; at a word boundary, or in a + /// paragraph holding no run, the mark waits for the next typed text. /// Formatting sits behind the scope gate whole. function format(style, at) { if (!odr.editing.isEnabled()) { @@ -713,8 +761,17 @@ refuse(null, "outOfScope", at); return false; } - var covering = wordAround(at); - var marked = covering === null ? null : markRange(covering, style); + if (isCollapsed(at)) { + var word = wordAround(at); + if (word === null) { + pending = pendingApplies(at) ? merged(pending, style) : style; + pendingAt = at.start; + reportSelection(true); + return true; + } + at = word; + } + var marked = markRange(at, style); if (marked === null) { refuse(null, "range", at); return false; @@ -723,23 +780,79 @@ return true; } + /// The style a toggle reads at @p at: the caret's word or the selection, + /// with a pending mark over it. + function shownAt(at) { + if (at === null) { + return {}; + } + var covering = isCollapsed(at) ? wordAround(at) || at : at; + var summary = summaryOf(coveredRuns(covering)); + return pendingApplies(at) ? merged(summary, pending) : summary; + } + + /// Flips @p property at @p at; a mixed selection turns on, as Word does. + function toggle(property, at) { + if (toggles.indexOf(property) === -1) { + refuse(null, "unsupportedEdit", at); + return false; + } + var style = {}; + style[property] = shownAt(at)[property] !== true; + return format(style, at); + } + + /// Marks @p typed, which `replaceRange` just put before @p caret, with the + /// pending mark, and answers where the caret then sits. + function applyPending(caret, typed) { + var mark = pending; + dropPending(); + var marked = markRange( + { + start: { + run: caret.run, + offset: caret.offset - typed.length, + paragraph: caret.paragraph, + }, + end: caret, + }, + mark + ); + if (marked === null) { + return caret; + } + return { + run: marked.end.run, + offset: marked.end.run.textContent.length, + paragraph: marked.end.paragraph, + }; + } + // what the selection last reported, so a move that changes nothing is quiet var lastReported = null; - function reportSelection() { + /// Reports what the selection shows; @p force says so even where it did + /// not change, as when a pending mark was set over it. + function reportSelection(force) { if (!odr.editing.isEnabled()) { return; } - var summary = summaryOf(coveredRuns(rangeOf({}))); + var at = rangeOf({}); + if (pending !== null && !pendingApplies(at)) { + dropPending(); + } + var summary = shownAt(at); var key = JSON.stringify(summary); - if (key === lastReported) { + if (key === lastReported && force !== true) { return; } lastReported = key; odr.editing.selectionChanged(summary); } - document.addEventListener("selectionchange", reportSelection); + document.addEventListener("selectionchange", function () { + reportSelection(false); + }); // ------------------------------------------------------------------ caret @@ -1316,12 +1429,7 @@ if (!odr.takesKeys("shortcuts")) { return; } - // mixed turns on, as Word does - var covering = wordAround(at); - var current = covering === null ? {} : summaryOf(coveredRuns(covering)); - var style = {}; - style[property] = current[property] !== true; - format(style, at); + toggle(property, at); return; } @@ -1345,11 +1453,20 @@ return; } - var caret = replaceRange(covering, text(event)); + var typed = text(event); + var caret = replaceRange(covering, typed); if (caret === null) { refuse(event, "range", at); return; } + if (typed !== "" && pendingApplies(at)) { + // the gate may have narrowed since the mark was set + if (odr.editing.scope() === "paragraph") { + dropPending(); + } else { + caret = applyPending(caret, typed); + } + } event.preventDefault(); restore(caret); }); @@ -1383,12 +1500,16 @@ root.setAttribute("contenteditable", "true"); }, disable: function () { + dropPending(); root.removeAttribute("contenteditable"); }, operations: operations, format: function (style) { return format(style, rangeOf({})); }, + toggle: function (property) { + return toggle(property, rangeOf({})); + }, canUndo: function () { return done.length > 0; }, @@ -1416,6 +1537,7 @@ return true; }, committed: function () { + dropPending(); done.length = 0; undone.length = 0; }, diff --git a/src/odr/internal/html/frontend/editing.js b/src/odr/internal/html/frontend/editing.js index 2338f9f2a..010994442 100644 --- a/src/odr/internal/html/frontend/editing.js +++ b/src/odr/internal/html/frontend/editing.js @@ -147,8 +147,8 @@ }, /// Adds one format's editor. Only `operations` is required; `enable`, - /// `disable`, `undo`, `redo`, `canUndo`, `canRedo`, `committed` and - /// `format` default. + /// `disable`, `undo`, `redo`, `canUndo`, `canRedo`, `committed`, `format` + /// and `toggle` default. attach: function (editor) { editors.push(editor); }, @@ -166,6 +166,20 @@ return false; }, + /// Flips `bold`, `italic`, `underline` or `strikethrough` on the + /// selection; a mixed selection turns on. On a collapsed caret at a word + /// boundary the mark waits for the next typed text, and + /// `onSelectionChange` shows it meanwhile. + toggle: function (property) { + for (var i = editors.length - 1; i >= 0; --i) { + if (typeof editors[i].toggle === "function") { + return editors[i].toggle(property) === true; + } + } + odr.editing.refuse("unsupportedEdit", null); + return false; + }, + /// The style the selection shows, a key per property the covered runs /// agree on; raised by an editor as the selection moves. selectionChanged: function (style) { diff --git a/test/browser/text/README.md b/test/browser/text/README.md index 6b298c5fe..563b69966 100644 --- a/test/browser/text/README.md +++ b/test/browser/text/README.md @@ -53,11 +53,12 @@ Why the checks look the way they do: - **Scope `paragraph` runs on the same fixture**: the group sets `data-odr-editing-scope` on ``, which the editor reads per edit. Its formatting refusals land in runs no other refusal of the group names. -- **Formatting is driven two ways**: `odr.editing.format` for a host's button - and a `formatBold` input for the chord. The checks read the `style` - attribute back and assert the cut. `onSelectionChange` is checked by - dispatching `selectionchange` by hand, since the browser raises it after - the script. +- **Formatting is driven two ways**: `odr.editing.format` and `toggle` for a + host's button and a `formatBold` input for the chord. The checks read the + `style` attribute back and assert the cut. `onSelectionChange` is checked + by dispatching `selectionchange` by hand, since the browser raises it after + the script; the same dispatch is what drops a pending mark when the caret + moved. **Scripted editing is not the editing a reader does, which is why no check uses `execCommand`.** Chrome's scripted path raises no cancelable `beforeinput`, so diff --git a/test/browser/text/tests.html b/test/browser/text/tests.html index 19029481d..72cf297e0 100644 --- a/test/browser/text/tests.html +++ b/test/browser/text/tests.html @@ -592,8 +592,11 @@ run(11).nextSibling.textContent === " run " ); select(run(11).nextSibling.firstChild, 1); - check("a caret at a word boundary is refused", odr.editing.format({ italic: true }) === false); - check("as a range", refusals.pop() === "range 1008"); + var marked = ops().length; + check( + "a caret at a word boundary takes the mark as pending", + odr.editing.format({ italic: true }) === true && ops().length === marked + ); // the rest of the properties, and what they paint reset(); @@ -673,6 +676,95 @@ odr.editing.redo() === true && run(13).style.fontWeight === "bold" ); + // toggle: the chord's rule for a host's button + reset(); + selectRuns(11, 0, 13, 4); + check( + "toggle over runs of mixed weight turns on", + odr.editing.toggle("bold") === true && run(12).style.fontWeight === "bold" + ); + check( + "and again turns off", + odr.editing.toggle("bold") === true && run(12).style.fontWeight === "normal" + ); + check("a property that is not a toggle is refused", odr.editing.toggle("color") === false); + check("as an edit we cannot replay", refusals.pop() === "unsupportedEdit 1007"); + + // a toggle on a collapsed caret at a word boundary waits for typing + reset(); + select(run(13).firstChild, run(13).textContent.length); + check("a toggle at the end of a run is taken", odr.editing.toggle("bold") === true); + check( + "and changes nothing yet", + ops().length === 0 && run(13).getAttribute("style") === null + ); + check( + "but the selection reports it", + selections.length > 0 && selections[selections.length - 1].bold === true + ); + check("typing is taken", input("insertText", "X") === "taken"); + var typed = run(13).nextSibling; + check( + "and the typed text opens a bold run of its own", + typed !== null && + typed.textContent === "X" && + typed.style.fontWeight === "bold" && + run(13).textContent === " and a tail" + ); + check("with the caret inside it", caret() === typed.getAttribute("data-odr-id") + ":1"); + check( + "as an insertText and a setTextStyle on the new run", + ops().some(function (op) { + return op.op === "insertText" && op.text === "X" && op.id < 0; + }) && + ops().some(function (op) { + return op.op === "setTextStyle" && op.id < 0 && op.style.bold === true; + }) + ); + check("more typing stays bold", input("insertText", "Y") === "taken" && typed.textContent === "XY"); + check( + "and folds into the insert", + ops().filter(function (op) { + return op.op === "insertText"; + }).length === 1 && + ops().filter(function (op) { + return op.op === "insertText"; + })[0].text === "XY" + ); + + // moving the caret drops a pending mark + reset(); + select(run(13).firstChild, run(13).textContent.length); + odr.editing.toggle("italic"); + select(run(11).firstChild, 0); + document.dispatchEvent(new Event("selectionchange")); + check( + "a caret that moved away drops the pending mark", + selections[selections.length - 1].italic === false + ); + input("insertText", "Z"); + check( + "and text typed elsewhere is plain", + run(11).getAttribute("style") === null && + ops().every(function (op) { + return op.op !== "setTextStyle"; + }) + ); + + // an empty paragraph takes a pending mark too + reset(); + select(run(13).firstChild, run(13).textContent.length); + input("insertParagraph"); + check("a toggle in an empty paragraph is taken", odr.editing.toggle("bold") === true); + check("and the first typed text opens a bold run", input("insertText", "n") === "taken"); + var opened = flow.querySelectorAll("x-p[data-odr-id]")[1].querySelector("x-s"); + check( + "in the paragraph, bold", + texts() === "first run a link and a tail|n|bold|third|" && + opened !== null && + opened.style.fontWeight === "bold" + ); + // what a mark reaches over reset(); selectRuns(51, 1, 53, 3); @@ -686,8 +778,11 @@ check("as a range", refusals.pop() === "range 1008"); var picture = paragraph(40); select(picture, 0, picture, picture.childNodes.length); - check("a mark in a paragraph holding no run is refused", odr.editing.format({ bold: true }) === false); - check("as a range too", refusals.pop() === "range 1008"); + var pictureOps = ops().length; + check( + "a mark in a paragraph holding no run waits for typing", + odr.editing.format({ bold: true }) === true && ops().length === pictureOps + ); // ---------------------------------------------- scope paragraph