diff --git a/packages/base/codemirror-editor.gts b/packages/base/codemirror-editor.gts index 3b7bfa838a7..4f324cbba0d 100644 --- a/packages/base/codemirror-editor.gts +++ b/packages/base/codemirror-editor.gts @@ -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, @@ -199,6 +199,15 @@ 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 — unless the toggle is active + // (e.g. the caret sits inside a link), since untoggling works at a bare + // caret. 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({ @@ -212,6 +221,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 && @@ -467,7 +479,15 @@ export default class CodeMirrorEditor extends GlimmerComponent (active ? 'true' : 'false'); - return [ + let enabled = this.toolbarEnabled; + let hasSelection = this._selectionInfo?.hasSelection ?? false; + // Inline-format toggles additionally require a highlighted selection, + // except when already active — an active toggle can always be untoggled + // (unlink works from a bare caret inside the link). Line-based buttons + // only require focus. + let disabledFor = (item: ToolbarItem) => + !enabled || (!!item.requiresSelection && !hasSelection && !item.active); + let items: ToolbarItem[] = [ { testId: 'bold', label: 'Bold', @@ -476,6 +496,7 @@ export default class CodeMirrorEditor extends GlimmerComponent <:trigger> @@ -1222,7 +1253,7 @@ export default class CodeMirrorEditor extends GlimmerComponent{{#let btn.icon as |Icon|}}= n.from && to <= n.to) { + return n; } } - if (link && from >= link.from && to <= link.to) { + return null; +} + +// Toggle a markdown link around the selection. A caret or selection inside an +// existing [text](url) unlinks it; a selection elsewhere wraps as a link. +function toggleLink(view: EditorView): boolean { + let { from, to } = view.state.selection.main; + + let link = findEnclosingLink(view.state, from, to); + if (link) { // Unlink: replace the whole node with just its text (between [ and ]). let marks: { from: number; to: number }[] = []; let c = link.cursor(); @@ -1099,12 +1108,9 @@ 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 }, - }); + // Caret outside any link: nothing to unlink, and inserting empty link + // syntax would leave a stray [](url) in the document. The wrap direction + // needs a selection. return true; } @@ -1258,11 +1264,15 @@ function createEditorState(options: CreateEditorStateOptions): EditorState { formats: hasSelection ? detectFormats(update.state, from, to) : { + // The wrap toggles no-op at a caret, so they read inactive. + // Link is the exception: toggleLink can still unlink an + // enclosing [text](url) from a bare caret, so report it + // active — the toolbar keeps an active toggle clickable. bold: false, italic: false, code: false, strikethrough: false, - link: false, + link: !!findEnclosingLink(update.state, from, to), }, currentRef, }); diff --git a/packages/host/tests/integration/components/codemirror-editor-test.gts b/packages/host/tests/integration/components/codemirror-editor-test.gts index cd2a05dd6f1..9e89e8416c3 100644 --- a/packages/host/tests/integration/components/codemirror-editor-test.gts +++ b/packages/host/tests/integration/components/codemirror-editor-test.gts @@ -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); @@ -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 { @@ -1482,6 +1478,57 @@ module('Integration | codemirror-context', function (hooks) { } }); + test('onSelectionChange reports link active at a bare caret inside a link', async function (assert) { + let element = document.createElement('div'); + document.body.appendChild(element); + + try { + let selectionInfo: { + hasSelection: boolean; + formats: { link: boolean; bold: boolean }; + } | null = null; + let state = cmContext.createEditorState({ + content: 'Click [here](https://example.com) for details', + onDocChange: () => {}, + onCardTargetsChange: () => {}, + onOpenCardSearch: () => {}, + onSelectionChange: (info) => { + selectionInfo = info; + }, + }); + + let view = new cmContext.EditorView({ + state, + parent: element, + }); + + // Collapsed caret inside the link text "here" + view.dispatch({ selection: { anchor: 9, head: 9 } }); + + assert.ok(selectionInfo, 'onSelectionChange was called'); + assert.false(selectionInfo!.hasSelection, 'no selection at a caret'); + assert.true( + selectionInfo!.formats.link, + 'link reads active — toggleLink can unlink from this caret, so the toolbar keeps the Link toggle clickable', + ); + assert.false( + selectionInfo!.formats.bold, + 'wrap toggles read inactive at a caret (they no-op there)', + ); + + // Caret outside the link + view.dispatch({ selection: { anchor: 2, head: 2 } }); + assert.false( + selectionInfo!.formats.link, + 'link reads inactive once the caret leaves the link', + ); + + view.destroy(); + } finally { + element.remove(); + } + }); + // ── Heading insertion (same logic as component's _insertHeading) ── test('heading prefix is added to line', async function (assert) { @@ -1929,22 +1976,9 @@ module('Integration | codemirror-context', function (hooks) { // as a user would in live preview where [ and ](url) are hidden view.dispatch({ selection: { anchor: 7, head: 11 } }); - // Simulate _toggleLink: scan for enclosing [text](url) - let doc = view.state.doc.toString(); - let { from, to } = view.state.selection.main; - let bracketOpen = doc.lastIndexOf('[', from); - let parenClose = doc.indexOf(')', to - 1); - let between = doc.slice(bracketOpen, parenClose + 1); - let linkMatch = between.match(/^\[(.+)\]\(.*\)$/); - assert.ok(linkMatch, 'enclosing link pattern found'); - view.dispatch({ - changes: { - from: bracketOpen, - to: parenClose + 1, - insert: linkMatch![1], - }, - }); + let result = cmContext.toggleLink(view); + assert.true(result, 'returns true after unlinking'); assert.strictEqual( view.state.doc.toString(), 'Click here for details', @@ -1957,6 +1991,77 @@ module('Integration | codemirror-context', function (hooks) { } }); + test('toggleLink does nothing at a caret outside any link', 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, no enclosing link + 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(); + } + }); + + test('toggleLink unlinks from a bare caret inside a link', async function (assert) { + let element = document.createElement('div'); + document.body.appendChild(element); + + try { + let state = cmContext.createEditorState({ + content: 'Click [here](https://example.com) for details', + onDocChange: () => {}, + onCardTargetsChange: () => {}, + onOpenCardSearch: () => {}, + }); + + let view = new cmContext.EditorView({ + state, + parent: element, + }); + + // Collapsed caret inside the link text "here" — no selection + view.dispatch({ selection: { anchor: 9, head: 9 } }); + let result = cmContext.toggleLink(view); + + assert.true(result, 'returns true after unlinking'); + assert.strictEqual( + view.state.doc.toString(), + 'Click here for details', + 'the enclosing link is unwrapped, leaving just its text', + ); + + view.destroy(); + } finally { + element.remove(); + } + }); + // ── Lazy loading ── test('globalThis.__loadCodeMirror returns context with expected exports', async function (assert) { diff --git a/packages/host/tests/integration/components/rich-markdown-field-test.gts b/packages/host/tests/integration/components/rich-markdown-field-test.gts index 3d4b9a49c8b..534bf9aa228 100644 --- a/packages/host/tests/integration/components/rich-markdown-field-test.gts +++ b/packages/host/tests/integration/components/rich-markdown-field-test.gts @@ -3,6 +3,7 @@ import type { RenderingTestContext } from '@ember/test-helpers'; import { click, render, + settled, triggerEvent, waitFor, waitUntil, @@ -1607,6 +1608,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'); @@ -1620,6 +1628,70 @@ module('Integration | RichMarkdownField', function (hooks) { await click('[data-test-markdown-mode-option="compose"]'); }); + test('the Link toggle reads active when the caret sits inside a link', async function (assert) { + // Enablement can't be asserted headless (document.hasFocus() is false — + // see the note in the "formatting controls start disabled" test), but the + // active-state wiring can: a bare caret inside a link marks the Link + // toggle pressed, since unlinking works from a caret. This pins the path + // from CodeMirror's selection-info emitter through the toolbar's + // change-detection to the button's aria-pressed. + class TestCard extends CardDef { + @field body = contains(RichMarkdownField); + static edit = class Edit extends Component { + + }; + } + + await setupIntegrationTestRealm({ + mockMatrixUtils, + contents: { + 'test-card.gts': { TestCard }, + }, + }); + + let card = new TestCard({ + body: new RichMarkdownField({ + content: 'Click [here](https://example.com) now', + }), + }); + await renderCard(loader, card, 'edit'); + await waitFor('[data-test-toolbar="link"]'); + + let editorEl = document.querySelector('.cm-editor') as HTMLElement; + assert.ok(editorEl, 'editor is rendered'); + let view = cmContext.EditorView.findFromDOM(editorEl); + assert.ok(view, 'live EditorView is reachable from the DOM'); + + // Collapsed caret inside the link text "here" — no selection + view!.dispatch({ selection: { anchor: 9, head: 9 } }); + await settled(); + assert + .dom('[data-test-toolbar="link"]') + .hasAttribute( + 'aria-pressed', + 'true', + 'Link toggle reads active at a caret inside a link', + ); + assert + .dom('[data-test-toolbar="bold"]') + .hasAttribute( + 'aria-pressed', + 'false', + 'wrap toggles stay inactive at a caret', + ); + + // Caret outside the link + view!.dispatch({ selection: { anchor: 2, head: 2 } }); + await settled(); + assert + .dom('[data-test-toolbar="link"]') + .hasAttribute( + 'aria-pressed', + 'false', + 'Link toggle deactivates once the caret leaves the link', + ); + }); + test('toolbar items are wrapped in tooltips whose hint is suppressed while the control is disabled', async function (assert) { // The enabled-on-focus transition depends on document.hasFocus(), which is // false in headless CI (see the "formatting controls start disabled" test),