From 0be9701e45b2462683d78a1d418d1820a0644478 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Thu, 13 Aug 2026 16:16:25 +0700 Subject: [PATCH 1/4] Clip markdown editor toolbar corners to the rounded outline The rich-markdown editor's docked toolbar is a position: sticky bar with square bottom corners living inside the editor's rounded, bordered container. Because the container did not clip its children, scrolling to the bottom of the body pushed the sticky toolbar down to the container's bottom edge, where its square grey corners spilled past the rounded outline. Wrap the toolbar and editor mount in a `.codemirror-body` element with `overflow: clip` so the rounded corners clip the toolbar's bottom corners when it docks. `clip` (not `hidden`) avoids establishing a scroll container, so the toolbar keeps sticking to the outer scroll panel. The card-search and format-picker popovers stay outside this wrapper so they can still overflow the editor box. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/base/codemirror-editor.gts | 238 ++++++++++-------- .../components/rich-markdown-field-test.gts | 49 ++++ 2 files changed, 178 insertions(+), 109 deletions(-) diff --git a/packages/base/codemirror-editor.gts b/packages/base/codemirror-editor.gts index 7c0305e9484..45b994ee005 100644 --- a/packages/base/codemirror-editor.gts +++ b/packages/base/codemirror-editor.gts @@ -938,129 +938,132 @@ 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 +1172,23 @@ 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', + ); + // 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('renders with null content without error', async function (assert) { class TestCard extends CardDef { @field body = contains(RichMarkdownField); From c7ce9a8067fa86110526fe6053db2cc4b1ddc16a Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Fri, 14 Aug 2026 14:37:08 +0700 Subject: [PATCH 2/4] Clarify the embed-dropdown clip contract and pin it with a test Correct the .codemirror-body comment to state that the Add-embed dropdown is intentionally clipped inside the wrapper, while the card-search popup and format picker stay outside it. Add an integration test asserting the dropdown opens inside the clipping wrapper so the contract can't silently drift. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/base/codemirror-editor.gts | 9 ++--- .../components/rich-markdown-field-test.gts | 35 +++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/packages/base/codemirror-editor.gts b/packages/base/codemirror-editor.gts index 45b994ee005..fd97bcb57ab 100644 --- a/packages/base/codemirror-editor.gts +++ b/packages/base/codemirror-editor.gts @@ -1176,10 +1176,11 @@ 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; + + // No embed is referenced yet, so the toolbar shows the Add-embed trigger. + await click('[data-test-toolbar="add-embed"]'); + assert + .dom('[data-test-toolbar-embed-popover]', wrapper) + .exists( + 'the Add-embed dropdown lives inside the clipping wrapper — it drops ' + + 'into the tall editor mount so the clip is invisible in practice', + ); + }); + test('renders with null content without error', async function (assert) { class TestCard extends CardDef { @field body = contains(RichMarkdownField); From 8a88e2f363b4eb68d387c91db60cea8d9144a716 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Tue, 18 Aug 2026 21:34:39 +0700 Subject: [PATCH 3/4] Keep the markdown toolbar menus out of the corner-clip box The `overflow: clip` corner-clip on `.codemirror-body` also clipped the toolbar's two dropdown menus. When the sticky toolbar docks at the bottom there is no room left below it inside the clip, so both menus were swallowed while their triggers still looked normal. Move both out of the clip: the mode selector wormholes out of place (BoxelSelect without renderInPlace) and the Add-embed popover is now position: fixed, pinned under its trigger and repositioned on scroll. Correct the CSS comment that documented the old clipped behaviour, invert the Add-embed test to assert the menu escapes the clip, and add a border-radius assertion so deleting the radius fails the wrapper test. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/base/codemirror-editor.gts | 70 ++++++++++++++++--- .../markdown-editor-mode-select.gts | 6 +- .../components/rich-markdown-field-test.gts | 36 +++++++--- 3 files changed, 89 insertions(+), 23 deletions(-) diff --git a/packages/base/codemirror-editor.gts b/packages/base/codemirror-editor.gts index fd97bcb57ab..e2a0edd38e5 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; @@ -993,7 +1032,9 @@ export default class CodeMirrorEditor extends GlimmerComponent