mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 06:40:57 +00:00
169 lines
4.5 KiB
TypeScript
169 lines
4.5 KiB
TypeScript
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<number>(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<Op, React.CSSProperties> = {
|
|
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 (
|
|
<Box mt={4}>
|
|
<Button
|
|
variant="subtle"
|
|
size="compact-xs"
|
|
color="gray"
|
|
px={4}
|
|
leftSection={
|
|
open ? <ChevronDown size={12} /> : <ChevronRight size={12} />
|
|
}
|
|
onClick={() => setOpen((v) => !v)}
|
|
>
|
|
{open ? "Hide changes" : "View changes"}
|
|
</Button>
|
|
|
|
{open && (
|
|
<Box
|
|
mt={6}
|
|
p="sm"
|
|
style={{
|
|
borderRadius: 8,
|
|
border: "1px solid var(--mantine-color-gray-3)",
|
|
background: "var(--mantine-color-gray-0)",
|
|
maxHeight: 320,
|
|
overflowY: "auto",
|
|
}}
|
|
>
|
|
<Text
|
|
size="xs"
|
|
component="div"
|
|
style={{ whiteSpace: "pre-wrap", lineHeight: 1.6 }}
|
|
>
|
|
{tokens.map((token, index) => (
|
|
<span key={index} style={OP_STYLE[token.op]}>
|
|
{token.text}
|
|
</span>
|
|
))}
|
|
</Text>
|
|
<Group gap="md" mt="xs">
|
|
<Group gap={4}>
|
|
<Box w={10} h={10} style={{ ...OP_STYLE.removed, borderRadius: 2 }} />
|
|
<Text size="10px" c="dimmed">
|
|
Removed
|
|
</Text>
|
|
</Group>
|
|
<Group gap={4}>
|
|
<Box w={10} h={10} style={{ ...OP_STYLE.added, borderRadius: 2 }} />
|
|
<Text size="10px" c="dimmed">
|
|
Added
|
|
</Text>
|
|
</Group>
|
|
</Group>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
);
|
|
}
|