From d61f21a9a80c5751466867255d1e830e6ce25d28 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sun, 20 Sep 2026 11:04:37 +0200 Subject: [PATCH 1/2] fix(html): editing scope `paragraph` holds the range, not the style `odr.editing.format` refused every call under scope `paragraph`, so a host that narrows the scope could offer no formatting at all - not bold on one word, not a highlight inside a sentence. Marking runs inside one paragraph opens no paragraph and joins none, so the scope was refusing something it is not about. It now refuses on the test every other edit uses, `outOfScope(at)`: a range inside one paragraph is taken, one over two is refused. Which styles are offered stays the host's, because the host draws the buttons. The one path it cannot draw is the keyboard, so the `formatBold` chords stay behind the gate, and `keyboard_shortcuts` remains the switch that takes every chord away. Checked with `test/browser/text` - 196 checks, none failing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FJJdfqpnVCKBNHXjAVxSou --- CHANGELOG.md | 6 ++++++ src/odr/internal/html/frontend/document.js | 15 ++++++++++++-- test/browser/text/tests.html | 23 +++++++++++++++------- 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db2cd584e..0bcba8783 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,12 @@ The release run heads these entries with the version and opens a fresh being edited, so a reader who tapped a cell twice lost it. A cell now holds its pin there; a row or column header still clears on that click. +- Editing scope `paragraph` no longer refuses `odr.editing.format`. It holds + the range, as its name says: marking runs inside one paragraph is taken, and + a format reaching over two is still refused. Which styles are offered is the + host's, because the host draws the buttons. The `formatBold` chords, which it + cannot draw, stay behind the gate. + ## v7.1.0 - 2026-09-19 - The pdf view reports the pending annotation count on diff --git a/src/odr/internal/html/frontend/document.js b/src/odr/internal/html/frontend/document.js index b4eda931e..fd01817bd 100644 --- a/src/odr/internal/html/frontend/document.js +++ b/src/odr/internal/html/frontend/document.js @@ -752,7 +752,11 @@ /// 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. + /// + /// Scope `paragraph` holds the range here, not the style: marking runs + /// inside one paragraph opens and joins nothing. Which styles are offered + /// is the host's - it draws the buttons - so the gate is on the chord that + /// it cannot draw, not on this call. function format(style, at) { gesture += 1; if (!odr.editing.isEnabled()) { @@ -767,7 +771,7 @@ refuse(null, "range", at); return false; } - if (odr.editing.scope() === "paragraph") { + if (outOfScope(at)) { refuse(null, "outOfScope", at); return false; } @@ -1499,6 +1503,13 @@ if (!odr.takesKeys("shortcuts")) { return; } + // A host offers what it draws a button for, and it draws none for a + // key. So a narrowed scope keeps the chord, where `format` is free of + // it: the host cannot take this one away by offering less. + if (odr.editing.scope() === "paragraph") { + refuse(event, "outOfScope", at); + return; + } toggle(property, at); return; } diff --git a/test/browser/text/tests.html b/test/browser/text/tests.html index 2adc3c608..2b8e435c7 100644 --- a/test/browser/text/tests.html +++ b/test/browser/text/tests.html @@ -799,15 +799,19 @@ 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. + // The gate holds the range, not the style: marking runs inside one + // paragraph opens and joins nothing. Which styles are offered is the + // host's, because the host draws the buttons - and the chord, which it + // cannot draw, stays behind the gate. select(run(12).firstChild, 2); - check("a chord is refused", input("formatBold") === "refused"); + check("a chord is refused, the host having no way to take the key", 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); + check("while the host's own format is taken", odr.editing.format({ bold: true }) === true); + check("with the run marked", run(51).getAttribute("style") !== null); + selectRuns(11, 2, 31, 2); + check("a format over two paragraphs is refused", odr.editing.format({ bold: true }) === false); + check("as out of scope", refusals.pop().indexOf("outOfScope ") === 0); select(run(11).firstChild, 5); check("typing inside a run is taken", input("insertText", "X") === "taken"); @@ -864,7 +868,12 @@ check( "the log names no paragraph operation", ops().every(function (op) { - return op.op === "setText" || op.op === "removeElement" || op.op === "insertText"; + return ( + op.op === "setText" || + op.op === "setTextStyle" || + op.op === "removeElement" || + op.op === "insertText" + ); }) ); From 1bc19b6de558ffc1ebd83c44f2183d4c40f5da81 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sun, 20 Sep 2026 11:39:09 +0200 Subject: [PATCH 2/2] docs(html): cut the scope comments and the changelog entry The reason a chord keeps the gate belongs at the chord, and `format` only needs to say that the scope holds the range. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0149gFxhkvKTBQidz6brU8Vt --- CHANGELOG.md | 8 +++----- src/odr/internal/html/frontend/document.js | 12 ++++-------- test/browser/text/tests.html | 8 +++----- 3 files changed, 10 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bcba8783..82b2c8675 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,11 +27,9 @@ The release run heads these entries with the version and opens a fresh being edited, so a reader who tapped a cell twice lost it. A cell now holds its pin there; a row or column header still clears on that click. -- Editing scope `paragraph` no longer refuses `odr.editing.format`. It holds - the range, as its name says: marking runs inside one paragraph is taken, and - a format reaching over two is still refused. Which styles are offered is the - host's, because the host draws the buttons. The `formatBold` chords, which it - cannot draw, stay behind the gate. +- Editing scope `paragraph` takes `odr.editing.format` inside one paragraph and + refuses it over two, rather than refusing every call. The `formatBold` chords + stay behind the gate. ## v7.1.0 - 2026-09-19 diff --git a/src/odr/internal/html/frontend/document.js b/src/odr/internal/html/frontend/document.js index fd01817bd..94642c146 100644 --- a/src/odr/internal/html/frontend/document.js +++ b/src/odr/internal/html/frontend/document.js @@ -752,11 +752,8 @@ /// 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. - /// - /// Scope `paragraph` holds the range here, not the style: marking runs - /// inside one paragraph opens and joins nothing. Which styles are offered - /// is the host's - it draws the buttons - so the gate is on the chord that - /// it cannot draw, not on this call. + /// Scope `paragraph` holds the range, not the style: a mark inside one + /// paragraph opens none. function format(style, at) { gesture += 1; if (!odr.editing.isEnabled()) { @@ -1503,9 +1500,8 @@ if (!odr.takesKeys("shortcuts")) { return; } - // A host offers what it draws a button for, and it draws none for a - // key. So a narrowed scope keeps the chord, where `format` is free of - // it: the host cannot take this one away by offering less. + // The host draws no button for a key, so it cannot offer less here. + // The chord keeps the gate `format` no longer carries. if (odr.editing.scope() === "paragraph") { refuse(event, "outOfScope", at); return; diff --git a/test/browser/text/tests.html b/test/browser/text/tests.html index 2b8e435c7..745366bfa 100644 --- a/test/browser/text/tests.html +++ b/test/browser/text/tests.html @@ -799,12 +799,10 @@ document.body.setAttribute("data-odr-editing-scope", "paragraph"); check("until the host narrows it", odr.editing.scope() === "paragraph"); - // The gate holds the range, not the style: marking runs inside one - // paragraph opens and joins nothing. Which styles are offered is the - // host's, because the host draws the buttons - and the chord, which it - // cannot draw, stays behind the gate. + // The gate holds the range, not the style. The chord keeps it: the + // host draws no button for a key, so it cannot offer less. select(run(12).firstChild, 2); - check("a chord is refused, the host having no way to take the key", input("formatBold") === "refused"); + check("a chord is still refused", input("formatBold") === "refused"); check("as out of scope", refusals.pop() === "outOfScope 1010"); selectRuns(51, 0, 51, 4); check("while the host's own format is taken", odr.editing.format({ bold: true }) === true);