Skip to content

fix(#3912): reflect formatting button state when moving cursor via bullet#4535

Open
ethan-james wants to merge 3 commits into
cybersemics:mainfrom
ethan-james:copilot/fix/3912-formatting-button-flicker
Open

fix(#3912): reflect formatting button state when moving cursor via bullet#4535
ethan-james wants to merge 3 commits into
cybersemics:mainfrom
ethan-james:copilot/fix/3912-formatting-button-flicker

Conversation

@ethan-james

@ethan-james ethan-james commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

#3912

Problem

On Chrome/Android, the Bold / Italic / Underline / Strikethrough toolbar buttons flickered back to their inactive state when the cursor was moved to a fully-formatted thought by tapping its bullet — even though the whole thought is formatted. The button briefly lit up (correct) and then settled to inactive (wrong).

Root cause

The toolbar button's active state comes from commandStateStore, which is derived from selection.html() when there is an active selection.

When the cursor is moved to a thought via its bullet, the caret is a collapsed selection whose startContainer is the editable <div> element itself. In that case selection.html() returned the element's outerHTML — i.e. the entire editable wrapper, including its attributes:

<div aria-label="editable-…" data-editable="true" class="editable" placeholder="<b>One</b>" contenteditable="true"><b>One</b></div>

Downstream, getCommandState walks this string for formatting tags, treats the leading <div …> wrapper as plain text, and clears every command match — so a fully-bold thought reported no formatting. (The placeholder="<b>One</b>" attribute, whose value contains raw HTML with > characters, made this even harder to parse defensively.)

Fix

Fix it at the source. selection.html() is meant to return the selection's contents, not the container element. Its Element branch now returns the editable's inner HTML when the node is the contentEditable host:

if (node instanceof Element) {
  containerHtml = node.getAttribute('contenteditable') === 'true' ? node.innerHTML : node.outerHTML
}

So a collapsed caret on the editable now yields <b></b> instead of the wrapped <div …>, and getCommandState reports bold: true.

The change is narrowly scoped — it only affects the case where the selection's startContainer is the editable element itself (inner <b>/<span> selections still use outerHTML). It's also a strict improvement for the other selection.html() callers (formatWithTag, useOnCopy, useOnCut, Note), which want selection content rather than the editable wrapper.

Changes

  • selection.ts — html() returns the editable's inner HTML (not the wrapper element) for a collapsed caret on the editable.
  • selection.ts — jsdom unit test for the collapsed-caret-on-editable case.
  • ToolbarButton.tsx — expose data-active so tests can assert the button's active state without brittle color/selector coupling.
  • format.ts — e2e regression test: bold a thought, move the cursor to a plain thought (button inactive), then move back via the bullet and assert the button is active.

Verification

  • Unit tests pass (getCommandState, selection incl. the new html() test).
  • E2E format.ts passes; behavior confirmed manually in Chrome.
  • eslint and tsc clean on the changed files.

Alternate Solution

You can see from 13cc49e that Copilot initially wanted to pursue a defensive fix to skip unrecognized tags in getCommandState. I opted to fix selection.html since this fix seems to me to be in line with its purpose. If you disagree, we can go back to the getCommandState fix.

…rsor via bullet

getCommandState treated the outer editable <div> wrapper (returned by
selection.html() for a collapsed caret) as text, which cleared every
command match and made the Bold/Italic/Underline/Strikethrough buttons
report no formatting. Skip non-formatting tags instead, respecting
quoted attribute values (e.g. placeholder="<b>One</b>") that may
contain '>'.

Adds a data-active hook to ToolbarButton, an e2e regression test, and
unit tests for the editable-div-wrapped cases.
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Vercel preview: https://em-tdfj9asjt-cybersemics.vercel.app

@ethan-james ethan-james self-assigned this Jul 7, 2026
…tead of getCommandState

Return the editable's inner HTML rather than its outer wrapper element when the
caret is collapsed on the editable element (e.g. cursor moved via bullet tap),
so selection.html() no longer emits the wrapper <div> and its placeholder
attribute (which contains raw HTML). This also benefits the other html()
callers (copy/cut/formatWithTag/Note).

Reverts the getCommandState tag-skipping change, which is no longer needed.
Adds a jsdom unit test for the html() collapsed-caret case.
@ethan-james ethan-james marked this pull request as ready for review July 7, 2026 22:23

@raineorshine raineorshine left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can see from 13cc49e that Copilot initially wanted to pursue a defensive fix to skip unrecognized tags in getCommandState. I opted to fix selection.html since this fix seems to me to be in line with its purpose. If you disagree, we can go back to the getCommandState fix.

Thanks, I'm with you. The sneaky polymorphism could be a gotcha in the future, but for now I don't foresee any usage of selection.html outside of Thoughts and Notes.

@raineorshine raineorshine requested a review from BayuAri July 8, 2026 19:01
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.

2 participants