Contribute rewrite Phase 3: tracked-change overlay in MarkdownPreview
Reintroduces the §8.10 inline tracked-change layer Phase 1 dropped when
Tiptap left, this time anchored to the rendered preview rather than a
writable editor. Each accepted change on the branch decorates the
preview DOM with a `<span class="tracked-insert">` at the proposed text
and a `<span class="tracked-delete">` strikethrough for the original;
hover surfaces the same ChangeTooltip DiffView uses (badge + prompt +
quote + reason), now extracted to its own file so both surfaces share
the affordance.
Design calls per the Phase 3 prompt's open list:
• Contribute pane: default-on. The pane exists for editorial review
of your own work; the overlay reinforces that.
• Discuss pane: opt-in via a new "Show tracked changes" toolbar
toggle on non-main branches. Clean prose stays the default.
• DiffView's "Review changes" toolbar is untouched — DiffView dies in
Phase 7 and overloading its toggle now creates surface to unwind.
Pre-fancy stance on the known overlay subtleties:
• Drift — if `proposed` no longer appears verbatim (later manual
edit touched it) we skip the decoration rather than mis-anchor.
• Overlap — decorate in `acted_at` ascending order; later spans win
on whatever text is still un-wrapped.
• Mermaid — text nodes inside `.mermaid-block` are skipped; a tracked
span that intersects diagram source drops silently. Flagged as a
known limitation rather than worked around.
• Code — `<pre>`/`<code>` parents skipped too; matching inside
verbatim code produced noisy false positives in fixtures.
Backend tests: 125 passed. Helper verified via Vite preview sandbox
eval across eight cases (single-paragraph match, distinct-original
strike, no-match skip, mermaid skip, two-change overlap ordering,
declined/pending ignored, whitespace-normalized cross-newline match,
code-block skip), plus computed-style proof for the new
`.markdown-preview .tracked-insert` / `.tracked-delete` rules. The
end-to-end CM6 → accept → overlay flow against a live branch wasn't
exercised (backend not running this session); the simpler unit-level
verification looked clean, but a future session with the backend up
should drive that golden path before Phase 4 piles on top.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,8 +19,10 @@
|
||||
// attribute, and the renderer takes (source) → SVG. A future
|
||||
// authoring layer can intercept before mount without restructuring.
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
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.
|
||||
@@ -71,6 +73,15 @@ 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
|
||||
@@ -79,8 +90,12 @@ export default function MarkdownPreview({
|
||||
// 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.
|
||||
// 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 || '')
|
||||
@@ -88,8 +103,11 @@ export default function MarkdownPreview({
|
||||
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])
|
||||
}, [content, showTrackedChanges, changes])
|
||||
|
||||
// Window-selection bridge for §8.12. Listen on document mouseup so
|
||||
// releases outside the preview still clear the prior selection.
|
||||
@@ -123,11 +141,42 @@ export default function MarkdownPreview({
|
||||
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 : ''}`}
|
||||
/>
|
||||
<>
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user