Add CodeMirror 6 editor with floating toolbar for RichMarkdownField (CS-10572)#4370
Add CodeMirror 6 editor with floating toolbar for RichMarkdownField (CS-10572)#4370
Conversation
Preview deployments |
There was a problem hiding this comment.
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
CodeMirrorEditorcomponent in@cardstack/baseand updateRichMarkdownFieldedit 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}} |
There was a problem hiding this comment.
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 />.
|
|
||
| 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, | ||
| }, | ||
| }); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| <span class='format-picker-label'> | ||
| Insert "{{this._formatPickerCardTitle}}" as: | ||
| </span> | ||
| <div class='format-picker-buttons'> | ||
| <button |
There was a problem hiding this comment.
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.
packages/base/codemirror-editor.gts
Outdated
| class='codemirror-card-search-result | ||
| {{if (eq index this._cardSearchIndex) "selected"}}' | ||
| data-test-card-search-result | ||
| {{on 'mousedown' (fn this._selectCardResult card)}} |
There was a problem hiding this comment.
Fixed in 1e408c9. Card search result buttons now use click for activation with mousedown preventDefault() to preserve focus.
packages/base/codemirror-editor.gts
Outdated
| <button | ||
| class='toolbar-btn {{if this.toolbarFormats.bold "toolbar-btn--active"}}' | ||
| data-test-toolbar-bold | ||
| title='Bold' | ||
| {{on 'mousedown' this._wrapBold}} |
There was a problem hiding this comment.
Fixed in 1e408c9. All toolbar buttons now use click for activation (keyboard accessible) with mousedown preventDefault() to preserve editor selection.
| 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' |
There was a problem hiding this comment.
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.
packages/base/codemirror-editor.gts
Outdated
| let input = document.querySelector( | ||
| '[data-test-card-search-input]', | ||
| ) as HTMLInputElement; |
There was a problem hiding this comment.
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.
| // 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}**` }, |
There was a problem hiding this comment.
Fixed in 1e408c9. The test now exercises the actual cmContext.wrapWith('**')(view) command instead of manually dispatching a text replacement.
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>
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>
2a7fbbf to
c714a32
Compare
Summary
RichMarkdownFieldwith Obsidian-style live previewFloating Toolbar
Appears above selected text with buttons for:
Positioned with
@floating-ui/domfor robust viewport-aware placement — auto-hides when the selection scrolls out of view.Live Preview
#) hidden when cursor is off-line, shown when editing[](url)syntax hidden until cursor is on the line<hr>elementsScreenshots
Design
We'll do a design pass after this.
Features
/card) → card search → format picker (inline/block)@codemirror/commandsglobalThis.__loadCodeMirrorpatternKey files
packages/host/app/lib/codemirror-context.ts— lazy-loaded CM6 enginepackages/base/codemirror-editor.gts— Glimmer editor componentpackages/host/tests/integration/components/codemirror-editor-test.gts— 41 testsTest plan
pnpm installsucceedscodemirror-editor-testtests pass/card— slash command triggers card search flow🤖 Generated with Claude Code