Files
rfc-app/frontend/src/components/MarkdownPreview.jsx
T
Ben Stull 46049d296b Contribute rewrite Phase 7: retire DiffView
The Phase 3 preview-pane inline overlay and Phase 4 raw-pane gutter
accent together cover the review affordance DiffView was the interim
home for, so the toolbar toggle collapses and the surface goes.

- frontend/src/components/DiffView.jsx deleted.
- RFCView.jsx: drop the DiffView + marked imports, the reviewMode /
  reviewHTML state, the toggleReviewMode callback, the "Review changes"
  toolbar button, and the reviewMode ternary in render. editorEditable
  simplifies to mode === 'contribute' && canContribute. File-header
  docstring updated: "DiffView" → "preview-pane tracked-change overlay
  (§8.10)".
- App.css: remove the .diff-view-wrapper and .diff-view-empty rules;
  relabel the DiffView section header as ChangeTooltip (which lives on
  as the Phase 3 overlay's hover affordance). Drop the dead .editor-
  content .tiptap rules — Tiptap had no remaining consumers after
  DiffView's deletion. The Contribute toolbar still renders the
  accepted/pending hint + Phase 6 Chat toggle; only the Review-changes
  button is removed.
- MarkdownPreview.jsx: comment updated to drop the DiffView mention
  from the scoped-Marked-instance rationale.
- SPEC.md: §8.10 retitled "Tracked-change markup" (was "...and the
  review-mode toggle"); the legacy-DiffView paragraph rewritten as a
  one-sentence retirement note. §8.1, §8.15, §9.4, §9.5 references to
  DiffView / review-mode cleaned up. §19.2's persistent-accepted-change
  candidate updated to point at the preview-pane overlay rather than
  DiffView.

No DiffView orphans remain (grep confirms .btn-review-toggle is still
the Discuss-mode "Show tracked changes" toggle, ChangeTooltip and
trackedOverlay carry historical comments only, PRView's §19.2 comment
is unrelated). Frontend reload shows no console errors.

Backend integration suite: 125 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:33:14 -07:00

223 lines
8.0 KiB
React

// 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, useState, useCallback } from 'react'
import { Marked } from 'marked'
import { decorateAcceptedChanges } from './trackedOverlay.js'
import ChangeTooltip from './ChangeTooltip.jsx'
// 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 => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
))
}
// Scoped Marked instance so the mermaid-fence renderer doesn't leak
// to any other call site that might construct its own Marked.
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,
// Phase 3 — tracked-change overlay (§8.10). When `showTrackedChanges`
// is true, accepted changes from the branch's changes list are
// decorated inline as <span class="tracked-insert"> / <span
// class="tracked-delete">. Hovering a decorated span surfaces a
// ChangeTooltip with the source message + reason. `messages` is
// optional; without it the tooltip falls back to badge + reason.
changes,
messages,
showTrackedChanges = false,
}) {
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)
// Tracked-change hover tooltip state. Shape: { change, position }.
const [tooltip, setTooltip] = useState(null)
// Render markdown → HTML on every content change. The tracked-change
// overlay runs in the same effect so accepted-change spans appear
// synchronously with the body itself — no flash of un-decorated text.
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 = []
if (showTrackedChanges && changes && changes.length > 0) {
decorateAcceptedChanges(hostRef.current, changes)
}
renderMermaidBlocks(hostRef.current, lastMermaidSourcesRef, token, renderTokenRef)
}, [content, showTrackedChanges, changes])
// 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])
// Hover handler for tracked-change spans (§8.10). The decorated spans
// carry data-change-id; we look the change row up out of `changes`
// and surface a ChangeTooltip anchored to the cursor.
const handleMouseMove = useCallback((e) => {
if (!showTrackedChanges || !changes) return
const span = e.target.closest?.('[data-change-id]')
if (!span) {
if (tooltip) setTooltip(null)
return
}
const id = span.getAttribute('data-change-id')
const change = changes.find(c => String(c.id) === String(id))
if (!change) return
setTooltip({ change, position: { x: e.clientX, y: e.clientY } })
}, [showTrackedChanges, changes, tooltip])
const handleMouseLeave = useCallback(() => {
setTooltip(null)
}, [])
return (
<>
<div
ref={hostRef}
className={`markdown-preview${className ? ' ' + className : ''}`}
onMouseMove={showTrackedChanges ? handleMouseMove : undefined}
onMouseLeave={showTrackedChanges ? handleMouseLeave : undefined}
/>
{tooltip && (
<ChangeTooltip
change={tooltip.change}
messages={messages || []}
position={tooltip.position}
/>
)}
</>
)
}
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
}