Don't insert stray markers when formatting with no selection (CS-11697) - #5673
Don't insert stray markers when formatting with no selection (CS-11697)#5673FadhlanR wants to merge 2 commits into
Conversation
Bold/Italic/Strikethrough/Code/Link inserted an empty marker pair (**, [](url), etc.) when triggered with a collapsed cursor, leaving stray markers visible in source and preview. wrapWith and toggleLink now no-op when nothing is selected, so neither the toolbar button nor the Mod-B/Mod-I shortcut produces stray markers. The inline-format toolbar buttons also disable until text is highlighted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Preview deploymentsHost Test Results 1 files 1 suites 2h 56m 0s ⏱️ Results for commit 7d74454. Realm Server Test Results 1 files ±0 1 suites ±0 13m 47s ⏱️ -43s Results for commit 7d74454. ± Comparison against earlier commit 35602ae. |
FadhlanR
left a comment
There was a problem hiding this comment.
[Claude Code 🤖] This review focused on whether the two layers of the fix — the command-level no-op and the toolbar enablement — stay in agreement, and whether any existing collapsed-cursor behavior was load-bearing before being gated off. The diff is small; the interesting questions are all one call site away from it.
Bottom line: no blocking issues. One minor behavior regression worth an explicit decision (collapsed-cursor unlink, inline thread on the Link button), everything else verified sound.
What lands right
- Fixing at both layers is the correct shape. The keymap can't be gated by toolbar state, so the command-layer no-op in
wrapWith/toggleLinkis the real fix; the disabled buttons are honest UI on top rather than the only line of defense. - The
hasSelectionaddition tosameToolbarStateis necessary, not defensive. Verified by tracing the memo: expanding a selection over unformatted text changes neitherhasFocus, nor anyformatsflag (all remain false), norcurrentRef— so without the new comparison the update is swallowed,_selectionInfonever refreshes, and the inline buttons stay disabled with text visibly highlighted. The new comparison catches exactly that transition. - Line-based buttons staying enabled is right — headings/lists/blockquote act on the cursor's line, so a collapsed cursor is a valid target for them.
- Test posture is sound. Both command-level no-ops are pinned (document unchanged and cursor position unchanged), and the headless focus limitation is documented rather than papered over with an assertion that would flake.
Recommendation
- Decide the collapsed-cursor unlink question — see the inline thread on the Link button's
requiresSelectioninpackages/base/codemirror-editor.gts. Non-blocking either way, but the code and comment should match whichever way it goes.
Adjacent, out of scope
- With the caret inside already-bold text, Mod-B now no-ops. Not a regression — the previous behavior inserted a stray empty marker pair there, which is worse — but conventional editors toggle formatting at the caret (unwrap the enclosing marks). If users ask for editor parity, that's the natural follow-up, and it lives entirely in
wrapWith. - The Add embed / Edit embed buttons at the left of the toolbar don't participate in the disabled model at all — they render enabled even before the editor has focus, unlike every other control. Pre-existing, noticed while reading the toolbar template; flagging for whoever touches that toolbar next.
| action: this._toggleLink, | ||
| active: f.link, | ||
| ariaPressed: pressed(f.link), | ||
| requiresSelection: true, |
There was a problem hiding this comment.
[Claude Code 🤖] Regression (minor, non-blocking): unlinking with a collapsed cursor is no longer reachable from the UI.
The mechanism. toggleLink in packages/host/app/lib/codemirror-context.ts resolves the syntax tree and checks for an enclosing Link node before its collapsed-selection check, and a collapsed cursor inside [text](url) satisfies from >= link.from && to <= link.to — so previously, placing the caret in a link and clicking the Link button unlinked it. This requiresSelection: true disables the button whenever the selection is collapsed, and the toolbar button is toggleLink's only caller (the markdown keymap binds only Mod-B / Mod-I / Mod-`), so the collapsed-cursor entry into the unlink branch is now dead in practice.
Verified. Traced the base-branch toggleLink: a collapsed caret inside a link takes the unlink branch (it precedes if (from === to)) and dispatches the text replacement. Grepped all non-test callers: toggleLink is invoked only via _toggleLink in this file.
What still works. Selecting any range within the link enables the button, and that selection still satisfies the unlink guard — unlink remains reachable, it just now demands a selection. That's why this is minor.
The way out — two options, either fine:
- Accept it (select-then-click still unlinks, and it's consistent with the disabled-button story). If so, consider tightening the no-op comment inside
toggleLink— "Linking applies to selected text only" isn't quite true of the function itself, whose earlier branch still unlinks at a collapsed caret; it's only true of what the UI now lets through. - Restore it: enable the Link button when the collapsed caret sits inside a link. That needs a link-at-caret signal in
SelectionInfo— note the update listener increateEditorStatehardcodesformatsto all-false for collapsed selections, soformats.linkcan't serve as-is.
There was a problem hiding this comment.
[Claude Code 🤖] Resolved by restoring the affordance (option 2). What changed:
findEnclosingLinkis extracted fromtoggleLink's unlink branch and shared with the selection-info emitter increateEditorState, so the toolbar's Link-button state is computed by the exact same syntax-tree check the command runs — the two can't drift.- At a collapsed caret the emitter now reports
formats.linkfrom that helper (the other formats stay false, matchingwrapWith's no-op at a caret), and the toolbar's enablement rule gains an escape: an active toggle stays clickable without a selection, since untoggling works at a bare caret. - The stale wording in
toggleLink's no-op comment is fixed — that branch is now correctly described as "caret outside any link".
Coverage added: a toggleLink unlink-from-bare-caret command test, a selection-info test pinning formats.link at a caret inside vs. outside a link, and a component test asserting the Link button's aria-pressed flips as the caret enters and leaves a link (drivable headless — only enablement needs OS focus). The pre-existing selection-based unlink test also now calls the real toggleLink instead of simulating it with a string scan. Both touched modules pass: codemirror-context 55/55, RichMarkdownField 30/30.
| // stray markers (e.g. **) visible in source and preview. Inline formatting | ||
| // applies to selected text only — the toolbar disables these buttons and | ||
| // the keyboard shortcut is a no-op until the user highlights something. | ||
| if (from === to) { |
There was a problem hiding this comment.
[Claude Code 🤖] Confirmation: returning true on a collapsed selection is the right call.
Returning false would let the key event fall through CodeMirror's keymap to the browser default, and these chords have browser-level bindings in some contexts (e.g. Ctrl-B toggling a bookmarks sidebar). Consuming the shortcut while editing nothing is the correct no-op, and the rewritten test pins both halves — returns true, document and cursor unchanged.
When this would stop being safe: if a lower-priority keymap ever wants to handle Mod-B/Mod-I/Mod-on a collapsed cursor, thistrue` will shadow it silently. Nothing binds them today.
Disabling the inline-format toggles on a collapsed selection also cut off toggleLink's unlink branch, which works from a bare caret inside an existing [text](url) and is only reachable through the toolbar button. The selection-info emitter now reports the Link format active when the caret sits inside a link (via findEnclosingLink, shared with toggleLink so button state can't drift from command behavior), and the toolbar keeps an active toggle clickable without a selection — untoggling works at a caret even though wrapping requires one. Tests: toggleLink unlink from a bare caret (the selection-based unlink test now exercises the real command instead of simulating it), the selection-info link-at-caret signal, and the toolbar's aria-pressed wiring for a caret inside a link. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lukemelia
left a comment
There was a problem hiding this comment.
In most writing environments I could type something, then press Ctrl+B and what I type after that is in bold and then press Ctrl+B again and what I type after that is not bold. Does that pattern work before/after this change?
Background and Goal
In the markdown editor, triggering an inline format with no text selected (cursor merely placed in the field) inserted a stray empty marker pair —
**/*/~~/`viawrapWith, or[](url)viatoggleLink— that stayed visible in both source and preview. Fixes CS-11697.The defect had two layers, and both are fixed:
packages/host/app/lib/codemirror-context.ts) —wrapWithandtoggleLinkinserted the empty marker pair whenever the selection was collapsed. This fired the same for the toolbar button and theMod-B/Mod-I/Mod-\`` keyboard shortcut, so it was wrong regardless of trigger. Both now do nothing (no dispatch) when nothing is selected. The keymap is untouched — the shortcut simply no-ops on a bare cursor instead of producing****. One exception is preserved:toggleLinkstill **unlinks** from a bare caret inside an existingtext` (its syntax-tree unlink branch runs before the collapsed-cursor check).packages/base/codemirror-editor.gts) — the inline-format buttons (Bold, Italic, Strikethrough, Code, Link) now carryrequiresSelection: trueand disable until text is highlighted, so a click can't reach the command and the UI signals inapplicability. An active toggle stays clickable without a selection, since untoggling works at a bare caret — concretely, a caret inside a link marks the Link button pressed and keeps it enabled so it can unlink. The link-at-caret signal comes fromfindEnclosingLink, the same syntax-tree checktoggleLink's unlink branch uses, so button state can't drift from command behavior. Line-based buttons (headings, lists, blockquote) act on a bare cursor and stay enabled.hasSelectionwas added tosameToolbarStateso the toolbar re-renders when the selection collapses/expands.Tests
codemirror-editor-test.gts— rewrote thewrapWithempty-selection test to assert no change; added atoggleLinkno-op test for a caret outside any link and an unlink test for a bare caret inside a link; the selection-based unlink test now calls the realtoggleLink(it previously simulated the command with a string scan); added a selection-info test pinningformats.linkat a caret inside vs. outside a link.rich-markdown-field-test.gts— extended the "controls start disabled" test to cover all five inline-format toggles, and added a test driving the live editor's caret into and out of a link to assert the Link button'saria-pressedwiring.Note: the focused-with-selection → enabled transition can't be asserted in headless CI because
view.hasFocusANDsdocument.hasFocus()(pre-existing, documented limitation); the disabled side, the active-state wiring, and the command-level behavior are covered.Screen Recording
Screen.Recording.2026-08-03.at.21.38.48.mov