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:
@@ -842,29 +842,33 @@ click-to-card binding from §8.8. The margin marker is scannable
|
|||||||
precise ("what changed here?"). The two answer different questions
|
precise ("what changed here?"). The two answer different questions
|
||||||
and both are kept.
|
and both are kept.
|
||||||
|
|
||||||
The inline markup is session-local. Each accepted change is already
|
The inline markup lives in the rendered preview surface, not on the
|
||||||
a clean commit on Gitea per §8.6, so the branch's canonical state at
|
writable editor. With the Contribute-mode split (§8.3) the raw markdown
|
||||||
any reload is the integrated text without markup. Regenerating
|
source is a CodeMirror buffer that can't host HTML decorations, and
|
||||||
markup on load by diffing against earlier commits is technically
|
even when a writable surface could host them — as Tiptap did in earlier
|
||||||
possible but adds a mechanism — a per-user seen-cursor for accepted
|
revisions — layering permanent diff overlay on top of writable text
|
||||||
changes, an explicit dismiss UI — to solve a problem that DiffView
|
degraded the writing surface. The preview pane is the natural home: on
|
||||||
already solves durably and at higher fidelity. The editor is for
|
the right side of the Contribute split it shows the editor's own work
|
||||||
writing; layering permanent diff overlay on top of writable text
|
in-context (default-on, since the pane exists for that editorial
|
||||||
degrades the writing surface, so the markup clears on reload and
|
review), and in Discuss mode the single preview pane gates the overlay
|
||||||
DiffView is the durable artifact for inspecting accepted changes in
|
behind a toolbar toggle so the default reading experience stays clean
|
||||||
context.
|
prose. The overlay regenerates on every render from the branch's
|
||||||
|
`changes` table — no per-user seen-cursor, no dismiss UI; every
|
||||||
|
accepted change on the branch is included, drift is tolerated by
|
||||||
|
skipping spans whose `proposed` text no longer matches verbatim, and
|
||||||
|
mermaid intersections are skipped as a known limitation. Hovering any
|
||||||
|
marked span surfaces a tooltip with the change's type badge (`ai` or
|
||||||
|
`manual`), the model identifier where applicable, the
|
||||||
|
`was_edited_before_accept` flag where set, the user prompt and
|
||||||
|
selection-quote that drove the change, and the AI's `reason`.
|
||||||
|
|
||||||
DiffView is the read-only render surface invoked via a toolbar
|
DiffView is the legacy read-only render surface invoked via the
|
||||||
toggle (§8.15). It reads from the
|
Contribute-mode toolbar toggle (§8.15) — a full-editor swap-in that
|
||||||
`changes` table for the branch, reconstructs the markup for every
|
reads the same `changes` table. It is retained as an interim path while
|
||||||
accepted change in branch history, and renders the result in-place
|
the preview-pane overlay matures and is slated for retirement once
|
||||||
where the editor was. Hovering any marked span surfaces a tooltip
|
gutter markers (the §8.10 paragraph-margin layer) land in the preview
|
||||||
with the change's type badge (`ai` or `manual`), the model
|
itself; at that point both visual layers live in the same surface and
|
||||||
identifier where applicable, the `was_edited_before_accept` flag
|
the toolbar toggle collapses.
|
||||||
where set, the user prompt and selection-quote that drove the
|
|
||||||
change, and the AI's `reason`. The toggle is reversible; returning
|
|
||||||
to the editor restores the live writing surface and reattaches the
|
|
||||||
session-local baseline.
|
|
||||||
|
|
||||||
### 8.11 Manual edits and collisions with AI proposals
|
### 8.11 Manual edits and collisions with AI proposals
|
||||||
|
|
||||||
|
|||||||
@@ -540,6 +540,21 @@
|
|||||||
background: rgba(99, 102, 241, 0.22);
|
background: rgba(99, 102, 241, 0.22);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tracked-change overlay (Phase 3, §8.10) — preview-pane equivalents of
|
||||||
|
the .editor-content .tiptap rules above. The Contribute pane shows
|
||||||
|
these by default; the Discuss pane gates them behind a toolbar
|
||||||
|
toggle. Hovering surfaces a ChangeTooltip with the source message
|
||||||
|
and reason. */
|
||||||
|
.markdown-preview .tracked-delete {
|
||||||
|
background: #fee2e2; color: #991b1b; text-decoration: line-through;
|
||||||
|
border-radius: 2px; padding: 1px 2px; cursor: pointer;
|
||||||
|
margin-right: 2px;
|
||||||
|
}
|
||||||
|
.markdown-preview .tracked-insert {
|
||||||
|
background: #dcfce7; color: #166534;
|
||||||
|
border-radius: 2px; padding: 1px 2px; cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
/* Narrow-viewport banner — Phase 2 punts the chat-drawer collapse to
|
/* Narrow-viewport banner — Phase 2 punts the chat-drawer collapse to
|
||||||
Phase 6, so the split layout asks for ≥1280px. */
|
Phase 6, so the split layout asks for ≥1280px. */
|
||||||
.narrow-viewport-banner {
|
.narrow-viewport-banner {
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// ChangeTooltip.jsx — the §8.10 hover affordance for a tracked-change
|
||||||
|
// span. Extracted from DiffView in Phase 3 so the MarkdownPreview
|
||||||
|
// tracked-changes overlay can reuse it. Same shape: badge row,
|
||||||
|
// user-prompt + selection-quote, and the AI's reason.
|
||||||
|
|
||||||
|
import { MODEL_STYLES } from '../modelStyles'
|
||||||
|
|
||||||
|
function tooltipStyle(x, y) {
|
||||||
|
const vw = window.innerWidth, vh = window.innerHeight
|
||||||
|
const style = {}
|
||||||
|
if (x > vw * 0.55) style.right = vw - x + 10
|
||||||
|
else style.left = x + 14
|
||||||
|
if (y > vh * 0.55) style.bottom = vh - y + 10
|
||||||
|
else style.top = y + 14
|
||||||
|
return style
|
||||||
|
}
|
||||||
|
|
||||||
|
export function changeContext(change, messages) {
|
||||||
|
if (!change?.source_message_id || !messages) return {}
|
||||||
|
const idx = messages.findIndex(m => m.id === change.source_message_id)
|
||||||
|
if (idx < 0) return {}
|
||||||
|
const assistant = messages[idx]
|
||||||
|
const userMsg = [...messages].slice(0, idx).reverse().find(m => m.role === 'user')
|
||||||
|
return { assistant, userMsg }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChangeTooltip({ change, messages, position }) {
|
||||||
|
const { assistant, userMsg } = changeContext(change, messages)
|
||||||
|
const style = { ...tooltipStyle(position.x, position.y), position: 'fixed' }
|
||||||
|
const modelStyle = MODEL_STYLES[assistant?.model_id] || MODEL_STYLES.default
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="diff-tooltip" style={style}>
|
||||||
|
<div className="diff-tooltip-header">
|
||||||
|
{change.kind === 'ai' ? (
|
||||||
|
<span className="diff-tooltip-badge" style={{ background: modelStyle.bg, color: modelStyle.color }}>
|
||||||
|
{modelStyle.label}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="diff-tooltip-badge diff-tooltip-badge--manual">Manual edit</span>
|
||||||
|
)}
|
||||||
|
{change.was_edited_before_accept && (
|
||||||
|
<span className="diff-tooltip-badge diff-tooltip-badge--edited">Edited before accept</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{userMsg && (
|
||||||
|
<div className="diff-tooltip-prompt">
|
||||||
|
{userMsg.quote && (
|
||||||
|
<div className="diff-tooltip-quote">
|
||||||
|
"{userMsg.quote.length > 120 ? userMsg.quote.slice(0, 120) + '…' : userMsg.quote}"
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="diff-tooltip-prompt-text">
|
||||||
|
{userMsg.text.length > 240 ? userMsg.text.slice(0, 240) + '…' : userMsg.text}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{change.reason && (
|
||||||
|
<div className="diff-tooltip-reason">
|
||||||
|
<span className="diff-tooltip-reason-label">Reason</span>
|
||||||
|
{change.reason}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!userMsg && change.kind === 'ai' && (
|
||||||
|
<div className="diff-tooltip-no-context">
|
||||||
|
No linked conversation message in this session.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,72 +7,7 @@
|
|||||||
// tooltip with the change's type/model/prompt/reason context.
|
// tooltip with the change's type/model/prompt/reason context.
|
||||||
|
|
||||||
import { useState, useCallback } from 'react'
|
import { useState, useCallback } from 'react'
|
||||||
import { MODEL_STYLES } from '../modelStyles'
|
import ChangeTooltip from './ChangeTooltip.jsx'
|
||||||
|
|
||||||
function tooltipStyle(x, y) {
|
|
||||||
const vw = window.innerWidth, vh = window.innerHeight
|
|
||||||
const style = {}
|
|
||||||
if (x > vw * 0.55) style.right = vw - x + 10
|
|
||||||
else style.left = x + 14
|
|
||||||
if (y > vh * 0.55) style.bottom = vh - y + 10
|
|
||||||
else style.top = y + 14
|
|
||||||
return style
|
|
||||||
}
|
|
||||||
|
|
||||||
function changeContext(change, messages) {
|
|
||||||
if (!change?.source_message_id) return {}
|
|
||||||
const idx = messages.findIndex(m => m.id === change.source_message_id)
|
|
||||||
if (idx < 0) return {}
|
|
||||||
const assistant = messages[idx]
|
|
||||||
const userMsg = [...messages].slice(0, idx).reverse().find(m => m.role === 'user')
|
|
||||||
return { assistant, userMsg }
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChangeTooltip({ change, messages, position }) {
|
|
||||||
const { assistant, userMsg } = changeContext(change, messages)
|
|
||||||
const style = { ...tooltipStyle(position.x, position.y), position: 'fixed' }
|
|
||||||
const modelStyle = MODEL_STYLES[assistant?.model_id] || MODEL_STYLES.default
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="diff-tooltip" style={style}>
|
|
||||||
<div className="diff-tooltip-header">
|
|
||||||
{change.kind === 'ai' ? (
|
|
||||||
<span className="diff-tooltip-badge" style={{ background: modelStyle.bg, color: modelStyle.color }}>
|
|
||||||
{modelStyle.label}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="diff-tooltip-badge diff-tooltip-badge--manual">Manual edit</span>
|
|
||||||
)}
|
|
||||||
{change.was_edited_before_accept && (
|
|
||||||
<span className="diff-tooltip-badge diff-tooltip-badge--edited">Edited before accept</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{userMsg && (
|
|
||||||
<div className="diff-tooltip-prompt">
|
|
||||||
{userMsg.quote && (
|
|
||||||
<div className="diff-tooltip-quote">
|
|
||||||
"{userMsg.quote.length > 120 ? userMsg.quote.slice(0, 120) + '…' : userMsg.quote}"
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="diff-tooltip-prompt-text">
|
|
||||||
{userMsg.text.length > 240 ? userMsg.text.slice(0, 240) + '…' : userMsg.text}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{change.reason && (
|
|
||||||
<div className="diff-tooltip-reason">
|
|
||||||
<span className="diff-tooltip-reason-label">Reason</span>
|
|
||||||
{change.reason}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!userMsg && change.kind === 'ai' && (
|
|
||||||
<div className="diff-tooltip-no-context">
|
|
||||||
No linked conversation message in this session.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DiffView({ html, changes, messages }) {
|
export default function DiffView({ html, changes, messages }) {
|
||||||
const [tooltip, setTooltip] = useState(null)
|
const [tooltip, setTooltip] = useState(null)
|
||||||
|
|||||||
@@ -19,8 +19,10 @@
|
|||||||
// attribute, and the renderer takes (source) → SVG. A future
|
// attribute, and the renderer takes (source) → SVG. A future
|
||||||
// authoring layer can intercept before mount without restructuring.
|
// 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 { Marked } from 'marked'
|
||||||
|
import { decorateAcceptedChanges } from './trackedOverlay.js'
|
||||||
|
import ChangeTooltip from './ChangeTooltip.jsx'
|
||||||
|
|
||||||
// Module-level mermaid loader. Holds the Promise across consumers so
|
// Module-level mermaid loader. Holds the Promise across consumers so
|
||||||
// the chunk fetches exactly once across the app lifetime.
|
// the chunk fetches exactly once across the app lifetime.
|
||||||
@@ -71,6 +73,15 @@ export default function MarkdownPreview({
|
|||||||
content,
|
content,
|
||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
className,
|
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)
|
const hostRef = useRef(null)
|
||||||
// Per-block source memo — lets us skip mermaid re-renders for blocks
|
// 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
|
// Monotonic counter the async mermaid pass uses to bail if a newer
|
||||||
// render has already replaced the DOM beneath it.
|
// render has already replaced the DOM beneath it.
|
||||||
const renderTokenRef = useRef(0)
|
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(() => {
|
useEffect(() => {
|
||||||
if (!hostRef.current) return
|
if (!hostRef.current) return
|
||||||
const html = previewMarked.parse(content || '')
|
const html = previewMarked.parse(content || '')
|
||||||
@@ -88,8 +103,11 @@ export default function MarkdownPreview({
|
|||||||
const token = ++renderTokenRef.current
|
const token = ++renderTokenRef.current
|
||||||
// Reset memo so the new block set re-renders from scratch.
|
// Reset memo so the new block set re-renders from scratch.
|
||||||
lastMermaidSourcesRef.current = []
|
lastMermaidSourcesRef.current = []
|
||||||
|
if (showTrackedChanges && changes && changes.length > 0) {
|
||||||
|
decorateAcceptedChanges(hostRef.current, changes)
|
||||||
|
}
|
||||||
renderMermaidBlocks(hostRef.current, lastMermaidSourcesRef, token, renderTokenRef)
|
renderMermaidBlocks(hostRef.current, lastMermaidSourcesRef, token, renderTokenRef)
|
||||||
}, [content])
|
}, [content, showTrackedChanges, changes])
|
||||||
|
|
||||||
// Window-selection bridge for §8.12. Listen on document mouseup so
|
// Window-selection bridge for §8.12. Listen on document mouseup so
|
||||||
// releases outside the preview still clear the prior selection.
|
// releases outside the preview still clear the prior selection.
|
||||||
@@ -123,11 +141,42 @@ export default function MarkdownPreview({
|
|||||||
return () => document.removeEventListener('mouseup', handleMouseUp)
|
return () => document.removeEventListener('mouseup', handleMouseUp)
|
||||||
}, [onSelectionChange])
|
}, [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 (
|
return (
|
||||||
<div
|
<>
|
||||||
ref={hostRef}
|
<div
|
||||||
className={`markdown-preview${className ? ' ' + className : ''}`}
|
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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,11 @@ export default function RFCView({ viewer }) {
|
|||||||
const [selection, setSelection] = useState(null)
|
const [selection, setSelection] = useState(null)
|
||||||
const [reviewMode, setReviewMode] = useState(false)
|
const [reviewMode, setReviewMode] = useState(false)
|
||||||
const [reviewHTML, setReviewHTML] = useState('')
|
const [reviewHTML, setReviewHTML] = useState('')
|
||||||
|
// Phase 3 — Discuss-mode opt-in for the §8.10 tracked-change overlay
|
||||||
|
// in the single-pane preview. Contribute mode is default-on (the
|
||||||
|
// pane exists for editorial review of your own work); Discuss mode
|
||||||
|
// keeps clean prose by default and exposes a toggle.
|
||||||
|
const [discussShowChanges, setDiscussShowChanges] = useState(false)
|
||||||
|
|
||||||
// Mode: discuss vs contribute (§8.3). Always discuss on main.
|
// Mode: discuss vs contribute (§8.3). Always discuss on main.
|
||||||
const [mode, setMode] = useState('discuss')
|
const [mode, setMode] = useState('discuss')
|
||||||
@@ -142,6 +147,7 @@ export default function RFCView({ viewer }) {
|
|||||||
setPendingDiscussChanges([])
|
setPendingDiscussChanges([])
|
||||||
setManualPending(null)
|
setManualPending(null)
|
||||||
setReviewMode(false)
|
setReviewMode(false)
|
||||||
|
setDiscussShowChanges(false)
|
||||||
setSelection(null)
|
setSelection(null)
|
||||||
setMode('discuss')
|
setMode('discuss')
|
||||||
|
|
||||||
@@ -649,6 +655,22 @@ export default function RFCView({ viewer }) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{inDiscuss && branchParam !== 'main'
|
||||||
|
&& changes.some(c => c.state === 'accepted') && (
|
||||||
|
<div className="editor-toolbar">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn-review-toggle${discussShowChanges ? ' active' : ''}`}
|
||||||
|
onClick={() => setDiscussShowChanges(v => !v)}
|
||||||
|
title="§8.10 — surface accepted tracked changes inline in the preview"
|
||||||
|
>
|
||||||
|
{discussShowChanges ? 'Hide tracked changes' : 'Show tracked changes'}
|
||||||
|
</button>
|
||||||
|
<span className="editor-toolbar-hint">
|
||||||
|
{changes.filter(c => c.state === 'accepted').length} accepted on this branch
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{reviewMode ? (
|
{reviewMode ? (
|
||||||
<DiffView html={reviewHTML} changes={changes} messages={messages} />
|
<DiffView html={reviewHTML} changes={changes} messages={messages} />
|
||||||
) : (
|
) : (
|
||||||
@@ -666,6 +688,9 @@ export default function RFCView({ viewer }) {
|
|||||||
<MarkdownPreview
|
<MarkdownPreview
|
||||||
content={previewContent}
|
content={previewContent}
|
||||||
onSelectionChange={handleSelectionChange}
|
onSelectionChange={handleSelectionChange}
|
||||||
|
changes={changes}
|
||||||
|
messages={messages}
|
||||||
|
showTrackedChanges
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -674,6 +699,9 @@ export default function RFCView({ viewer }) {
|
|||||||
content={editorContent}
|
content={editorContent}
|
||||||
onSelectionChange={handleSelectionChange}
|
onSelectionChange={handleSelectionChange}
|
||||||
className="markdown-preview-solo"
|
className="markdown-preview-solo"
|
||||||
|
changes={changes}
|
||||||
|
messages={messages}
|
||||||
|
showTrackedChanges={inDiscuss && discussShowChanges}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<SelectionTooltip
|
<SelectionTooltip
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
// trackedOverlay.js — Phase 3 of the Contribute rewrite. Decorates the
|
||||||
|
// rendered MarkdownPreview DOM with `<span class="tracked-insert">` and
|
||||||
|
// `<span class="tracked-delete">` markup for each accepted change on
|
||||||
|
// the branch, restoring the §8.10 inline tracked-change layer that
|
||||||
|
// Phase 1 dropped when Tiptap left.
|
||||||
|
//
|
||||||
|
// The pipeline is DOM-side (post-marked.parse) rather than markdown-side
|
||||||
|
// so the overlay sees the exact text the reader sees — header /
|
||||||
|
// emphasis / inline code shape doesn't matter for matching, and we
|
||||||
|
// don't try to re-anchor proposed text that crosses inline tags.
|
||||||
|
// Single-text-node match is the pre-fancy stance; cross-tag spans skip
|
||||||
|
// cleanly per the Phase 3 instructions ("if proposed doesn't match,
|
||||||
|
// skip the decoration rather than mis-anchor").
|
||||||
|
//
|
||||||
|
// Subtleties handled:
|
||||||
|
// • acted_at ordering — later spans win on overlap because we always
|
||||||
|
// search for un-wrapped occurrences; an earlier wrap removes that
|
||||||
|
// substring from the search space, so a later change that hits the
|
||||||
|
// same passage just lands its mark on whatever is left.
|
||||||
|
// • mermaid intersections — text nodes inside `.mermaid-block` are
|
||||||
|
// skipped before matching, so a tracked span whose `proposed`
|
||||||
|
// only matches inside diagram source gets dropped silently.
|
||||||
|
// • drift — if the proposed text was further edited since acceptance
|
||||||
|
// it won't match verbatim; we skip rather than mis-anchor.
|
||||||
|
//
|
||||||
|
// Out of scope for Phase 3 (per the prompt): retro-fitting DiffView
|
||||||
|
// (which uses dangerouslySetInnerHTML — the per-session injection it
|
||||||
|
// expects is dying in Phase 7); rendering changes that have been
|
||||||
|
// declined or are still pending; cursor-aware "what's new since last
|
||||||
|
// visit" filtering.
|
||||||
|
|
||||||
|
const DECORATABLE_PARENT_SKIP = new Set([
|
||||||
|
'SCRIPT',
|
||||||
|
'STYLE',
|
||||||
|
'CODE',
|
||||||
|
'PRE',
|
||||||
|
])
|
||||||
|
|
||||||
|
function isInsideMermaid(node) {
|
||||||
|
let p = node.parentNode
|
||||||
|
while (p && p.nodeType === Node.ELEMENT_NODE) {
|
||||||
|
if (p.classList?.contains('mermaid-block')) return true
|
||||||
|
p = p.parentNode
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInsideTrackedSpan(node) {
|
||||||
|
let p = node.parentNode
|
||||||
|
while (p && p.nodeType === Node.ELEMENT_NODE) {
|
||||||
|
if (p.classList?.contains('tracked-insert')) return true
|
||||||
|
if (p.classList?.contains('tracked-delete')) return true
|
||||||
|
p = p.parentNode
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yield every text node inside `root` whose nearest non-text parent is
|
||||||
|
// neither inside a mermaid block nor inside an already-applied tracked
|
||||||
|
// span. Code/pre nodes are excluded — accepted-change text rarely
|
||||||
|
// targets verbatim code blocks and matching inside them produces noisy
|
||||||
|
// false positives.
|
||||||
|
function* eligibleTextNodes(root) {
|
||||||
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
||||||
|
acceptNode(node) {
|
||||||
|
if (!node.nodeValue) return NodeFilter.FILTER_REJECT
|
||||||
|
const parent = node.parentElement
|
||||||
|
if (!parent) return NodeFilter.FILTER_REJECT
|
||||||
|
if (DECORATABLE_PARENT_SKIP.has(parent.tagName)) return NodeFilter.FILTER_REJECT
|
||||||
|
if (isInsideMermaid(node)) return NodeFilter.FILTER_REJECT
|
||||||
|
if (isInsideTrackedSpan(node)) return NodeFilter.FILTER_REJECT
|
||||||
|
return NodeFilter.FILTER_ACCEPT
|
||||||
|
},
|
||||||
|
})
|
||||||
|
let n = walker.nextNode()
|
||||||
|
while (n) {
|
||||||
|
yield n
|
||||||
|
n = walker.nextNode()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapse the kind of whitespace differences marked produces (newlines
|
||||||
|
// become spaces, indentation folds) so a proposed string authored
|
||||||
|
// against raw markdown can still match against rendered text.
|
||||||
|
function normalizeForMatch(s) {
|
||||||
|
return String(s || '').replace(/\s+/g, ' ').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the first text node containing `needle` (after whitespace
|
||||||
|
// normalization) and return the (node, offsetInNodeValue,
|
||||||
|
// matchedLength) for splitting. Returns null when no eligible node
|
||||||
|
// contains the needle.
|
||||||
|
function findFirstMatch(root, needle) {
|
||||||
|
const target = normalizeForMatch(needle)
|
||||||
|
if (!target) return null
|
||||||
|
for (const node of eligibleTextNodes(root)) {
|
||||||
|
const haystack = node.nodeValue
|
||||||
|
const normalized = haystack.replace(/\s+/g, ' ')
|
||||||
|
const idx = normalized.indexOf(target)
|
||||||
|
if (idx < 0) continue
|
||||||
|
// Translate the normalized index back to the raw nodeValue offset.
|
||||||
|
// We walk the raw string counting non-whitespace characters until
|
||||||
|
// we hit the normalized index, then count out the matched length.
|
||||||
|
const start = mapNormalizedOffsetToRaw(haystack, idx)
|
||||||
|
const end = mapNormalizedOffsetToRaw(haystack, idx + target.length)
|
||||||
|
if (start == null || end == null || end <= start) continue
|
||||||
|
return { node, start, end }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapNormalizedOffsetToRaw(raw, normalizedOffset) {
|
||||||
|
// Re-derive the position in `raw` that corresponds to position
|
||||||
|
// `normalizedOffset` in `raw.replace(/\s+/g, ' ')`. We walk raw,
|
||||||
|
// tracking how many normalized characters we've emitted so far.
|
||||||
|
let emitted = 0
|
||||||
|
let i = 0
|
||||||
|
let inWhitespaceRun = false
|
||||||
|
while (i <= raw.length) {
|
||||||
|
if (emitted === normalizedOffset) return i
|
||||||
|
if (i === raw.length) break
|
||||||
|
const c = raw[i]
|
||||||
|
if (/\s/.test(c)) {
|
||||||
|
if (!inWhitespaceRun) {
|
||||||
|
emitted += 1 // collapsed whitespace counts as one space
|
||||||
|
inWhitespaceRun = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
emitted += 1
|
||||||
|
inWhitespaceRun = false
|
||||||
|
}
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
return emitted === normalizedOffset ? raw.length : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSpan(className, text, change) {
|
||||||
|
const span = document.createElement('span')
|
||||||
|
span.className = className
|
||||||
|
span.setAttribute('data-change-id', String(change.id))
|
||||||
|
if (change.source_message_id != null) {
|
||||||
|
span.setAttribute('data-source-message-id', String(change.source_message_id))
|
||||||
|
}
|
||||||
|
span.textContent = text
|
||||||
|
return span
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace the matched range inside `node` with: optional tracked-delete
|
||||||
|
// span (rendering `original` struck-through), followed by tracked-insert
|
||||||
|
// span wrapping the matched proposed text.
|
||||||
|
function decorateMatch(match, change) {
|
||||||
|
const { node, start, end } = match
|
||||||
|
const before = node.nodeValue.slice(0, start)
|
||||||
|
const matched = node.nodeValue.slice(start, end)
|
||||||
|
const after = node.nodeValue.slice(end)
|
||||||
|
|
||||||
|
const parent = node.parentNode
|
||||||
|
if (!parent) return
|
||||||
|
|
||||||
|
const insertSpan = makeSpan('tracked-insert', matched, change)
|
||||||
|
let deleteSpan = null
|
||||||
|
const originalText = (change.original || '').trim()
|
||||||
|
// Skip the strikethrough when original is empty (pure insertion) or
|
||||||
|
// when it would duplicate the inserted text exactly — common for
|
||||||
|
// light AI edits where the proposal essentially repeats the source.
|
||||||
|
if (originalText && normalizeForMatch(originalText) !== normalizeForMatch(matched)) {
|
||||||
|
deleteSpan = makeSpan('tracked-delete', change.original, change)
|
||||||
|
}
|
||||||
|
|
||||||
|
const beforeNode = before ? document.createTextNode(before) : null
|
||||||
|
const afterNode = after ? document.createTextNode(after) : null
|
||||||
|
|
||||||
|
// Build the replacement sequence in order.
|
||||||
|
const frag = document.createDocumentFragment()
|
||||||
|
if (beforeNode) frag.appendChild(beforeNode)
|
||||||
|
if (deleteSpan) frag.appendChild(deleteSpan)
|
||||||
|
frag.appendChild(insertSpan)
|
||||||
|
if (afterNode) frag.appendChild(afterNode)
|
||||||
|
|
||||||
|
parent.replaceChild(frag, node)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort accepted changes by acted_at ascending so later spans win on
|
||||||
|
// overlap. Falls back to id ordering when acted_at is null/equal.
|
||||||
|
function orderForDecoration(changes) {
|
||||||
|
return changes
|
||||||
|
.filter(c => c?.state === 'accepted' && c.proposed)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aa = a.acted_at || ''
|
||||||
|
const bb = b.acted_at || ''
|
||||||
|
if (aa === bb) return (a.id || 0) - (b.id || 0)
|
||||||
|
return aa.localeCompare(bb)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decorateAcceptedChanges(root, changes) {
|
||||||
|
if (!root || !changes || changes.length === 0) return
|
||||||
|
const ordered = orderForDecoration(changes)
|
||||||
|
for (const change of ordered) {
|
||||||
|
const match = findFirstMatch(root, change.proposed)
|
||||||
|
if (!match) continue
|
||||||
|
try {
|
||||||
|
decorateMatch(match, change)
|
||||||
|
} catch {
|
||||||
|
// Tolerate per-change failures so one bad span doesn't kill the
|
||||||
|
// overlay for everything that came after it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user