2a7c099a33
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>
211 lines
7.8 KiB
JavaScript
211 lines
7.8 KiB
JavaScript
// 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.
|
|
}
|
|
}
|
|
}
|