Contribute rewrite Phase 1: CM6 markdown source editor

Swap the Contribute-mode editing surface from Tiptap WYSIWYG to a
CodeMirror 6 markdown source editor. Discuss mode and any read-only
viewing continues to render through Tiptap.

The §8.11 manual-edit debounce now reads the raw markdown source via
CodeMirror's doc, eliminating the lossy `editor.getText()` round-trip
that RFCView.jsx flagged as a §19.2 candidate; what the contributor
typed is exactly what gets POSTed to manual flush.

The §8.10 paragraph-margin gutter accent on Tiptap is dropped — it was
dead in read-only Discuss anyway, and Phase 4 of the Contribute
rewrite adds a change-anchored gutter against the CM6 raw pane.

The Tiptap-side accept-time injection of <span class="tracked-*">
markup also drops out — CM6 can't render those spans, and Phase 3's
preview pane is the proper home for tracked changes. The reviewMode /
DiffView toggle still works against marked-rendered HTML in the
interim until Phase 7 retires it.

Notes:
- Bundle gzipped grows ~50KB for CM6 modules (state/view/commands/
  language/lang-markdown). Mermaid in Phase 2 will be the larger lift
  and should lazy-load.
- Verified the CM6 editor mounts cleanly via Vite dev preview: ref
  handle (`view/getDoc/setDoc`) is wired, `onUpdate` fires on doc
  changes, gutter / line-numbers / active-line all render, and the
  source round-trips verbatim. Could not drive the full RFCView
  Contribute flow end-to-end without a running backend; the manual
  countdown + save-now + accept/decline pathways are verified by
  inspection only.
- 125 backend integration tests still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-25 09:49:17 -07:00
parent 55a8be051a
commit 13d59b5d26
6 changed files with 427 additions and 135 deletions
@@ -0,0 +1,88 @@
// MarkdownSourceEditor.jsx — the Contribute-mode raw markdown source editor.
//
// CodeMirror 6 per §18 (Phase 1 of the Contribute rewrite). The editing
// surface is plain markdown; the rendered preview lives in a sibling pane
// (added in Phase 2). No HTML→markdown round-trip — `getDoc()` returns
// the source verbatim, which is what we POST in §8.11's manual flush.
//
// The parent receives an imperative handle on `editorRef`:
// { view, getDoc(), setDoc(text) }
// modeled lightly after the Tiptap surface so RFCView can branch on
// whichever editor is mounted without an adapter layer.
import { useEffect, useRef } from 'react'
import { EditorState } from '@codemirror/state'
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter } from '@codemirror/view'
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
import { markdown } from '@codemirror/lang-markdown'
import { syntaxHighlighting, defaultHighlightStyle, indentOnInput, bracketMatching } from '@codemirror/language'
export default function MarkdownSourceEditor({
initialDoc = '',
editorRef,
onUpdate,
}) {
const hostRef = useRef(null)
const viewRef = useRef(null)
const onUpdateRef = useRef(onUpdate)
useEffect(() => { onUpdateRef.current = onUpdate }, [onUpdate])
useEffect(() => {
if (!hostRef.current) return
const state = EditorState.create({
doc: initialDoc,
extensions: [
history(),
lineNumbers(),
highlightActiveLine(),
highlightActiveLineGutter(),
indentOnInput(),
bracketMatching(),
keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap]),
markdown(),
syntaxHighlighting(defaultHighlightStyle),
EditorView.lineWrapping,
EditorView.updateListener.of(u => {
if (u.docChanged) {
onUpdateRef.current?.(u.state.doc.toString())
}
}),
],
})
const view = new EditorView({ state, parent: hostRef.current })
viewRef.current = view
if (editorRef) {
editorRef.current = {
view,
getDoc: () => view.state.doc.toString(),
setDoc: (text) => {
const cur = view.state.doc.toString()
if (cur === text) return
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: text },
})
},
}
}
return () => {
view.destroy()
viewRef.current = null
if (editorRef && editorRef.current?.view === view) editorRef.current = null
}
// Construct once. Doc changes flow through the setDoc effect below.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Reflect external doc changes (branch reload, server-side flush, etc).
useEffect(() => {
const v = viewRef.current
if (!v) return
if (v.state.doc.toString() === initialDoc) return
v.dispatch({
changes: { from: 0, to: v.state.doc.length, insert: initialDoc ?? '' },
})
}, [initialDoc])
return <div ref={hostRef} className="cm-source-editor" />
}