// trackedOverlay.js — Phase 3 of the Contribute rewrite. Decorates the // rendered MarkdownPreview DOM with `` and // `` 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. } } }