From cab2dec576a9c7cb22d9f7a30cdaab5f1f452377 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Mon, 3 Aug 2026 13:35:00 +0700 Subject: [PATCH 1/2] Open the embed chooser for the /card slash command Typing `/card` in the rich markdown editor opened a custom inline popup that could only be dismissed with Escape while its input was focused, and whose search filtered on a non-existent `name` field so it never matched. Point the slash command at the same markdown embed chooser modal the toolbar's Add-embed button already uses, and remove the inline card-search and format-picker popups along with their now-dead helpers. The modal provides working search, card/file tabs, format selection, and standard dismissal. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/base/codemirror-editor.gts | 472 +----------------- .../markdown-embed-chooser-test.gts | 78 ++- 2 files changed, 82 insertions(+), 468 deletions(-) diff --git a/packages/base/codemirror-editor.gts b/packages/base/codemirror-editor.gts index 3b7bfa838a7..d2787f56577 100644 --- a/packages/base/codemirror-editor.gts +++ b/packages/base/codemirror-editor.gts @@ -11,7 +11,6 @@ import { eq, not } from '@cardstack/boxel-ui/helpers'; import { baseRRI, - maybeRelativeReference, resolveRRIReference, rri, CardContextName, @@ -131,28 +130,6 @@ function resolveUrl(raw: string, baseUrl: string | null | undefined): string { } } -function makeCardRef( - cardUrl: string, - baseUrl: string | null | undefined, -): string { - if (!baseUrl) return cardUrl; - try { - return maybeRelativeReference( - new URL(cardUrl), - new URL(baseUrl), - undefined, - ); - } catch { - return cardUrl; - } -} - -function labelFromUrl(url: string): string { - let cleaned = trimJsonExtension(url); - let parts = cleaned.split('/'); - return parts[parts.length - 1] || cleaned; -} - // `getCards` is typed to return CardDef instances (its generic is constrained to // `T extends CardDef`, and FileDef extends BaseDef — not CardDef). A query routed // through `on: FileDef` actually yields FileDef instances, so we reinterpret the @@ -238,16 +215,6 @@ export default class CodeMirrorEditor extends GlimmerComponent { + // Typing `/card` reuses the same embed chooser modal as the toolbar's + // Add-embed button. The slash completion's `apply` already deleted the typed + // `/`, so the caret sits where the directive should land — `_openEmbedChooser` + // inserts at the current selection. + private _handleOpenCardSearch = () => { if (isDestroying(this) || isDestroyed(this)) return; - this._cardSearchMode = true; - this._cardSearchText = ''; - this._cardSearchIndex = 0; - this._updateMenuCoords(); - scheduleOnce('afterRender', this, this._focusSearchInput); - }; - - private _updateMenuCoords() { - let view = this.editorView; - if (!view) { - this._menuCoords = null; - return; - } - try { - let { head } = view.state.selection.main; - let coords = view.coordsAtPos(head); - let editorRect = view.dom - .closest('.codemirror-editor') - ?.getBoundingClientRect(); - if (editorRect && coords) { - this._menuCoords = { - left: coords.left - editorRect.left, - top: coords.bottom - editorRect.top + 4, - }; - } - } catch { - this._menuCoords = null; - } - } - - get menuStyle(): string { - let coords = this._menuCoords; - if (!coords) return 'display: none'; - return `left: ${coords.left}px; top: ${coords.top}px;`; - } - - private _focusSearchInput = () => { - // Scope query to this editor instance's container to avoid focusing - // the wrong input when multiple editors exist on the page - let container = this.editorView?.dom?.closest('.codemirror-editor'); - let input = (container ?? document).querySelector( - '[data-codemirror-card-search-input]', - ) as HTMLInputElement; - input?.focus(); - }; - - // ── Card search resource ───────────────────────────────────────────────── - - private _searchResourceCreated = false; - private _searchResource: { - instances: CardDef[]; - isLoading: boolean; - } | null = null; - - get cardSearchResults(): CardDef[] { - if (!this._cardSearchMode) return []; - if (!this._searchResourceCreated) { - this._searchResourceCreated = true; - try { - let getCards = this.args.getCards; - if (typeof getCards === 'function') { - this._searchResource = - getCards(this, () => { - let text = this._cardSearchText?.trim(); - if (!text) return undefined; - return { - filter: { contains: { name: text } }, - page: { size: 10 }, - }; - }) ?? null; - } - } catch { - // Card search not available - } - } - return this._searchResource?.instances ?? []; - } - - get isSearchLoading(): boolean { - return this._searchResource?.isLoading ?? false; - } - - _handleCardSearchInput = (event: Event) => { - this._cardSearchText = (event.target as HTMLInputElement).value; - this._cardSearchIndex = 0; - }; - - _handleCardSearchKeydown = (evt: Event) => { - let event = evt as KeyboardEvent; - if (event.key === 'Escape') { - event.preventDefault(); - this._dismissCardSearch(); - return; - } - if (event.key === 'ArrowDown') { - event.preventDefault(); - let max = this.cardSearchResults.length; - if (max > 0) { - this._cardSearchIndex = (this._cardSearchIndex + 1) % max; - } - return; - } - if (event.key === 'ArrowUp') { - event.preventDefault(); - let max = this.cardSearchResults.length; - if (max > 0) { - this._cardSearchIndex = (this._cardSearchIndex - 1 + max) % max; - } - return; - } - if (event.key === 'Enter') { - event.preventDefault(); - // Check if the input looks like a URL - let text = this._cardSearchText.trim(); - if ( - text && - (text.startsWith('http://') || - text.startsWith('https://') || - text.startsWith('./')) - ) { - this._formatPickerCardUrl = text; - this._formatPickerCardTitle = labelFromUrl(text); - this._cardSearchMode = false; - return; - } - // Otherwise select the highlighted search result - let results = this.cardSearchResults; - let card = results[this._cardSearchIndex]; - if (card) { - this._selectCardResult(card); - } - return; - } - }; - - _selectCardResult = (card: CardDef) => { - if (!card.id) return; - this._formatPickerCardUrl = card.id; - this._formatPickerCardTitle = (card as any).title ?? labelFromUrl(card.id); - this._cardSearchMode = false; - }; - - _dismissCardSearch = () => { - this._cardSearchMode = false; - this._cardSearchText = ''; - this._cardSearchIndex = 0; - this._menuCoords = null; - this.editorView?.focus(); + this._openEmbedChooser('card'); }; // ── Docked toolbar ────────────────────────────────────────────────────── @@ -816,61 +640,6 @@ export default class CodeMirrorEditor extends GlimmerComponent { - let cardUrl = this._formatPickerCardUrl; - if (!cardUrl) return; - - let view = this.editorView; - if (!view) return; - - let baseUrl = this.args.cardReferenceBaseUrl; - let ref = makeCardRef(cardUrl, baseUrl); - - let { from } = view.state.selection.main; - - if (format === 'inline') { - view.dispatch({ - changes: { from, insert: `:card[${ref}]` }, - }); - } else { - // For block cards, insert on a new line - let line = view.state.doc.lineAt(from); - let insertPos = line.to; - let prefix = line.text.trim() === '' ? '' : '\n'; - view.dispatch({ - changes: { from: insertPos, insert: `${prefix}::card[${ref}]\n` }, - }); - } - - // Clean up all popup state - this._formatPickerCardUrl = null; - this._formatPickerCardTitle = null; - this._cardSearchMode = false; - this._cardSearchText = ''; - this._menuCoords = null; - - view.focus(); - - // Trigger save immediately - let onUpdate = this.args.onUpdate; - if (onUpdate) { - if (this.saveTimer) { - clearTimeout(this.saveTimer); - this.saveTimer = null; - } - onUpdate(view.state.doc.toString()); - } - }; - - _dismissFormatPicker = () => { - this._formatPickerCardUrl = null; - this._formatPickerCardTitle = null; - this._menuCoords = null; - this.editorView?.focus(); - }; - // ── Reference resolution via getCards ───────────────────────────────────── // The linkedCards/linkedFiles linksToMany queries on RichMarkdownField return // empty in edit mode because nested FieldDef instances lack a card store. We @@ -1040,12 +809,7 @@ export default class CodeMirrorEditor extends GlimmerComponent - {{! ── Card search popup ── }} - {{! template-lint-disable no-pointer-down-event-binding }} - {{#if this._cardSearchMode}} - - {{/if}} - - {{! ── Format picker popup ── }} - {{! template-lint-disable no-pointer-down-event-binding }} - {{#if this._formatPickerCardUrl}} -
- - Insert "{{this._formatPickerCardTitle}}" as: - -
- - -
- -
- {{/if}} {{#if this.livePreview}} @@ -1793,146 +1473,6 @@ export default class CodeMirrorEditor extends GlimmerComponent diff --git a/packages/host/tests/acceptance/markdown-embed-chooser-test.gts b/packages/host/tests/acceptance/markdown-embed-chooser-test.gts index a6df5afe881..fa9aaeebb03 100644 --- a/packages/host/tests/acceptance/markdown-embed-chooser-test.gts +++ b/packages/host/tests/acceptance/markdown-embed-chooser-test.gts @@ -6,6 +6,8 @@ import { waitUntil, } from '@ember/test-helpers'; +import { acceptCompletion, startCompletion } from '@codemirror/autocomplete'; + import { module, test } from 'qunit'; import cmContext from '@cardstack/host/lib/codemirror-context'; @@ -194,8 +196,7 @@ module('Acceptance | markdown embed chooser modal', function (hooks) { ? cmContext.EditorView.findFromDOM(editorEl)?.state.doc.toString() : undefined; // The picked Pet lives in a sibling directory to the edited Note, so the - // inserted ref is relativized against the document — `../Pet/mango` — the - // same form the `/`-search format-picker path produces. + // inserted ref is relativized against the document — `../Pet/mango`. assert.strictEqual( docText, `:card[../Pet/mango]`, @@ -203,6 +204,79 @@ module('Acceptance | markdown embed chooser modal', function (hooks) { ); }); + test('the `/card` slash command opens the embed chooser and inserts the picked card', async function (assert) { + await visitOperatorMode({ + stacks: [[{ id: noteId, format: 'isolated' }]], + }); + + await click(`[data-test-operator-mode-stack="0"] [data-test-edit-button]`); + await waitFor( + `[data-test-stack-card="${noteId}"] [data-test-codemirror-editor]`, + { timeout: 5000 }, + ); + + // Trigger the `/card` slash completion the way a user does: type `/` at the + // caret, open the autocomplete list, and accept the (single) `/card` option. + // Its `apply` deletes the typed `/` and asks the editor to open the chooser. + let editorEl = document.querySelector( + `[data-test-stack-card="${noteId}"] [data-test-codemirror-editor] .cm-editor`, + ) as HTMLElement | null; + let view = editorEl ? cmContext.EditorView.findFromDOM(editorEl) : null; + assert.ok(view, 'codemirror view is reachable'); + view!.focus(); + view!.dispatch({ + changes: { from: 0, insert: '/' }, + selection: { anchor: 1 }, + }); + startCompletion(view!); + await waitFor('.cm-tooltip-autocomplete', { timeout: 5000 }); + acceptCompletion(view!); + await settled(); + + // The `/card` path now reuses the same chooser modal as the toolbar, + // instead of the old inline popup. + await waitFor('[data-test-markdown-embed-chooser-modal]', { + timeout: 5000, + }); + assert + .dom('[data-test-markdown-embed-chooser-tab="card"]') + .hasAttribute('aria-selected', 'true', 'chooser opens on the Cards tab'); + assert + .dom('[data-test-card-search]') + .doesNotExist('the old inline card-search popup is gone'); + + // Search for Mango, pick the row, insert. + await fillIn( + '[data-test-markdown-embed-chooser-tab-panel="card"] [data-test-search-field]', + 'Mango', + ); + await waitFor( + `[data-test-markdown-embed-chooser-tab-panel="card"] [data-test-item-button="${mangoId}"]`, + { timeout: 5000 }, + ); + await click( + `[data-test-markdown-embed-chooser-tab-panel="card"] [data-test-item-button="${mangoId}"]`, + ); + await waitFor('[data-test-markdown-embed-preview-cta]:not([disabled])', { + timeout: 5000, + }); + await click('[data-test-markdown-embed-preview-cta]'); + + await waitUntil( + () => !document.querySelector('[data-test-markdown-embed-chooser-modal]'), + ); + await settled(); + + let docText = cmContext.EditorView.findFromDOM(editorEl!) + ?.state.doc.toString() + ?.trim(); + assert.strictEqual( + docText, + `:card[../Pet/mango]`, + 'the slash flow inserts the picked card as an inline directive, and the typed `/` is gone', + ); + }); + test('Custom-size fitted holds Accept disabled until a valid size is entered', async function (assert) { await visitOperatorMode({ stacks: [[{ id: noteId, format: 'isolated' }]], From fd0e3207b24e013de5d75e9b60f4d738224e2989 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Mon, 3 Aug 2026 18:33:51 +0700 Subject: [PATCH 2/2] Drive the /card completion apply directly in the acceptance test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `acceptCompletion` depends on the autocomplete tooltip's selected-option state, which is racy under the test runner and left the chooser modal unopened. Invoke the `/card` completion's `apply` directly instead — the same callback the real accept runs — so the accept is deterministic. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../markdown-embed-chooser-test.gts | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/packages/host/tests/acceptance/markdown-embed-chooser-test.gts b/packages/host/tests/acceptance/markdown-embed-chooser-test.gts index fa9aaeebb03..c509749fe54 100644 --- a/packages/host/tests/acceptance/markdown-embed-chooser-test.gts +++ b/packages/host/tests/acceptance/markdown-embed-chooser-test.gts @@ -6,7 +6,7 @@ import { waitUntil, } from '@ember/test-helpers'; -import { acceptCompletion, startCompletion } from '@codemirror/autocomplete'; +import { currentCompletions, startCompletion } from '@codemirror/autocomplete'; import { module, test } from 'qunit'; @@ -216,8 +216,11 @@ module('Acceptance | markdown embed chooser modal', function (hooks) { ); // Trigger the `/card` slash completion the way a user does: type `/` at the - // caret, open the autocomplete list, and accept the (single) `/card` option. - // Its `apply` deletes the typed `/` and asks the editor to open the chooser. + // caret and open the autocomplete list. Accepting the `/card` option runs + // its `apply`, which deletes the typed `/` and asks the editor to open the + // chooser. We invoke that `apply` directly (rather than simulating an Enter + // keystroke) so the accept is deterministic and not subject to the + // tooltip's selected-option timing. let editorEl = document.querySelector( `[data-test-stack-card="${noteId}"] [data-test-codemirror-editor] .cm-editor`, ) as HTMLElement | null; @@ -229,8 +232,24 @@ module('Acceptance | markdown embed chooser modal', function (hooks) { selection: { anchor: 1 }, }); startCompletion(view!); - await waitFor('.cm-tooltip-autocomplete', { timeout: 5000 }); - acceptCompletion(view!); + await waitUntil( + () => currentCompletions(view!.state).some((c) => c.label === '/card'), + { timeout: 5000 }, + ); + let cardOption = currentCompletions(view!.state).find( + (c) => c.label === '/card', + ); + assert.ok(cardOption, 'the `/card` slash completion is offered'); + // The completion spans the typed `/` (doc positions 0–1); accepting it + // deletes the `/` and opens the chooser. + ( + cardOption!.apply as ( + v: unknown, + c: unknown, + f: number, + t: number, + ) => void + )(view!, cardOption!, 0, 1); await settled(); // The `/card` path now reuses the same chooser modal as the toolbar,