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:
@@ -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" />
|
||||
}
|
||||
Reference in New Issue
Block a user