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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user