diff --git a/packages/base/codemirror-editor.gts b/packages/base/codemirror-editor.gts index 7c0305e9484..a7be93b61ae 100644 --- a/packages/base/codemirror-editor.gts +++ b/packages/base/codemirror-editor.gts @@ -527,15 +527,54 @@ export default class CodeMirrorEditor extends GlimmerComponent { - this._embedPopoverOpen = !this._embedPopoverOpen; + // Pin the popover directly under the trigger, in viewport coordinates. The + // trigger is inside a sticky toolbar, so this is recomputed on scroll/resize + // (see `_trackEmbedPopover`) to keep the two glued together. + _positionEmbedPopover = () => { + let trigger = this._embedTriggerEl; + if (!trigger) return; + let r = trigger.getBoundingClientRect(); + this._embedPopoverStyle = `position: fixed; top: ${r.bottom + 4}px; left: ${r.left}px;`; }; + _toggleEmbedPopover = (e: Event) => { + if (this._embedPopoverOpen) { + this._embedPopoverOpen = false; + return; + } + this._embedTriggerEl = e.currentTarget as HTMLElement; + this._positionEmbedPopover(); + this._embedPopoverOpen = true; + }; + + // While the popover is open, keep it pinned under the trigger as the outer + // panel scrolls or the window resizes. Installed on the popover element, so + // its lifecycle tracks the popover's presence in the DOM. + _trackEmbedPopover = modifier(() => { + this._positionEmbedPopover(); + let reposition = () => this._positionEmbedPopover(); + window.addEventListener('scroll', reposition, { + capture: true, + passive: true, + }); + window.addEventListener('resize', reposition); + return () => { + window.removeEventListener('scroll', reposition, { capture: true }); + window.removeEventListener('resize', reposition); + }; + }); + _openEmbedChooser = async (defaultTab: 'card' | 'file') => { this._embedPopoverOpen = false; let chooser = this.cardContext?.markdownEmbedChooser; @@ -938,129 +977,134 @@ 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 +
+ + <:trigger> + + + <:content> + + Add 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> - - - <:content> - - {{btn.label}} - {{#if btn.shortcut}} - {{btn.shortcut}} - {{/if}} - - - - {{/if}} - {{/each}} + + <:trigger> + + + <:content> + + {{btn.label}} + {{#if btn.shortcut}} + {{btn.shortcut}} + {{/if}} + + + + {{/if}} + {{/each}} +
+ + {{! template-lint-disable no-invalid-interactive }} +
- - {{! template-lint-disable no-invalid-interactive }} -
{{#if this.livePreview}} @@ -1169,6 +1213,32 @@ export default class CodeMirrorEditor extends GlimmerComponent { + + }; + } + + 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.strictEqual( + getComputedStyle(wrapper).overflowY, + 'clip', + 'the wrapper clips its overflow', + ); + // 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"]'); + let popover = root.querySelector( + '[data-test-toolbar-embed-popover]', + ) as HTMLElement; + assert.dom(popover).exists('the Add-embed dropdown opens'); + // The dropdown must not be swallowed by `.codemirror-body`'s `overflow: + // clip`: when the toolbar docks at the bottom there is zero room below it + // inside that box. `position: fixed` roots the dropdown to the viewport — + // a box the clip cannot reach — so it stays visible and clickable. An + // absolute dropdown (its containing block inside the clip) would be cut off. + assert.strictEqual( + getComputedStyle(popover).position, + 'fixed', + 'the Add-embed dropdown is fixed-positioned so the corner clip cannot swallow it', + ); + }); + test('renders with null content without error', async function (assert) { class TestCard extends CardDef { @field body = contains(RichMarkdownField);