Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
34 changes: 30 additions & 4 deletions packages/base/codemirror-editor.gts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { modifier } from 'ember-modifier';
import { fn } from '@ember/helper';
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';

Expand All @@ -16,7 +17,10 @@ import {
CardContextName,
trimJsonExtension,
} from '@cardstack/runtime-common';
import { type BfmRefRange } from '@cardstack/runtime-common/bfm-card-references';
import {
type BfmRefFormat,
type BfmRefRange,
} from '@cardstack/runtime-common/bfm-card-references';
import { consume } from 'ember-provide-consume-context';
import {
type BaseDef,
Expand Down Expand Up @@ -47,10 +51,13 @@ import PencilIcon from '@cardstack/boxel-icons/pencil';
interface CardWidgetTarget {
element: HTMLElement;
cardId: string;
format: 'atom' | 'embedded';
format: BfmRefFormat;
kind: 'inline' | 'block';
// 'card' refs resolve to CardDef instances; 'file' refs to FileDef instances.
refType: 'card' | 'file';
// Inline sizing derived from the directive's size specifier (width/height plus
// `overflow: hidden` for fitted). Undefined for non-fitted formats.
style?: string;
}

interface CardRenderTarget extends CardWidgetTarget {
Expand Down Expand Up @@ -995,7 +1002,11 @@ export default class CodeMirrorEditor extends GlimmerComponent<CodeMirrorEditorS
t.cardId === pending[i].cardId &&
t.kind === pending[i].kind &&
t.refType === pending[i].refType &&
t.element === pending[i].element,
t.element === pending[i].element &&
// A size-only edit changes format/style without touching url/kind/
// refType/element — include them so the preview actually re-renders.
t.format === pending[i].format &&
t.style === pending[i].style,
)
) {
return;
Expand Down Expand Up @@ -1339,7 +1350,13 @@ export default class CodeMirrorEditor extends GlimmerComponent<CodeMirrorEditorS
}}
{{#if (isInline target.kind)}}
<span
class='codemirror-card-slot codemirror-card-slot--inline'
class='codemirror-card-slot
{{if
(eq target.format 'atom')
'codemirror-card-slot--inline'
'codemirror-card-slot--inline-embed'
}}'
style={{if target.style (htmlSafe target.style)}}
data-test-codemirror-file-slot-inline={{if
(eq target.refType 'file')
''
Expand All @@ -1363,6 +1380,7 @@ export default class CodeMirrorEditor extends GlimmerComponent<CodeMirrorEditorS
{{else}}
<div
class='codemirror-card-slot codemirror-card-slot--block'
style={{if target.style (htmlSafe target.style)}}
data-test-codemirror-file-slot-block={{if
(eq target.refType 'file')
''
Expand Down Expand Up @@ -1636,6 +1654,14 @@ export default class CodeMirrorEditor extends GlimmerComponent<CodeMirrorEditorS
cursor: pointer;
}

/* Inline embeds with an explicit non-atom format flow inline-block so a
sized card sits in the text run without the atom pill's flex chrome,
mirroring the saved/preview markdown renderers. */
.codemirror-editor :deep(.codemirror-card-slot--inline-embed) {
display: inline-block;
vertical-align: middle;
}

.codemirror-editor :deep(.codemirror-card-slot--block) {
display: block;
border: 1px solid var(--boxel-border-color, #c4c4c4);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,18 @@ export default class EmbedFormatSelection {
}
}

// A Custom-size fitted embed must carry at least one explicit dimension —
// otherwise it serializes to the size-less bare `fitted`, which defeats the
// point of the Custom option. Every named format (atom/embedded/isolated and
// the fixed fitted variants) already carries its size, so it is always
// insertable; the consumer disables the insert CTA when this is false.
get hasValidSize(): boolean {
if (this.category !== 'custom') {
return true;
}
return this.width !== undefined || this.height !== undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep valid bare fitted embeds actionable

When the chooser opens in edit mode for an existing ::card[url | fitted] directive, deriveFormatSeeds treats that valid bare fitted spec as category === 'custom' with empty width/height, so this returns false and the pane disables the Done/Accept button. The markdown parser/renderer explicitly supports bare fitted with no size attributes, so users editing existing/manual content in that format cannot accept the unchanged embed unless they add an arbitrary dimension; please only block blank Custom for new inserts or preserve seeded bare-fitted edit state as valid.

Useful? React with 👍 / 👎.

}
Comment on lines +286 to +291

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[Claude Code 🤖] Editing an existing bare ::card[url | fitted] disables the "Done" CTA, so the unchanged embed can't be accepted. The automated reviewer flagged this; I traced it end to end and it reproduces.

The path. Bare fitted is a first-class supported form — parseBfmSizeSpec('fitted') returns { format: 'fitted' }, and serializeBfmSizeSpec emits bare fitted for a dimensionless fitted spec. When the pencil opens the chooser on such a directive, codemirror-editor.gts calls editEmbed({ sizeSpec: 'fitted', kind: 'block' }), and deriveFormatSeeds('fitted', 'block') takes the fitted default branch → { format: 'custom', width: undefined, height: undefined, kind: 'block' }. The constructed EmbedFormatSelection therefore has category === 'custom' with empty widthInput/heightInput, so hasValidSize returns falsepane.gts's ctaDisabled is true → the "Done" button is disabled. The user can't accept the embed they opened without either typing a dimension (which silently rewrites the directive) or removing it.

Scope: regression introduced here — before this guard the CTA was always enabled, so the edit round-trip worked. It's narrow (only bare fitted; named variants and explicit W×H seed dimensions and are unaffected), so non-blocking — but it does break a valid edit round-trip.

A way out. The guard wants to block a new Custom insert left empty, not a seeded embed opened unchanged; dirtiness distinguishes the two — e.g. in pane.gts:

private get ctaDisabled(): boolean {
  return !this.args.selection.hasValidSize && this.args.selection.isDirty;
}

An unchanged bare-fitted edit has isDirty === false, so "Done" stays enabled and re-serializes the same | fitted; a fresh Custom-with-no-dims pick is dirty (selection moved off atom) and stays blocked — so the acceptance test added here still passes. Alternatively, scope the block to new inserts / preserve seeded bare-fitted as valid, as the automated reviewer suggested.

Coverage. The new acceptance test covers the insert path (pick Custom → disabled → enter dims → enabled). The gap is the edit path: open the chooser on an existing ::card[url | fitted] and assert "Done" is enabled and accepting re-inserts the bare fitted.


Generated by Claude Code


// True once any format/placement/size field has diverged from the seeded
// snapshot. Target identity (the user swapped in a different card/file) is
// tracked separately by the tab panel, which owns the resolved target.
Expand Down
8 changes: 8 additions & 0 deletions packages/host/app/components/markdown-embed-chooser/pane.gts
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,15 @@ export default class MarkdownEmbedPreviewPane extends Component<Signature> {
this.args.selection.setKind(kind);
}

// A Custom-size fitted embed with no dimensions would insert a size-less
// bare `fitted`, so the CTA is held disabled until a valid size is entered.
private get ctaDisabled(): boolean {
return !this.args.selection.hasValidSize;
}

@action
private insert() {
if (this.ctaDisabled) return;
let bfm = this.bfmString;
if (!bfm) return;
this.args.onInsert(bfm);
Expand Down Expand Up @@ -207,6 +214,7 @@ export default class MarkdownEmbedPreviewPane extends Component<Signature> {
<Button
@kind='primary'
@size='small'
@disabled={{this.ctaDisabled}}
class='markdown-embed-preview-pane__cta'
data-test-markdown-embed-preview-cta
{{on 'click' this.insert}}
Expand Down
125 changes: 112 additions & 13 deletions packages/host/app/lib/codemirror-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,20 +45,27 @@ import {
} from '@codemirror/view';

import {
type BfmRefFormat,
type BfmRefRange,
bfmRefFormatAndSize,
extractBfmRefRanges,
parseBfmSizeSpec,
} from '@cardstack/runtime-common/bfm-card-references';

// ── Card widget target interface ────────────────────────────────────────────

export interface CardWidgetTarget {
element: HTMLElement;
cardId: string;
format: 'atom' | 'embedded';
format: BfmRefFormat;
kind: 'inline' | 'block';
// 'card' refs (`:card[URL]`) resolve to CardDef instances; 'file' refs
// (`:file[URL]`) resolve to FileDef instances.
refType: 'card' | 'file';
// Inline sizing (`width`/`height`, plus `overflow: hidden` for fitted) derived
// from the directive's size specifier. Undefined for non-fitted formats.
// Mirrors the style the saved/preview markdown renderers apply.
style?: string;
}

// ── State effect for opening card search ────────────────────────────────────
Expand Down Expand Up @@ -140,6 +147,12 @@ class CardWidget extends WidgetType {
readonly cardId: string,
readonly kind: 'inline' | 'block',
readonly refType: 'card' | 'file' = 'card',
// Size specifier fields parsed from the directive's `| spec` segment.
// `width`/`height` stay strings for a clean DOM data-attr round-trip;
// `format` is the parsed render format. Undefined when absent.
readonly format?: BfmRefFormat,
readonly width?: string,
readonly height?: string,
) {
super();
}
Expand All @@ -148,7 +161,12 @@ class CardWidget extends WidgetType {
return (
this.cardId === other.cardId &&
this.kind === other.kind &&
this.refType === other.refType
this.refType === other.refType &&
// A size-only edit (e.g. `w:400` → `w:600`) must invalidate the widget so
// CM6 rebuilds the DOM with fresh data-attrs instead of reusing the old one.
this.format === other.format &&
this.width === other.width &&
this.height === other.height
);
}

Expand All @@ -158,6 +176,17 @@ class CardWidget extends WidgetType {
el.setAttribute('data-card-id', this.cardId);
el.setAttribute('data-card-kind', this.kind);
el.setAttribute('data-bfm-ref-type', this.refType);
// Emit the size spec so `notifyTargets` can re-derive format + style off the
// DOM, using the same attribute names as the render-side BFM markup.
if (this.format !== undefined) {
el.setAttribute('data-boxel-bfm-format', this.format);
}
if (this.width !== undefined) {
el.setAttribute('data-boxel-bfm-width', this.width);
}
if (this.height !== undefined) {
el.setAttribute('data-boxel-bfm-height', this.height);
}
el.className = `cm-card-widget cm-card-widget--${this.kind}`;
el.contentEditable = 'false';
return el;
Expand Down Expand Up @@ -639,6 +668,34 @@ function buildLinkDecorations(

// ── Card decorations ───────────────────────────────────────────────────────

// Parse a directive's inner content (between `[` and `]`) into the URL/id and
// the CardWidget size args. `width`/`height` are kept as strings so they
// round-trip cleanly through DOM data-attrs, where `notifyTargets` re-derives
// format + style.
function parseCardWidgetContent(content: string): {
cardId: string;
format?: BfmRefFormat;
width?: string;
height?: string;
} {
let trimmed = content.trim();
let pipeIdx = trimmed.indexOf('|');
if (pipeIdx < 0) {
return { cardId: trimmed };
}
let cardId = trimmed.substring(0, pipeIdx).trim();
let spec = parseBfmSizeSpec(trimmed.substring(pipeIdx + 1).trim());
if (!spec) {
return { cardId };
}
return {
cardId,
format: spec.format,
width: spec.width !== undefined ? String(spec.width) : undefined,
height: spec.height !== undefined ? String(spec.height) : undefined,
};
}

function buildCardDecorations(
state: EditorState,
cursorLine: number,
Expand All @@ -657,11 +714,7 @@ function buildCardDecorations(
let to = from + match[0].length;
if (isInsideCode(state, from, to)) continue;

let cardId = match[1].trim();
let pipeIdx = cardId.indexOf('|');
if (pipeIdx >= 0) {
cardId = cardId.substring(0, pipeIdx).trim();
}
let { cardId, format, width, height } = parseCardWidgetContent(match[1]);

let line = doc.lineAt(from);
let onCursor = livePreview && line.number === cursorLine;
Expand All @@ -684,15 +737,29 @@ function buildCardDecorations(
class: 'cm-bfm-card-ref cm-bfm-card-ref--inline',
}),
});
let previewWidget = new CardWidget(cardId, 'block', refType);
let previewWidget = new CardWidget(
cardId,
'block',
refType,
format,
width,
height,
);
decos.push({
from: to,
to: to,
value: Decoration.widget({ widget: previewWidget, side: 1 }),
});
} else {
// Replace source text with widget
let widget = new CardWidget(cardId, 'block', refType);
let widget = new CardWidget(
cardId,
'block',
refType,
format,
width,
height,
);
decos.push({
from,
to,
Expand All @@ -708,7 +775,7 @@ function buildCardDecorations(
let to = from + match[0].length;
if (isInsideCode(state, from, to)) continue;

let cardId = match[1].trim();
let { cardId, format, width, height } = parseCardWidgetContent(match[1]);
let onCursor = livePreview && isOnCursorLine(state, from, cursorLine);

if (!livePreview) {
Expand All @@ -729,15 +796,29 @@ function buildCardDecorations(
class: 'cm-bfm-card-ref cm-bfm-card-ref--inline',
}),
});
let previewWidget = new CardWidget(cardId, 'inline', refType);
let previewWidget = new CardWidget(
cardId,
'inline',
refType,
format,
width,
height,
);
decos.push({
from: to,
to: to,
value: Decoration.widget({ widget: previewWidget, side: 1 }),
});
} else {
// Replace source text with inline widget
let widget = new CardWidget(cardId, 'inline', refType);
let widget = new CardWidget(
cardId,
'inline',
refType,
format,
width,
height,
);
decos.push({
from,
to,
Expand Down Expand Up @@ -851,12 +932,30 @@ function createCardTargetNotifier(
(el.getAttribute('data-bfm-ref-type') as 'card' | 'file') ??
'card';
if (cardId) {
// Re-derive format + sizing from the directive's size attributes,
// matching the saved/preview markdown renderers. Inline embeds
// default to atom, block embeds to embedded.
let { format, sizeStyle } = bfmRefFormatAndSize(
el.getAttribute('data-boxel-bfm-format') ?? undefined,
el.getAttribute('data-boxel-bfm-width') ?? undefined,
el.getAttribute('data-boxel-bfm-height') ?? undefined,
kind === 'inline' ? 'atom' : 'embedded',
);
// Fitted slots carry the width/height plus `overflow: hidden` so
// the resolved instance occupies the requested footprint.
let style =
format === 'fitted'
? sizeStyle
? `${sizeStyle}; overflow: hidden`
: 'overflow: hidden'
: undefined;
Comment on lines +946 to +951

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[Claude Code 🤖] Confirming this is correct, and documenting the invariant it now depends on. This block is the compose-editor twin of the saved/preview renderer in operator-mode/preview-panel/rendered-markdown.gts, and I verified the two match hop for hop: the same bfmRefFormatAndSize(..., kind === 'inline' ? 'atom' : 'embedded') call, and the identical fitted-style formula (${sizeStyle}; overflow: hidden, else overflow: hidden). The atom-vs-embed class split added in codemirror-editor.gts mirrors markdown-bfm-card-slot--inline / --inline-embed there as well. That parity is the whole point of the fix and the thing a future edit must preserve — the two derivations have no shared home, so a change to one silently drifts from the other. Extracting a single shared helper ("derive slot format + resolved style from BFM attrs") would remove the drift risk, but that's a follow-up, not this PR.

Also confirming htmlSafe(target.style) in the template is safe here: style is assembled only from bfmRefFormatAndSize, which admits width/height only after /^\d+%$/ / /^\d+$/ gates, so no free-form text reaches the style attribute.


Generated by Claude Code

targets.push({
element: el as HTMLElement,
cardId,
format: kind === 'inline' ? 'atom' : 'embedded',
format,
kind,
refType,
style,
});
}
}
Expand Down
Loading
Loading