diff --git a/packages/base/codemirror-editor.gts b/packages/base/codemirror-editor.gts index 7c0305e9484..bf7653cab35 100644 --- a/packages/base/codemirror-editor.gts +++ b/packages/base/codemirror-editor.gts @@ -6,7 +6,7 @@ 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 { BoxelDropdown, Tooltip } from '@cardstack/boxel-ui/components'; import { eq } from '@cardstack/boxel-ui/helpers'; import { @@ -526,18 +526,20 @@ export default class CodeMirrorEditor extends GlimmerComponent { - this._embedPopoverOpen = !this._embedPopoverOpen; + // Close the Add-embed dropdown, then open the chooser. The dropdown lives in + // BoxelDropdown's wormhole (outside the toolbar's corner-clip box), so its + // positioning, viewport-edge flipping, and outside-click dismissal are the + // dropdown's concern, not ours. + _chooseEmbed = (close: () => void, defaultTab: 'card' | 'file') => { + close(); + this._openEmbedChooser(defaultTab); }; _openEmbedChooser = async (defaultTab: 'card' | 'file') => { - this._embedPopoverOpen = false; let chooser = this.cardContext?.markdownEmbedChooser; if (!chooser) { // No chooser provided (e.g. card running outside the host) — warn and @@ -938,129 +940,136 @@ export default class CodeMirrorEditor extends GlimmerComponent - {{! ── Docked toolbar ── }} - {{! template-lint-disable no-pointer-down-event-binding }} -
- {{yield to='leadingControls'}} - {{#if (has-block 'leadingControls')}} - - {{/if}} - - {{#if this._currentBfmRef}} - - <:trigger> - - - <:content> - - Edit embed - - - - {{else}} -
- +
+ {{! ── Docked toolbar ── }} + {{! template-lint-disable no-pointer-down-event-binding }} +
+ {{yield to='leadingControls'}} + {{#if (has-block 'leadingControls')}} + + {{/if}} + + {{#if this._currentBfmRef}} + <:trigger> + {{on 'click' this._openEditEmbed}} + > <:content> - Add embed + Edit embed - {{#if this._embedPopoverOpen}} -
- - -
- {{/if}} -
- {{/if}} - - - {{#each this.toolbarButtons as |btn|}} - {{#if btn.divider}} - {{else}} - {{! Every item gets a styled tooltip — the label, plus a shortcut - key badge when the item has a CodeMirror binding. The tooltip - is suppressed while the control is disabled. }} - - <:trigger> - + {{! The dropdown renders in the shared wormhole (BoxelDropdown), + so it escapes the toolbar's corner-clip box and handles its + own viewport-edge flipping and outside-click dismissal. }} + + <:trigger as |bindings|> + + <:trigger> + + + <:content> + + Add embed + + + - <:content> - - {{btn.label}} - {{#if btn.shortcut}} - {{btn.shortcut}} - {{/if}} - + <:content as |dd|> +
+ + +
-
+ {{/if}} - {{/each}} -
+ - {{! template-lint-disable no-invalid-interactive }} -
+ {{#each this.toolbarButtons as |btn|}} + {{#if btn.divider}} + + {{else}} + {{! Every item gets a styled tooltip — the label, plus a shortcut + key badge when the item has a CodeMirror binding. The tooltip + is suppressed while the control is disabled. }} + + <:trigger> + + + <:content> + + {{btn.label}} + {{#if btn.shortcut}} + {{btn.shortcut}} + {{/if}} + + + + {{/if}} + {{/each}} +
+ + {{! template-lint-disable no-invalid-interactive }} +
+
{{#if this.livePreview}} @@ -1169,6 +1178,32 @@ export default class CodeMirrorEditor extends GlimmerComponent + {{! The Add-embed dropdown content renders in the shared wormhole, outside + this component's scoped-style reach — so its rules are matched by the + @contentClass here rather than scoped. BoxelDropdown provides the + surface (background, border, radius, shadow); these add the menu's + padding and the item layout. }} + {{! template-lint-disable require-scoped-style }} + } diff --git a/packages/base/components/markdown-editor-mode-select.gts b/packages/base/components/markdown-editor-mode-select.gts index 220ec6eb9a9..fced54ff2be 100644 --- a/packages/base/components/markdown-editor-mode-select.gts +++ b/packages/base/components/markdown-editor-mode-select.gts @@ -50,7 +50,11 @@ export default class MarkdownEditorModeSelect extends GlimmerComponent .boxel-select__dropdown.markdown-editor-mode-select-dropdown { - /* Match the trigger: 2px less rounded than the default menu radius. */ - --boxel-form-control-border-radius: calc( - var(--boxel-border-radius) - 2px - ); + /* The trigger's 2px-tighter radius arrives on the wormhole via + BoxelSelect's `--boxel-form-control-border-radius` sync (resolved + against the card theme at the trigger). Don't re-declare it here: + this element sits at the app root, where the card's + `--boxel-border-radius` doesn't apply, so a local `calc()` would + override the synced value with the app default under a theme. */ width: max-content; min-width: 7rem; } 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 534bf9aa228..6becb3296c1 100644 --- a/packages/host/tests/integration/components/rich-markdown-field-test.gts +++ b/packages/host/tests/integration/components/rich-markdown-field-test.gts @@ -133,6 +133,115 @@ module('Integration | RichMarkdownField', function (hooks) { ); }); + test('docked toolbar and editor mount share an overflow-clipping wrapper so the sticky toolbar corners conform to the rounded editor outline', async function (assert) { + 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: 'Edit me' }), + }); + let root = await renderCard(loader, card, 'edit'); + await waitFor('[data-test-codemirror-body]', { timeout: 5000 }); + + let wrapper = root.querySelector( + '[data-test-codemirror-body]', + ) as HTMLElement; + assert.dom(wrapper).exists('the clipping wrapper is rendered'); + // The wrapper must clip (not scroll) so the toolbar's square bottom + // corners follow the editor's rounded outline when it docks at the + // bottom, while keeping position: sticky resolving to the outer panel. + // Assert both axes (the shorthand): the source sets `overflow: clip`, and + // it is both axes that must stay out of scroll-container territory — a + // stray `overflow-x: visible` would let the corners spill horizontally. + let overflow = getComputedStyle(wrapper); + assert.strictEqual( + overflow.overflowX, + 'clip', + 'the wrapper clips overflow-x', + ); + assert.strictEqual( + overflow.overflowY, + 'clip', + 'the wrapper clips overflow-y', + ); + // The clip only rounds the toolbar's corners because the wrapper itself + // carries a radius — delete that declaration and the clip has nothing to + // round off, and the reported square-corner bug returns with every other + // assertion here still green. Assert it is non-zero rather than a specific + // px, since the radius token is themeable per card. + assert.notStrictEqual( + getComputedStyle(wrapper).borderRadius, + '0px', + 'the wrapper is rounded, so the clip has corners to round off', + ); + // Toolbar + mount live inside the wrapper (so they get clipped)… + assert + .dom('[data-test-markdown-toolbar]', wrapper) + .exists('the toolbar is inside the clipping wrapper'); + assert + .dom('[data-test-codemirror-mount]', wrapper) + .exists('the editor mount is inside the clipping wrapper'); + // …but the wrapper is nested directly in the bordered/rounded editor. + assert + .dom(wrapper.parentElement) + .hasClass( + 'codemirror-editor', + 'the wrapper is a direct child of the editor container', + ); + }); + + test('the Add-embed dropdown escapes the corner-clipping wrapper so it stays visible when the toolbar docks at the bottom', async function (assert) { + 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: 'Edit me' }), + }); + let root = await renderCard(loader, card, 'edit'); + await waitFor('[data-test-codemirror-body]', { timeout: 5000 }); + + // No embed is referenced yet, so the toolbar shows the Add-embed trigger. + await click('[data-test-toolbar="add-embed"]'); + // BoxelDropdown renders the menu in the shared dropdown wormhole at the app + // root, so it lives outside `.codemirror-body` entirely — the `overflow: + // clip` corner box is not in its ancestor chain and cannot swallow it when + // the toolbar docks at the bottom, where an in-box menu would have zero + // room below it. Query the whole document since the menu is not under the + // card's render root. + let popover = document.querySelector('[data-test-toolbar-embed-popover]'); + let clipBox = root.querySelector('[data-test-codemirror-body]'); + assert.ok(popover, 'the Add-embed dropdown opens'); + assert + .dom('[data-test-codemirror-body]') + .exists('the corner-clip box is present'); + assert.notOk( + clipBox?.contains(popover), + 'the Add-embed menu renders outside the corner-clip box, so the clip cannot swallow it', + ); + }); + test('renders with null content without error', async function (assert) { class TestCard extends CardDef { @field body = contains(RichMarkdownField);