/* * shared/diff.js — a small, dependency-free text diff. * * Attaches to window.zddc.diff. Must load AFTER shared/zddc.js (which * creates the window.zddc object). Used by the browse tool's markdown * version-history viewer to show what changed between any two saved * versions; kept in shared/ so other tools can reuse it. * * API: * window.zddc.diff.lines(oldStr, newStr) * → [{ type: 'eq'|'del'|'add', text }] line-level diff (LCS) * window.zddc.diff.words(oldStr, newStr) * → [{ type: 'eq'|'del'|'add', text }] token-level diff for one * changed line (whitespace-preserving), for intra-line highlights * window.zddc.diff.stats(ops) → { added, removed } * * The line diff trims the common prefix/suffix before running the O(n*m) * LCS dynamic program, so a small edit in a large file stays cheap. A * safety cap falls back to "replace whole block" when the changed middle * is pathologically large, so the UI never freezes. */ (function () { 'use strict'; var LCS_CELL_CAP = 4000000; // ~4M cells (n*m) before the fallback function splitLines(s) { return String(s == null ? '' : s).replace(/\r\n/g, '\n').split('\n'); } // LCS diff of two arrays of strings → ordered [{type, text}] ops. function lcsDiff(a, b) { var n = a.length, m = b.length; if (n === 0 && m === 0) return []; if (n === 0) return b.map(function (t) { return { type: 'add', text: t }; }); if (m === 0) return a.map(function (t) { return { type: 'del', text: t }; }); if (n * m > LCS_CELL_CAP) { // Too large to diff finely without risking a UI stall: treat // the whole block as a wholesale replacement. var out = a.map(function (t) { return { type: 'del', text: t }; }); return out.concat(b.map(function (t) { return { type: 'add', text: t }; })); } // dp[i][j] = LCS length of a[i:] and b[j:]. var dp = new Array(n + 1); for (var i = 0; i <= n; i++) dp[i] = new Array(m + 1).fill(0); for (var ii = n - 1; ii >= 0; ii--) { for (var jj = m - 1; jj >= 0; jj--) { if (a[ii] === b[jj]) dp[ii][jj] = dp[ii + 1][jj + 1] + 1; else dp[ii][jj] = Math.max(dp[ii + 1][jj], dp[ii][jj + 1]); } } var ops = [], i = 0, j = 0; while (i < n && j < m) { if (a[i] === b[j]) { ops.push({ type: 'eq', text: a[i] }); i++; j++; } else if (dp[i + 1][j] >= dp[i][j + 1]) { ops.push({ type: 'del', text: a[i] }); i++; } else { ops.push({ type: 'add', text: b[j] }); j++; } } while (i < n) ops.push({ type: 'del', text: a[i++] }); while (j < m) ops.push({ type: 'add', text: b[j++] }); return ops; } function diffLines(oldStr, newStr) { var a = splitLines(oldStr), b = splitLines(newStr); var ops = []; // Common prefix. var start = 0; while (start < a.length && start < b.length && a[start] === b[start]) start++; // Common suffix (not overlapping the prefix). var endA = a.length, endB = b.length; while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) { endA--; endB--; } for (var p = 0; p < start; p++) ops.push({ type: 'eq', text: a[p] }); var mid = lcsDiff(a.slice(start, endA), b.slice(start, endB)); for (var k = 0; k < mid.length; k++) ops.push(mid[k]); for (var s = endA; s < a.length; s++) ops.push({ type: 'eq', text: a[s] }); return ops; } // Whitespace-preserving tokenization: words and the runs of // whitespace between them are separate tokens, so a re-diff lines up // on word boundaries while keeping the original spacing renderable. function tokenize(s) { return String(s == null ? '' : s).split(/(\s+)/).filter(function (x) { return x !== ''; }); } function diffWords(oldStr, newStr) { return lcsDiff(tokenize(oldStr), tokenize(newStr)); } function stats(ops) { var added = 0, removed = 0; for (var i = 0; i < ops.length; i++) { if (ops[i].type === 'add') added++; else if (ops[i].type === 'del') removed++; } return { added: added, removed: removed }; } if (!window.zddc) { throw new Error('shared/diff.js: window.zddc must be loaded first'); } window.zddc.diff = { lines: diffLines, words: diffWords, stats: stats }; })();