Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 31 additions & 4 deletions packages/base/codemirror-editor.gts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { on } from '@ember/modifier';
import { scheduleOnce } from '@ember/runloop';
import { htmlSafe } from '@ember/template';
import { Tooltip } from '@cardstack/boxel-ui/components';
import { eq, not } from '@cardstack/boxel-ui/helpers';
import { eq } from '@cardstack/boxel-ui/helpers';

import {
baseRRI,
Expand Down Expand Up @@ -199,6 +199,13 @@ interface ToolbarItem {
// binding in the CodeMirror keymap (bold/italic/code); absent items render a
// label-only tooltip.
shortcut?: string;
// Inline-format toggles (bold/italic/etc.) wrap the current selection, so they
// only make sense when text is highlighted. Set for those buttons so they
// disable when the selection is collapsed. Line-based buttons omit it.
requiresSelection?: boolean;
// Computed enablement for this button, folding in focus and (for
// selection-requiring buttons) whether text is highlighted.
disabled?: boolean;
}

const EMPTY_FORMATS: SelectionFormats = Object.freeze({
Expand All @@ -212,6 +219,9 @@ const EMPTY_FORMATS: SelectionFormats = Object.freeze({
function sameToolbarState(a: SelectionInfo, b: SelectionInfo): boolean {
return (
a.hasFocus === b.hasFocus &&
// Selection presence gates the inline-format buttons' enablement, so a
// collapse/expand must refresh the toolbar even when nothing else changed.
a.hasSelection === b.hasSelection &&
a.formats.bold === b.formats.bold &&
a.formats.italic === b.formats.italic &&
a.formats.code === b.formats.code &&
Expand Down Expand Up @@ -467,7 +477,13 @@ export default class CodeMirrorEditor extends GlimmerComponent<CodeMirrorEditorS
get toolbarButtons(): ToolbarItem[] {
let f = this.toolbarFormats;
let pressed = (active: boolean) => (active ? 'true' : 'false');
return [
let enabled = this.toolbarEnabled;
let hasSelection = this._selectionInfo?.hasSelection ?? false;
// Inline-format toggles additionally require a highlighted selection;
// line-based buttons only require focus.
let disabledFor = (requiresSelection?: boolean) =>
!enabled || (!!requiresSelection && !hasSelection);
let items: ToolbarItem[] = [
{
testId: 'bold',
label: 'Bold',
Expand All @@ -476,6 +492,7 @@ export default class CodeMirrorEditor extends GlimmerComponent<CodeMirrorEditorS
active: f.bold,
ariaPressed: pressed(f.bold),
shortcut: `${modKey}B`,
requiresSelection: true,
},
{
testId: 'italic',
Expand All @@ -485,6 +502,7 @@ export default class CodeMirrorEditor extends GlimmerComponent<CodeMirrorEditorS
active: f.italic,
ariaPressed: pressed(f.italic),
shortcut: `${modKey}I`,
requiresSelection: true,
},
{
testId: 'strikethrough',
Expand All @@ -493,6 +511,7 @@ export default class CodeMirrorEditor extends GlimmerComponent<CodeMirrorEditorS
action: this._wrapStrikethrough,
active: f.strikethrough,
ariaPressed: pressed(f.strikethrough),
requiresSelection: true,
},
{
testId: 'code',
Expand All @@ -502,6 +521,7 @@ export default class CodeMirrorEditor extends GlimmerComponent<CodeMirrorEditorS
active: f.code,
ariaPressed: pressed(f.code),
shortcut: `${modKey}\``,
requiresSelection: true,
},
{
testId: 'link',
Expand All @@ -510,6 +530,7 @@ export default class CodeMirrorEditor extends GlimmerComponent<CodeMirrorEditorS
action: this._toggleLink,
active: f.link,
ariaPressed: pressed(f.link),
requiresSelection: true,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[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:

  1. 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.
  2. 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 in createEditorState hardcodes formats to all-false for collapsed selections, so formats.link can't serve as-is.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Claude Code 🤖] Resolved by restoring the affordance (option 2). What changed:

  • findEnclosingLink is extracted from toggleLink's unlink branch and shared with the selection-info emitter in createEditorState, 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.link from that helper (the other formats stay false, matching wrapWith'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.

},
{ divider: true },
{
Expand Down Expand Up @@ -550,6 +571,12 @@ export default class CodeMirrorEditor extends GlimmerComponent<CodeMirrorEditorS
action: this._toggleBlockquote,
},
];
for (let item of items) {
if (!item.divider) {
item.disabled = disabledFor(item.requiresSelection);
}
}
return items;
}

/** Prevent mousedown on toolbar/popup buttons from stealing editor focus/selection */
Expand Down Expand Up @@ -1212,7 +1239,7 @@ export default class CodeMirrorEditor extends GlimmerComponent<CodeMirrorEditorS
is suppressed while the control is disabled. }}
<Tooltip
@placement='top'
@disabled={{not this.toolbarEnabled}}
@disabled={{btn.disabled}}
data-test-toolbar-tooltip={{btn.testId}}
>
<:trigger>
Expand All @@ -1222,7 +1249,7 @@ export default class CodeMirrorEditor extends GlimmerComponent<CodeMirrorEditorS
type='button'
aria-label={{btn.label}}
aria-pressed={{btn.ariaPressed}}
disabled={{not this.toolbarEnabled}}
disabled={{btn.disabled}}
{{on 'mousedown' this._preventFocusLoss}}
{{on 'click' btn.action}}
>{{#let btn.icon as |Icon|}}<Icon
Expand Down
18 changes: 6 additions & 12 deletions packages/host/app/lib/codemirror-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1011,13 +1011,11 @@ function wrapWith(marker: string) {
let { from, to } = view.state.selection.main;
let len = marker.length;

// No selection: insert an empty pair of markers and drop the cursor
// between them so the user can type the content (e.g. **|**).
// No selection: do nothing. Inserting an empty marker pair here leaves
// 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) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[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.

view.dispatch({
changes: { from, insert: marker + marker },
selection: { anchor: from + len },
});
return true;
}

Expand Down Expand Up @@ -1099,12 +1097,8 @@ function toggleLink(view: EditorView): boolean {
}

if (from === to) {
// No selection: insert empty link syntax with the cursor inside the
// brackets so the user can type the link text — [|](url).
view.dispatch({
changes: { from, insert: '[](url)' },
selection: { anchor: from + 1 },
});
// No selection: do nothing. Inserting empty link syntax leaves a stray
// [](url) in the document. Linking applies to selected text only.
return true;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1327,7 +1327,7 @@ module('Integration | codemirror-context', function (hooks) {
}
});

test('wrapWith inserts empty markers with cursor centered when no selection', async function (assert) {
test('wrapWith does nothing when there is no selection', async function (assert) {
let element = document.createElement('div');
document.body.appendChild(element);

Expand All @@ -1348,19 +1348,15 @@ module('Integration | codemirror-context', function (hooks) {
view.dispatch({ selection: { anchor: 6, head: 6 } });
let result = cmContext.wrapWith('**')(view);

assert.true(result, 'returns true after inserting markers');
assert.true(result, 'returns true (shortcut consumed) without editing');
assert.strictEqual(
view.state.doc.toString(),
'Hello ****World',
'an empty pair of bold markers is inserted at the cursor',
'Hello World',
'no stray markers are inserted when nothing is selected',
);
let sel = view.state.selection.main;
assert.true(sel.empty, 'cursor is collapsed (no selection)');
assert.strictEqual(
sel.from,
8,
'cursor sits between the two pairs of markers',
);
assert.strictEqual(sel.from, 6, 'cursor stays where it was');

view.destroy();
} finally {
Expand Down Expand Up @@ -1957,6 +1953,43 @@ module('Integration | codemirror-context', function (hooks) {
}
});

test('toggleLink does nothing when there is no selection', async function (assert) {
let element = document.createElement('div');
document.body.appendChild(element);

try {
let state = cmContext.createEditorState({
content: 'Hello World',
onDocChange: () => {},
onCardTargetsChange: () => {},
onOpenCardSearch: () => {},
});

let view = new cmContext.EditorView({
state,
parent: element,
});

// Cursor at position 6, no selection
view.dispatch({ selection: { anchor: 6, head: 6 } });
let result = cmContext.toggleLink(view);

assert.true(result, 'returns true without editing');
assert.strictEqual(
view.state.doc.toString(),
'Hello World',
'no stray [](url) is inserted when nothing is selected',
);
let sel = view.state.selection.main;
assert.true(sel.empty, 'cursor is collapsed (no selection)');
assert.strictEqual(sel.from, 6, 'cursor stays where it was');

view.destroy();
} finally {
element.remove();
}
});

// ── Lazy loading ──

test('globalThis.__loadCodeMirror returns context with expected exports', async function (assert) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1607,6 +1607,13 @@ module('Integration | RichMarkdownField', function (hooks) {
assert
.dom('[data-test-toolbar="bold"]')
.isDisabled('Bold is disabled before the editor gains focus');
// The inline-format toggles wrap a selection, so they stay disabled with no
// focus (and, once focused, until text is highlighted — see the note above).
for (let testId of ['italic', 'strikethrough', 'code', 'link']) {
assert
.dom(`[data-test-toolbar="${testId}"]`)
.isDisabled(`${testId} is disabled before the editor gains focus`);
}
assert
.dom('[data-test-toolbar="blockquote"]')
.isDisabled('all formatting controls start disabled');
Expand Down
Loading