From e4744ea5902a63b0108e181a09d6a9a6b3b8a48f Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Mon, 22 Jun 2026 15:59:32 +0700 Subject: [PATCH 1/3] Add MarkdownEmbedPreview component + preview pane (CS-11673, CS-11674) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build the reusable markdown-embed preview and its right-hand preview pane, the companion the combined chooser modal will compose later. - MarkdownEmbedPreview: renders a resolved card/file in a given format/size via CardRenderer; fitted sizing reuses bfmBlockFormatAndSize so the preview footprint matches the live markdown renderer. @kind controls inline/block placement, not the render format. - Preview pane: format dropdown (Atom / Embedded / each Fitted variant / Custom), always-on W×H inputs for Fitted with smart bidirectional variant matching, Inline/Block toggle (Block disabled while Atom is selected), and a dynamic "Insert as ..." CTA that emits the serialized BFM via @onInsert. - runtime-common: add serializeBfmSizeSpec (inverse of parseBfmSizeSpec) and serializeBfmRef as the single source of truth for BFM directive syntax; base markdown-helpers delegate to it so file refs serialize too. - Freestyle demos for both components plus unit and integration tests. Combines CS-11674 (W×H inputs) into this branch. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/base/markdown-helpers.ts | 27 +- .../markdown-embed-chooser/pane-usage.gts | 112 +++++ .../markdown-embed-chooser/pane.gts | 427 ++++++++++++++++++ .../markdown-embed-chooser/preview/index.gts | 123 +++++ .../markdown-embed-chooser/preview/usage.gts | 129 ++++++ .../host/app/templates/host-freestyle.gts | 4 + .../markdown-embed-preview-pane-test.gts | 344 ++++++++++++++ .../markdown-embed-preview-test.gts | 234 ++++++++++ .../tests/unit/bfm-card-references-test.ts | 122 +++++ .../runtime-common/bfm-card-references.ts | 59 +++ 10 files changed, 1568 insertions(+), 13 deletions(-) create mode 100644 packages/host/app/components/markdown-embed-chooser/pane-usage.gts create mode 100644 packages/host/app/components/markdown-embed-chooser/pane.gts create mode 100644 packages/host/app/components/markdown-embed-chooser/preview/index.gts create mode 100644 packages/host/app/components/markdown-embed-chooser/preview/usage.gts create mode 100644 packages/host/tests/integration/components/markdown-embed-preview-pane-test.gts create mode 100644 packages/host/tests/integration/components/markdown-embed-preview-test.gts diff --git a/packages/base/markdown-helpers.ts b/packages/base/markdown-helpers.ts index f1c63147345..bb340fb5a93 100644 --- a/packages/base/markdown-helpers.ts +++ b/packages/base/markdown-helpers.ts @@ -3,7 +3,7 @@ // construction, and so future adjustments happen in one place. import { markdownEscape } from '@cardstack/boxel-ui/helpers'; -import { isValidDate } from '@cardstack/runtime-common'; +import { isValidDate, serializeBfmRef } from '@cardstack/runtime-common'; // Date formatting shared by DateField, DateTimeField, and DateRangeField so // their markdown output is consistent. Matches the existing `en-US` `{year: @@ -158,24 +158,25 @@ interface MarkdownEmbedOptions { size?: string; } +// Returns a BFM reference directive (`:card`/`::card`, `:file`/`::file`, …) +// for a single reference by keyword + id. Returns `''` for a missing id. +// Thin wrapper over the shared `serializeBfmRef` builder so base-realm and +// host code emit identical BFM syntax. +export function markdownEmbedForRef( + refType: string, + id: string | null | undefined, + options?: MarkdownEmbedOptions, +): string { + return serializeBfmRef(refType, id, options); +} + // Returns a BFM card-reference directive for a single card. // Returns `''` for null/undefined cards. export function markdownEmbedForCard( card: CardLike | null | undefined, options?: MarkdownEmbedOptions, ): string { - if (!card?.id) { - return ''; - } - let kind = options?.kind ?? 'block'; - if (kind === 'inline') { - return `:card[${card.id}]`; - } - let size = options?.size; - if (size) { - return `::card[${card.id} | ${size}]`; - } - return `::card[${card.id}]`; + return serializeBfmRef('card', card?.id, options); } interface MarkdownEmbedsOptions extends MarkdownEmbedOptions { diff --git a/packages/host/app/components/markdown-embed-chooser/pane-usage.gts b/packages/host/app/components/markdown-embed-chooser/pane-usage.gts new file mode 100644 index 00000000000..22bcad4df78 --- /dev/null +++ b/packages/host/app/components/markdown-embed-chooser/pane-usage.gts @@ -0,0 +1,112 @@ +import { action } from '@ember/object'; +import { service } from '@ember/service'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; + +import FreestyleUsage from 'ember-freestyle/components/freestyle/usage'; + +import { isCardErrorJSONAPI } from '@cardstack/runtime-common'; + +import MiniCardChooser from '@cardstack/host/components/card-chooser/mini'; + +import type StoreService from '@cardstack/host/services/store'; + +import type { CardDef } from 'https://cardstack.com/base/card-api'; + +import MarkdownEmbedPreviewPane from './pane'; + +export default class MarkdownEmbedPreviewPaneUsage extends Component { + @service declare private store: StoreService; + + @tracked private target: CardDef | undefined; + @tracked private inserted: string | undefined; + + @action private async onSelect(url: string) { + let result = await this.store.get(url); + if (!isCardErrorJSONAPI(result)) { + this.target = result as CardDef; + } + } + + @action private onInsert(bfm: string) { + this.inserted = bfm; + } + + +} diff --git a/packages/host/app/components/markdown-embed-chooser/pane.gts b/packages/host/app/components/markdown-embed-chooser/pane.gts new file mode 100644 index 00000000000..01ec4cc63f3 --- /dev/null +++ b/packages/host/app/components/markdown-embed-chooser/pane.gts @@ -0,0 +1,427 @@ +import { fn } from '@ember/helper'; +import { on } from '@ember/modifier'; +import { action } from '@ember/object'; + +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; + +import { + BoxelInput, + BoxelSelect, + Button, +} from '@cardstack/boxel-ui/components'; +import { + eq, + type FittedFormatId, + type FittedFormatSpec, + FITTED_FORMAT_SIZES, + fittedFormatById, +} from '@cardstack/boxel-ui/helpers'; + +import { + serializeBfmRef, + serializeBfmSizeSpec, + type BfmSizeSpec, +} from '@cardstack/runtime-common/bfm-card-references'; + +import type { CardDef, FileDef } from 'https://cardstack.com/base/card-api'; + +import MarkdownEmbedPreview from './preview'; + +type EmbedFormat = 'atom' | 'embedded' | 'fitted' | 'isolated'; +type FormatCategory = 'atom' | 'embedded' | 'fitted' | 'custom'; +type OptionValue = 'atom' | 'embedded' | FittedFormatId | 'custom'; + +interface FormatOption { + value: OptionValue; + label: string; + category: FormatCategory; +} + +// Flat dropdown list (no group headers): Atom, Embedded, every Fitted variant, +// then Custom — matching the designer's dropdown. `Custom` is labelled +// `Fitted - Custom size` for grouping but is its own CTA category. +function buildFormatOptions(): FormatOption[] { + let options: FormatOption[] = [ + { value: 'atom', label: 'Atom - Variable size', category: 'atom' }, + { + value: 'embedded', + label: 'Embedded - Variable size', + category: 'embedded', + }, + ]; + for (let spec of FITTED_FORMAT_SIZES) { + options.push({ + value: spec.id, + label: `Fitted - ${spec.title} - ${spec.width}x${spec.height}`, + category: 'fitted', + }); + } + options.push({ + value: 'custom', + label: 'Fitted - Custom size', + category: 'custom', + }); + return options; +} + +interface Signature { + Element: HTMLElement; + Args: { + // Resolved instance being previewed. Its `id` is the BFM ref URL. + target: CardDef | FileDef; + // Which BFM keyword to emit: `:card[...]` vs `:file[...]`. + refType: 'card' | 'file'; + // Receives the serialized BFM directive when the CTA is clicked. The host + // owns actual cursor insertion (a later ticket). + onInsert: (bfm: string) => void; + }; +} + +// Right-hand companion to the mini choosers: a live preview plus the controls +// that decide how a card/file embeds — format dropdown, always-on W×H inputs +// for Fitted (with smart variant matching), an Inline/Block toggle, and a +// dynamic "Insert as …" CTA. +export default class MarkdownEmbedPreviewPane extends Component { + private formatOptions: FormatOption[] = buildFormatOptions(); + + // Atom is the default on first selection; atom is inline-only (see below). + @tracked private selectedValue: OptionValue = 'atom'; + @tracked private kind: 'inline' | 'block' = 'inline'; + // Raw input strings so a partially-typed value (e.g. while clearing) doesn't + // throw away the user's keystrokes. `%` widths are preserved verbatim. + @tracked private widthInput = ''; + @tracked private heightInput = ''; + + private get selectedOption(): FormatOption { + return ( + this.formatOptions.find((o) => o.value === this.selectedValue) ?? + this.formatOptions[0] + ); + } + + private get category(): FormatCategory { + return this.selectedOption.category; + } + + // Atom has no `::card[... | atom]` block form, so block is disallowed while + // Atom is selected — the toggle is forced/locked to inline. + private get blockDisabled(): boolean { + return this.category === 'atom'; + } + + private get showSizeInputs(): boolean { + return this.category === 'fitted' || this.category === 'custom'; + } + + private get previewFormat(): EmbedFormat { + switch (this.category) { + case 'atom': + return 'atom'; + case 'embedded': + return 'embedded'; + default: + return 'fitted'; + } + } + + // px number, `%` string, or undefined for an unparseable/empty input. + private get width(): number | string | undefined { + let v = this.widthInput.trim(); + if (/^\d+%$/.test(v)) return v; + if (/^\d+$/.test(v)) return parseInt(v, 10); + return undefined; + } + + private get height(): number | undefined { + let v = this.heightInput.trim(); + return /^\d+$/.test(v) ? parseInt(v, 10) : undefined; + } + + private get sizeSpec(): BfmSizeSpec | undefined { + if (!this.showSizeInputs) { + return undefined; + } + return { format: 'fitted', width: this.width, height: this.height }; + } + + private get categoryLabel(): string { + switch (this.category) { + case 'atom': + return 'Atom'; + case 'embedded': + return 'Embedded'; + case 'custom': + return 'Custom'; + case 'fitted': + default: + return 'Fitted'; + } + } + + private get ctaLabel(): string { + return `Insert as ${this.categoryLabel}`; + } + + // Block size specifier: the variant id for a named Fitted variant, `embedded` + // for Embedded, and `w: h:` for Custom dimensions (per CS-11674). + private get blockSizeSpecifier(): string | undefined { + switch (this.category) { + case 'embedded': + return 'embedded'; + case 'fitted': + return this.selectedValue; + case 'custom': + return serializeBfmSizeSpec({ + format: 'fitted', + width: this.width, + height: this.height, + }); + default: + return undefined; + } + } + + private get bfmString(): string { + let url = this.args.target.id; + if (this.kind === 'inline') { + return serializeBfmRef(this.args.refType, url, { kind: 'inline' }); + } + return serializeBfmRef(this.args.refType, url, { + kind: 'block', + size: this.blockSizeSpecifier, + }); + } + + @action + private selectFormat(option: FormatOption) { + this.selectedValue = option.value; + if (option.category === 'atom') { + this.kind = 'inline'; + } else { + // A sized embed is meaningful as a block; the user can still toggle back + // to inline afterward. + this.kind = 'block'; + } + if (option.category === 'fitted') { + let spec = fittedFormatById.get(option.value as FittedFormatId); + if (spec) { + this.widthInput = String(spec.width); + this.heightInput = String(spec.height); + } + } + } + + // Bidirectional sync: editing either dimension re-points the dropdown to the + // matching named variant, or to Custom when nothing matches exactly. + private syncVariantFromSize() { + let w = this.width; + let h = this.height; + if (typeof w === 'number' && typeof h === 'number') { + let match = FITTED_FORMAT_SIZES.find( + (s: FittedFormatSpec) => s.width === w && s.height === h, + ); + this.selectedValue = match ? match.id : 'custom'; + } else { + this.selectedValue = 'custom'; + } + } + + @action + private setWidth(value: string) { + this.widthInput = value; + this.syncVariantFromSize(); + } + + @action + private setHeight(value: string) { + this.heightInput = value; + this.syncVariantFromSize(); + } + + @action + private setKind(kind: 'inline' | 'block') { + if (kind === 'block' && this.blockDisabled) { + return; + } + this.kind = kind; + } + + @action + private insert() { + this.args.onInsert(this.bfmString); + } + + +} diff --git a/packages/host/app/components/markdown-embed-chooser/preview/index.gts b/packages/host/app/components/markdown-embed-chooser/preview/index.gts new file mode 100644 index 00000000000..f22cc05fed7 --- /dev/null +++ b/packages/host/app/components/markdown-embed-chooser/preview/index.gts @@ -0,0 +1,123 @@ +import { htmlSafe } from '@ember/template'; + +import Component from '@glimmer/component'; + +import { eq } from '@cardstack/boxel-ui/helpers'; + +import { + bfmBlockFormatAndSize, + type BfmSizeSpec, +} from '@cardstack/runtime-common/bfm-card-references'; + +import CardRenderer from '@cardstack/host/components/card-renderer'; + +import type { + CardDef, + FileDef, + Format, +} from 'https://cardstack.com/base/card-api'; + +type EmbedFormat = 'atom' | 'embedded' | 'fitted' | 'isolated'; + +interface Signature { + Element: HTMLElement; + Args: { + // Already-resolved instance to preview. Both card refs (`:card[...]`) and + // file refs (`:file[...]`) render through the same CardRenderer, so the + // caller resolves the URL and hands us the instance — this component loads + // nothing. + target: CardDef | FileDef; + // Render format. `fitted` consults `@sizeSpec` for its width/height; + // atom/embedded/isolated ignore it. + format: EmbedFormat; + // Width/height for fitted renders. Width may be a px number or a `%` + // string; height is a px number. Ignored unless `@format` is 'fitted'. + sizeSpec?: BfmSizeSpec; + // Placement only: inline flows the preview within text (``); block + // gives it its own line (`
`). Does NOT change the render format. + // Default: 'block'. + kind?: 'inline' | 'block'; + }; +} + +// Renders a resolved card/file in the requested format + size, matching how +// `rendered-markdown.gts` paints the eventual document slot. The chooser's +// preview pane wraps this with format/size controls; later tickets reuse it in +// the Edit modal and inline overlay. +export default class MarkdownEmbedPreview extends Component { + private get kind(): 'inline' | 'block' { + return this.args.kind ?? 'block'; + } + + private get renderFormat(): Format { + return this.args.format; + } + + // Fitted slots carry an inline width/height plus `overflow: hidden` so the + // instance occupies the requested footprint — derived through the same helper + // the live markdown renderer uses (`rendered-markdown.gts`). + private get sizeStyle(): ReturnType | undefined { + if (this.args.format !== 'fitted') { + return undefined; + } + let { width, height } = this.args.sizeSpec ?? { format: 'fitted' }; + let { sizeStyle } = bfmBlockFormatAndSize( + 'fitted', + width === undefined ? undefined : String(width), + height === undefined ? undefined : String(height), + ); + return htmlSafe( + sizeStyle ? `${sizeStyle}; overflow: hidden` : 'overflow: hidden', + ); + } + + +} diff --git a/packages/host/app/components/markdown-embed-chooser/preview/usage.gts b/packages/host/app/components/markdown-embed-chooser/preview/usage.gts new file mode 100644 index 00000000000..c76114838f9 --- /dev/null +++ b/packages/host/app/components/markdown-embed-chooser/preview/usage.gts @@ -0,0 +1,129 @@ +import { action } from '@ember/object'; +import { service } from '@ember/service'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; + +import FreestyleUsage from 'ember-freestyle/components/freestyle/usage'; + +import { isCardErrorJSONAPI } from '@cardstack/runtime-common'; + +import MiniCardChooser from '@cardstack/host/components/card-chooser/mini'; + +import type StoreService from '@cardstack/host/services/store'; + +import type { CardDef } from 'https://cardstack.com/base/card-api'; + +import MarkdownEmbedPreview from './index'; + +export default class MarkdownEmbedPreviewUsage extends Component { + @service declare private store: StoreService; + + @tracked private target: CardDef | undefined; + + @action private async onSelect(url: string) { + let result = await this.store.get(url); + if (!isCardErrorJSONAPI(result)) { + this.target = result as CardDef; + } + } + + + + private tallTile = { format: 'fitted', width: 150, height: 275 } as const; + private custom = { format: 'fitted', width: 300, height: 200 } as const; +} diff --git a/packages/host/app/templates/host-freestyle.gts b/packages/host/app/templates/host-freestyle.gts index 9e63a71c57a..a81c4564dff 100644 --- a/packages/host/app/templates/host-freestyle.gts +++ b/packages/host/app/templates/host-freestyle.gts @@ -24,6 +24,8 @@ import AiAssistantMessageUsage from '@cardstack/host/components/ai-assistant/mes import AiAssistantSkillMenuUsage from '@cardstack/host/components/ai-assistant/skill-menu/usage'; import MiniCardChooserUsage from '@cardstack/host/components/card-chooser/mini/usage'; import CardChooserModal from '@cardstack/host/components/card-chooser/modal'; +import MarkdownEmbedPreviewPaneUsage from '@cardstack/host/components/markdown-embed-chooser/pane-usage'; +import MarkdownEmbedPreviewUsage from '@cardstack/host/components/markdown-embed-chooser/preview/usage'; import PillMenuUsage from '@cardstack/host/components/pill-menu/usage'; import SearchSheetUsage from '@cardstack/host/components/search-sheet/usage'; @@ -76,6 +78,8 @@ class HostFreestyleComponent extends Component { ['AiAssistant::PillMenu', PillMenuUsage], ['AiAssistant::SkillMenu', AiAssistantSkillMenuUsage], ['MiniCardChooser', MiniCardChooserUsage], + ['MarkdownEmbedChooser::Preview', MarkdownEmbedPreviewUsage], + ['MarkdownEmbedChooser::Pane', MarkdownEmbedPreviewPaneUsage], ['SearchSheet', SearchSheetUsage], ].map(([name, c]) => { return { diff --git a/packages/host/tests/integration/components/markdown-embed-preview-pane-test.gts b/packages/host/tests/integration/components/markdown-embed-preview-pane-test.gts new file mode 100644 index 00000000000..e53dcbbf5d6 --- /dev/null +++ b/packages/host/tests/integration/components/markdown-embed-preview-pane-test.gts @@ -0,0 +1,344 @@ +import type { TOC } from '@ember/component/template-only'; +import { + type RenderingTestContext, + click, + fillIn, + render, + waitFor, +} from '@ember/test-helpers'; + +import GlimmerComponent from '@glimmer/component'; + +import { getService } from '@universal-ember/test-support'; +import { provide } from 'ember-provide-consume-context'; + +import { module, test } from 'qunit'; + +import { + baseRealm, + CardContextName, + GetCardContextName, + GetCardCollectionContextName, + GetCardsContextName, +} from '@cardstack/runtime-common'; + +import MarkdownEmbedPreviewPane from '@cardstack/host/components/markdown-embed-chooser/pane'; +import { getCardCollection } from '@cardstack/host/resources/card-collection'; +import { getCard } from '@cardstack/host/resources/card-resource'; +import type StoreService from '@cardstack/host/services/store'; + +// The base-realm helper below exports `CardDef` as a value (for defining test +// card classes); import the instance *type* separately for annotations. +import type { CardDef as CardDefInstance } from 'https://cardstack.com/base/card-api'; + +import { + setupIntegrationTestRealm, + setupLocalIndexing, + testRealmURL, +} from '../../helpers'; +import { + CardDef, + StringField, + contains, + field, + setupBaseRealm, +} from '../../helpers/base-realm'; +import { setupMockMatrix } from '../../helpers/mock-matrix'; +import { setupRenderingTest } from '../../helpers/setup'; + +const PaneBox: TOC<{ Blocks: { default: [] } }> = ; + +class HostContextProvider extends GlimmerComponent<{ + Blocks: { default: [] }; +}> { + @provide(GetCardContextName) + get getCardFn() { + return getCard; + } + @provide(GetCardsContextName) + get getCardsFn() { + let store = getService('store') as StoreService; + return store.getSearchResource.bind(store); + } + @provide(GetCardCollectionContextName) + get getCardCollectionFn() { + return getCardCollection; + } + @provide(CardContextName) + get cardContext() { + return {}; + } + +} + +class InsertHarness { + inserts: string[] = []; + onInsert = (bfm: string) => { + this.inserts.push(bfm); + }; + get last() { + return this.inserts[this.inserts.length - 1]; + } +} + +async function chooseFormat(value: string) { + await click( + '[data-test-markdown-embed-preview-format-select] .ember-power-select-trigger', + ); + await waitFor('.ember-power-select-option', { timeout: 3000 }); + await click(`[data-test-format-option="${value}"]`); +} + +module('Integration | markdown-embed-preview-pane', function (hooks) { + setupRenderingTest(hooks); + setupBaseRealm(hooks); + setupLocalIndexing(hooks); + + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + activeRealms: [baseRealm.url, testRealmURL], + autostart: true, + }); + + const mango = `${testRealmURL}books/mango`; + + hooks.beforeEach(async function (this: RenderingTestContext) { + class Book extends CardDef { + static displayName = 'Book'; + @field title = contains(StringField); + } + await setupIntegrationTestRealm({ + mockMatrixUtils, + realmURL: testRealmURL, + contents: { + 'book.gts': { Book }, + 'books/mango.json': new Book({ title: 'Mango' }), + }, + }); + await getService('realm').login(testRealmURL); + }); + + async function loadCard(): Promise { + let store = getService('store') as StoreService; + return (await store.get(mango)) as CardDefInstance; + } + + test('atom is the default; block is disabled and inline emits :card[url]', async function (assert) { + let card = await loadCard(); + let harness = new InsertHarness(); + await render( + , + ); + + assert + .dom('[data-test-markdown-embed-preview-cta]') + .hasText('Insert as Atom', 'CTA reflects the default Atom category'); + assert + .dom('[data-test-markdown-embed-preview-block]') + .isDisabled('Block toggle is disabled while Atom is selected'); + assert + .dom('[data-test-markdown-embed-preview-size]') + .doesNotExist('no W/H inputs for Atom'); + + await click('[data-test-markdown-embed-preview-cta]'); + assert.strictEqual( + harness.last, + `:card[${card.id}]`, + 'atom inserts the inline atom directive', + ); + }); + + test('embedded emits a block directive with the embedded keyword', async function (assert) { + let card = await loadCard(); + let harness = new InsertHarness(); + await render( + , + ); + + await chooseFormat('embedded'); + assert + .dom('[data-test-markdown-embed-preview-cta]') + .hasText('Insert as Embedded'); + assert + .dom('[data-test-markdown-embed-preview-block]') + .isNotDisabled('Block re-enables for non-atom formats'); + + await click('[data-test-markdown-embed-preview-cta]'); + assert.strictEqual(harness.last, `::card[${card.id} | embedded]`); + }); + + test('fitted variant prefills W/H, emits the variant id, and inline drops the size', async function (assert) { + let card = await loadCard(); + let harness = new InsertHarness(); + await render( + , + ); + + await chooseFormat('tall-tile'); + assert + .dom('[data-test-markdown-embed-preview-cta]') + .hasText('Insert as Fitted'); + assert + .dom('[data-test-markdown-embed-preview-width]') + .hasValue('150', 'width prefilled from the variant'); + assert + .dom('[data-test-markdown-embed-preview-height]') + .hasValue('275', 'height prefilled from the variant'); + + await click('[data-test-markdown-embed-preview-cta]'); + assert.strictEqual( + harness.last, + `::card[${card.id} | tall-tile]`, + 'block emits the named variant id', + ); + + await click('[data-test-markdown-embed-preview-inline]'); + await click('[data-test-markdown-embed-preview-cta]'); + assert.strictEqual( + harness.last, + `:card[${card.id}]`, + 'inline drops the size and emits the atom directive', + ); + }); + + test('editing W/H to unknown dims switches to Custom and emits w:/h:', async function (assert) { + let card = await loadCard(); + let harness = new InsertHarness(); + await render( + , + ); + + await chooseFormat('tall-tile'); + await fillIn('[data-test-markdown-embed-preview-width]', '300'); + await fillIn('[data-test-markdown-embed-preview-height]', '200'); + + assert + .dom('[data-test-markdown-embed-preview-cta]') + .hasText('Insert as Custom', 'unknown dims fall back to Custom'); + + await click('[data-test-markdown-embed-preview-cta]'); + assert.strictEqual(harness.last, `::card[${card.id} | w:300 h:200]`); + }); + + test('editing W/H to a known variant follows the dropdown to that variant', async function (assert) { + let card = await loadCard(); + let harness = new InsertHarness(); + await render( + , + ); + + await chooseFormat('tall-tile'); + // regular-tile = 250x170 + await fillIn('[data-test-markdown-embed-preview-width]', '250'); + await fillIn('[data-test-markdown-embed-preview-height]', '170'); + + assert + .dom('[data-test-markdown-embed-preview-cta]') + .hasText( + 'Insert as Fitted', + 'an exact match stays in the Fitted category', + ); + + await click('[data-test-markdown-embed-preview-cta]'); + assert.strictEqual( + harness.last, + `::card[${card.id} | regular-tile]`, + 'the dropdown follows the dimensions to the matching variant', + ); + }); + + test('refType drives the keyword (file)', async function (assert) { + let card = await loadCard(); + let harness = new InsertHarness(); + await render( + , + ); + + await click('[data-test-markdown-embed-preview-cta]'); + assert.strictEqual( + harness.last, + `:file[${card.id}]`, + 'atom inline file ref', + ); + + await chooseFormat('embedded'); + await click('[data-test-markdown-embed-preview-cta]'); + assert.strictEqual(harness.last, `::file[${card.id} | embedded]`); + }); +}); diff --git a/packages/host/tests/integration/components/markdown-embed-preview-test.gts b/packages/host/tests/integration/components/markdown-embed-preview-test.gts new file mode 100644 index 00000000000..e63fbf68616 --- /dev/null +++ b/packages/host/tests/integration/components/markdown-embed-preview-test.gts @@ -0,0 +1,234 @@ +import type { TOC } from '@ember/component/template-only'; +import { type RenderingTestContext, render } from '@ember/test-helpers'; + +import GlimmerComponent from '@glimmer/component'; + +import { getService } from '@universal-ember/test-support'; +import { provide } from 'ember-provide-consume-context'; + +import { module, test } from 'qunit'; + +import { + baseRealm, + CardContextName, + GetCardContextName, + GetCardCollectionContextName, + GetCardsContextName, + type BfmSizeSpec, +} from '@cardstack/runtime-common'; + +import MarkdownEmbedPreview from '@cardstack/host/components/markdown-embed-chooser/preview'; +import { getCardCollection } from '@cardstack/host/resources/card-collection'; +import { getCard } from '@cardstack/host/resources/card-resource'; +import type StoreService from '@cardstack/host/services/store'; + +// The base-realm helper below exports `CardDef` as a value (for defining test +// card classes); import the instance *type* separately for annotations. +import type { CardDef as CardDefInstance } from 'https://cardstack.com/base/card-api'; + +import { + setupIntegrationTestRealm, + setupLocalIndexing, + testRealmURL, +} from '../../helpers'; +import { + CardDef, + StringField, + contains, + field, + setupBaseRealm, +} from '../../helpers/base-realm'; +import { setupMockMatrix } from '../../helpers/mock-matrix'; +import { setupRenderingTest } from '../../helpers/setup'; + +const PreviewBox: TOC<{ Blocks: { default: [] } }> = ; + +// Provides the contexts CardRenderer reads via @consume so the preview can +// render outside OperatorMode. +class HostContextProvider extends GlimmerComponent<{ + Blocks: { default: [] }; +}> { + @provide(GetCardContextName) + get getCardFn() { + return getCard; + } + @provide(GetCardsContextName) + get getCardsFn() { + let store = getService('store') as StoreService; + return store.getSearchResource.bind(store); + } + @provide(GetCardCollectionContextName) + get getCardCollectionFn() { + return getCardCollection; + } + @provide(CardContextName) + get cardContext() { + return {}; + } + +} + +function styleOf(): string { + return ( + document + .querySelector('[data-test-markdown-embed-preview]') + ?.getAttribute('style') ?? '' + ); +} + +module('Integration | markdown-embed-preview', function (hooks) { + setupRenderingTest(hooks); + setupBaseRealm(hooks); + setupLocalIndexing(hooks); + + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + activeRealms: [baseRealm.url, testRealmURL], + autostart: true, + }); + + const mango = `${testRealmURL}books/mango`; + + hooks.beforeEach(async function (this: RenderingTestContext) { + class Book extends CardDef { + static displayName = 'Book'; + @field title = contains(StringField); + } + await setupIntegrationTestRealm({ + mockMatrixUtils, + realmURL: testRealmURL, + contents: { + 'book.gts': { Book }, + 'books/mango.json': new Book({ title: 'Mango' }), + }, + }); + await getService('realm').login(testRealmURL); + }); + + async function loadCard(): Promise { + let store = getService('store') as StoreService; + return (await store.get(mango)) as CardDefInstance; + } + + test('renders atom format with no fitted sizing', async function (assert) { + let card = await loadCard(); + await render( + , + ); + assert + .dom('[data-test-markdown-embed-preview]') + .hasAttribute('data-test-markdown-embed-preview-format', 'atom'); + assert + .dom('[data-test-markdown-embed-preview].markdown-embed-preview--fitted') + .doesNotExist('atom carries no fitted sizing'); + }); + + test('renders embedded format', async function (assert) { + let card = await loadCard(); + await render( + , + ); + assert + .dom('[data-test-markdown-embed-preview]') + .hasAttribute('data-test-markdown-embed-preview-format', 'embedded'); + }); + + test('renders a named fitted variant at its exact footprint', async function (assert) { + let card = await loadCard(); + // tall-tile = 150x275 + let tallTile: BfmSizeSpec = { format: 'fitted', width: 150, height: 275 }; + await render( + , + ); + assert + .dom('[data-test-markdown-embed-preview]') + .hasAttribute('data-test-markdown-embed-preview-format', 'fitted'); + let style = styleOf(); + assert.ok(style.includes('width: 150px'), `width applied (${style})`); + assert.ok(style.includes('height: 275px'), `height applied (${style})`); + assert.ok(style.includes('overflow: hidden'), 'fitted clips overflow'); + }); + + test('renders an arbitrary custom W×H', async function (assert) { + let card = await loadCard(); + let custom: BfmSizeSpec = { format: 'fitted', width: 300, height: 200 }; + await render( + , + ); + let style = styleOf(); + assert.ok(style.includes('width: 300px'), `custom width (${style})`); + assert.ok(style.includes('height: 200px'), `custom height (${style})`); + }); + + test('kind controls placement: inline renders a span, block renders a div', async function (assert) { + let card = await loadCard(); + await render( + , + ); + assert + .dom('span[data-test-markdown-embed-preview]') + .exists('inline kind renders a span'); + assert + .dom('div[data-test-markdown-embed-preview]') + .doesNotExist('no block wrapper for inline kind'); + }); +}); diff --git a/packages/host/tests/unit/bfm-card-references-test.ts b/packages/host/tests/unit/bfm-card-references-test.ts index 4a1b07cfd29..40ee88f3f66 100644 --- a/packages/host/tests/unit/bfm-card-references-test.ts +++ b/packages/host/tests/unit/bfm-card-references-test.ts @@ -1,5 +1,7 @@ import { module, test } from 'qunit'; +import { fittedFormatIds } from '@cardstack/boxel-ui/helpers'; + import { extractCardReferenceUrls, extractFileReferenceUrls, @@ -8,6 +10,9 @@ import { bfmCardReferenceExtensions, bfmExtensionsForKeyword, parseBfmSizeSpec, + serializeBfmSizeSpec, + serializeBfmRef, + type BfmSizeSpec, } from '@cardstack/runtime-common/bfm-card-references'; import { markdownToHtml } from '@cardstack/runtime-common/marked-sync'; import { VirtualNetwork } from '@cardstack/runtime-common/virtual-network'; @@ -998,4 +1003,121 @@ module('Unit | bfm-card-references', function () { }); }); }); + + module('serializeBfmSizeSpec', function () { + test('isolated / embedded round-trip to their keyword', function (assert) { + assert.strictEqual( + serializeBfmSizeSpec({ format: 'isolated' }), + 'isolated', + ); + assert.strictEqual( + serializeBfmSizeSpec({ format: 'embedded' }), + 'embedded', + ); + }); + + test('bare fitted with no dimensions serializes to `fitted`', function (assert) { + assert.strictEqual(serializeBfmSizeSpec({ format: 'fitted' }), 'fitted'); + }); + + test('fitted dimensions serialize to the explicit-key form', function (assert) { + assert.strictEqual( + serializeBfmSizeSpec({ format: 'fitted', width: 300, height: 200 }), + 'w:300 h:200', + ); + }); + + test('percentage width is preserved', function (assert) { + assert.strictEqual( + serializeBfmSizeSpec({ format: 'fitted', width: '50%', height: 200 }), + 'w:50% h:200', + ); + }); + + test('a single dimension serializes on its own', function (assert) { + assert.strictEqual( + serializeBfmSizeSpec({ format: 'fitted', height: 300 }), + 'h:300', + ); + }); + + test('round-trips through parseBfmSizeSpec for isolated, embedded, dims, and %', function (assert) { + let specs: BfmSizeSpec[] = [ + { format: 'isolated' }, + { format: 'embedded' }, + { format: 'fitted', width: 300, height: 200 }, + { format: 'fitted', width: '50%', height: 120 }, + { format: 'fitted', height: 300 }, + ]; + for (let spec of specs) { + assert.deepEqual( + parseBfmSizeSpec(serializeBfmSizeSpec(spec)), + spec, + `round-trips ${JSON.stringify(spec)}`, + ); + } + }); + + test('every named fitted id round-trips dimensionally', function (assert) { + for (let id of fittedFormatIds) { + let parsed = parseBfmSizeSpec(id)!; + // The serializer emits `w:N h:N`, which re-parses to the same spec — + // the named identity is intentionally not reconstructed. + assert.deepEqual( + parseBfmSizeSpec(serializeBfmSizeSpec(parsed)), + parsed, + `${id} round-trips dimensionally`, + ); + } + }); + }); + + module('serializeBfmRef', function () { + let url = 'https://example.com/Author/jane'; + + test('inline drops the size and emits the single-colon form', function (assert) { + assert.strictEqual( + serializeBfmRef('card', url, { kind: 'inline', size: 'tall-tile' }), + `:card[${url}]`, + ); + }); + + test('block with no size emits the bare double-colon form', function (assert) { + assert.strictEqual( + serializeBfmRef('card', url, { kind: 'block' }), + `::card[${url}]`, + ); + }); + + test('block with a size appends the specifier', function (assert) { + assert.strictEqual( + serializeBfmRef('card', url, { kind: 'block', size: 'tall-tile' }), + `::card[${url} | tall-tile]`, + ); + assert.strictEqual( + serializeBfmRef('card', url, { kind: 'block', size: 'w:300 h:200' }), + `::card[${url} | w:300 h:200]`, + ); + }); + + test('defaults to block', function (assert) { + assert.strictEqual(serializeBfmRef('card', url), `::card[${url}]`); + }); + + test('honors the refType keyword (file)', function (assert) { + assert.strictEqual( + serializeBfmRef('file', url, { kind: 'inline' }), + `:file[${url}]`, + ); + assert.strictEqual( + serializeBfmRef('file', url, { kind: 'block', size: 'embedded' }), + `::file[${url} | embedded]`, + ); + }); + + test('returns empty string for a missing url', function (assert) { + assert.strictEqual(serializeBfmRef('card', undefined), ''); + assert.strictEqual(serializeBfmRef('card', ''), ''); + }); + }); }); diff --git a/packages/runtime-common/bfm-card-references.ts b/packages/runtime-common/bfm-card-references.ts index d9c1d9c8a4b..ba9d954276a 100644 --- a/packages/runtime-common/bfm-card-references.ts +++ b/packages/runtime-common/bfm-card-references.ts @@ -110,6 +110,65 @@ export function parseBfmSizeSpec(specifier: string): BfmSizeSpec | null { return null; } +/** + * Serializes a `BfmSizeSpec` back into the specifier string that goes after + * `|` in `::card[url | spec]` — the inverse of `parseBfmSizeSpec`. + * + * `isolated` / `embedded` round-trip to their keyword. Fitted specs serialize + * to the explicit-key form (`w: h:`, `w:50%`, `h:200`, or bare `fitted` + * when no dimensions are present). Named variants are intentionally NOT + * reconstructed here: a `BfmSizeSpec` only carries dimensions, not the named + * identity, so callers that want the friendlier `tall-tile` form must emit it + * themselves (the chooser pane does this off the user's explicit selection). + * The `w:`/`h:` output still parses back to a dimensionally-identical spec. + */ +export function serializeBfmSizeSpec(spec: BfmSizeSpec): string { + if (spec.format === 'isolated' || spec.format === 'embedded') { + return spec.format; + } + let parts: string[] = []; + if (spec.width !== undefined) { + parts.push(`w:${spec.width}`); + } + if (spec.height !== undefined) { + parts.push(`h:${spec.height}`); + } + return parts.length ? parts.join(' ') : 'fitted'; +} + +export interface BfmRefOptions { + // 'inline' produces `:[url]`, 'block' produces + // `::[url]` (or `::[url | size]`). Default: 'block'. + kind?: 'inline' | 'block'; + // Size specifier appended after `|` in block embeds (e.g. 'fitted', + // 'tall-tile', 'w:300 h:200'). Ignored for inline embeds, which always + // render atom and carry no size. + size?: string; +} + +/** + * Builds a BFM reference directive (`:card[url]`, `::file[url | size]`, …) for + * a single reference by keyword + url. Returns `''` for a missing url. This is + * the single source of truth for BFM directive syntax, shared by the base-realm + * markdown helpers and host-side serializers (the `extract*`/`parse*` functions + * above are the matching readers). + */ +export function serializeBfmRef( + refType: string, + url: string | null | undefined, + options?: BfmRefOptions, +): string { + if (!url) { + return ''; + } + let kind = options?.kind ?? 'block'; + if (kind === 'inline') { + return `:${refType}[${url}]`; + } + let size = options?.size; + return size ? `::${refType}[${url} | ${size}]` : `::${refType}[${url}]`; +} + export type BfmBlockFormat = 'embedded' | 'fitted' | 'isolated'; /** From 6700fe029558d9ee0e954363fb56bc6fa9fb93b5 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Tue, 23 Jun 2026 19:09:05 +0700 Subject: [PATCH 2/3] Refine markdown embed preview pane: in-context preview + format/placement decoupling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - host-freestyle: provide CardContext so previewed cards/files actually render (CardRenderer consumes it; the freestyle route only had the Get* contexts). - Pane layout: move the format dropdown to the top; place the W×H inputs after the Inline/Block toggle in the footer. - MarkdownEmbedPreview: add opt-in @showSurroundingText that renders the embed inside skeleton document text (inline flows in the paragraph, block breaks to its own line); extract a shared Embed component so the bare and in-context paths don't duplicate the inline/block markup. - Decouple format from placement: every format is selectable in both inline and block, and the preview tracks the true format × placement. The pane declares intent (kind + size) uniformly to serializeBfmRef; whether inline carries the size is owned by BFM's serializer and handled in CS-11704. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../markdown-embed-chooser/pane.gts | 132 ++++++------ .../markdown-embed-chooser/preview/index.gts | 190 ++++++++++++++---- .../markdown-embed-chooser/preview/usage.gts | 22 ++ .../host/app/templates/host-freestyle.gts | 23 +++ .../markdown-embed-preview-pane-test.gts | 69 ++++++- .../markdown-embed-preview-test.gts | 51 +++++ 6 files changed, 368 insertions(+), 119 deletions(-) diff --git a/packages/host/app/components/markdown-embed-chooser/pane.gts b/packages/host/app/components/markdown-embed-chooser/pane.gts index 01ec4cc63f3..41e1325ad91 100644 --- a/packages/host/app/components/markdown-embed-chooser/pane.gts +++ b/packages/host/app/components/markdown-embed-chooser/pane.gts @@ -104,16 +104,12 @@ export default class MarkdownEmbedPreviewPane extends Component { return this.selectedOption.category; } - // Atom has no `::card[... | atom]` block form, so block is disallowed while - // Atom is selected — the toggle is forced/locked to inline. - private get blockDisabled(): boolean { - return this.category === 'atom'; - } - private get showSizeInputs(): boolean { return this.category === 'fitted' || this.category === 'custom'; } + // The preview renders the selected format in the chosen placement; format and + // inline/block are independent (every format works in both modes). private get previewFormat(): EmbedFormat { switch (this.category) { case 'atom': @@ -163,10 +159,13 @@ export default class MarkdownEmbedPreviewPane extends Component { return `Insert as ${this.categoryLabel}`; } - // Block size specifier: the variant id for a named Fitted variant, `embedded` - // for Embedded, and `w: h:` for Custom dimensions (per CS-11674). - private get blockSizeSpecifier(): string | undefined { + // Size specifier for the chosen format, independent of placement: `atom` / + // `embedded` keywords, the variant id for a named Fitted variant, and + // `w: h:` for Custom dimensions (per CS-11674). + private get sizeSpecifier(): string | undefined { switch (this.category) { + case 'atom': + return 'atom'; case 'embedded': return 'embedded'; case 'fitted': @@ -184,25 +183,24 @@ export default class MarkdownEmbedPreviewPane extends Component { private get bfmString(): string { let url = this.args.target.id; - if (this.kind === 'inline') { - return serializeBfmRef(this.args.refType, url, { kind: 'inline' }); - } + // Declare intent uniformly (kind + size); the BFM serializer in + // runtime-common owns whether a given kind carries the size. Today it drops + // the size for inline (so inline emits `:card[url]`); the BFM "all formats + // in both modes" ticket makes inline honor it, after which this chooser + // emits `:card[url | spec]` with no change here. return serializeBfmRef(this.args.refType, url, { - kind: 'block', - size: this.blockSizeSpecifier, + kind: this.kind, + size: this.sizeSpecifier, }); } @action private selectFormat(option: FormatOption) { this.selectedValue = option.value; - if (option.category === 'atom') { - this.kind = 'inline'; - } else { - // A sized embed is meaningful as a block; the user can still toggle back - // to inline afterward. - this.kind = 'block'; - } + // Pick a sensible default placement for the format — atom reads as inline, + // sized formats as block — but the toggle stays free, so the user can flip + // either way afterward. + this.kind = option.category === 'atom' ? 'inline' : 'block'; if (option.category === 'fitted') { let spec = fittedFormatById.get(option.value as FittedFormatId); if (spec) { @@ -241,9 +239,6 @@ export default class MarkdownEmbedPreviewPane extends Component { @action private setKind(kind: 'inline' | 'block') { - if (kind === 'block' && this.blockDisabled) { - return; - } this.kind = kind; } @@ -258,16 +253,7 @@ export default class MarkdownEmbedPreviewPane extends Component { data-test-markdown-embed-preview-pane ...attributes > -
- -
- -
+
{ > {{option.label}} +
- {{#if this.showSizeInputs}} -
- - - -
- {{/if}} +
+
@@ -329,7 +299,6 @@ export default class MarkdownEmbedPreviewPane extends Component { class='markdown-embed-preview-pane__toggle-option {{if (eq this.kind "block") "is-active"}}' aria-pressed='{{if (eq this.kind "block") "true" "false"}}' - disabled={{this.blockDisabled}} data-test-markdown-embed-preview-block {{on 'click' (fn this.setKind 'block')}} > @@ -337,6 +306,32 @@ export default class MarkdownEmbedPreviewPane extends Component {
+ {{#if this.showSizeInputs}} +
+ + + +
+ {{/if}} +