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
+220
View File
@@ -8,6 +8,11 @@
"name": "rfc-app-frontend", "name": "rfc-app-frontend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/language": "^6.12.3",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.43.0",
"@tiptap/extension-placeholder": "^3.5.0", "@tiptap/extension-placeholder": "^3.5.0",
"@tiptap/pm": "^3.5.0", "@tiptap/pm": "^3.5.0",
"@tiptap/react": "^3.5.0", "@tiptap/react": "^3.5.0",
@@ -24,6 +29,136 @@
"vite": "^8.0.12" "vite": "^8.0.12"
} }
}, },
"node_modules/@codemirror/autocomplete": {
"version": "6.20.2",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.2.tgz",
"integrity": "sha512-G5FPkgIiLjOgZMjqVjvuKQ1rGPtHogLldJr33eFJdVLtmwY+giGrlv/ewljLz6b9BSQLkjxuwBc6g6omDM+YxQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.10.3",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz",
"integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-css": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.2",
"@lezer/css": "^1.1.7"
}
},
"node_modules/@codemirror/lang-html": {
"version": "6.4.11",
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
"integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
"@codemirror/language": "^6.4.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/css": "^1.1.0",
"@lezer/html": "^1.3.12"
}
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/javascript": "^1.0.0"
}
},
"node_modules/@codemirror/lang-markdown": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz",
"integrity": "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.7.1",
"@codemirror/lang-html": "^6.0.0",
"@codemirror/language": "^6.3.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.2.1",
"@lezer/markdown": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.12.3",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.6",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.6.tgz",
"integrity": "sha512-6Kp7r6XfCi/D/5sdXieMfg9pJU1bUEx96WITuLU6ESaKizCz0QHFMjY/TaFSbigDdEAIgi93itLBIUETP4oK+A==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.42.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.43.0",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.0.tgz",
"integrity": "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.6.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.10.0", "version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
@@ -86,6 +221,79 @@
"license": "MIT", "license": "MIT",
"optional": true "optional": true
}, },
"node_modules/@lezer/common": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
"license": "MIT"
},
"node_modules/@lezer/css": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.3.tgz",
"integrity": "sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/html": {
"version": "1.3.13",
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/javascript": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
"integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/markdown": {
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.6.3.tgz",
"integrity": "sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
@@ -912,6 +1120,12 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -1585,6 +1799,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT"
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.16", "version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
+5
View File
@@ -9,6 +9,11 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/language": "^6.12.3",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.43.0",
"@tiptap/extension-placeholder": "^3.5.0", "@tiptap/extension-placeholder": "^3.5.0",
"@tiptap/pm": "^3.5.0", "@tiptap/pm": "^3.5.0",
"@tiptap/react": "^3.5.0", "@tiptap/react": "^3.5.0",
+26 -12
View File
@@ -1,4 +1,4 @@
/* Adapted from the prototype's App.css per §18, narrowed to slice-1 surfaces. */ /* App.css — narrowed to slice-1 surfaces. */
.boot { .boot {
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
@@ -441,13 +441,6 @@
.editor-content .tiptap ul, .editor-content .tiptap ol { padding-left: 24px; } .editor-content .tiptap ul, .editor-content .tiptap ol { padding-left: 24px; }
.editor-content .tiptap code { background: #f0f0ee; padding: 1px 5px; border-radius: 3px; font-size: 13px; } .editor-content .tiptap code { background: #f0f0ee; padding: 1px 5px; border-radius: 3px; font-size: 13px; }
.editor-content .tiptap .paragraph-changed {
border-left: 3px solid #f59e0b;
padding-left: 10px;
margin-left: -13px;
background: linear-gradient(to right, #fffbeb 0%, transparent 60%);
border-radius: 0 4px 4px 0;
}
.editor-content .tiptap .selection-highlight { .editor-content .tiptap .selection-highlight {
background: rgba(99, 102, 241, 0.15); background: rgba(99, 102, 241, 0.15);
border-radius: 2px; border-radius: 2px;
@@ -463,6 +456,27 @@
border-radius: 2px; padding: 1px 2px; cursor: pointer; border-radius: 2px; padding: 1px 2px; cursor: pointer;
} }
/* CodeMirror 6 source editor (Contribute mode). */
.cm-source-editor {
flex: 1; min-height: 0;
display: flex; flex-direction: column;
overflow: hidden;
}
.cm-source-editor .cm-editor {
flex: 1; min-height: 0;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 13px; line-height: 1.6;
background: #fff;
}
.cm-source-editor .cm-editor.cm-focused { outline: none; }
.cm-source-editor .cm-scroller { padding: 24px 0; }
.cm-source-editor .cm-content { padding: 0 32px; max-width: 880px; }
.cm-source-editor .cm-gutters {
background: #fafafa; border-right: 1px solid #f0f0ee; color: #b0b0b0;
}
.cm-source-editor .cm-activeLineGutter { background: #f3f4f6; color: #555; }
.cm-source-editor .cm-activeLine { background: #fafbfc; }
.readonly-bar { .readonly-bar {
border-top: 1px solid #e5e5e5; border-top: 1px solid #e5e5e5;
padding: 10px 16px; text-align: center; padding: 10px 16px; text-align: center;
@@ -1236,8 +1250,8 @@
color: #fbbf24; /* admin link sits a notch warmer to signal authority */ color: #fbbf24; /* admin link sits a notch warmer to signal authority */
} }
/* /philosophy — the §14.2 read surface. The body inherits the /* /philosophy — the §14.2 read surface. The body uses the shared
prototype's markdown styling; the header is a thin chrome strip. */ markdown styling; the header is a thin chrome strip. */
.chrome-pane { .chrome-pane {
flex: 1; min-width: 0; overflow: auto; flex: 1; min-width: 0; overflow: auto;
padding: 0; background: #fff; padding: 0; background: #fff;
@@ -1298,8 +1312,8 @@
} }
/* Richer landing page (§14.1) — adds the three-item deck under the /* Richer landing page (§14.1) — adds the three-item deck under the
pitch. The .landing container's flex centering stays from the pitch. The .landing container handles flex centering; the new
prototype; the new content lives inside .landing-inner. */ content lives inside .landing-inner. */
.landing-inner { .landing-inner {
max-width: 620px; max-width: 620px;
display: flex; flex-direction: column; align-items: center; display: flex; flex-direction: column; align-items: center;
+9 -66
View File
@@ -1,22 +1,18 @@
// Editor.jsx the §8 center-column editor. // 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.
// //
// Tiptap on ProseMirror per §18. Two ProseMirror plugins live alongside // One ProseMirror plugin still lives alongside StarterKit:
// StarterKit:
//
// paragraphDiff the §8.10 paragraph-margin gutter accent. Compares
// each paragraph against an open-session baseline (the
// `originalParagraphsRef` ref the parent owns and refreshes when the
// baseline shifts e.g. on branch switch or a server-side flush).
// //
// selectionHighlight keeps a selected passage highlighted while // selectionHighlight keeps a selected passage highlighted while
// focus moves to the §8.12 selection tooltip. Driven by meta // focus moves to the §8.12 selection tooltip. Driven by meta
// transactions dispatched from the parent. // transactions dispatched from the parent.
// //
// The inline tracked-delete / tracked-insert markup from §8.10 is // The paragraph-margin gutter accent that used to live here is dropped;
// session-local HTML the parent injects via `editor.commands.setContent` // Phase 4 of the Contribute rewrite adds a proper change-anchored gutter
// when a change is accepted; the editor itself doesn't own that state. // against the CodeMirror raw pane.
// On reload the markup clears and DiffView (toolbar toggle) is the
// durable read of accepted changes.
import { useEditor, EditorContent, Extension } from '@tiptap/react' import { useEditor, EditorContent, Extension } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit' import StarterKit from '@tiptap/starter-kit'
@@ -25,47 +21,6 @@ import { marked } from 'marked'
import { Plugin, PluginKey } from 'prosemirror-state' import { Plugin, PluginKey } from 'prosemirror-state'
import { Decoration, DecorationSet } from 'prosemirror-view' import { Decoration, DecorationSet } from 'prosemirror-view'
// Paragraph diff plugin
const diffKey = new PluginKey('paragraphDiff')
function makeDiffPlugin(originalParagraphsRef) {
return new Plugin({
key: diffKey,
props: {
decorations(state) {
const originals = originalParagraphsRef?.current
if (!originals || originals.length === 0) return DecorationSet.empty
const decorations = []
let idx = 0
state.doc.descendants((node, pos) => {
if (node.type.name === 'paragraph' || node.type.name === 'heading') {
const current = node.textContent.trim()
const original = (originals[idx] ?? '').trim()
if (current !== original) {
decorations.push(
Decoration.node(pos, pos + node.nodeSize, { class: 'paragraph-changed' })
)
}
idx++
}
})
return DecorationSet.create(state.doc, decorations)
},
},
})
}
function DiffExtension(originalParagraphsRef) {
return Extension.create({
name: 'paragraphDiff',
addProseMirrorPlugins() {
return [makeDiffPlugin(originalParagraphsRef)]
},
})
}
// Selection highlight plugin // Selection highlight plugin
export const selectionHighlightKey = new PluginKey('selectionHighlight') export const selectionHighlightKey = new PluginKey('selectionHighlight')
@@ -110,7 +65,6 @@ function SelectionHighlightExtension() {
export default function Editor({ export default function Editor({
content, content,
editorRef, editorRef,
originalParagraphsRef,
onSelectionChange, onSelectionChange,
onUpdate, onUpdate,
editable = true, editable = true,
@@ -132,7 +86,6 @@ export default function Editor({
const editor = useEditor({ const editor = useEditor({
extensions: [ extensions: [
StarterKit, StarterKit,
DiffExtension(originalParagraphsRef),
SelectionHighlightExtension(), SelectionHighlightExtension(),
], ],
content: '<p></p>', content: '<p></p>',
@@ -167,20 +120,10 @@ export default function Editor({
} }
}, [editor, onSelectionChange, reportSelection]) }, [editor, onSelectionChange, reportSelection])
// Reload content + snapshot baseline paragraphs.
useEffect(() => { useEffect(() => {
if (!editor || content == null) return if (!editor || content == null) return
const html = marked.parse(content) const html = marked.parse(content)
editor.commands.setContent(html, false) editor.commands.setContent(html, false)
if (originalParagraphsRef) {
const paragraphs = []
editor.state.doc.descendants(node => {
if (node.type.name === 'paragraph' || node.type.name === 'heading') {
paragraphs.push(node.textContent.trim())
}
})
originalParagraphsRef.current = paragraphs
}
}, [content, editor]) }, [content, editor])
return ( return (
@@ -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 HTMLmarkdown 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" />
}
+79 -57
View File
@@ -34,7 +34,9 @@ import {
streamChatTurn, streamChatTurn,
} from '../api' } from '../api'
import Editor, { selectionHighlightKey } from './Editor.jsx' import Editor, { selectionHighlightKey } from './Editor.jsx'
import MarkdownSourceEditor from './MarkdownSourceEditor.jsx'
import SelectionTooltip from './SelectionTooltip.jsx' import SelectionTooltip from './SelectionTooltip.jsx'
import { marked } from 'marked'
import PromptBar from './PromptBar.jsx' import PromptBar from './PromptBar.jsx'
import ChatPanel from './ChatPanel.jsx' import ChatPanel from './ChatPanel.jsx'
import ChangePanel from './ChangePanel.jsx' import ChangePanel from './ChangePanel.jsx'
@@ -51,6 +53,15 @@ function debounce(fn, ms) {
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), 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 }) { export default function RFCView({ viewer }) {
const { slug } = useParams() const { slug } = useParams()
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
@@ -66,8 +77,11 @@ export default function RFCView({ viewer }) {
const [selectedModel, setSelectedModel] = useState('') const [selectedModel, setSelectedModel] = useState('')
// Editor state owned here so accept/decline can mutate it. // 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 editorRef = useRef(null)
const originalParagraphsRef = useRef([]) const originalSourceLinesRef = useRef([])
const [editorContent, setEditorContent] = useState('') const [editorContent, setEditorContent] = useState('')
// Selection + tooltip + selection highlight per §8.12. // Selection + tooltip + selection highlight per §8.12.
@@ -132,6 +146,7 @@ export default function RFCView({ viewer }) {
.then(view => { .then(view => {
setBranchView(view) setBranchView(view)
setEditorContent(view.body || '') setEditorContent(view.body || '')
originalSourceLinesRef.current = splitSourceParagraphs(view.body || '')
setChanges(view.changes || []) setChanges(view.changes || [])
}) })
.catch(err => setError(err.message)) .catch(err => setError(err.message))
@@ -152,21 +167,23 @@ export default function RFCView({ viewer }) {
useEffect(() => { useEffect(() => {
const editor = editorRef.current 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)) editor.view.dispatch(editor.state.tr.setMeta(selectionHighlightKey, highlightRange))
}, [highlightRange]) }, [highlightRange])
// Manual-edit debounced upsert per §8.11 produces a pending manual // 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
// HTMLgetText round-trip, so §19.2 lossiness is gone.
const flushManualBuffer = useCallback(async () => { const flushManualBuffer = useCallback(async () => {
const editor = editorRef.current const editor = editorRef.current
if (!editor || !branchView || mode !== 'contribute') return if (!editor || !branchView || mode !== 'contribute') return
const text = editor.getText() if (typeof editor.getDoc !== 'function') return
// Convert to a rough markdown by stripping HTML for v1 we round-trip const raw = editor.getDoc()
// through the editor's getText; this matches the prototype's behavior. const newContent = raw.endsWith('\n') ? raw : raw + '\n'
// A faithful HTMLmarkdown round-trip is a §19.2 candidate. if (newContent.trim() === (branchView.body || '').trim()) {
const newContent = text.trim() + '\n'
if (!newContent || newContent.trim() === (branchView.body || '').trim()) {
setManualPending(null) setManualPending(null)
return return
} }
@@ -179,6 +196,7 @@ export default function RFCView({ viewer }) {
const fresh = await getBranch(slug, branchParam) const fresh = await getBranch(slug, branchParam)
setBranchView(fresh) setBranchView(fresh)
setChanges(fresh.changes || []) setChanges(fresh.changes || [])
originalSourceLinesRef.current = splitSourceParagraphs(fresh.body || '')
setManualPending(null) setManualPending(null)
loadAllMessages(slug, branchParam, fresh.threads).then(setMessages) loadAllMessages(slug, branchParam, fresh.threads).then(setMessages)
} }
@@ -187,22 +205,15 @@ export default function RFCView({ viewer }) {
} }
}, [slug, branchParam, branchView, mode, manualPending?.paragraphCount]) }, [slug, branchParam, branchView, mode, manualPending?.paragraphCount])
const handleEditorUpdate = useMemo(() => debounce((plainText) => { const handleEditorUpdate = useMemo(() => debounce((doc) => {
if (mode !== 'contribute' || !branchView) return if (mode !== 'contribute' || !branchView) return
const editor = editorRef.current const current = splitSourceParagraphs(doc)
if (!editor) return const baseline = originalSourceLinesRef.current || []
const currentParagraphs = [] const len = Math.max(current.length, baseline.length)
editor.state.doc.descendants(node => {
if (node.type.name === 'paragraph' || node.type.name === 'heading') {
currentParagraphs.push(node.textContent.trim())
}
})
const baseline = originalParagraphsRef.current || []
let changed = 0 let changed = 0
currentParagraphs.forEach((t, i) => { for (let i = 0; i < len; i++) {
const orig = (baseline[i] ?? '').trim() if ((current[i] ?? '') !== (baseline[i] ?? '')) changed++
if (t !== orig) changed++ }
})
if (changed > 0) { if (changed > 0) {
setManualPending({ paragraphCount: changed }) setManualPending({ paragraphCount: changed })
setManualCountdown({ deadline: Date.now() + MANUAL_IDLE_MS }) setManualCountdown({ deadline: Date.now() + MANUAL_IDLE_MS })
@@ -347,24 +358,16 @@ export default function RFCView({ viewer }) {
// Accept / decline / reask // Accept / decline / reask
const handleAccept = useCallback(async ({ change, proposed, wasEdited }) => { const handleAccept = useCallback(async ({ change, proposed, wasEdited }) => {
try { try {
const { commit_sha } = await apiAccept(slug, branchParam, change.id, { proposed, wasEdited }) await apiAccept(slug, branchParam, change.id, { proposed, wasEdited })
// Inject tracked-change markup into the editor so it renders inline. // Pull authoritative branch state and reset the editor to the new
const editor = editorRef.current // body. The proper tracked-changes overlay lives in Phase 3's
if (editor && change.original) { // preview pane; with CM6 as the source editor there is no HTML
const html = editor.getHTML() // surface to inject `<span class="tracked-*">` into.
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.
const fresh = await getBranch(slug, branchParam) const fresh = await getBranch(slug, branchParam)
setBranchView(fresh) setBranchView(fresh)
setChanges(fresh.changes || []) setChanges(fresh.changes || [])
// We do not reset editorContent here the editor is showing the setEditorContent(fresh.body || '')
// tracked markup overlay; resetting would clear the visual diff originalSourceLinesRef.current = splitSourceParagraphs(fresh.body || '')
// until DiffView is toggled.
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} }
@@ -419,10 +422,21 @@ export default function RFCView({ viewer }) {
const toggleReviewMode = useCallback(() => { const toggleReviewMode = useCallback(() => {
setReviewMode(prev => { 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 return !prev
}) })
}, []) }, [branchView])
// Branch dropdown navigation // Branch dropdown navigation
const onPickBranch = useCallback((name) => { const onPickBranch = useCallback((name) => {
@@ -621,23 +635,31 @@ export default function RFCView({ viewer }) {
<DiffView html={reviewHTML} changes={changes} messages={messages} /> <DiffView html={reviewHTML} changes={changes} messages={messages} />
) : ( ) : (
<> <>
<Editor {editorEditable ? (
content={editorContent} <MarkdownSourceEditor
editorRef={editorRef} initialDoc={editorContent}
originalParagraphsRef={originalParagraphsRef} editorRef={editorRef}
onSelectionChange={handleSelectionChange} onUpdate={handleEditorUpdate}
onUpdate={editorEditable ? handleEditorUpdate : undefined} />
editable={editorEditable} ) : (
/> <Editor
<SelectionTooltip content={editorContent}
selection={selection} editorRef={editorRef}
onAsk={handleTooltipAsk} onSelectionChange={handleSelectionChange}
onFlag={handleTooltipFlag} editable={false}
disabled={isStreaming || !viewer} />
models={models} )}
selectedModel={selectedModel} {inDiscuss && (
onModelChange={setSelectedModel} <SelectionTooltip
/> selection={selection}
onAsk={handleTooltipAsk}
onFlag={handleTooltipFlag}
disabled={isStreaming || !viewer}
models={models}
selectedModel={selectedModel}
onModelChange={setSelectedModel}
/>
)}
{showPromptBar ? ( {showPromptBar ? (
<PromptBar <PromptBar
selection={selection?.text || null} selection={selection?.text || null}