feat(edit-history): word-level diff view

The Edit History modal listed each version's full text with no indication
of what changed. Add a word-level diff: each edit highlights the words
added (green) and removed (struck-through red) relative to the previous
version, so a one-word fix is obvious at a glance.

- New pure, dependency-free diffWords (LCS over word/whitespace tokens) in
  utils/textDiff.ts, with 8 unit tests (insert/delete/replace, whitespace
  preserved, empty, no-mutation, word-not-char granularity).
- EditHistoryModal renders each edit via a DiffText component using
  semantic <ins>/<del> (screen-reader-meaningful) styled with folds
  Success/Critical tokens. A "Highlight changes" header toggle (default
  on) switches back to full text, which keeps the rich formatted render;
  the Original row is always the plain baseline.
- Diff is plain-text (body) only by design; formatted markup isn't diffed
  (the toggle restores the rich view), and media/no-body edits diff as
  empty strings gracefully.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 11:02:59 -04:00
co-authored by Claude Opus 4.8
parent b6413d763d
commit 961789fd71
4 changed files with 233 additions and 1 deletions
+66
View File
@@ -0,0 +1,66 @@
// Word-level text diff for the Edit History viewer. Pure and dependency-free.
export type DiffSegment = {
type: 'equal' | 'added' | 'removed';
text: string;
};
// Split into a sequence of tokens where each token is either a run of
// whitespace or a run of non-whitespace, so word boundaries and the original
// spacing/newlines are both preserved when segments are re-joined.
const tokenize = (text: string): string[] => text.match(/\s+|\S+/g) ?? [];
/**
* Word-level diff of `oldText` → `newText` via a classic LCS. Returns segments
* in reading order: `equal` runs, `removed` runs (present only in old), and
* `added` runs (present only in new). Consecutive same-type tokens are merged.
* Pure — never mutates its inputs. Whitespace is preserved.
*/
export function diffWords(oldText: string, newText: string): DiffSegment[] {
const a = tokenize(oldText);
const b = tokenize(newText);
const n = a.length;
const m = b.length;
// lcs[i][j] = length of the longest common subsequence of a[i..] and b[j..].
const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0));
for (let i = n - 1; i >= 0; i -= 1) {
for (let j = m - 1; j >= 0; j -= 1) {
lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
}
}
const raw: { type: DiffSegment['type']; text: string }[] = [];
let i = 0;
let j = 0;
while (i < n && j < m) {
if (a[i] === b[j]) {
raw.push({ type: 'equal', text: a[i] });
i += 1;
j += 1;
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
raw.push({ type: 'removed', text: a[i] });
i += 1;
} else {
raw.push({ type: 'added', text: b[j] });
j += 1;
}
}
while (i < n) {
raw.push({ type: 'removed', text: a[i] });
i += 1;
}
while (j < m) {
raw.push({ type: 'added', text: b[j] });
j += 1;
}
// Merge consecutive same-type tokens into segments.
const segments: DiffSegment[] = [];
raw.forEach((tok) => {
const last = segments[segments.length - 1];
if (last && last.type === tok.type) last.text += tok.text;
else segments.push({ type: tok.type, text: tok.text });
});
return segments;
}