Skip to content

Don't comment on a stale range when the selection is in a widget - #3

Open
petergaultney wants to merge 2 commits into
No-Instructions:mainfrom
TrilliantHealth:comments/trust-widget-selection
Open

Don't comment on a stale range when the selection is in a widget#3
petergaultney wants to merge 2 commits into
No-Instructions:mainfrom
TrilliantHealth:comments/trust-widget-selection

Conversation

@petergaultney

@petergaultney petergaultney commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

🍋:

Selecting text inside a table and clicking the inline comment button writes the comment around the wrong text — usually the first few characters of the document. The mark is created, but nowhere near the selection, so the highlight never appears and the sidebar reports no comments on the note. Reported by two people on our team today; here is what one of their files looked like afterward, where the intended target was a cell far below:

{==# c==}{{authorId="..." author="...">>does this do weird stuff?<<}}aleb.gregory@example.com

Obsidian renders tables, and other embedded blocks, as CodeMirror widgets marked contenteditable="false". A selection made inside one is a real document selection, but it produces no CodeMirror transaction, because CodeMirror's own state never changes. state.selection keeps describing the previous selection and still reports itself non-empty.

I measured this in a live vault over the debug protocol. With 1.8.26 selected in a table cell:

domSelection: "1.8.26"      // what the user selected
cmSelection:  "Some prose"  // what state.selection still says, from before

The comment button trusts that range twice over. Placement is only recomputed from a ViewUpdate, and none arrives, so the button stays visible from the previous selection; then its click handler reads that same stale range. Both selection.empty and editor.somethingSelected() are satisfied, because there genuinely is a selection — just not the one on screen.

The change

selectionTrust in src/editor/selection-trust.ts compares CodeMirror's idea of the selection against the document's. A contenteditable="false" ancestor at either end means the range cannot be trusted; as a backstop, text that disagrees once whitespace is squeezed means the same. Containment in contentDOM cannot answer this on its own, since widgets live inside the content element too — that was my first attempt and it accepts every broken case.

The button now withdraws rather than going stale, and the click handler declines a range it cannot trust.

Details

  • The decision is a pure function over a small facts record, with readDomSelectionFacts doing the DOM reading separately, so the interesting part is testable in the existing testEnvironment: "node" suite. Seven unit tests cover each verdict, including a widget with one end outside it and the whitespace-insensitivity of the text comparison.
  • The keyboard command and context-menu paths go through editor.getSelection(), which already reports empty inside a widget, so they insert at the cursor instead of at a distant range. I left them alone.
  • No new dependencies. I wanted DOM-level tests for readDomSelectionFacts too, but the suite is node-only and adding jsdom seemed like your call rather than mine — happy to add them if you'd like it.

Verified with npm run check, npm run check:no-node, npm run lint, and the full unit suite. I also loaded the build into a real vault and confirmed the trust facts come back trusted for an ordinary prose selection made by a real drag, so the button is not being suppressed in the normal case.

Second commit: per-fragment editors

Testing the fix in a real vault turned up a related problem in the same area. Editing a table cell gets its own CodeMirror view, mounted inside the host editor, whose entire document is that cell's text ("1.8.26" for one cell). Extensions are installed in it too, so:

  • the floating comment button appeared a second time, inside the table
  • the state field built decorations against offsets that are the cell's rather than the note's, on text Obsidian's own renderer had already marked up

A fragment editor now gets no button, and reports itself as not-live-preview so the state field builds nothing there. The check is nesting — an editor whose ancestor is an editor — rather than a table-specific class, so callouts and embeds are covered too.

One detail worth flagging if you review the shape: the check has to be recomputed per use rather than cached in the constructor. Obsidian attaches the fragment editor's DOM into the host after the view plugin is built, so a constructor-time check sees an unparented element and reports false. I had it cached first and it silently did nothing. The button is therefore created on first placement instead of at construction.

Confirmed in a live vault: editing a table cell now yields two editors but one button, and that button is not inside the table.

Not in scope here

Obsidian's own table renderer treats {==text==} as its native ==highlight== syntax, consuming the == and leaving the braces visible — a cell shows folder-{notes}. I confirmed this happens with Relay Comments fully disabled, so it is upstream of this plugin rather than something these commits cause or can fix. Mentioning it because it makes a comment on table text look broken even once anchoring is correct; happy to open a separate issue if it is useful to track.

Screenshot 2026-08-18 at 09 20 27

Obsidian renders tables, and other embedded blocks, as CodeMirror widgets marked
`contenteditable="false"`. A selection made inside one is a real document
selection that produces no CodeMirror transaction, because CodeMirror's own state
never changes. `state.selection` therefore keeps describing the *previous*
selection and still reports itself non-empty.

The inline comment button trusted that range twice over. It stayed visible,
because placement is only recomputed from a ViewUpdate and no update arrives, and
its click handler read the same stale range. So selecting text in a table and
clicking the button wrote a comment around whatever had been selected before -
typically the first characters of the document, since that is where the cursor
starts. The mark existed but pointed nowhere near the table, which is why the
selection did not highlight and the sidebar reported no comments on the note.

`selectionTrust` compares CodeMirror's idea of the selection against the
document's: an uneditable ancestor at either end, or text that disagrees once
whitespace is squeezed, means the range cannot be trusted. Containment in
`contentDOM` cannot answer this on its own, because widgets live inside the
content element too.

The button now withdraws instead of going stale, and the click handler declines a
range it cannot trust. The keyboard command and context-menu paths go through
`editor.getSelection()`, which already reports empty in a widget, so they insert
at the cursor rather than at a distant range and needed no change.
Editing a table cell gets its own CodeMirror view, mounted inside the host
editor, whose whole document is that cell's text. Editor extensions are
installed in it too, so the floating comment button appeared a second time
*inside the table*, and the state field built decorations against offsets that
are the cell's rather than the note's - on text Obsidian's own renderer has
already marked up.

A fragment editor now gets no button and reports itself as not-live-preview,
which is how a view plugin tells the state field to build nothing.

The check is nesting (an editor whose ancestor is an editor) rather than a
table-specific class, so callouts and embeds are covered by the same test. It is
recomputed per use rather than cached in the constructor: Obsidian attaches the
fragment editor's DOM into the host after the view plugin is built, so a
constructor-time check sees an unparented element and reports false. The button
is therefore created on first placement instead of at construction.
@petergaultney
petergaultney marked this pull request as ready for review August 18, 2026 13:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant