886bbf5512
Restores the §8.10 paragraph-margin marker layer Phase 1 dropped when
Tiptap left, this time against the CM6 raw pane on the left half of
the Contribute split. The matching inline tracked-insert/tracked-
delete overlay Phase 3 shipped lives on the preview pane to the
right; the two visual layers answer different questions on the two
halves of the split — "did anything change in this region?" (gutter,
amber, scannable) vs. "what changed here?" (inline, green/red,
precise) — and are deliberately separate signals.
New file: frontend/src/components/diffGutterExtension.js. The
extension exposes a `setBaseline` StateEffect and a gutter that marks
every line whose text differs from the same-indexed line of the
baseline (the last server-confirmed branch body). Per-line, not
paragraph-block — CM6's natural unit; collapsing to paragraph ranges
is more spec-faithful but adds code, and the per-line stance is the
pre-fancy default. A TODO is left for a future paragraph-collapse
pass if the result reads noisy.
MarkdownSourceEditor.jsx changes:
- Install the gutter extension in the editor's extension list.
- Seed the baseline to `initialDoc` at construct time.
- In the existing `initialDoc`-watching effect, dispatch the doc
replacement AND a `setBaseline` effect in the SAME transaction so
there's no one-frame window where the new doc reads as "divergent"
against the old baseline. This carries through every server-
confirmed branch refresh that RFCView already wires (accept,
decline, manual flush, branch switch); no RFCView changes needed
because all four paths already re-push `initialDoc` after pulling
fresh state.
Design calls per the Phase 4 prompt's open list:
• Per-line marks, not paragraph-block ranges. Pre-fancy stance.
• Amber (#f59e0b) thin 3px vertical bar, distinct from the
preview pane's green-on-light / red-on-light tracked-change
inline overlay. Reads as "in-flight / not yet on the server."
• Baseline reset on every branchView refresh (accept / decline /
manual flush / branch switch), matching RFCView's existing
originalSourceLinesRef discipline. Gutter then reads as "what's
in the buffer but not on the server."
• No hover / no click. The inline overlay already carries the
click-to-card binding; the gutter is scannable only.
Known caveats kept deliberately:
• Insert/delete shifts. Adding a line in the middle shifts every
subsequent line's index and the naive compare lights up
everything below — tolerated as the honest "you've touched
stuff below this point" cue. A future LCS-anchored pass could
fix it; Phase 4 doesn't need to.
SPEC §8.10:
- First paragraph rewritten to name the CM6 raw pane as the gutter's
home and replace the stale "ProseMirror plugin" wording with the
CodeMirror gutter framing. Mirrors how Phase 3 named the preview
pane as the inline overlay's home.
- DiffView retirement paragraph adjusted: gutter and inline overlay
together (across the split) cover its review affordance, not
"both layers in the same surface" — the layers are deliberately on
different surfaces.
Verification:
- Vite preview sandbox eval — standalone CM6 mount, dispatch tests
across construct (no marks on identity), per-line edit (mark on
exactly the touched line, confirmed by Y-coordinate matching the
line-number gutter element), baseline reset clears, atomic
doc+baseline dispatch leaves zero marks (the RFCView accept-flow
path), insert-in-middle exhibits the expected cascade, and
computed-style proof for the amber bar (`#f59e0b`, 3px wide,
positioned left of the line-numbers gutter at x=21 vs x=24).
- Screenshot captures the bar on the touched line only.
- 125 backend integration tests still green.
- Live RFCView accept → branch refresh → gutter clear flow against a
real branch was not driven (backend not running this session,
carrying forward Phase 2/3's verification gap). The sandbox-level
proof covers the atomic dispatch correctness; the next session
with a backend up should drive that golden path before piling
Phase 5 work on top.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
100 lines
3.7 KiB
React
100 lines
3.7 KiB
React
// 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'
|
|
import { diffGutterExtension, setBaseline } from './diffGutterExtension.js'
|
|
|
|
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(),
|
|
diffGutterExtension(),
|
|
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 })
|
|
// Seed the §8.10 gutter baseline to the initial doc — nothing
|
|
// diverges at construct time. Subsequent baseline resets ride the
|
|
// initialDoc-watching effect below so they dispatch atomically
|
|
// with the doc replacement.
|
|
view.dispatch({ effects: setBaseline.of(initialDoc ?? '') })
|
|
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).
|
|
// The §8.10 gutter baseline is reset in the same dispatch so the
|
|
// newly-pushed doc doesn't briefly read as "divergent" against the
|
|
// old baseline.
|
|
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 ?? '' },
|
|
effects: setBaseline.of(initialDoc ?? ''),
|
|
})
|
|
}, [initialDoc])
|
|
|
|
return <div ref={hostRef} className="cm-source-editor" />
|
|
}
|