Skip to content

Add CodeMirror 6 editor with floating toolbar for RichMarkdownField (CS-10572)#4370

Draft
lukemelia wants to merge 28 commits intomainfrom
cs-10572-codemirror-integration
Draft

Add CodeMirror 6 editor with floating toolbar for RichMarkdownField (CS-10572)#4370
lukemelia wants to merge 28 commits intomainfrom
cs-10572-codemirror-integration

Conversation

@lukemelia
Copy link
Copy Markdown
Contributor

@lukemelia lukemelia commented Apr 9, 2026

Summary

  • Adds a CodeMirror 6 markdown editor for the RichMarkdownField with Obsidian-style live preview
  • CodeMirror edits markdown text directly — no intermediate document model, no parse/serialize layer, lossless by design
  • Includes a Notion-style floating toolbar that appears on text selection with formatting actions

Floating Toolbar

Appears above selected text with buttons for:

  • Inline formatting: Bold, Italic, Strikethrough, Code, Link
  • Headings: H1, H2, H3
  • Block formatting: Bullet List, Numbered List, Blockquote

Positioned with @floating-ui/dom for robust viewport-aware placement — auto-hides when the selection scrolls out of view.

Live Preview

  • Heading markers (#) hidden when cursor is off-line, shown when editing
  • Bold, italic, strikethrough, and code rendered inline with markers hidden
  • Links show clickable text with [](url) syntax hidden until cursor is on the line
  • Card references rendered as live card previews via widget decorations
  • Horizontal rules rendered as <hr> elements
  • Code blocks styled with background and fence markers

Screenshots

toolbar-bold-active

Design

We'll do a design pass after this.

Features

  • Notion-style floating toolbar for formatting actions
  • Slash command (/card) → card search → format picker (inline/block)
  • Live card previews inside the editor via widget decorations
  • Markdown formatting shortcuts (Mod-B, Mod-I, Mod-`)
  • Undo/redo via @codemirror/commands
  • Debounced save (500ms)
  • Lazy-loaded via globalThis.__loadCodeMirror pattern

Key files

  • packages/host/app/lib/codemirror-context.ts — lazy-loaded CM6 engine
  • packages/base/codemirror-editor.gts — Glimmer editor component
  • packages/host/tests/integration/components/codemirror-editor-test.gts — 41 tests

Test plan

  • pnpm install succeeds
  • Lint passes on host and base packages
  • All 41 codemirror-editor-test tests pass
  • Open RichMarkdownPlayground in edit mode — CodeMirror editor renders
  • Select text — floating toolbar appears with correct active states
  • Toolbar buttons toggle formatting (bold, italic, strikethrough, code, link, headings, lists, blockquote)
  • Type /card — slash command triggers card search flow
  • Card references show widget previews with live card rendering
  • Mod-B/I/` formatting shortcuts work on selections
  • Toolbar hides when selection scrolls out of visible area
  • Content round-trips perfectly (save, reload, content is identical)

🤖 Generated with Claude Code

@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 9, 2026

Host Test Results

0 tests   - 2 194   0 ✅  - 2 179   0s ⏱️ - 2h 22m 37s
0 suites  -     1   0 💤  -    15 
0 files    -     1   0 ❌ ±    0 

Results for commit c714a32. ± Comparison against base commit 2a8cb5f.

♻️ This comment has been updated with latest results.

@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 9, 2026

Preview deployments

@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 9, 2026

Realm Server Test Results

  1 files  ±0    1 suites  ±0   13m 18s ⏱️ - 1m 15s
844 tests ±0  844 ✅ ±0  0 💤 ±0  0 ❌ ±0 
915 runs  ±0  915 ✅ ±0  0 💤 ±0  0 ❌ ±0 

Results for commit c714a32. ± Comparison against base commit 2a8cb5f.

♻️ This comment has been updated with latest results.

@lukemelia lukemelia changed the title Add CodeMirror 6 editor for RichMarkdownField (CS-10572) Add CodeMirror 6 editor with floating toolbar for RichMarkdownField (CS-10572) Apr 9, 2026
@lukemelia lukemelia requested a review from Copilot April 10, 2026 02:26
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR introduces a CodeMirror 6–based markdown editing experience for RichMarkdownField, including Obsidian-style live preview behavior and a Notion-style floating formatting toolbar, with lazy-loading via a host-provided globalThis.__loadCodeMirror entrypoint.

Changes:

  • Add a lazy-loaded CodeMirror 6 “engine” module in the host app and wire it into the application route.
  • Add a new CodeMirrorEditor component in @cardstack/base and update RichMarkdownField edit UI with Edit/Source/Preview mode switching.
  • Add/adjust integration tests and playground content to exercise the new editor, toolbar, and mode behavior.

Reviewed changes

Copilot reviewed 13 out of 14 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
pnpm-workspace.yaml Adds CodeMirror/Lezer packages to the workspace catalog.
pnpm-lock.yaml Locks new CodeMirror/Lezer dependency graph.
packages/host/package.json Adds CodeMirror/Lezer dependencies for the host app.
packages/base/package.json Adds @floating-ui/dom for the base editor UI (toolbar positioning).
packages/host/app/routes/application.ts Registers globalThis.__loadCodeMirror lazy loader and cleans it up on destroy.
packages/host/app/lib/externals.ts Shims @floating-ui/dom into the virtual network for realm-loaded code.
packages/host/app/lib/codemirror-context.ts New lazy-loaded CodeMirror context module (state, decorations, card widgets, slash command).
packages/base/codemirror-editor.gts New Glimmer component for CodeMirror editor, floating toolbar, and card search/insert flows.
packages/base/rich-markdown.gts Updates RichMarkdownField edit UI to use CodeMirror + adds Edit/Source/Preview mode switcher.
packages/base/default-templates/markdown.gts Expands content arg type to allow undefined.
packages/host/tests/integration/components/rich-markdown-field-test.gts Updates expectations for CodeMirror editor and adds mode switcher tests.
packages/host/tests/integration/components/codemirror-editor-test.gts Adds extensive integration tests around the CodeMirror context behaviors.
packages/experiments-realm/RichMarkdownPlayground/playground.json Adds playground content exercising features (card refs, code blocks, formatting).
packages/experiments-realm/rich-markdown-playground.gts Updates playground copy/styles to guide users toward Edit/Preview behavior.
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

<div class='rich-markdown-preview' data-test-markdown-preview>
<MarkdownTemplate
@content={{@model.content}}
@linkedCards={{@model.linkedCards}}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is no longer applicable — the mode switcher (and its Preview branch) was removed from rich-markdown.gts during a subsequent refactor. The edit template now delegates entirely to <@fields.content />.

Comment on lines +885 to +905

if (
selected.startsWith(marker) &&
selected.endsWith(marker) &&
selected.length >= marker.length * 2
) {
view.dispatch({
changes: {
from,
to,
insert: selected.slice(marker.length, -marker.length),
},
});
} else {
view.dispatch({
changes: { from, to, insert: marker + selected + marker },
selection: {
anchor: from + marker.length,
head: to + marker.length,
},
});
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good observation. The wrapWith function works for keyboard shortcuts where the selection naturally includes markers in the document text. For the toolbar, the pre-bound _toolbarAction method delegates to wrapWith, which does have this limitation in live preview mode. The _toggleLink method already handles this by scanning for enclosing [text](url) boundaries. Extending the same pattern to bold/italic/strikethrough/code is tracked as a follow-up improvement.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 2743e6b. wrapWith now checks for markers adjacent to the selection (not just inside it). In live preview mode where markers are hidden, selecting just the visible text (e.g. "bold" instead of "bold") will now correctly toggle off the formatting by detecting and removing the adjacent markers.

Comment on lines +930 to +934
<span class='format-picker-label'>
Insert "{{this._formatPickerCardTitle}}" as:
</span>
<div class='format-picker-buttons'>
<button
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 1e408c9. All toolbar, card search, and format picker buttons now use click for activation (keyboard accessible via Enter/Space) with a separate mousedown handler that calls preventDefault() to preserve editor selection.

class='codemirror-card-search-result
{{if (eq index this._cardSearchIndex) "selected"}}'
data-test-card-search-result
{{on 'mousedown' (fn this._selectCardResult card)}}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 1e408c9. Card search result buttons now use click for activation with mousedown preventDefault() to preserve focus.

Comment on lines +804 to +808
<button
class='toolbar-btn {{if this.toolbarFormats.bold "toolbar-btn--active"}}'
data-test-toolbar-bold
title='Bold'
{{on 'mousedown' this._wrapBold}}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 1e408c9. All toolbar buttons now use click for activation (keyboard accessible) with mousedown preventDefault() to preserve editor selection.

Comment on lines +807 to +825
title='Bold'
{{on 'mousedown' this._wrapBold}}
><BoldIcon @width='16' @height='16' /></button>
<button
class='toolbar-btn {{if this.toolbarFormats.italic "toolbar-btn--active"}}'
data-test-toolbar-italic
title='Italic'
{{on 'mousedown' this._wrapItalic}}
><ItalicIcon @width='16' @height='16' /></button>
<button
class='toolbar-btn {{if this.toolbarFormats.strikethrough "toolbar-btn--active"}}'
data-test-toolbar-strikethrough
title='Strikethrough'
{{on 'mousedown' this._wrapStrikethrough}}
><StrikethroughIcon @width='16' @height='16' /></button>
<button
class='toolbar-btn {{if this.toolbarFormats.code "toolbar-btn--active"}}'
data-test-toolbar-code
title='Code'
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 1e408c9. All icon-only toolbar buttons now have aria-label attributes, and toggle buttons (bold, italic, strikethrough, code, link) also have aria-pressed for assistive tech.

Comment on lines +219 to +221
let input = document.querySelector(
'[data-test-card-search-input]',
) as HTMLInputElement;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 1e408c9. The query is now scoped to this.editorView.dom.parentElement (falling back to document if unavailable), so multiple editor instances will each focus their own search input.

Comment on lines +402 to +406
// We test the formatting by dispatching the same text wrapping
let { from, to } = view.state.selection.main;
let selected = view.state.sliceDoc(from, to);
view.dispatch({
changes: { from, to, insert: `**${selected}**` },
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 1e408c9. The test now exercises the actual cmContext.wrapWith('**')(view) command instead of manually dispatching a text replacement.

lukemelia and others added 23 commits April 10, 2026 10:02
Parallel implementation to the ProseMirror branch for comparison.
CodeMirror edits markdown text directly — no intermediate document
model, no parse/serialize layer, lossless by design. Card previews
use CM6's decoration/widget system.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
CM6 plugins cannot provide replace decorations that span line breaks.
Switch block cards to widget + mark approach: source text is hidden
via CSS when not on cursor line, widget preview shown after the text.
Also fix regex to avoid matching newline characters in \s*.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
CM6 disallows block widget decorations from plugins entirely.
Use inline widgets with block styling via CSS instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The [^\]] character class in card reference regexes matched newline
characters, causing Decoration.mark() to span across lines — which
CM6 plugins are not allowed to do. Changed to [^\]\n] and added a
safety guard that skips any mark whose range contains a newline.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
CM6 restricts plugin-provided decorations from replacing line breaks,
but state-field decorations have no such restriction. Move card
decorations to a StateField and keep a separate ViewPlugin only for
DOM target notification (which provides no decorations).

Also adds tests for complex documents with mixed card refs, code
blocks, and formatting to catch this class of error.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Pipe-separated size spec test: add preceding line so cursor is not
  on the card ref line (block widgets are hidden when cursor is on
  the same line)
- Block card insertion test: fix out-of-bounds position (from: 9 for
  doc of length 8)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Two issues:

1. Debounced save echo: when onUpdate fires after 500ms, the parent
   sets @model.content which changes this.args.content. The modifier
   re-runs, sees content !== lastExternalContent, and destroys/
   recreates the editor. Fix: update lastExternalContent in onDocChange
   so the save echo is recognized as the editor's own change.

2. Unnecessary Glimmer re-renders: _widgetTargets was set on every
   keystroke even when targets hadn't changed, causing {{#in-element}}
   re-renders into CM6's DOM. Fix: shallow equality check before
   updating the tracked property.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The Ember modifier lifecycle calls the cleanup function before every
re-run. When args.content changed (debounced save echo), the cleanup
destroyed the EditorView, losing focus. The re-run then created a
new editor without focus.

Fix: move editor destruction to willDestroy(). The modifier cleanup
now only clears the debounce timer. On modifier re-runs triggered by
save echoes, the early return keeps the existing editor intact.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Rewrites the decoration system to use the Lezer markdown syntax tree
for cursor-aware rendering: headings, bold/italic/code, links, code
blocks, blockquotes, horizontal rules, and lists all render with
formatted styling, revealing raw markdown syntax only when the cursor
is on the relevant line. Card references now use Decoration.replace()
with actual card widgets instead of pill placeholders.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds a segmented control to RichMarkdownField edit template with three
modes: Edit (live preview with cursor-aware syntax hiding), Source (all
syntax visible), and Preview (rendered markdown). Includes focus-aware
decoration tracking so unfocused editors hide all markers, and exports
focusChangeEffect for testability. Adds 12 new tests covering source
mode decorations, focus behavior, mode switching UI, and preview
rendering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Cards referenced via :card[URL] and ::card[URL] now render as actual
card components inside the editor. When the cursor is on a card
reference line, both the editable source syntax and the rendered card
preview are shown so the author can verify the URL resolves correctly.

Key changes:
- Use getCards from CardContext to resolve card URLs independently,
  bypassing the FallbackCardStore issue where linkedCards returns empty
  for nested FieldDef instances
- Add white-space: normal to card widget containers to prevent CM6's
  break-spaces from inflating card component layouts
- Show both raw syntax and card preview when cursor is on card line
- Update playground to use existing Author card references

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds a floating toolbar that appears above text selections with formatting
buttons (Bold, Italic, Strikethrough, Code, H1, H2, H3). The toolbar uses
position:fixed with viewport coords from coordsAtPos(), tracks scroll via
a listener on the card container, and hides when the selection scrolls out
of view. Also exports wrapWith and adds selection change tracking to the
editor state factory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace manual coord computation, scroll listeners, and visibility checks
with @floating-ui/dom's computePosition + autoUpdate. The positionToolbar
modifier creates a virtual element from the CM6 selection and lets
floating-ui handle placement (top with flip/shift), scroll tracking,
and repositioning. Hides the toolbar when the selection scrolls out of
the card container's visible bounds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds three new toolbar buttons with a _toggleLinePrefix action that
prepends/removes a prefix on all selected lines. Supports multi-line
selections and toggling off when all lines already have the prefix.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tests wrapWith toggling (bold, italic, strikethrough, code), onSelectionChange
callback, heading insertion/removal/switching, and line prefix toggling for
bullet lists, numbered lists, and blockquotes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add Link button that wraps selection in [text](url) with cursor on the
  url placeholder; unwraps by scanning for enclosing link boundaries so
  it works even when markdown syntax is hidden in live preview
- Render ~~strikethrough~~ with line-through styling in live preview mode,
  hiding ~~ markers when cursor is off-line (same pattern as bold/italic)
- Add tests for link wrap/unwrap

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Remove unused SLASH_COMMANDS, prefix unused params with underscore
- Add `false | void` return types to tree.iterate enter callbacks (TS7030)
- Fix fn helper type errors by adding optional event params (TS2554)
- Fix KeyboardEvent/Event mismatch in card search keydown handler (TS2345)
- Accept `string | undefined` in content args for MarkdownTemplate and
  CodeMirrorEditor signatures (TS2322)
- Replace @Tracked decorator in static class expression with TrackedObject (TS1206)
- Fix import order for @floating-ui/dom in externals.ts
- Fix prettier formatting in codemirror-context.ts
- Fix qunit/no-assert-logical-expression in rich-markdown-field tests
- Add ts-expect-error for dynamic import in application.ts (TS2307)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Glint's fn helper produces a zero-arg function when all params are
curried, but the on modifier passes the event as an argument. Use
pre-bound arrow properties (_wrapBold, _insertH1, _toggleBulletList,
etc.) that call the underlying method directly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Guard async callbacks (onDocChange, onSelectionChange, onCardTargetsChange,
  onOpenCardSearch) with isDestroying/isDestroyed checks to prevent updates
  to destroyed components
- Cancel pending requestAnimationFrame in ViewPlugin's destroy() method and
  deduplicate rapid rAF scheduling
- Clear _widgetTargets, _pendingTargets, _selectionInfo, and _cm references
  in willDestroy() to release DOM elements and large objects
- Guard debounced save setTimeout callback against component destruction

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Replace this.args.content/onUpdate with @content/@onUpdate (no-args-paths)
- Add aria-label to card search input (require-input-label)
- Disable no-pointer-down-event-binding for toolbar, card search, and
  format picker sections (mousedown is required to prevent editor from
  losing focus/selection before actions run)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
lukemelia and others added 5 commits April 10, 2026 10:02
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The boxel-icons Signature has no Args, so @width/@height caused Glint
TS2554 errors. Use plain HTML attributes which pass through ...attributes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Toolbar/search/picker buttons: use click for activation (keyboard
  accessible) with mousedown preventDefault to preserve editor selection
- Add aria-label and aria-pressed to icon-only toolbar buttons
- Scope card search input focus query to editor container
- Fix test to exercise wrapWith command instead of manual dispatch

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…view)

In live preview mode, markers like ** are hidden by decorations, so users
select just the visible text. wrapWith now checks for markers adjacent to
the selection and removes them, matching the toggle-off behavior users
expect from the toolbar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@lukemelia lukemelia force-pushed the cs-10572-codemirror-integration branch from 2a7fbbf to c714a32 Compare April 10, 2026 14:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants