diff --git a/packages/base/codemirror-editor.gts b/packages/base/codemirror-editor.gts index 813a9ecd3b9..f18bcff5f94 100644 --- a/packages/base/codemirror-editor.gts +++ b/packages/base/codemirror-editor.gts @@ -13,6 +13,11 @@ import { maybeRelativeReference, type VirtualNetwork, } from '@cardstack/runtime-common'; +import { + type BfmRefRange, + chooseMarkdownEmbed, + editMarkdownEmbed, +} from '@cardstack/runtime-common/bfm-card-references'; import { type BaseDef, type CardDef, @@ -32,6 +37,8 @@ import ListIcon from '@cardstack/boxel-icons/list'; import ListOrderedIcon from '@cardstack/boxel-icons/list-ordered'; import BlockquoteIcon from '@cardstack/boxel-icons/blockquote'; import LinkIcon from '@cardstack/boxel-icons/link'; +import PlusIcon from '@cardstack/boxel-icons/plus'; +import PencilIcon from '@cardstack/boxel-icons/pencil'; // The CodeMirrorContext type is defined in the host app's lazy-loaded module. // We only use it as a type here — the actual module is loaded at runtime via @@ -63,6 +70,9 @@ interface SelectionInfo { from: number; to: number; formats: SelectionFormats; + // BFM directive the cursor is currently inside, if any. Drives the toolbar + // swap between the Add-embed popover and the Edit-embed pencil. + currentRef?: BfmRefRange; } interface CodeMirrorContext { @@ -193,7 +203,9 @@ function sameToolbarState(a: SelectionInfo, b: SelectionInfo): boolean { a.formats.italic === b.formats.italic && a.formats.code === b.formats.code && a.formats.strikethrough === b.formats.strikethrough && - a.formats.link === b.formats.link + a.formats.link === b.formats.link && + a.currentRef?.from === b.currentRef?.from && + a.currentRef?.to === b.currentRef?.to ); } @@ -432,19 +444,84 @@ export default class CodeMirrorEditor extends GlimmerComponent (active ? 'true' : 'false'); return [ - { testId: 'bold', label: 'Bold', icon: BoldIcon, action: this._wrapBold, active: f.bold, ariaPressed: pressed(f.bold) }, - { testId: 'italic', label: 'Italic', icon: ItalicIcon, action: this._wrapItalic, active: f.italic, ariaPressed: pressed(f.italic) }, - { testId: 'strikethrough', label: 'Strikethrough', icon: StrikethroughIcon, action: this._wrapStrikethrough, active: f.strikethrough, ariaPressed: pressed(f.strikethrough) }, - { testId: 'code', label: 'Code', icon: CodeIcon, action: this._wrapCode, active: f.code, ariaPressed: pressed(f.code) }, - { testId: 'link', label: 'Link', icon: LinkIcon, action: this._toggleLink, active: f.link, ariaPressed: pressed(f.link) }, + { + testId: 'bold', + label: 'Bold', + icon: BoldIcon, + action: this._wrapBold, + active: f.bold, + ariaPressed: pressed(f.bold), + }, + { + testId: 'italic', + label: 'Italic', + icon: ItalicIcon, + action: this._wrapItalic, + active: f.italic, + ariaPressed: pressed(f.italic), + }, + { + testId: 'strikethrough', + label: 'Strikethrough', + icon: StrikethroughIcon, + action: this._wrapStrikethrough, + active: f.strikethrough, + ariaPressed: pressed(f.strikethrough), + }, + { + testId: 'code', + label: 'Code', + icon: CodeIcon, + action: this._wrapCode, + active: f.code, + ariaPressed: pressed(f.code), + }, + { + testId: 'link', + label: 'Link', + icon: LinkIcon, + action: this._toggleLink, + active: f.link, + ariaPressed: pressed(f.link), + }, { divider: true }, - { testId: 'h1', label: 'Heading 1', icon: Heading1Icon, action: this._insertH1 }, - { testId: 'h2', label: 'Heading 2', icon: Heading2Icon, action: this._insertH2 }, - { testId: 'h3', label: 'Heading 3', icon: Heading3Icon, action: this._insertH3 }, + { + testId: 'h1', + label: 'Heading 1', + icon: Heading1Icon, + action: this._insertH1, + }, + { + testId: 'h2', + label: 'Heading 2', + icon: Heading2Icon, + action: this._insertH2, + }, + { + testId: 'h3', + label: 'Heading 3', + icon: Heading3Icon, + action: this._insertH3, + }, { divider: true }, - { testId: 'bullet-list', label: 'Bullet List', icon: ListIcon, action: this._toggleBulletList }, - { testId: 'numbered-list', label: 'Numbered List', icon: ListOrderedIcon, action: this._toggleNumberedList }, - { testId: 'blockquote', label: 'Blockquote', icon: BlockquoteIcon, action: this._toggleBlockquote }, + { + testId: 'bullet-list', + label: 'Bullet List', + icon: ListIcon, + action: this._toggleBulletList, + }, + { + testId: 'numbered-list', + label: 'Numbered List', + icon: ListOrderedIcon, + action: this._toggleNumberedList, + }, + { + testId: 'blockquote', + label: 'Blockquote', + icon: BlockquoteIcon, + action: this._toggleBlockquote, + }, ]; } @@ -565,6 +642,124 @@ export default class CodeMirrorEditor extends GlimmerComponent { + this._embedPopoverOpen = !this._embedPopoverOpen; + }; + + _openEmbedChooser = async (defaultTab: 'card' | 'file') => { + this._embedPopoverOpen = false; + let result; + try { + result = await chooseMarkdownEmbed({ defaultTab }); + } catch (e) { + // Bridge not registered (e.g. card running outside the host) — silently + // no-op so the toolbar click doesn't blow up the editor. + console.warn('markdown-embed chooser unavailable', e); + return; + } + if (!result || 'remove' in result) { + // Cancelled, or { remove: true } returned by mistake (no current ref to + // remove in Add mode) — either way, do nothing. + return; + } + this._insertBfm(result.bfm); + }; + + _openEditEmbed = async () => { + let ref = this._currentBfmRef; + if (!ref) return; + let view = this.editorView; + if (!view) return; + let result; + try { + result = await editMarkdownEmbed({ + refType: ref.refType as 'card' | 'file', + url: ref.url, + sizeSpec: ref.sizeSpec, + }); + } catch (e) { + console.warn('markdown-embed chooser unavailable', e); + return; + } + if (!result) return; + if ('remove' in result) { + if (result.remove) { + this._deleteRange(ref); + } + return; + } + this._replaceRange(ref, result.bfm); + }; + + _insertBfm = (bfm: string) => { + let view = this.editorView; + if (!view) return; + let { from } = view.state.selection.main; + + // Inline vs block placement is encoded in the directive's `::` prefix. + if (bfm.startsWith('::')) { + let line = view.state.doc.lineAt(from); + let insertPos = line.to; + let prefix = line.text.trim() === '' ? '' : '\n'; + view.dispatch({ + changes: { from: insertPos, insert: `${prefix}${bfm}\n` }, + }); + } else { + view.dispatch({ changes: { from, insert: bfm } }); + } + view.focus(); + + let onUpdate = this.args.onUpdate; + if (onUpdate) { + if (this.saveTimer) { + clearTimeout(this.saveTimer); + this.saveTimer = null; + } + onUpdate(view.state.doc.toString()); + } + }; + + _replaceRange = (range: BfmRefRange, replacement: string) => { + let view = this.editorView; + if (!view) return; + view.dispatch({ + changes: { from: range.from, to: range.to, insert: replacement }, + }); + view.focus(); + let onUpdate = this.args.onUpdate; + if (onUpdate) { + onUpdate(view.state.doc.toString()); + } + }; + + _deleteRange = (range: BfmRefRange) => { + let view = this.editorView; + if (!view) return; + // Block directives sit on their own line — extend the delete to swallow + // the surrounding newline so we don't leave a blank line behind. + let doc = view.state.doc; + let from = range.from; + let to = range.to; + if (range.kind === 'block') { + if (doc.sliceString(to, to + 1) === '\n') to += 1; + else if (from > 0 && doc.sliceString(from - 1, from) === '\n') from -= 1; + } + view.dispatch({ changes: { from, to, insert: '' } }); + view.focus(); + let onUpdate = this.args.onUpdate; + if (onUpdate) { + onUpdate(view.state.doc.toString()); + } + }; + // ── Card insertion ─────────────────────────────────────────────────────── _insertCardWithFormat = (format: string) => { @@ -886,6 +1081,58 @@ export default class CodeMirrorEditor extends GlimmerComponent {{/if}} + {{#if this._currentBfmRef}} + + {{else}} +
+ + {{#if this._embedPopoverOpen}} + + {{/if}} +
+ {{/if}} + + {{#each this.toolbarButtons as |btn|}} {{#if btn.divider}} @@ -917,89 +1164,89 @@ export default class CodeMirrorEditor extends GlimmerComponent {{! ── Card search popup ── }} - {{! template-lint-disable no-pointer-down-event-binding }} - {{#if this._cardSearchMode}} - - {{/if}} + {{! 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: - -
- + {{! ── Format picker popup ── }} + {{! template-lint-disable no-pointer-down-event-binding }} + {{#if this._formatPickerCardUrl}} +
+ + Insert "{{this._formatPickerCardTitle}}" as: + +
+ + +
- -
- {{/if}} + {{/if}}
{{#if this.livePreview}} @@ -1389,6 +1636,39 @@ export default class CodeMirrorEditor extends GlimmerComponent { height: 100%; min-height: 0; background-color: var(--boxel-light); + /* Share the file chooser's compact scale so the two lists match when + toggling tabs in the markdown embed chooser. */ + font: var(--boxel-font-sm); } .mini-card-chooser__header { flex: 0 0 auto; - padding: var(--boxel-sp-xs) var(--boxel-sp-xs) 0; + /* Small bottom inset so the search bar's 2px focus outline isn't + painted over by the results list sitting directly below it. */ + padding: var(--boxel-sp-xs) var(--boxel-sp-xs) var(--boxel-sp-4xs); } /* Pill-shaped, design-matched bar height. SearchBar's defaults are tuned for the full search-sheet (50px tall, generous focus ring); diff --git a/packages/host/app/components/card-search/panel-content.gts b/packages/host/app/components/card-search/panel-content.gts index d0b05585b37..469e423a380 100644 --- a/packages/host/app/components/card-search/panel-content.gts +++ b/packages/host/app/components/card-search/panel-content.gts @@ -461,6 +461,11 @@ export default class PanelContent extends Component { .search-sheet-content.mini :deep(.search-result-header) { padding-block: var(--boxel-sp-xs); } + /* The summary is 16px bold in the full sheet; in the mini envelope drop + it to the chooser's shared 14px scale (weight stays 600). */ + .search-sheet-content.mini :deep(.search-result-header .summary) { + font: 600 var(--boxel-font-sm); + } /* Summary + Sort sit on one row, with the Sort dropdown shrunk to fit its label rather than padded to a comfortable touch target. */ .search-sheet-content.mini :deep(.search-result-header .controls) { diff --git a/packages/host/app/components/card-search/sheet-results.gts b/packages/host/app/components/card-search/sheet-results.gts index 2514ae2afb9..a2224133e6b 100644 --- a/packages/host/app/components/card-search/sheet-results.gts +++ b/packages/host/app/components/card-search/sheet-results.gts @@ -205,6 +205,20 @@ export default class SheetResults extends Component { return [...new Set(urls)]; } + // The global summary + Sort row. Hidden in the mini chooser's default + // Recents view (empty search): there the Recents section supplies its own + // header (label + count), and the design shows no Sort control until the + // user actually searches. Unaffected for the full search sheet. + private get showGlobalHeader(): boolean { + if (!this.args.showHeader || this.args.isCompact) { + return false; + } + if (this.args.variant === 'mini' && this.args.isSearchKeyEmpty) { + return false; + } + return true; + } + private get hasNoResults(): boolean { return ( this.sections.length === 0 && @@ -230,24 +244,22 @@ export default class SheetResults extends Component { }