Contribute rewrite Phase 2: split-pane preview with mermaid
Add a rendered preview pane and split the Contribute-mode center
column. Discuss mode is now a single rendered preview (no more
read-only Tiptap mount); Contribute mode renders the CM6 raw editor
on the left and a live-updating preview on the right at 50/50, with
the existing chat + change-card panels untouched on the right.
The new MarkdownPreview component renders markdown via marked and
lazy-loads mermaid on the first encounter of a ```mermaid fence in
any rendered doc. Mermaid (~200 KB gzipped, plus dagre/graphlib
subchunks) stays out of the main bundle and is fetched only when a
diagram actually appears in the rendered content. The marked
renderer is scoped via a per-component Marked instance so the
mermaid-fence behavior does not leak to Editor.jsx / DiffView.
XSS hardening on mermaid: securityLevel: 'strict' is set explicitly
in mermaid.initialize. Verified end-to-end via a sandbox eval — a
hostile `<script>window.X=true</script>` payload inside a mermaid
fence is neutralized (no script tags in the rendered SVG, no global
side-effect, diagram still renders with the offending node label
empty).
The §8.12 selection tooltip is now sourced from window.getSelection()
inside the preview surface rather than Tiptap PM positions. The
SelectionTooltip's existing `{text, coords}` contract is unchanged;
coords come from the selection range's bounding rect. Anchor payloads
for flag threads now carry just the quote text — the PM-position
from/to fields that the Tiptap-era code attached are dropped (they
were never meaningful as durable anchors anyway; quote is what §8.12
and §8.13 specify).
Design decisions confirmed before coding (all defaults):
- PromptBar spans both panes at the bottom of the center column; it
operates on the document, not on either pane individually.
- Start-Contributing is a hard cut — no transition animation. Phase
6's chat-drawer collapse will redo the layout machinery anyway.
- Mermaid lazy-loads on first ```mermaid fence detected, not on
Contribute-mode entry. Keeps the gzipped cost off any doc that has
no diagrams.
- Below 1280px viewport, a narrow-viewport banner surfaces in
Contribute mode. The drawer collapse that fixes this properly is
Phase 6.
Mermaid integration is kept loose for the future authoring tool: the
placeholder DOM node carries the source as a data attribute, the
renderer takes (source) → SVG via mermaid.render, and per-block
memoization keys on source. A future authoring pane can intercept
the placeholder before SVG render without restructuring.
SPEC §8.3 updated to reflect the split-pane Contribute layout — the
smallest edit that captures the new shape.
Verification notes:
- Vite preview on :5180 — sandbox mount of MarkdownPreview confirms
rendering, mermaid SVG output, securityLevel:'strict' XSS
neutralization, and the window.getSelection → {text, coords}
bridge end to end.
- Network log confirms mermaid + sub-chunks are absent from initial
load and fetched only after first ```mermaid encounter.
- Backend was not running this session, so the manual-debounce
save-now / accept / decline / live-preview-mirror pathways were
not driven against a real branch; they remain verified by
inspection. Phase 1's verification gap therefore carries forward.
- 125 backend integration tests still green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -672,9 +672,12 @@ scoped to the current branch rather than global.
|
|||||||
and a contribution CTA surfaces in the right column when buffered
|
and a contribution CTA surfaces in the right column when buffered
|
||||||
changes exist.
|
changes exist.
|
||||||
- **Contribute mode** flips a single branch into edit-enabled. The
|
- **Contribute mode** flips a single branch into edit-enabled. The
|
||||||
editor becomes editable. AI changes apply to the change-card panel.
|
center column splits into a markdown-source pane on the left and a
|
||||||
Manual edits are tracked. The mode is reversible; the user can
|
rendered preview on the right; the preview reflects the source pane
|
||||||
return to discuss mode on the same branch without losing state.
|
live and renders fenced `mermaid` diagrams inline. AI changes apply
|
||||||
|
to the change-card panel. Manual edits are tracked. The mode is
|
||||||
|
reversible; the user can return to discuss mode on the same branch
|
||||||
|
without losing state.
|
||||||
|
|
||||||
On main, contribute mode is not available directly — main is read-only
|
On main, contribute mode is not available directly — main is read-only
|
||||||
by definition (PRs are the only path to change main). The "Start
|
by definition (PRs are the only path to change main). The "Start
|
||||||
|
|||||||
Generated
+1094
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,7 @@
|
|||||||
"@tiptap/react": "^3.5.0",
|
"@tiptap/react": "^3.5.0",
|
||||||
"@tiptap/starter-kit": "^3.5.0",
|
"@tiptap/starter-kit": "^3.5.0",
|
||||||
"marked": "^18.0.4",
|
"marked": "^18.0.4",
|
||||||
|
"mermaid": "^11.15.0",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-router-dom": "^7.2.0"
|
"react-router-dom": "^7.2.0"
|
||||||
|
|||||||
@@ -462,6 +462,94 @@
|
|||||||
display: flex; flex-direction: column;
|
display: flex; flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Split-pane Contribute layout (Phase 2) ──────────────────────────── */
|
||||||
|
.editor-split {
|
||||||
|
flex: 1; min-height: 0;
|
||||||
|
display: flex; flex-direction: row;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.editor-split-pane {
|
||||||
|
flex: 1 1 50%; min-width: 0; min-height: 0;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.editor-split-raw { border-right: 1px solid #e5e5e5; }
|
||||||
|
.editor-split-preview { background: #fff; overflow-y: auto; }
|
||||||
|
.editor-split-preview .markdown-preview { padding: 24px 32px; }
|
||||||
|
|
||||||
|
/* ── Markdown preview (Phase 2 render pane) ──────────────────────────── */
|
||||||
|
.markdown-preview {
|
||||||
|
font-size: 15px; line-height: 1.75; color: #1a1a1a;
|
||||||
|
max-width: 720px; margin: 0 auto;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.markdown-preview-solo {
|
||||||
|
flex: 1; overflow-y: auto;
|
||||||
|
padding: 32px 48px;
|
||||||
|
}
|
||||||
|
.markdown-preview h1 { font-size: 22px; font-weight: 700; margin: 24px 0 12px; }
|
||||||
|
.markdown-preview h2 { font-size: 17px; font-weight: 600; margin: 20px 0 8px; }
|
||||||
|
.markdown-preview h3 { font-size: 15px; font-weight: 600; margin: 16px 0 6px; }
|
||||||
|
.markdown-preview p { margin: 0 0 12px; }
|
||||||
|
.markdown-preview ul, .markdown-preview ol { padding-left: 24px; }
|
||||||
|
.markdown-preview code {
|
||||||
|
background: #f0f0ee; padding: 1px 5px; border-radius: 3px; font-size: 13px;
|
||||||
|
}
|
||||||
|
.markdown-preview pre {
|
||||||
|
background: #f6f6f4; padding: 12px 16px; border-radius: 6px;
|
||||||
|
overflow-x: auto; font-size: 13px; line-height: 1.5;
|
||||||
|
}
|
||||||
|
.markdown-preview pre code { background: none; padding: 0; }
|
||||||
|
.markdown-preview blockquote {
|
||||||
|
margin: 0 0 12px; padding: 4px 14px;
|
||||||
|
border-left: 3px solid #d4d4d4; color: #555;
|
||||||
|
}
|
||||||
|
.markdown-preview table { border-collapse: collapse; margin: 0 0 12px; }
|
||||||
|
.markdown-preview th, .markdown-preview td {
|
||||||
|
border: 1px solid #e5e5e5; padding: 6px 10px;
|
||||||
|
}
|
||||||
|
.markdown-preview th { background: #fafafa; font-weight: 600; }
|
||||||
|
|
||||||
|
/* Mermaid placeholder shown until the lazy-loaded module resolves.
|
||||||
|
The .mermaid-block container is preserved so future authoring tools
|
||||||
|
can decorate it; SVG output replaces only the inner contents. */
|
||||||
|
.markdown-preview .mermaid-block {
|
||||||
|
margin: 12px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.markdown-preview .mermaid-block svg { max-width: 100%; height: auto; }
|
||||||
|
.markdown-preview .mermaid-placeholder {
|
||||||
|
background: #f6f6f4; color: #777;
|
||||||
|
padding: 12px 16px; border-radius: 6px;
|
||||||
|
font-size: 12px; line-height: 1.5;
|
||||||
|
text-align: left; white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.markdown-preview .mermaid-error {
|
||||||
|
background: #fee2e2; color: #991b1b;
|
||||||
|
padding: 10px 14px; border-radius: 6px;
|
||||||
|
font-size: 12px; line-height: 1.5;
|
||||||
|
text-align: left; white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Browser-native selection styling inside the preview — replaces the
|
||||||
|
PM selection-highlight plugin that lived in Tiptap's surface. The
|
||||||
|
SelectionTooltip's onMouseDown preventDefault keeps the selection
|
||||||
|
visible while the tooltip captures input. */
|
||||||
|
.markdown-preview ::selection {
|
||||||
|
background: rgba(99, 102, 241, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Narrow-viewport banner — Phase 2 punts the chat-drawer collapse to
|
||||||
|
Phase 6, so the split layout asks for ≥1280px. */
|
||||||
|
.narrow-viewport-banner {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: #fef3c7; border-bottom: 1px solid #fcd34d;
|
||||||
|
color: #78350f; font-size: 12px;
|
||||||
|
}
|
||||||
|
@media (min-width: 1280px) {
|
||||||
|
.narrow-viewport-banner { display: none; }
|
||||||
|
}
|
||||||
.cm-source-editor .cm-editor {
|
.cm-source-editor .cm-editor {
|
||||||
flex: 1; min-height: 0;
|
flex: 1; min-height: 0;
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
// MarkdownPreview.jsx — Phase 2 of the Contribute rewrite. The rendered
|
||||||
|
// preview pane that replaces Tiptap's read-only render for Discuss mode
|
||||||
|
// and sits next to the CM6 raw pane in Contribute mode.
|
||||||
|
//
|
||||||
|
// Two responsibilities:
|
||||||
|
// • Markdown → HTML via marked, with ```mermaid fences extracted to
|
||||||
|
// placeholder nodes that resolve once mermaid lazy-loads.
|
||||||
|
// • Window-selection bridge per §8.12 — onMouseUp inside the preview
|
||||||
|
// reports {text, coords} to the parent so the SelectionTooltip can
|
||||||
|
// anchor against the rendered DOM (no PM/Tiptap surface here).
|
||||||
|
//
|
||||||
|
// Mermaid lazy-load: triggered on the first appearance of a ```mermaid
|
||||||
|
// fence in any rendered doc, not on mount. The module (~200 KB gzipped)
|
||||||
|
// stays out of the main bundle. `securityLevel: 'strict'` neutralizes
|
||||||
|
// hostile <script>/onclick payloads embedded in diagram source.
|
||||||
|
//
|
||||||
|
// The mermaid render path is deliberately decoupled from "blocks are
|
||||||
|
// immutable" — placeholder DOM nodes carry the source as a data
|
||||||
|
// attribute, and the renderer takes (source) → SVG. A future
|
||||||
|
// authoring layer can intercept before mount without restructuring.
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { Marked } from 'marked'
|
||||||
|
|
||||||
|
// Module-level mermaid loader. Holds the Promise across consumers so
|
||||||
|
// the chunk fetches exactly once across the app lifetime.
|
||||||
|
let mermaidPromise = null
|
||||||
|
function loadMermaid() {
|
||||||
|
if (!mermaidPromise) {
|
||||||
|
mermaidPromise = import('mermaid').then(m => {
|
||||||
|
const mermaid = m.default
|
||||||
|
mermaid.initialize({
|
||||||
|
startOnLoad: false,
|
||||||
|
securityLevel: 'strict',
|
||||||
|
suppressErrorRendering: true,
|
||||||
|
})
|
||||||
|
return mermaid
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return mermaidPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s).replace(/[&<>"']/g, c => (
|
||||||
|
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scoped Marked instance so the mermaid-fence renderer doesn't leak
|
||||||
|
// to other call sites (Editor.jsx, DiffView review HTML, etc.).
|
||||||
|
const previewMarked = new Marked({
|
||||||
|
renderer: {
|
||||||
|
code({ text, lang }) {
|
||||||
|
const tag = (lang || '').trim().split(/\s+/)[0]
|
||||||
|
if (tag === 'mermaid') {
|
||||||
|
const encoded = encodeURIComponent(text || '')
|
||||||
|
// Placeholder shows the source as a <pre> until mermaid resolves;
|
||||||
|
// keeps the preview useful even if mermaid never loads.
|
||||||
|
return (
|
||||||
|
`<div class="mermaid-block" data-mermaid-src="${encoded}">`
|
||||||
|
+ `<pre class="mermaid-placeholder">${escapeHtml(text || '')}</pre>`
|
||||||
|
+ `</div>`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return false // fall through to the default code renderer
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export default function MarkdownPreview({
|
||||||
|
content,
|
||||||
|
onSelectionChange,
|
||||||
|
className,
|
||||||
|
}) {
|
||||||
|
const hostRef = useRef(null)
|
||||||
|
// Per-block source memo — lets us skip mermaid re-renders for blocks
|
||||||
|
// whose source hasn't changed across doc updates.
|
||||||
|
const lastMermaidSourcesRef = useRef([])
|
||||||
|
// Monotonic counter the async mermaid pass uses to bail if a newer
|
||||||
|
// render has already replaced the DOM beneath it.
|
||||||
|
const renderTokenRef = useRef(0)
|
||||||
|
|
||||||
|
// Render markdown → HTML on every content change.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hostRef.current) return
|
||||||
|
const html = previewMarked.parse(content || '')
|
||||||
|
hostRef.current.innerHTML = html
|
||||||
|
const token = ++renderTokenRef.current
|
||||||
|
// Reset memo so the new block set re-renders from scratch.
|
||||||
|
lastMermaidSourcesRef.current = []
|
||||||
|
renderMermaidBlocks(hostRef.current, lastMermaidSourcesRef, token, renderTokenRef)
|
||||||
|
}, [content])
|
||||||
|
|
||||||
|
// Window-selection bridge for §8.12. Listen on document mouseup so
|
||||||
|
// releases outside the preview still clear the prior selection.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!onSelectionChange) return
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
const sel = window.getSelection?.()
|
||||||
|
if (!sel || sel.isCollapsed || sel.rangeCount === 0) {
|
||||||
|
onSelectionChange(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const range = sel.getRangeAt(0)
|
||||||
|
const host = hostRef.current
|
||||||
|
if (!host || !host.contains(range.commonAncestorContainer)) {
|
||||||
|
// Selection is outside the preview — leave any active tooltip
|
||||||
|
// selection alone (it belongs to another surface).
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const text = sel.toString()
|
||||||
|
if (!text || !text.trim()) {
|
||||||
|
onSelectionChange(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const rect = range.getBoundingClientRect()
|
||||||
|
onSelectionChange({
|
||||||
|
text,
|
||||||
|
coords: { top: rect.top, left: rect.left },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
document.addEventListener('mouseup', handleMouseUp)
|
||||||
|
return () => document.removeEventListener('mouseup', handleMouseUp)
|
||||||
|
}, [onSelectionChange])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={hostRef}
|
||||||
|
className={`markdown-preview${className ? ' ' + className : ''}`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderMermaidBlocks(host, lastSourcesRef, token, tokenRef) {
|
||||||
|
const blocks = host.querySelectorAll('.mermaid-block')
|
||||||
|
if (blocks.length === 0) {
|
||||||
|
lastSourcesRef.current = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let mermaid
|
||||||
|
try {
|
||||||
|
mermaid = await loadMermaid()
|
||||||
|
} catch (err) {
|
||||||
|
// Loading failed — leave the <pre> placeholders in place.
|
||||||
|
console.error('mermaid load failed', err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (tokenRef.current !== token) return // stale pass
|
||||||
|
const prev = lastSourcesRef.current
|
||||||
|
const next = []
|
||||||
|
for (let i = 0; i < blocks.length; i++) {
|
||||||
|
const block = blocks[i]
|
||||||
|
const src = decodeURIComponent(block.dataset.mermaidSrc || '')
|
||||||
|
next.push(src)
|
||||||
|
if (prev[i] === src && block.querySelector('svg')) continue
|
||||||
|
if (tokenRef.current !== token) return
|
||||||
|
try {
|
||||||
|
const id = `mmd-${Math.random().toString(36).slice(2, 10)}`
|
||||||
|
const { svg, bindFunctions } = await mermaid.render(id, src)
|
||||||
|
if (tokenRef.current !== token) return
|
||||||
|
if (!host.contains(block)) return
|
||||||
|
block.innerHTML = svg
|
||||||
|
bindFunctions?.(block)
|
||||||
|
} catch (err) {
|
||||||
|
block.innerHTML = (
|
||||||
|
`<pre class="mermaid-error">Mermaid parse error: `
|
||||||
|
+ escapeHtml(err?.message || String(err))
|
||||||
|
+ `</pre>`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastSourcesRef.current = next
|
||||||
|
}
|
||||||
@@ -33,8 +33,8 @@ import {
|
|||||||
startEditBranch,
|
startEditBranch,
|
||||||
streamChatTurn,
|
streamChatTurn,
|
||||||
} from '../api'
|
} from '../api'
|
||||||
import Editor, { selectionHighlightKey } from './Editor.jsx'
|
|
||||||
import MarkdownSourceEditor from './MarkdownSourceEditor.jsx'
|
import MarkdownSourceEditor from './MarkdownSourceEditor.jsx'
|
||||||
|
import MarkdownPreview from './MarkdownPreview.jsx'
|
||||||
import SelectionTooltip from './SelectionTooltip.jsx'
|
import SelectionTooltip from './SelectionTooltip.jsx'
|
||||||
import { marked } from 'marked'
|
import { marked } from 'marked'
|
||||||
import PromptBar from './PromptBar.jsx'
|
import PromptBar from './PromptBar.jsx'
|
||||||
@@ -77,16 +77,21 @@ 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
|
// editorRef points at the MarkdownSourceEditor handle ({view, getDoc,
|
||||||
// MarkdownSourceEditor handle ({view, getDoc, setDoc}) (Contribute);
|
// setDoc}) when Contribute mode is mounted; otherwise null. The
|
||||||
// call sites branch on shape.
|
// read-only render is now MarkdownPreview, which has no imperative
|
||||||
|
// surface to expose.
|
||||||
const editorRef = useRef(null)
|
const editorRef = useRef(null)
|
||||||
const originalSourceLinesRef = useRef([])
|
const originalSourceLinesRef = useRef([])
|
||||||
const [editorContent, setEditorContent] = useState('')
|
const [editorContent, setEditorContent] = useState('')
|
||||||
|
// Mirror of the live CM6 doc for the Contribute-mode preview pane,
|
||||||
|
// debounced so the preview doesn't re-render on every keystroke.
|
||||||
|
const [previewContent, setPreviewContent] = useState('')
|
||||||
|
|
||||||
// Selection + tooltip + selection highlight per §8.12.
|
// Selection + tooltip per §8.12. With Tiptap retired from this view,
|
||||||
|
// selection is sourced from window.getSelection() inside the
|
||||||
|
// MarkdownPreview surface — see MarkdownPreview's onSelectionChange.
|
||||||
const [selection, setSelection] = useState(null)
|
const [selection, setSelection] = useState(null)
|
||||||
const [highlightRange, setHighlightRange] = useState(null)
|
|
||||||
const [reviewMode, setReviewMode] = useState(false)
|
const [reviewMode, setReviewMode] = useState(false)
|
||||||
const [reviewHTML, setReviewHTML] = useState('')
|
const [reviewHTML, setReviewHTML] = useState('')
|
||||||
|
|
||||||
@@ -138,7 +143,6 @@ export default function RFCView({ viewer }) {
|
|||||||
setManualPending(null)
|
setManualPending(null)
|
||||||
setReviewMode(false)
|
setReviewMode(false)
|
||||||
setSelection(null)
|
setSelection(null)
|
||||||
setHighlightRange(null)
|
|
||||||
setMode('discuss')
|
setMode('discuss')
|
||||||
|
|
||||||
getRFCMain(slug).then(setMainView).catch(err => setError(err.message))
|
getRFCMain(slug).then(setMainView).catch(err => setError(err.message))
|
||||||
@@ -146,6 +150,7 @@ export default function RFCView({ viewer }) {
|
|||||||
.then(view => {
|
.then(view => {
|
||||||
setBranchView(view)
|
setBranchView(view)
|
||||||
setEditorContent(view.body || '')
|
setEditorContent(view.body || '')
|
||||||
|
setPreviewContent(view.body || '')
|
||||||
originalSourceLinesRef.current = splitSourceParagraphs(view.body || '')
|
originalSourceLinesRef.current = splitSourceParagraphs(view.body || '')
|
||||||
setChanges(view.changes || [])
|
setChanges(view.changes || [])
|
||||||
})
|
})
|
||||||
@@ -158,21 +163,13 @@ export default function RFCView({ viewer }) {
|
|||||||
loadAllMessages(slug, branchParam, branchView.threads).then(setMessages)
|
loadAllMessages(slug, branchParam, branchView.threads).then(setMessages)
|
||||||
}, [branchView?.main_thread_id, slug, branchParam])
|
}, [branchView?.main_thread_id, slug, branchParam])
|
||||||
|
|
||||||
// Selection + highlight wiring (§8.12).
|
// Selection wiring (§8.12). MarkdownPreview reports {text, coords}
|
||||||
|
// sourced from window.getSelection(); the SelectionTooltip
|
||||||
|
// positions itself from coords. No PM/Tiptap surface is involved.
|
||||||
const handleSelectionChange = useCallback((sel) => {
|
const handleSelectionChange = useCallback((sel) => {
|
||||||
setSelection(sel)
|
setSelection(sel)
|
||||||
if (sel?.from != null) setHighlightRange({ from: sel.from, to: sel.to })
|
|
||||||
else setHighlightRange(null)
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const editor = editorRef.current
|
|
||||||
// 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
|
// Manual-edit debounced upsert per §8.11 — produces a pending manual
|
||||||
// card with a live countdown and an explicit Save now. With the CM6
|
// 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
|
// markdown source editor (Phase 1), the doc IS the markdown — no more
|
||||||
@@ -196,6 +193,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 || [])
|
||||||
|
setPreviewContent(fresh.body || '')
|
||||||
originalSourceLinesRef.current = splitSourceParagraphs(fresh.body || '')
|
originalSourceLinesRef.current = splitSourceParagraphs(fresh.body || '')
|
||||||
setManualPending(null)
|
setManualPending(null)
|
||||||
loadAllMessages(slug, branchParam, fresh.threads).then(setMessages)
|
loadAllMessages(slug, branchParam, fresh.threads).then(setMessages)
|
||||||
@@ -223,6 +221,19 @@ export default function RFCView({ viewer }) {
|
|||||||
}
|
}
|
||||||
}, MANUAL_DEBOUNCE_MS), [mode, branchView])
|
}, MANUAL_DEBOUNCE_MS), [mode, branchView])
|
||||||
|
|
||||||
|
// Faster debounce for the live preview pane — manual-edit tracking
|
||||||
|
// can stay at MANUAL_DEBOUNCE_MS, but the rendered preview should feel
|
||||||
|
// responsive while typing. ~150ms keeps marked + mermaid re-renders off
|
||||||
|
// the keystroke hot path without feeling stale.
|
||||||
|
const handleEditorUpdatePreview = useMemo(() => debounce((doc) => {
|
||||||
|
setPreviewContent(doc)
|
||||||
|
}, 150), [])
|
||||||
|
|
||||||
|
const handleEditorDocUpdate = useCallback((doc) => {
|
||||||
|
handleEditorUpdate(doc)
|
||||||
|
handleEditorUpdatePreview(doc)
|
||||||
|
}, [handleEditorUpdate, handleEditorUpdatePreview])
|
||||||
|
|
||||||
// Idle flush — auto-save when countdown elapses.
|
// Idle flush — auto-save when countdown elapses.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!manualCountdown) return
|
if (!manualCountdown) return
|
||||||
@@ -344,7 +355,7 @@ export default function RFCView({ viewer }) {
|
|||||||
await createThread(slug, branchParam, {
|
await createThread(slug, branchParam, {
|
||||||
thread_kind: 'flag',
|
thread_kind: 'flag',
|
||||||
anchor_kind: 'range',
|
anchor_kind: 'range',
|
||||||
anchor_payload: { quote, from: highlightRange?.from, to: highlightRange?.to },
|
anchor_payload: { quote },
|
||||||
label,
|
label,
|
||||||
})
|
})
|
||||||
const fresh = await getBranch(slug, branchParam)
|
const fresh = await getBranch(slug, branchParam)
|
||||||
@@ -353,7 +364,7 @@ export default function RFCView({ viewer }) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
}
|
}
|
||||||
}, [slug, branchParam, viewer, highlightRange])
|
}, [slug, branchParam, viewer])
|
||||||
|
|
||||||
// ── Accept / decline / reask ──────────────────────────────────────────
|
// ── Accept / decline / reask ──────────────────────────────────────────
|
||||||
const handleAccept = useCallback(async ({ change, proposed, wasEdited }) => {
|
const handleAccept = useCallback(async ({ change, proposed, wasEdited }) => {
|
||||||
@@ -367,6 +378,7 @@ export default function RFCView({ viewer }) {
|
|||||||
setBranchView(fresh)
|
setBranchView(fresh)
|
||||||
setChanges(fresh.changes || [])
|
setChanges(fresh.changes || [])
|
||||||
setEditorContent(fresh.body || '')
|
setEditorContent(fresh.body || '')
|
||||||
|
setPreviewContent(fresh.body || '')
|
||||||
originalSourceLinesRef.current = splitSourceParagraphs(fresh.body || '')
|
originalSourceLinesRef.current = splitSourceParagraphs(fresh.body || '')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
@@ -615,6 +627,12 @@ export default function RFCView({ viewer }) {
|
|||||||
creator or an arbiter can grant access.
|
creator or an arbiter can grant access.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{mode === 'contribute' && canContribute && (
|
||||||
|
<div className="narrow-viewport-banner">
|
||||||
|
The split-pane Contribute layout works best on screens at least
|
||||||
|
1280px wide. Resize to a wider window for full rendering.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{mode === 'contribute' && canContribute && (
|
{mode === 'contribute' && canContribute && (
|
||||||
<div className="editor-toolbar">
|
<div className="editor-toolbar">
|
||||||
<button
|
<button
|
||||||
@@ -636,30 +654,37 @@ export default function RFCView({ viewer }) {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{editorEditable ? (
|
{editorEditable ? (
|
||||||
<MarkdownSourceEditor
|
<div className="editor-split">
|
||||||
initialDoc={editorContent}
|
<div className="editor-split-pane editor-split-raw">
|
||||||
editorRef={editorRef}
|
<MarkdownSourceEditor
|
||||||
onUpdate={handleEditorUpdate}
|
initialDoc={editorContent}
|
||||||
/>
|
editorRef={editorRef}
|
||||||
|
onUpdate={handleEditorDocUpdate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="editor-split-pane editor-split-preview">
|
||||||
|
<MarkdownPreview
|
||||||
|
content={previewContent}
|
||||||
|
onSelectionChange={handleSelectionChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Editor
|
<MarkdownPreview
|
||||||
content={editorContent}
|
content={editorContent}
|
||||||
editorRef={editorRef}
|
|
||||||
onSelectionChange={handleSelectionChange}
|
onSelectionChange={handleSelectionChange}
|
||||||
editable={false}
|
className="markdown-preview-solo"
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{inDiscuss && (
|
|
||||||
<SelectionTooltip
|
|
||||||
selection={selection}
|
|
||||||
onAsk={handleTooltipAsk}
|
|
||||||
onFlag={handleTooltipFlag}
|
|
||||||
disabled={isStreaming || !viewer}
|
|
||||||
models={models}
|
|
||||||
selectedModel={selectedModel}
|
|
||||||
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}
|
||||||
|
|||||||
Reference in New Issue
Block a user