I had a text-compare tool that asked people to compare two things it would not show them at the same time.
One textarea, then another textarea underneath it, then a single-column unified diff below that — removals in red, additions in green, in the order a diff algorithm emits them. Everything on the page was correct and the tool was close to useless, because comparing is a thing your eyes do in parallel and the layout made it serial.
Rebuilding it as two columns turned out to have three problems in it. Only one of them is layout, and the interesting one is the layout problem.
Start with the diff you already have
A line-level diff comes out of a longest-common-subsequence backtrack as a flat list:
[
{ t: 'same', line: 'The quick brown fox' },
{ t: 'del', line: 'jumps over the lazy dog' },
{ t: 'add', line: 'leaps over the lazy dog' },
{ t: 'same', line: 'and runs away.' }
]
That is exactly what a unified view wants. It is not what a side-by-side view wants, and the difference is the whole job.
Problem one: an edited line is two entries
In that list, editing a line produces a del and an add — and in a longer diff they can be several rows apart, because the algorithm emits a run of removals and then a run of additions.
Render that naively into two columns and you get the removal on row 4 of the left column and the addition on row 9 of the right one. The view is now worse than the unified one: two columns, and the thing you are comparing is in different places in each.
So the walk has to buffer. Consecutive non-same entries are collected as a block, then paired off index by index:
function sideBySide(diff) {
const rows = [];
let i = 0, na = 0, nb = 0; // line numbers, counted per side
while (i < diff.length) {
if (diff[i].t === 'same') {
na++; nb++;
rows.push({ kind: 'same', na, nb, a: diff[i].line, b: diff[i].line });
i++;
continue;
}
const dels = [], adds = [];
while (i < diff.length && diff[i].t !== 'same') {
(diff[i].t === 'del' ? dels : adds).push(diff[i].line);
i++;
}
for (let k = 0; k < Math.max(dels.length, adds.length); k++) {
const da = k < dels.length ? dels[k] : null;
const db = k < adds.length ? adds[k] : null;
if (da !== null) na++;
if (db !== null) nb++;
if (da !== null && db !== null) rows.push({ kind: 'chg', na, nb, a: da, b: db });
else if (da !== null) rows.push({ kind: 'del', na, nb: null, a: da, b: null });
else rows.push({ kind: 'add', na: null, nb, a: null, b: db });
}
}
return rows;
}
Two things worth noticing.
The blocks are rarely the same length. Three lines removed and one added means one paired row and two rows with an empty right side. That is correct — the surplus really has no counterpart — and it is why the pairing is a max() loop rather than a zip.
And the line numbers count separately per side, which is what every code review tool does and what people expect without being able to say so. After an insertion the two columns are permanently offset, and a number that pointed at "row 7 of the display" instead of "line 7 of that version" would be useless the moment you tried to find the line in your editor.
Problem two: the eye still has to re-read both lines
Once jumps over the lazy dog and leaps over the lazy dog sit next to each other, you still have to read both to find what moved. On a long line you read it three times.
The fix is the same algorithm one level down. Run the line diff over the two lines' word arrays and mark the tokens that differ:
function wordSegments(a, b) {
const ta = a.split(/(\s+)/), tb = b.split(/(\s+)/); // captured group keeps the spacing
const left = [], right = [];
lineDiff(ta, tb).forEach(x => {
const blank = !x.line || !x.line.trim();
if (x.t === 'same') { left.push({ s: x.line }); right.push({ s: x.line }); }
else if (x.t === 'del') { left.push({ s: x.line, m: !blank }); }
else { right.push({ s: x.line, m: !blank }); }
});
return [left, right];
}
Splitting on a captured separator (/(\s+)/ rather than /\s+/) is what makes this work. The whitespace comes back as its own token, so it survives the diff and the rebuilt line still reads like the original. Without it, marks land in the right places and every run of spaces collapses to one.
The blank check earns its place too. Whitespace tokens rarely match exactly — one line has two spaces where the other has three — so without it you get coloured blobs floating between words, drawing the eye to nothing.
One guard: lineDiff builds an (n+1) × (m+1) table, so two very long lines are quadratic in a way nobody wants on a keystroke. Past a threshold, mark the whole line and move on.
Problem three: two scrolling panes is a trap
This is the one I would have got wrong if I had started from the picture instead of the behaviour.
The obvious implementation of a side-by-side view is two panes, each scrolling its own content. It looks right in a mockup. It is a bad idea, and the reason is not performance or complexity — it is that the panes can drift.
Two independently scrollable panes have two scroll positions. Keeping them in step means listening to scroll on both, writing to the other, and guarding against the feedback loop that creates. Then you discover the panes have different content heights — one side has extra rows where the other has gaps — so equal scrollTop does not mean equal position, and you are mapping between them.
And the moment they are out of step by even one row, the view stops doing the one thing it exists to do. A diff that shows you line 40 on the left and line 41 on the right is not a diff; it is two lists.
The alternative is boring and cannot drift:
<div class="diff-row">
<div class="diff-cell">…left…</div>
<div class="diff-cell">…right…</div>
</div>
.diff-split-body { max-height: 32rem; overflow: auto; } /* one scroller */
.diff-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; }
.diff-cell { display: flex; gap: .6rem; min-width: 0; }
One scroll container, one grid, two cells per row. The two halves of a row are in the same row element, so there is no state that could disagree. No scroll listeners, no synchronisation, no drift — because there is nothing to synchronise.
The general version of this: when two things must stay aligned, prefer a structure where misalignment is unrepresentable over code that keeps them aligned. Synchronising code has a bug rate. A shared row does not.
The bit that only shows up on a phone
Two columns of prose at 380px is unreadable, so under 720px both the inputs and the diff collapse to one column — old line above new, with a coloured left edge to say which is which.
The tempting shortcut is to keep the column headers and hide one. I did that first. On a stacked list the surviving header sits above rows from both versions and confidently mislabels half of them. Dropping the header entirely is more honest: the red and green edges already carry the information, and no text is better than text that is wrong for every second row.
Worth testing, and easy to
The pairing walk is pure data in, data out, which makes it worth pinning down properly. The cases that matter are not the happy path:
- an edit lands on one row, old left and new right
- a pure insertion has nothing on the left, and A's numbering does not advance
- a pure deletion is the same in reverse
- an uneven block (three removed, one added) pairs what it can and gives the surplus its own rows
- identical input produces only
samerows - an empty diff produces no rows
Plus, for the word marks: only the changed word gets marked, both lines rebuild to exactly the original text, whitespace is never marked, and the long-line guard does not lose any text.
Twenty-three assertions, no DOM, runs in a second. The line-numbering-after-an-insert case caught a real bug — worth more than the rest of the suite put together, and the one I would not have thought to check by hand.
I build Utilorax, a set of free browser-based tools — including the text compare this came out of.
Top comments (0)