Files
rfc-app/frontend/src/components/Editor.jsx
T
Ben Stull 13d59b5d26 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>
2026-05-25 09:49:17 -07:00

135 lines
4.3 KiB
React

// Editor.jsx — the Discuss-mode read-only renderer (and main/no-contribute
// fallback). Tiptap on ProseMirror per §18; the Contribute-mode editing
// surface moved to MarkdownSourceEditor (CodeMirror 6) in the Phase 1
// rewrite of Contribute, leaving this surface for read-only rendering
// plus the §8.12 selection tooltip's coords.
//
// One ProseMirror plugin still lives alongside StarterKit:
//
// • selectionHighlight — keeps a selected passage highlighted while
// focus moves to the §8.12 selection tooltip. Driven by meta
// transactions dispatched from the parent.
//
// The paragraph-margin gutter accent that used to live here is dropped;
// Phase 4 of the Contribute rewrite adds a proper change-anchored gutter
// against the CodeMirror raw pane.
import { useEditor, EditorContent, Extension } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { useEffect, useRef, useCallback } from 'react'
import { marked } from 'marked'
import { Plugin, PluginKey } from 'prosemirror-state'
import { Decoration, DecorationSet } from 'prosemirror-view'
// ── Selection highlight plugin ────────────────────────────────────────────
export const selectionHighlightKey = new PluginKey('selectionHighlight')
function makeSelectionHighlightPlugin() {
return new Plugin({
key: selectionHighlightKey,
state: {
init: () => null,
apply(tr, prev) {
const meta = tr.getMeta(selectionHighlightKey)
return meta !== undefined ? meta : prev
},
},
props: {
decorations(state) {
const range = selectionHighlightKey.getState(state)
if (!range || range.from >= range.to) return DecorationSet.empty
try {
return DecorationSet.create(state.doc, [
Decoration.inline(range.from, range.to, { class: 'selection-highlight' }),
])
} catch {
return DecorationSet.empty
}
},
},
})
}
function SelectionHighlightExtension() {
return Extension.create({
name: 'selectionHighlight',
addProseMirrorPlugins() {
return [makeSelectionHighlightPlugin()]
},
})
}
// ── Editor component ──────────────────────────────────────────────────────
export default function Editor({
content,
editorRef,
onSelectionChange,
onUpdate,
editable = true,
}) {
const isMouseDownRef = useRef(false)
const reportSelection = useCallback((editor) => {
if (isMouseDownRef.current) return
const { from, to } = editor.state.selection
if (from !== to) {
const text = editor.state.doc.textBetween(from, to, ' ')
const coords = editor.view.coordsAtPos(from)
onSelectionChange?.({ text, coords, from, to })
} else {
onSelectionChange?.(null)
}
}, [onSelectionChange])
const editor = useEditor({
extensions: [
StarterKit,
SelectionHighlightExtension(),
],
content: '<p></p>',
editable,
onUpdate: ({ editor }) => {
onUpdate?.(editor.getText(), editor.getHTML())
},
onSelectionUpdate: ({ editor }) => {
reportSelection(editor)
},
})
// Expose editor instance to the parent.
useEffect(() => {
if (editorRef) editorRef.current = editor
}, [editor, editorRef])
useEffect(() => {
if (editor) editor.setEditable(editable)
}, [editor, editable])
useEffect(() => {
if (!editor) return
const el = editor.view.dom
const onMouseDown = () => { isMouseDownRef.current = true; onSelectionChange?.(null) }
const onMouseUp = () => { isMouseDownRef.current = false; reportSelection(editor) }
el.addEventListener('mousedown', onMouseDown)
document.addEventListener('mouseup', onMouseUp)
return () => {
el.removeEventListener('mousedown', onMouseDown)
document.removeEventListener('mouseup', onMouseUp)
}
}, [editor, onSelectionChange, reportSelection])
useEffect(() => {
if (!editor || content == null) return
const html = marked.parse(content)
editor.commands.setContent(html, false)
}, [content, editor])
return (
<div className="editor-wrapper">
<EditorContent editor={editor} className="editor-content" />
</div>
)
}