diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5e86e3a..6cacb26 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,11 @@ "name": "rfc-app-frontend", "version": "0.1.0", "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/pm": "^3.5.0", "@tiptap/react": "^3.5.0", @@ -24,6 +29,136 @@ "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": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -86,6 +221,79 @@ "license": "MIT", "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": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", @@ -912,6 +1120,12 @@ "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": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1585,6 +1799,12 @@ "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": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7710cbd..19c4cde 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,11 @@ "preview": "vite preview" }, "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/pm": "^3.5.0", "@tiptap/react": "^3.5.0", diff --git a/frontend/src/App.css b/frontend/src/App.css index 69f79f8..f056355 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -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 { 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 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 { background: rgba(99, 102, 241, 0.15); border-radius: 2px; @@ -463,6 +456,27 @@ 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 { border-top: 1px solid #e5e5e5; padding: 10px 16px; text-align: center; @@ -1236,8 +1250,8 @@ color: #fbbf24; /* admin link sits a notch warmer to signal authority */ } -/* /philosophy — the §14.2 read surface. The body inherits the - prototype's markdown styling; the header is a thin chrome strip. */ +/* /philosophy — the §14.2 read surface. The body uses the shared + markdown styling; the header is a thin chrome strip. */ .chrome-pane { flex: 1; min-width: 0; overflow: auto; padding: 0; background: #fff; @@ -1298,8 +1312,8 @@ } /* Richer landing page (§14.1) — adds the three-item deck under the - pitch. The .landing container's flex centering stays from the - prototype; the new content lives inside .landing-inner. */ + pitch. The .landing container handles flex centering; the new + content lives inside .landing-inner. */ .landing-inner { max-width: 620px; display: flex; flex-direction: column; align-items: center; diff --git a/frontend/src/components/Editor.jsx b/frontend/src/components/Editor.jsx index f9f46bb..a7285e0 100644 --- a/frontend/src/components/Editor.jsx +++ b/frontend/src/components/Editor.jsx @@ -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 -// 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). +// 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 inline tracked-delete / tracked-insert markup from §8.10 is -// session-local HTML the parent injects via `editor.commands.setContent` -// when a change is accepted; the editor itself doesn't own that state. -// On reload the markup clears and DiffView (toolbar toggle) is the -// durable read of accepted changes. +// 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' @@ -25,47 +21,6 @@ import { marked } from 'marked' import { Plugin, PluginKey } from 'prosemirror-state' 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 ──────────────────────────────────────────── export const selectionHighlightKey = new PluginKey('selectionHighlight') @@ -110,7 +65,6 @@ function SelectionHighlightExtension() { export default function Editor({ content, editorRef, - originalParagraphsRef, onSelectionChange, onUpdate, editable = true, @@ -132,7 +86,6 @@ export default function Editor({ const editor = useEditor({ extensions: [ StarterKit, - DiffExtension(originalParagraphsRef), SelectionHighlightExtension(), ], content: '

', @@ -167,20 +120,10 @@ export default function Editor({ } }, [editor, onSelectionChange, reportSelection]) - // Reload content + snapshot baseline paragraphs. useEffect(() => { if (!editor || content == null) return const html = marked.parse(content) 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]) return ( diff --git a/frontend/src/components/MarkdownSourceEditor.jsx b/frontend/src/components/MarkdownSourceEditor.jsx new file mode 100644 index 0000000..6052a0e --- /dev/null +++ b/frontend/src/components/MarkdownSourceEditor.jsx @@ -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
+} diff --git a/frontend/src/components/RFCView.jsx b/frontend/src/components/RFCView.jsx index 9391051..eb5adfc 100644 --- a/frontend/src/components/RFCView.jsx +++ b/frontend/src/components/RFCView.jsx @@ -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 = - `${change.original}` + - `${proposed}` - 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 `` 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 }) { ) : ( <> - - + {editorEditable ? ( + + ) : ( + + )} + {inDiscuss && ( + + )} {showPromptBar ? (