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:
@@ -34,7 +34,9 @@ import {
|
||||
streamChatTurn,
|
||||
} from '../api'
|
||||
import Editor, { selectionHighlightKey } from './Editor.jsx'
|
||||
import MarkdownSourceEditor from './MarkdownSourceEditor.jsx'
|
||||
import SelectionTooltip from './SelectionTooltip.jsx'
|
||||
import { marked } from 'marked'
|
||||
import PromptBar from './PromptBar.jsx'
|
||||
import ChatPanel from './ChatPanel.jsx'
|
||||
import ChangePanel from './ChangePanel.jsx'
|
||||
@@ -51,6 +53,15 @@ function debounce(fn, ms) {
|
||||
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms) }
|
||||
}
|
||||
|
||||
// Split markdown source into paragraph-ish blocks on blank lines for the
|
||||
// §8.11 manual-edit count. Headings and lists each count as one block.
|
||||
function splitSourceParagraphs(md) {
|
||||
return (md || '')
|
||||
.split(/\n\s*\n/)
|
||||
.map(p => p.trim())
|
||||
.filter(p => p.length > 0)
|
||||
}
|
||||
|
||||
export default function RFCView({ viewer }) {
|
||||
const { slug } = useParams()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
@@ -66,8 +77,11 @@ export default function RFCView({ viewer }) {
|
||||
const [selectedModel, setSelectedModel] = useState('')
|
||||
|
||||
// Editor state — owned here so accept/decline can mutate it.
|
||||
// editorRef holds either a Tiptap instance (Discuss / read-only) or a
|
||||
// MarkdownSourceEditor handle ({view, getDoc, setDoc}) (Contribute);
|
||||
// call sites branch on shape.
|
||||
const editorRef = useRef(null)
|
||||
const originalParagraphsRef = useRef([])
|
||||
const originalSourceLinesRef = useRef([])
|
||||
const [editorContent, setEditorContent] = useState('')
|
||||
|
||||
// Selection + tooltip + selection highlight per §8.12.
|
||||
@@ -132,6 +146,7 @@ export default function RFCView({ viewer }) {
|
||||
.then(view => {
|
||||
setBranchView(view)
|
||||
setEditorContent(view.body || '')
|
||||
originalSourceLinesRef.current = splitSourceParagraphs(view.body || '')
|
||||
setChanges(view.changes || [])
|
||||
})
|
||||
.catch(err => setError(err.message))
|
||||
@@ -152,21 +167,23 @@ export default function RFCView({ viewer }) {
|
||||
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current
|
||||
if (!editor?.view) return
|
||||
// Only Tiptap exposes `state.tr` — when CM6 is mounted, the selection
|
||||
// highlight has no surface to render against, so skip the dispatch.
|
||||
if (!editor?.view || !editor?.state?.tr) return
|
||||
editor.view.dispatch(editor.state.tr.setMeta(selectionHighlightKey, highlightRange))
|
||||
}, [highlightRange])
|
||||
|
||||
// Manual-edit debounced upsert per §8.11 — produces a pending manual
|
||||
// card with a live countdown and an explicit Save now.
|
||||
// card with a live countdown and an explicit Save now. With the CM6
|
||||
// markdown source editor (Phase 1), the doc IS the markdown — no more
|
||||
// HTML→getText round-trip, so §19.2 lossiness is gone.
|
||||
const flushManualBuffer = useCallback(async () => {
|
||||
const editor = editorRef.current
|
||||
if (!editor || !branchView || mode !== 'contribute') return
|
||||
const text = editor.getText()
|
||||
// Convert to a rough markdown by stripping HTML — for v1 we round-trip
|
||||
// through the editor's getText; this matches the prototype's behavior.
|
||||
// A faithful HTML→markdown round-trip is a §19.2 candidate.
|
||||
const newContent = text.trim() + '\n'
|
||||
if (!newContent || newContent.trim() === (branchView.body || '').trim()) {
|
||||
if (typeof editor.getDoc !== 'function') return
|
||||
const raw = editor.getDoc()
|
||||
const newContent = raw.endsWith('\n') ? raw : raw + '\n'
|
||||
if (newContent.trim() === (branchView.body || '').trim()) {
|
||||
setManualPending(null)
|
||||
return
|
||||
}
|
||||
@@ -179,6 +196,7 @@ export default function RFCView({ viewer }) {
|
||||
const fresh = await getBranch(slug, branchParam)
|
||||
setBranchView(fresh)
|
||||
setChanges(fresh.changes || [])
|
||||
originalSourceLinesRef.current = splitSourceParagraphs(fresh.body || '')
|
||||
setManualPending(null)
|
||||
loadAllMessages(slug, branchParam, fresh.threads).then(setMessages)
|
||||
}
|
||||
@@ -187,22 +205,15 @@ export default function RFCView({ viewer }) {
|
||||
}
|
||||
}, [slug, branchParam, branchView, mode, manualPending?.paragraphCount])
|
||||
|
||||
const handleEditorUpdate = useMemo(() => debounce((plainText) => {
|
||||
const handleEditorUpdate = useMemo(() => debounce((doc) => {
|
||||
if (mode !== 'contribute' || !branchView) return
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
const currentParagraphs = []
|
||||
editor.state.doc.descendants(node => {
|
||||
if (node.type.name === 'paragraph' || node.type.name === 'heading') {
|
||||
currentParagraphs.push(node.textContent.trim())
|
||||
}
|
||||
})
|
||||
const baseline = originalParagraphsRef.current || []
|
||||
const current = splitSourceParagraphs(doc)
|
||||
const baseline = originalSourceLinesRef.current || []
|
||||
const len = Math.max(current.length, baseline.length)
|
||||
let changed = 0
|
||||
currentParagraphs.forEach((t, i) => {
|
||||
const orig = (baseline[i] ?? '').trim()
|
||||
if (t !== orig) changed++
|
||||
})
|
||||
for (let i = 0; i < len; i++) {
|
||||
if ((current[i] ?? '') !== (baseline[i] ?? '')) changed++
|
||||
}
|
||||
if (changed > 0) {
|
||||
setManualPending({ paragraphCount: changed })
|
||||
setManualCountdown({ deadline: Date.now() + MANUAL_IDLE_MS })
|
||||
@@ -347,24 +358,16 @@ export default function RFCView({ viewer }) {
|
||||
// ── Accept / decline / reask ──────────────────────────────────────────
|
||||
const handleAccept = useCallback(async ({ change, proposed, wasEdited }) => {
|
||||
try {
|
||||
const { commit_sha } = await apiAccept(slug, branchParam, change.id, { proposed, wasEdited })
|
||||
// Inject tracked-change markup into the editor so it renders inline.
|
||||
const editor = editorRef.current
|
||||
if (editor && change.original) {
|
||||
const html = editor.getHTML()
|
||||
const tracked =
|
||||
`<span class="tracked-delete" data-change-id="${change.id}">${change.original}</span>` +
|
||||
`<span class="tracked-insert" data-change-id="${change.id}">${proposed}</span>`
|
||||
const next = html.replace(change.original, tracked)
|
||||
if (next !== html) editor.commands.setContent(next, false)
|
||||
}
|
||||
// Pull the authoritative branch state — body, sha, changes.
|
||||
await apiAccept(slug, branchParam, change.id, { proposed, wasEdited })
|
||||
// Pull authoritative branch state and reset the editor to the new
|
||||
// body. The proper tracked-changes overlay lives in Phase 3's
|
||||
// preview pane; with CM6 as the source editor there is no HTML
|
||||
// surface to inject `<span class="tracked-*">` into.
|
||||
const fresh = await getBranch(slug, branchParam)
|
||||
setBranchView(fresh)
|
||||
setChanges(fresh.changes || [])
|
||||
// We do not reset editorContent here — the editor is showing the
|
||||
// tracked markup overlay; resetting would clear the visual diff
|
||||
// until DiffView is toggled.
|
||||
setEditorContent(fresh.body || '')
|
||||
originalSourceLinesRef.current = splitSourceParagraphs(fresh.body || '')
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
}
|
||||
@@ -419,10 +422,21 @@ export default function RFCView({ viewer }) {
|
||||
|
||||
const toggleReviewMode = useCallback(() => {
|
||||
setReviewMode(prev => {
|
||||
if (!prev) setReviewHTML(editorRef.current?.getHTML() || '')
|
||||
if (!prev) {
|
||||
// CM6 is the contribute-mode editor in Phase 1, so source HTML
|
||||
// comes from rendering the current markdown via marked. The
|
||||
// per-session inline tracked-change spans that Tiptap accumulated
|
||||
// are gone for now; Phase 3's preview pane is the proper home
|
||||
// for tracked changes anyway.
|
||||
const editor = editorRef.current
|
||||
const md = typeof editor?.getDoc === 'function'
|
||||
? editor.getDoc()
|
||||
: (branchView?.body || '')
|
||||
setReviewHTML(marked.parse(md))
|
||||
}
|
||||
return !prev
|
||||
})
|
||||
}, [])
|
||||
}, [branchView])
|
||||
|
||||
// ── Branch dropdown navigation ─────────────────────────────────────────
|
||||
const onPickBranch = useCallback((name) => {
|
||||
@@ -621,23 +635,31 @@ export default function RFCView({ viewer }) {
|
||||
<DiffView html={reviewHTML} changes={changes} messages={messages} />
|
||||
) : (
|
||||
<>
|
||||
<Editor
|
||||
content={editorContent}
|
||||
editorRef={editorRef}
|
||||
originalParagraphsRef={originalParagraphsRef}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onUpdate={editorEditable ? handleEditorUpdate : undefined}
|
||||
editable={editorEditable}
|
||||
/>
|
||||
<SelectionTooltip
|
||||
selection={selection}
|
||||
onAsk={handleTooltipAsk}
|
||||
onFlag={handleTooltipFlag}
|
||||
disabled={isStreaming || !viewer}
|
||||
models={models}
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={setSelectedModel}
|
||||
/>
|
||||
{editorEditable ? (
|
||||
<MarkdownSourceEditor
|
||||
initialDoc={editorContent}
|
||||
editorRef={editorRef}
|
||||
onUpdate={handleEditorUpdate}
|
||||
/>
|
||||
) : (
|
||||
<Editor
|
||||
content={editorContent}
|
||||
editorRef={editorRef}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
editable={false}
|
||||
/>
|
||||
)}
|
||||
{inDiscuss && (
|
||||
<SelectionTooltip
|
||||
selection={selection}
|
||||
onAsk={handleTooltipAsk}
|
||||
onFlag={handleTooltipFlag}
|
||||
disabled={isStreaming || !viewer}
|
||||
models={models}
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={setSelectedModel}
|
||||
/>
|
||||
)}
|
||||
{showPromptBar ? (
|
||||
<PromptBar
|
||||
selection={selection?.text || null}
|
||||
|
||||
Reference in New Issue
Block a user