import { useMemo, useState } from "react"; import { Box, Button, Group, Text } from "@mantine/core"; import { ChevronDown, ChevronRight } from "lucide-react"; type Op = "same" | "added" | "removed"; interface Token { op: Op; text: string; } /** Split on whitespace but KEEP it, so rebuilt text preserves its spacing. */ function tokenize(text: string): string[] { return text.split(/(\s+)/).filter((t) => t !== ""); } /** * Word-level diff via the classic LCS table. * * ponytail: O(n·m) time and memory over word counts. Contract articles are * paragraphs (hundreds of words), so this is microseconds; the guard below * bails to a whole-block replace if an article ever gets pathological. Swap in * a real diff library only if that guard starts firing. */ const MAX_TOKENS = 1200; export function diffWords(before: string, after: string): Token[] { const a = tokenize(before); const b = tokenize(after); if (a.length > MAX_TOKENS || b.length > MAX_TOKENS) { return [ { op: "removed", text: before }, { op: "added", text: after }, ]; } // lcs[i][j] = length of the longest common subsequence of a[i:] and b[j:]. const lcs: number[][] = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0), ); for (let i = a.length - 1; i >= 0; i--) { for (let j = b.length - 1; j >= 0; j--) { lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]); } } const tokens: Token[] = []; // Merge runs of the same op so the output is spans, not one node per word. const push = (op: Op, text: string) => { const last = tokens[tokens.length - 1]; if (last && last.op === op) last.text += text; else tokens.push({ op, text }); }; let i = 0; let j = 0; while (i < a.length && j < b.length) { if (a[i] === b[j]) { push("same", a[i]); i++; j++; } else if (lcs[i + 1][j] >= lcs[i][j + 1]) { push("removed", a[i]); i++; } else { push("added", b[j]); j++; } } while (i < a.length) push("removed", a[i++]); while (j < b.length) push("added", b[j++]); return tokens; } const OP_STYLE: Record = { same: {}, added: { background: "var(--mantine-color-teal-1)", color: "var(--mantine-color-teal-9)", borderRadius: 3, }, removed: { background: "var(--mantine-color-red-1)", color: "var(--mantine-color-red-9)", borderRadius: 3, textDecoration: "line-through", }, }; /** * Inline before/after of an edited article body: removed words struck through * in red, inserted words highlighted in green. Collapsed by default — a * revision list stays scannable, and the full text is one click away. */ export function ArticleBodyDiff({ fromBody, toBody, }: { fromBody: string; toBody: string; }) { const [open, setOpen] = useState(false); const tokens = useMemo( () => (open ? diffWords(fromBody, toBody) : []), [open, fromBody, toBody], ); return ( {open && ( {tokens.map((token, index) => ( {token.text} ))} Removed Added )} ); }