Diff Visualizer
by sloprun seed
2026-08-14
0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously
A real Myers diff — the same minimal-edit algorithm git runs — in about a hundred lines of plain JavaScript, wired live to two editable versions of a small function. The interesting part is what happens after the line diff: changed lines get diffed a second time at the word level, and the deleted/inserted lines inside a block are paired by similarity rather than by position, so const value = ... lines up with its own rewrite instead of with whatever happened to be next. Try editing either box and watching the hunks recompute, then hit Unified to read it as a patch. Added is amber and removed is red — never green/red, partly because green means "it ran" here, and partly because red-green is the one pair roughly one man in twelve can't tell apart.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Diff Visualizer — sloprun.dev</title>
<style>
/* sloprun design tokens — inline this block into every demo (self-contained rule).
Identity: instrument-panel. Machine facts in mono; human words in sans.
Green is EARNED: only for "it ran / it worked" states, never decoration. */
:root {
--bg: #F6F6F3; /* neutral paper, not cream */
--surface: #FFFFFF;
--ink: #1A1D21;
--muted: #5A6068;
--line: #E3E2DC;
--accent: #C05600; /* phosphor amber, darkened for light bg */
--accent-soft: #FFF3E6;
--run: #1A7F37; /* earned green */
--run-soft: #E7F4EA;
--danger: #C93C37;
--radius: 10px;
--font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
}
:root[data-theme="dark"] {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }
/* --- diff palette: amber = added, red = removed.
Deliberately NOT green/red: green is reserved for "it worked", and
amber/red survives red-green colour blindness. --- */
:root {
--add-bg:#FFF4E8; --add-hi:#FFD9A8; --add-fg:#8A4200; --add-line:#E6B27C;
--del-bg:#FDECEA; --del-hi:#F7C9C5; --del-fg:#9A2B27; --del-line:#E9A6A2;
--fold-bg:#F0EFEA; --fill-bg:#FAFAF7;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--add-bg:#2E2611; --add-hi:#5C4A1C; --add-fg:#FFC880; --add-line:#5E4626;
--del-bg:#301618; --del-hi:#5C2024; --del-fg:#FF9E96; --del-line:#5E2A27;
--fold-bg:#22262E; --fill-bg:#181B21;
}
}
:root[data-theme="dark"] {
--add-bg:#2E2611; --add-hi:#5C4A1C; --add-fg:#FFC880; --add-line:#5E4626;
--del-bg:#301618; --del-hi:#5C2024; --del-fg:#FF9E96; --del-line:#5E2A27;
--fold-bg:#22262E; --fill-bg:#181B21;
}
*,*::before,*::after{box-sizing:border-box}
html,body{margin:0;padding:0;overflow-x:hidden}
body{-webkit-text-size-adjust:100%}
[hidden]{display:none !important}
.wrap{max-width:1140px;margin:0 auto;padding:18px 14px 26px}
h1{font-size:19px;margin:0;letter-spacing:-.01em;font-weight:650}
.head{display:flex;flex-wrap:wrap;align-items:center;gap:8px 10px}
.lede{margin:6px 0 14px;color:var(--muted);font-size:13.5px;line-height:1.55;max-width:70ch}
.badge{font-family:var(--font-mono);font-size:10px;letter-spacing:.08em;text-transform:uppercase;
color:var(--accent);background:var(--accent-soft);border:1px solid var(--add-line);padding:2px 7px;border-radius:999px}
.btn{font-family:var(--font-sans);font-size:12.5px;color:var(--ink);background:var(--surface);
border:1px solid var(--line);border-radius:8px;padding:6px 10px;cursor:pointer;line-height:1.15}
.btn:hover:not(:disabled){border-color:var(--accent)}
.btn[aria-pressed="true"]{background:var(--accent-soft);border-color:var(--accent);color:var(--accent);font-weight:600}
.btn:disabled{opacity:.42;cursor:default}
.btn--mono{font-family:var(--font-mono);font-size:11.5px;padding:6px 9px}
.seg{display:inline-flex;gap:0;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--surface)}
.seg .btn{border:0;border-radius:0;padding:6px 12px}
.seg .btn + .btn{border-left:1px solid var(--line)}
:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
/* the panes fill a clipped (overflow:hidden) card, so an outset ring would be
shaved off by the card edge — draw theirs inside instead */
.pane:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
.chip{font-family:var(--font-mono);font-size:11.5px;padding:3px 8px;border-radius:999px;
border:1px solid var(--line);background:var(--surface);color:var(--muted);white-space:nowrap}
.chip b{font-weight:650}
.chip--add{color:var(--add-fg);background:var(--add-bg);border-color:var(--add-line)}
.chip--del{color:var(--del-fg);background:var(--del-bg);border-color:var(--del-line)}
.chip--ok{color:var(--run);background:var(--run-soft);border-color:var(--run)}
.chip--coarse{color:var(--ink);background:var(--fold-bg);border-color:var(--line);border-style:dashed;cursor:help}
.bar{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
.bar--tools{margin:14px 0 10px;justify-content:space-between}
.grp{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
.sep{width:1px;align-self:stretch;background:var(--line);margin:0 2px}
.lbl{font-family:var(--font-mono);font-size:11px;color:var(--muted);letter-spacing:.04em;text-transform:uppercase}
.mono{font-family:var(--font-mono);font-size:.92em;color:var(--ink)}
.note{color:var(--muted)}
.opt{display:inline-flex;align-items:center;gap:6px;font-size:12.5px;color:var(--ink);cursor:pointer;user-select:none}
.opt input{accent-color:var(--accent);width:14px;height:14px;margin:0;cursor:pointer}
.editors{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:10px}
.card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);overflow:hidden;min-width:0}
.card > header{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:6px 10px;border-bottom:1px solid var(--line)}
.tag{font-family:var(--font-mono);font-size:11px;color:var(--muted);letter-spacing:.03em}
.tag--v1{color:var(--del-fg)}
.tag--v2{color:var(--add-fg)}
textarea{display:block;width:100%;border:0;background:transparent;color:var(--ink);
font-family:var(--font-mono);font-size:12.5px;line-height:1.55;padding:8px 10px;
resize:vertical;outline:none;tab-size:2;white-space:pre;overflow:auto;height:172px}
textarea:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
.diff{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);overflow:hidden}
.sbs{display:grid;grid-template-columns:1fr 1fr}
.paneHead{padding:5px 10px;border-bottom:1px solid var(--line);background:var(--bg);min-width:0}
.sbs .paneHead:first-child{border-right:1px solid var(--line)}
.pane{overflow:auto;position:relative;max-height:clamp(220px,42vh,520px);min-height:120px;min-width:0;scrollbar-width:thin}
.sbs #paneL{border-right:1px solid var(--line)}
.rows{min-width:max-content;font-family:var(--font-mono);font-size:12.5px;line-height:20px}
.row{display:flex;align-items:stretch;min-height:20px;--row-bg:var(--surface);background:var(--row-bg)}
.row--del{--row-bg:var(--del-bg)}
.row--ins{--row-bg:var(--add-bg)}
.row--fill{--row-bg:var(--fill-bg)}
/* min-height 24 (not 20) so the inline "show them" control clears the 24px
minimum target size; both panes get it, so the two sides stay row-aligned */
.row--fold{--row-bg:var(--fold-bg);color:var(--muted);font-size:11.5px;padding-left:10px;gap:8px;
align-items:center;white-space:nowrap;min-height:24px}
.row.cur{box-shadow:inset 3px 0 0 var(--accent)}
.row.cur .gut:first-of-type{box-shadow:inset 3px 0 0 var(--accent)}
.gut{position:sticky;left:0;z-index:2;background:var(--row-bg);color:var(--muted);text-align:right;
padding:0 7px;width:44px;flex:none;font-size:11px;user-select:none;font-variant-numeric:tabular-nums}
.uni .gut{width:40px}
.uni .gut + .gut{left:40px}
.mark{position:sticky;left:44px;z-index:2;background:var(--row-bg);width:16px;flex:none;text-align:center;
border-right:1px solid var(--line);color:var(--muted);user-select:none}
.uni .mark{left:80px}
.row--del .mark{color:var(--del-fg);font-weight:700;background:var(--del-hi)}
.row--ins .mark{color:var(--add-fg);font-weight:700;background:var(--add-hi)}
.code{padding:0 10px;white-space:pre;flex:1 0 auto}
.w{background:var(--add-hi);border-radius:3px;box-shadow:0 0 0 1px var(--add-hi)}
.wd{background:var(--del-hi);border-radius:3px;box-shadow:0 0 0 1px var(--del-hi)}
.foldbtn{appearance:none;background:none;border:0;padding:2px 0;margin:0;font:inherit;
font-family:var(--font-mono);color:var(--accent);cursor:pointer;text-decoration:underline;text-underline-offset:2px}
.same{padding:26px 16px;text-align:center;color:var(--muted);font-size:13.5px}
.same .chip{margin-top:8px;display:inline-block}
.hint{margin:10px 0 0;color:var(--muted);font-size:12px;line-height:1.6}
kbd{font-family:var(--font-mono);font-size:11px;border:1px solid var(--line);border-bottom-width:2px;
border-radius:5px;padding:1px 5px;background:var(--surface);color:var(--ink)}
footer{margin-top:18px;padding-top:12px;border-top:1px solid var(--line);
font-family:var(--font-mono);font-size:11px;color:var(--muted);letter-spacing:.04em}
.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
@media (max-width:760px){
.editors{grid-template-columns:1fr}
textarea{height:128px}
.bar--tools{gap:10px}
.sep{display:none}
}
/* phone widths: give the code back the room the gutters were eating */
@media (max-width:480px){
/* on a phone the two editors stack, so keep them short enough that the diff
itself — the point of the demo — is not pushed a screen and a half down */
textarea{height:104px}
.lede{font-size:13px;margin-bottom:10px}
.rows{font-size:12px}
.gut{width:30px;padding:0 5px}
.mark{left:30px}
.uni .gut{width:26px}
.uni .gut + .gut{left:26px}
.uni .mark{left:52px}
.code{padding:0 6px}
}
@media (prefers-reduced-motion: reduce){
*{animation-duration:.001ms !important;animation-iteration-count:1 !important;
transition-duration:.001ms !important;scroll-behavior:auto !important}
}
</style>
</head>
<body>
<main class="wrap">
<div class="head">
<h1>Diff Visualizer</h1>
<span class="badge">myers · line + word</span>
</div>
<p class="lede">
Two versions of a small function, lined up by a real Myers diff — the same minimal-edit
algorithm <span class="mono">git</span> uses. Changed lines are then diffed again word by word,
so you see the edit inside the line. Type in either box and everything recomputes live.
</p>
<div class="bar">
<span class="lbl">example</span>
<button class="btn" type="button" data-preset="0" aria-pressed="true">Bytes formatter</button>
<button class="btn" type="button" data-preset="2" aria-pressed="false">One-line guard</button>
<button class="btn" type="button" data-preset="1" aria-pressed="false">Callbacks → async</button>
<button class="btn" type="button" data-preset="3" aria-pressed="false">Module rewrite</button>
<span class="sep" aria-hidden="true"></span>
<button class="btn btn--mono" type="button" id="swap" title="Swap the two versions">⇄ swap</button>
<button class="btn btn--mono" type="button" id="reset">↺ reset</button>
</div>
<div class="editors">
<div class="card">
<header>
<span class="tag tag--v1">v1 · before</span>
<span class="chip" id="countA">0 lines</span>
</header>
<label class="sr" for="ta">Version 1 source</label>
<textarea id="ta" spellcheck="false" autocapitalize="off" autocorrect="off" wrap="off"></textarea>
</div>
<div class="card">
<header>
<span class="tag tag--v2">v2 · after</span>
<span class="chip" id="countB">0 lines</span>
</header>
<label class="sr" for="tb">Version 2 source</label>
<textarea id="tb" spellcheck="false" autocapitalize="off" autocorrect="off" wrap="off"></textarea>
</div>
</div>
<div class="bar bar--tools">
<div class="grp">
<div class="seg" role="group" aria-label="Diff layout">
<button class="btn" type="button" id="vSplit" aria-pressed="true">Side-by-side</button>
<button class="btn" type="button" id="vUni" aria-pressed="false">Unified</button>
</div>
<span class="chip chip--add" id="chipAdd"><b>+0</b></span>
<span class="chip chip--del" id="chipDel"><b>-0</b></span>
<span class="chip" id="chipHunks">0 hunks</span>
<span class="chip" id="chipTime">0.00 ms</span>
<span class="chip chip--coarse" id="chipCoarse" hidden
title="Too many differences to search exactly — the two sides are shown as a wholesale replacement.">approximate</span>
</div>
<div class="grp">
<label class="opt"><input type="checkbox" id="optWs"> ignore whitespace</label>
<label class="opt" id="optSyncWrap"><input type="checkbox" id="optSync" checked> sync scroll</label>
<span class="sep" aria-hidden="true"></span>
<button class="btn btn--mono" type="button" id="prev" title="Previous change (p)" aria-label="Previous change">↑</button>
<span class="chip" id="chipNav">–/0</span>
<button class="btn btn--mono" type="button" id="next" title="Next change (n)" aria-label="Next change">↓</button>
<button class="btn btn--mono" type="button" id="copy">copy patch</button>
</div>
</div>
<div class="diff">
<div class="sbs" id="sbs">
<div class="paneHead"><span class="tag tag--v1">v1 · before</span></div>
<div class="paneHead"><span class="tag tag--v2">v2 · after</span></div>
<div class="pane" id="paneL" tabindex="0" role="group" aria-label="Version 1, side-by-side diff"><div class="rows" id="rowsL"></div></div>
<div class="pane" id="paneR" tabindex="0" role="group" aria-label="Version 2, side-by-side diff"><div class="rows" id="rowsR"></div></div>
</div>
<div id="uniWrap" hidden>
<div class="paneHead"><span class="tag">unified · <span id="uniName">patch</span></span></div>
<div class="pane uni" id="paneU" tabindex="0" role="group" aria-label="Unified diff"><div class="rows" id="rowsU"></div></div>
</div>
<div class="same" id="same" hidden>
<span id="sameMsg">Both versions are identical — edit one of them to see a diff.</span>
<br><span class="chip">0 changes</span>
</div>
</div>
<p class="hint">
<kbd>n</kbd> / <kbd>p</kbd> jump between changes · panes are focusable, so arrows and
<kbd>PgDn</kbd> scroll them · amber is added, red is removed
<span class="note">(not green — on sloprun, green means "it worked")</span>
</p>
<p class="sr" role="status" id="status"></p>
<footer>demo · sloprun.dev</footer>
</main>
<script>
(function () {
'use strict';
var $ = function (s) { return document.querySelector(s); };
/* ---------- platform theme bridge (also works standalone via prefers-color-scheme) ---------- */
window.addEventListener('message', function (e) {
var d = e.data;
if (d && d.type === 'sloprun:theme' && (d.theme === 'light' || d.theme === 'dark')) {
document.documentElement.setAttribute('data-theme', d.theme);
}
});
/* =======================================================================
Myers O(ND) diff — greedy forward pass with a saved trace, then backtrack.
Generic over arrays, so the same code diffs lines and words.
Cost scales with D, the number of differences — NOT with file size. So the
guard rail below is a cap on D, not on how much text you paste: a 5000-line
file with two edited lines is D=4 and finishes instantly. Only genuinely
unrelated inputs run out of budget, and those fall back to an honest
"replace the whole block" script with the UI saying it's approximate.
======================================================================= */
var MAXD = 2000; // ~16 MB of trace at the ceiling; a real edit never gets close
var coarse = false; // set when the cap above was hit; read + reset by compute()
function replaceAll(a, b) {
var out = [], i;
for (i = 0; i < a.length; i++) out.push({ t: -1, ai: i, bi: -1 });
for (i = 0; i < b.length; i++) out.push({ t: 1, ai: -1, bi: i });
return out;
}
function myersCore(a, b, eq) {
var N = a.length, M = b.length;
if (!N && !M) return [];
if (!N || !M) return replaceAll(a, b);
var MAX = N + M, lim = MAX < MAXD ? MAX : MAXD;
/* off is padded by one so the band slice below can never run off the front */
var off = MAX + 1, v = new Int32Array(2 * MAX + 4), trace = [], D = -1, d, k, x, y, done = false;
for (d = 0; d <= lim && !done; d++) {
/* the backtrack only ever reads diagonals |k| <= d+1, so keep that band
instead of a full copy of the front — the difference between a few MB
and a few hundred on a big paste. Band d starts at index off-d-1. */
trace.push(v.slice(off - d - 1, off + d + 2));
for (k = -d; k <= d; k += 2) {
if (k === -d || (k !== d && v[off + k - 1] < v[off + k + 1])) x = v[off + k + 1];
else x = v[off + k - 1] + 1;
y = x - k;
while (x < N && y < M && eq(a[x], b[y])) { x++; y++; }
v[off + k] = x;
if (x >= N && y >= M) { D = d; done = true; break; }
}
}
if (D < 0) { coarse = true; return replaceAll(a, b); }
var ops = [], vp, prevK, prevX, prevY;
x = N; y = M;
for (d = D; d >= 0; d--) {
vp = trace[d]; // band d: index i of the full front lives at i-(off-d-1)
k = x - y;
if (d === 0) { prevX = 0; prevY = 0; }
else {
if (k === -d || (k !== d && vp[k + d] < vp[k + d + 2])) prevK = k + 1;
else prevK = k - 1;
prevX = vp[prevK + d + 1];
prevY = prevX - prevK;
}
while (x > prevX && y > prevY) { x--; y--; ops.push({ t: 0, ai: x, bi: y }); }
if (d > 0) {
if (x === prevX) ops.push({ t: 1, ai: -1, bi: prevY });
else ops.push({ t: -1, ai: prevX, bi: -1 });
}
x = prevX; y = prevY;
}
ops.reverse();
return ops;
}
/* Trim the common head/tail first: same result, far less work. */
function diffSeq(a, b, eq) {
eq = eq || function (p, q) { return p === q; };
var ops = [], s = 0, ae = a.length, be = b.length, i;
while (s < ae && s < be && eq(a[s], b[s])) s++;
while (ae > s && be > s && eq(a[ae - 1], b[be - 1])) { ae--; be--; }
for (i = 0; i < s; i++) ops.push({ t: 0, ai: i, bi: i });
var mid = myersCore(a.slice(s, ae), b.slice(s, be), eq);
for (i = 0; i < mid.length; i++) {
ops.push({ t: mid[i].t, ai: mid[i].ai < 0 ? -1 : mid[i].ai + s, bi: mid[i].bi < 0 ? -1 : mid[i].bi + s });
}
for (i = ae; i < a.length; i++) ops.push({ t: 0, ai: i, bi: be + (i - ae) });
return ops;
}
/* ---------- word-level diff inside a changed line ---------- */
var TOKENS = /\s+|[A-Za-z0-9_$]+|[^\sA-Za-z0-9_$]/g;
function tokenize(s) { return s.match(TOKENS) || []; }
function pushPart(arr, t, s) {
var last = arr[arr.length - 1];
if (last && last.t === t) last.s += s; else arr.push({ t: t, s: s });
}
function wordDiff(l, r) {
if (!l.trim() || !r.trim()) return null;
var a = tokenize(l), b = tokenize(r);
if (a.length + b.length > 1200) return null;
var ops = diffSeq(a, b), same = 0, i, o;
for (i = 0; i < ops.length; i++) if (ops[i].t === 0) same += a[ops[i].ai].trim().length;
var la = l.trim().length, lb = r.trim().length;
var sim = (la + lb) === 0 ? 1 : (2 * same) / (la + lb);
var left = [], right = [];
for (i = 0; i < ops.length; i++) {
o = ops[i];
if (o.t === 0) { pushPart(left, 'eq', a[o.ai]); pushPart(right, 'eq', b[o.bi]); }
else if (o.t === -1) pushPart(left, 'del', a[o.ai]);
else pushPart(right, 'ins', b[o.bi]);
}
[left, right].forEach(function (arr) { // don't shout about pure whitespace
arr.forEach(function (p) { if (p.t !== 'eq' && !p.s.trim()) p.t = 'eq'; });
});
return { sim: sim, left: left, right: right };
}
var PAIR_MIN = 0.4; // below this two lines are unrelated: pair them, but no word highlight
/* ---------- line ops -> aligned rows ----------
Inside a changed block the deleted and inserted lines have to be paired up.
Pairing them by position is what most viewers do, and it happily lines up
unrelated lines. Instead: score every (deleted, inserted) candidate by its
word-level similarity and pick the best monotonic matching (a small DP),
then fill the gaps positionally so the block stays compact. */
function emitPair(rows, A, B, d, s, w) {
if (d && s) {
var use = w && w.sim >= PAIR_MIN;
rows.push({ k: 'mod', ln: d.ai, rn: s.bi, l: A[d.ai], r: B[s.bi],
lp: use ? w.left : null, rp: use ? w.right : null });
} else if (d) rows.push({ k: 'del', ln: d.ai, l: A[d.ai] });
else if (s) rows.push({ k: 'ins', rn: s.bi, r: B[s.bi] });
}
function emitPositional(rows, A, B, dels, inss) {
var n = Math.max(dels.length, inss.length), j, d, s;
for (j = 0; j < n; j++) {
d = dels[j]; s = inss[j];
emitPair(rows, A, B, d, s, (d && s) ? wordDiff(A[d.ai], B[s.bi]) : null);
}
}
function emitBlock(rows, A, B, dels, inss) {
var m = dels.length, n = inss.length, i, j, v;
if (!m || !n || m * n > 600) { emitPositional(rows, A, B, dels, inss); return; }
var wd = [], sim = [], w;
for (i = 0; i < m; i++) {
wd[i] = []; sim[i] = [];
for (j = 0; j < n; j++) {
w = wordDiff(A[dels[i].ai], B[inss[j].bi]);
wd[i][j] = w; sim[i][j] = w ? w.sim : 0;
}
}
var best = [];
for (i = 0; i <= m; i++) { best[i] = []; for (j = 0; j <= n; j++) best[i][j] = 0; }
for (i = m - 1; i >= 0; i--) for (j = n - 1; j >= 0; j--) {
v = best[i + 1][j] > best[i][j + 1] ? best[i + 1][j] : best[i][j + 1];
if (sim[i][j] >= PAIR_MIN && sim[i][j] + best[i + 1][j + 1] > v) v = sim[i][j] + best[i + 1][j + 1];
best[i][j] = v;
}
var pi = 0, pj = 0;
i = 0; j = 0;
while (i < m && j < n) {
if (sim[i][j] >= PAIR_MIN && sim[i][j] + best[i + 1][j + 1] >= best[i][j] - 1e-9) {
emitPositional(rows, A, B, dels.slice(pi, i), inss.slice(pj, j));
emitPair(rows, A, B, dels[i], inss[j], wd[i][j]);
i++; j++; pi = i; pj = j;
} else if (best[i + 1][j] >= best[i][j + 1]) i++;
else j++;
}
emitPositional(rows, A, B, dels.slice(pi), inss.slice(pj));
}
function buildRows(A, B, ops) {
var rows = [], i = 0, dels, inss;
while (i < ops.length) {
if (ops[i].t === 0) {
rows.push({ k: 'eq', ln: ops[i].ai, rn: ops[i].bi, l: A[ops[i].ai], r: B[ops[i].bi] });
i++; continue;
}
dels = []; inss = [];
while (i < ops.length && ops[i].t !== 0) { (ops[i].t === -1 ? dels : inss).push(ops[i]); i++; }
emitBlock(rows, A, B, dels, inss);
}
return rows;
}
/* collapse long unchanged runs, keeping 3 lines of context on each side */
function foldRows(rows, expanded) {
var out = [], i = 0, j, run, keepTop, keepBot, key, x;
while (i < rows.length) {
if (rows[i].k !== 'eq') { out.push(rows[i]); i++; continue; }
j = i; while (j < rows.length && rows[j].k === 'eq') j++;
run = j - i;
/* key a fold by what it contains, not by where it sits in the row list:
an index key shifts on every insertion above it, which quietly snaps
open folds shut (or opens the wrong one) while you type */
key = 'f' + run + ':' + rows[i].l + '…' + rows[j - 1].l;
keepTop = i === 0 ? 0 : 3;
keepBot = j === rows.length ? 0 : 3;
if (run > 8 && run > keepTop + keepBot + 2 && !expanded[key]) {
for (x = i; x < i + keepTop; x++) out.push(rows[x]);
out.push({ k: 'fold', key: key, count: run - keepTop - keepBot });
for (x = j - keepBot; x < j; x++) out.push(rows[x]);
} else {
for (x = i; x < j; x++) out.push(rows[x]);
}
i = j;
}
return out;
}
/* ---------- unified patch text ---------- */
function makePatch(A, B, ops) {
var ent = [], i, o, oa = 0, ob = 0;
for (i = 0; i < ops.length; i++) {
o = ops[i];
ent.push({ t: o.t, text: o.t === 1 ? B[o.bi] : A[o.ai], oa: oa, ob: ob });
if (o.t !== 1) oa++;
if (o.t !== -1) ob++;
}
var ctx = 3, groups = [], start = -1, lastChange = -1;
for (i = 0; i < ent.length; i++) {
if (ent[i].t !== 0) {
if (start < 0) start = Math.max(0, i - ctx);
else if (i - lastChange > ctx * 2) { groups.push([start, Math.min(ent.length - 1, lastChange + ctx)]); start = i - ctx; }
lastChange = i;
}
}
if (start >= 0) groups.push([start, Math.min(ent.length - 1, lastChange + ctx)]);
if (!groups.length) return '';
var out = ['--- v1', '+++ v2'];
for (i = 0; i < groups.length; i++) {
var g = groups[i], a0 = ent[g[0]].oa, b0 = ent[g[0]].ob, ac = 0, bc = 0, body = [], x, e;
for (x = g[0]; x <= g[1]; x++) {
e = ent[x];
if (e.t !== 1) ac++;
if (e.t !== -1) bc++;
body.push((e.t === 0 ? ' ' : e.t === 1 ? '+' : '-') + e.text);
}
out.push('@@ -' + (ac ? a0 + 1 : a0) + ',' + ac + ' +' + (bc ? b0 + 1 : b0) + ',' + bc + ' @@');
out = out.concat(body);
}
return out.join('\n') + '\n';
}
/* ================================ examples ================================ */
var PRESETS = [
{
name: 'format-bytes.js',
a: [
"export function formatBytes(bytes, digits = 1) {",
" if (bytes === 0) return '0 B';",
" const units = ['B', 'KB', 'MB', 'GB', 'TB'];",
" const i = Math.floor(Math.log(bytes) / Math.log(1024));",
" const value = bytes / Math.pow(1024, i);",
" return value.toFixed(digits) + ' ' + units[i];",
"}"
].join('\n'),
b: [
"export function formatBytes(bytes, digits = 1) {",
" if (!Number.isFinite(bytes) || bytes < 1) return '0 B';",
" const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];",
" const exp = Math.floor(Math.log(bytes) / Math.log(1024));",
" const i = Math.min(exp, units.length - 1);",
" const value = bytes / 1024 ** i;",
" return `${value.toFixed(digits)} ${units[i]}`;",
"}"
].join('\n')
},
{
name: 'fetch-user.js',
a: [
"function fetchUser(id, cb) {",
" var url = '/api/users/' + id;",
" request(url, function (err, res) {",
" if (err) {",
" cb(err);",
" return;",
" }",
" if (res.status !== 200) {",
" cb(new Error('bad status'));",
" return;",
" }",
" cb(null, JSON.parse(res.body));",
" });",
"}"
].join('\n'),
b: [
"async function fetchUser(id, { signal } = {}) {",
" const url = `/api/users/${encodeURIComponent(id)}`;",
" const res = await request(url, { signal });",
" if (res.status !== 200) {",
" throw new HttpError('bad status', res.status);",
" }",
" return JSON.parse(res.body);",
"}"
].join('\n')
},
{
name: 'chunk.js',
a: [
"export function chunk(items, size) {",
" const out = [];",
" for (let i = 0; i < items.length; i += size) {",
" out.push(items.slice(i, i + size));",
" }",
" return out;",
"}"
].join('\n'),
b: [
"export function chunk(items, size) {",
" if (size < 1) throw new RangeError('size must be >= 1');",
" const out = [];",
" for (let i = 0; i < items.length; i += size) {",
" out.push(items.slice(i, i + size));",
" }",
" return out;",
"}"
].join('\n')
},
{
name: 'rates.js',
a: [
"// rates.js — currency helpers",
"const RATES = {",
" usd: 1,",
" eur: 0.92,",
" gbp: 0.79,",
" jpy: 157.2,",
"};",
"",
"function round(n) {",
" return Math.round(n * 100) / 100;",
"}",
"",
"export function convert(amount, from, to) {",
" const a = RATES[from];",
" const b = RATES[to];",
" if (!a || !b) {",
" return null;",
" }",
" return round((amount / a) * b);",
"}",
"",
"export function symbolFor(code) {",
" switch (code) {",
" case 'usd': return '$';",
" case 'eur': return '€';",
" case 'gbp': return '£';",
" case 'jpy': return '¥';",
" default: return '';",
" }",
"}",
"",
"export function format(amount, code) {",
" return symbolFor(code) + amount.toFixed(2);",
"}",
"",
"export function total(rows, code) {",
" let sum = 0;",
" for (const row of rows) {",
" sum += convert(row.amount, row.code, code);",
" }",
" return round(sum);",
"}"
].join('\n'),
b: [
"// rates.js — currency helpers",
"const RATES = {",
" usd: 1,",
" eur: 0.94,",
" gbp: 0.79,",
" jpy: 155.8,",
"};",
"",
"function round(n) {",
" return Math.round(n * 100) / 100;",
"}",
"",
"export function convert(amount, from, to) {",
" const a = RATES[from];",
" const b = RATES[to];",
" if (!a || !b) {",
" throw new UnknownCurrency(from, to);",
" }",
" return round((amount / a) * b);",
"}",
"",
"export function symbolFor(code) {",
" switch (code) {",
" case 'usd': return '$';",
" case 'eur': return '€';",
" case 'gbp': return '£';",
" case 'jpy': return '¥';",
" default: return '';",
" }",
"}",
"",
"export function format(amount, code) {",
" return symbolFor(code) + amount.toFixed(2);",
"}",
"",
"export function total(rows, code) {",
" return round(",
" rows.reduce((sum, row) => sum + convert(row.amount, row.code, code), 0)",
" );",
"}"
].join('\n')
}
];
/* ================================ state + view ================================ */
var ta = $('#ta'), tb = $('#tb'), rowsL = $('#rowsL'), rowsR = $('#rowsR'), rowsU = $('#rowsU'),
paneL = $('#paneL'), paneR = $('#paneR'), paneU = $('#paneU'),
sbs = $('#sbs'), uniWrap = $('#uniWrap'), sameBox = $('#same'), sameMsg = $('#sameMsg'),
diffBox = $('.diff'), statusEl = $('#status');
var state = { view: 'split', ws: false, sync: true, preset: 0, expanded: {}, cur: -1,
rows: [], disp: [], hunks: 0, ms: 0, patch: '', adds: 0, dels: 0,
coarse: false, rawDiffers: false };
var reduced = window.matchMedia ? window.matchMedia('(prefers-reduced-motion: reduce)') : { matches: false };
function esc(s) {
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
}
/* attribute values carry raw source lines (fold keys), so quotes have to go
too — otherwise one `const s = "x"` in the file ends the attribute early
and the fold button silently stops matching its own key. */
function escAttr(s) { return esc(s).replace(/"/g, '"'); }
function partsHTML(parts, cls, fallback) {
if (!parts) return esc(fallback);
var h = '', i, p;
for (i = 0; i < parts.length; i++) {
p = parts[i];
h += p.t === 'eq' ? esc(p.s) : '<span class="' + cls + '">' + esc(p.s) + '</span>';
}
return h;
}
function splitLines(v) { return v.replace(/\n$/, '').split('\n'); }
function norm(s) { return s.replace(/\s+/g, ' ').trim(); }
function hAttr(r) { return r.h == null ? '' : ' data-hunk="' + r.h + '"'; }
function rowHTML(cls, guts, mark, code, extra) {
var g = '', i;
for (i = 0; i < guts.length; i++) g += '<span class="gut">' + guts[i] + '</span>';
return '<div class="row' + (cls ? ' ' + cls : '') + '"' + (extra || '') + '>' + g +
'<span class="mark">' + mark + '</span><span class="code">' + code + '</span></div>';
}
function foldHTML(r, interactive) {
var label = '⋯ ' + r.count + ' unchanged line' + (r.count === 1 ? '' : 's');
var btn = interactive
? '<button class="foldbtn" type="button" data-fold="' + escAttr(r.key) + '">show them</button>'
: '';
return '<div class="row row--fold">' + label + ' ' + btn + '</div>';
}
/* ---------- renderers ---------- */
function renderSplit(disp) {
var L = [], R = [], i, r, a;
for (i = 0; i < disp.length; i++) {
r = disp[i]; a = hAttr(r);
if (r.k === 'fold') { L.push(foldHTML(r, true)); R.push(foldHTML(r, false)); continue; }
if (r.k === 'eq') {
L.push(rowHTML('', [r.ln + 1], ' ', esc(r.l), a));
R.push(rowHTML('', [r.rn + 1], ' ', esc(r.r), a));
} else if (r.k === 'del') {
L.push(rowHTML('row--del', [r.ln + 1], '-', esc(r.l), a));
R.push(rowHTML('row--fill', [''], ' ', '', a));
} else if (r.k === 'ins') {
L.push(rowHTML('row--fill', [''], ' ', '', a));
R.push(rowHTML('row--ins', [r.rn + 1], '+', esc(r.r), a));
} else {
L.push(rowHTML('row--del', [r.ln + 1], '-', partsHTML(r.lp, 'wd', r.l), a));
R.push(rowHTML('row--ins', [r.rn + 1], '+', partsHTML(r.rp, 'w', r.r), a));
}
}
rowsL.innerHTML = L.join('');
rowsR.innerHTML = R.join('');
}
function renderUnified(disp) {
var U = [], i, r, a;
for (i = 0; i < disp.length; i++) {
r = disp[i]; a = hAttr(r);
if (r.k === 'fold') { U.push(foldHTML(r, true)); continue; }
if (r.k === 'eq') U.push(rowHTML('', [r.ln + 1, r.rn + 1], ' ', esc(r.l), a));
else if (r.k === 'del') U.push(rowHTML('row--del', [r.ln + 1, ''], '-', esc(r.l), a));
else if (r.k === 'ins') U.push(rowHTML('row--ins', ['', r.rn + 1], '+', esc(r.r), a));
else {
U.push(rowHTML('row--del', [r.ln + 1, ''], '-', partsHTML(r.lp, 'wd', r.l), a));
U.push(rowHTML('row--ins', ['', r.rn + 1], '+', partsHTML(r.rp, 'w', r.r), a));
}
}
rowsU.innerHTML = U.join('');
}
/* ---------- compute + paint ---------- */
function compute() {
var A = splitLines(ta.value), B = splitLines(tb.value), i;
var t0 = (window.performance || Date).now();
var ops;
coarse = false;
if (state.ws) {
var An = [], Bn = [];
for (i = 0; i < A.length; i++) An.push(norm(A[i]));
for (i = 0; i < B.length; i++) Bn.push(norm(B[i]));
ops = diffSeq(An, Bn);
} else {
ops = diffSeq(A, B);
}
state.coarse = coarse; // the line diff ran out of budget, say so
state.rawDiffers = ta.value !== tb.value;
var rows = buildRows(A, B, ops);
state.ms = (window.performance || Date).now() - t0;
state.rows = rows;
state.patch = makePatch(A, B, ops);
var adds = 0, dels = 0;
for (i = 0; i < rows.length; i++) {
if (rows[i].k === 'ins') adds++;
else if (rows[i].k === 'del') dels++;
else if (rows[i].k === 'mod') { adds++; dels++; }
}
state.adds = adds; state.dels = dels;
$('#countA').textContent = A.length + ' line' + (A.length === 1 ? '' : 's');
$('#countB').textContent = B.length + ' line' + (B.length === 1 ? '' : 's');
render();
}
function render() {
var disp = foldRows(state.rows, state.expanded), i, r, h = -1, inChange = false;
for (i = 0; i < disp.length; i++) {
r = disp[i];
if (r.k === 'eq' || r.k === 'fold') { r.h = null; inChange = false; }
else { if (!inChange) h++; r.h = h; inChange = true; }
}
state.disp = disp;
state.hunks = h + 1;
if (state.cur >= state.hunks) state.cur = state.hunks - 1;
var identical = state.hunks === 0;
sameBox.hidden = !identical;
if (identical) {
/* "no hunks" is not always "same text": with whitespace ignored the two
sides can still differ by spacing, and claiming otherwise would lie. */
sameMsg.textContent = !state.rawDiffers
? 'Both versions are identical — edit one of them to see a diff.'
: state.ws
? 'No differences except whitespace — untick “ignore whitespace” to see the spacing changes.'
: 'The two versions differ only by a trailing newline — nothing to show line by line.';
}
sbs.hidden = identical || state.view !== 'split';
uniWrap.hidden = identical || state.view !== 'unified';
if (identical) { rowsL.innerHTML = rowsR.innerHTML = rowsU.innerHTML = ''; }
else if (state.view === 'split') { rowsU.innerHTML = ''; renderSplit(disp); }
else { rowsL.innerHTML = rowsR.innerHTML = ''; renderUnified(disp); }
paintCurrent();
$('#chipAdd').innerHTML = '<b>+' + state.adds + '</b>';
$('#chipDel').innerHTML = '<b>-' + state.dels + '</b>';
$('#chipHunks').textContent = state.hunks + ' hunk' + (state.hunks === 1 ? '' : 's');
$('#chipTime').textContent = state.ms < 0.005 ? '< 0.01 ms' : state.ms.toFixed(2) + ' ms';
$('#chipCoarse').hidden = !state.coarse;
$('#chipNav').textContent = (state.cur < 0 ? '–' : state.cur + 1) + '/' + state.hunks;
$('#prev').disabled = state.hunks === 0;
$('#next').disabled = state.hunks === 0;
$('#copy').disabled = state.hunks === 0;
$('#optSyncWrap').hidden = state.view !== 'split';
/* the fallback patch panel is a second view of the same state — if it is
open while you keep typing it has to follow along, not sit on stale text */
if (patchPanel && !patchPanel.hidden) patchPanel._out.value = state.patch;
}
function activePanes() {
return state.view === 'split' ? [paneL, paneR] : [paneU];
}
function paintCurrent() {
var old = document.querySelectorAll('.row.cur'), i;
for (i = 0; i < old.length; i++) old[i].classList.remove('cur');
if (state.cur < 0) return;
var now = document.querySelectorAll('[data-hunk="' + state.cur + '"]');
for (i = 0; i < now.length; i++) now[i].classList.add('cur');
}
function gotoHunk(idx) {
if (!state.hunks) return;
state.cur = (idx + state.hunks) % state.hunks;
paintCurrent();
var panes = activePanes(), i, el, top;
for (i = 0; i < panes.length; i++) {
el = panes[i].querySelector('[data-hunk="' + state.cur + '"]');
if (!el) continue;
top = Math.max(0, el.offsetTop - panes[i].clientHeight * 0.3);
if (panes[i].scrollTo) panes[i].scrollTo({ top: top, behavior: reduced.matches ? 'auto' : 'smooth' });
else panes[i].scrollTop = top;
}
$('#chipNav').textContent = (state.cur + 1) + '/' + state.hunks;
say('change ' + (state.cur + 1) + ' of ' + state.hunks);
}
function say(msg) { statusEl.textContent = msg; }
/* ---------- synchronized scrolling ---------- */
var lock = false;
function link(a, b) {
a.addEventListener('scroll', function () {
if (!state.sync || lock || state.view !== 'split') return;
lock = true;
b.scrollTop = a.scrollTop;
b.scrollLeft = a.scrollLeft;
requestAnimationFrame(function () { lock = false; });
}, { passive: true });
}
link(paneL, paneR);
link(paneR, paneL);
/* ---------- wiring ---------- */
var timer = null;
function onEdit() {
if (timer) clearTimeout(timer);
timer = setTimeout(function () { timer = null; compute(); }, 110);
}
ta.addEventListener('input', onEdit);
tb.addEventListener('input', onEdit);
function loadPreset(i) {
var p = PRESETS[i];
state.preset = i; state.expanded = {}; state.cur = -1;
ta.value = p.a; tb.value = p.b;
$('#uniName').textContent = p.name;
var btns = document.querySelectorAll('[data-preset]'), k;
for (k = 0; k < btns.length; k++) {
btns[k].setAttribute('aria-pressed', String(Number(btns[k].getAttribute('data-preset')) === i));
}
paneL.scrollTop = paneR.scrollTop = paneU.scrollTop = 0;
compute();
}
document.querySelectorAll('[data-preset]').forEach(function (b) {
b.addEventListener('click', function () {
loadPreset(Number(b.getAttribute('data-preset')));
say('loaded example ' + $('#uniName').textContent);
});
});
$('#reset').addEventListener('click', function () {
loadPreset(state.preset);
say('example reset');
});
$('#swap').addEventListener('click', function () {
var t = ta.value; ta.value = tb.value; tb.value = t;
state.expanded = {}; state.cur = -1;
compute();
say('versions swapped');
});
function setView(v) {
state.view = v;
$('#vSplit').setAttribute('aria-pressed', String(v === 'split'));
$('#vUni').setAttribute('aria-pressed', String(v === 'unified'));
render();
say(v === 'split' ? 'side by side view' : 'unified view');
}
$('#vSplit').addEventListener('click', function () { setView('split'); });
$('#vUni').addEventListener('click', function () { setView('unified'); });
$('#optWs').addEventListener('change', function () {
state.ws = this.checked;
compute();
say(state.ws ? 'ignoring whitespace' : 'whitespace significant');
});
$('#optSync').addEventListener('change', function () {
state.sync = this.checked;
if (state.sync) paneR.scrollTop = paneL.scrollTop;
say(state.sync ? 'scroll linked' : 'scroll unlinked');
});
$('#next').addEventListener('click', function () { gotoHunk(state.cur + 1); });
$('#prev').addEventListener('click', function () { gotoHunk(state.cur < 0 ? -1 : state.cur - 1); });
diffBox.addEventListener('click', function (e) {
var b = e.target.closest ? e.target.closest('[data-fold]') : null;
if (!b) return;
state.expanded[b.getAttribute('data-fold')] = true;
render();
say('unchanged lines expanded');
});
document.addEventListener('keydown', function (e) {
if (e.metaKey || e.ctrlKey || e.altKey) return;
var t = e.target, tag = t && t.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || (t && t.isContentEditable)) return;
if (!e.key) return;
var k = e.key.toLowerCase();
if (k === 'n' || k === 'j') { e.preventDefault(); gotoHunk(state.cur + 1); }
else if (k === 'p' || k === 'k') { e.preventDefault(); gotoHunk(state.cur < 0 ? -1 : state.cur - 1); }
});
/* ---------- copy patch (with a real fallback if the sandbox blocks it) ---------- */
var copyBtn = $('#copy'), copyTimer = null, patchPanel = null;
function copyDone(ok) {
if (copyTimer) clearTimeout(copyTimer);
copyBtn.textContent = ok ? '✓ copied' : 'patch below ↓';
copyBtn.classList.toggle('chip--ok', !!ok);
copyBtn.style.color = ok ? 'var(--run)' : '';
copyBtn.style.borderColor = ok ? 'var(--run)' : '';
say(ok ? 'patch copied to clipboard' : 'clipboard blocked; patch shown below, already selected');
copyTimer = setTimeout(function () {
copyBtn.textContent = 'copy patch';
copyBtn.style.color = ''; copyBtn.style.borderColor = '';
copyBtn.classList.remove('chip--ok');
}, 1800);
}
function showPatchPanel() {
if (!patchPanel) {
patchPanel = document.createElement('div');
patchPanel.className = 'card';
patchPanel.style.marginTop = '10px';
patchPanel.innerHTML = '<header><span class="tag">unified patch · select and copy</span>' +
'<button class="btn btn--mono" type="button" data-closepatch>close</button></header>';
var out = document.createElement('textarea');
out.setAttribute('readonly', '');
out.setAttribute('aria-label', 'Unified patch text');
out.style.height = '140px';
patchPanel.appendChild(out);
patchPanel.addEventListener('click', function (ev) {
if (ev.target.closest && ev.target.closest('[data-closepatch]')) {
patchPanel.hidden = true; copyBtn.focus(); say('patch panel closed');
}
});
diffBox.parentNode.insertBefore(patchPanel, diffBox.nextSibling);
patchPanel._out = out;
}
patchPanel.hidden = false;
patchPanel._out.value = state.patch;
patchPanel._out.focus();
patchPanel._out.select();
}
function legacyCopy() {
var ok = false, tmp;
try {
tmp = document.createElement('textarea');
tmp.value = state.patch;
tmp.setAttribute('readonly', '');
tmp.style.cssText = 'position:fixed;top:0;left:-9999px';
document.body.appendChild(tmp);
tmp.select();
ok = document.execCommand('copy');
document.body.removeChild(tmp);
} catch (err) { ok = false; }
if (!ok) showPatchPanel();
copyDone(ok);
}
copyBtn.addEventListener('click', function () {
if (!state.patch) return;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(state.patch).then(function () { copyDone(true); }, legacyCopy);
} else legacyCopy();
});
/* ---------- go ---------- */
if (window.innerWidth < 620) { state.view = 'unified'; setView('unified'); }
loadPreset(0);
})();
</script>
</body>
</html>
source-visible by construction · nothing is published here without its code
Comments
0 totalNo comments yet. If you ran it, say what happened.