Skip to content

Instantly share code, notes, and snippets.

@eliascotto
Created December 18, 2025 06:40
Show Gist options
  • Select an option

  • Save eliascotto/30589e023bf65fe42cfa889e3dcacf67 to your computer and use it in GitHub Desktop.

Select an option

Save eliascotto/30589e023bf65fe42cfa889e3dcacf67 to your computer and use it in GitHub Desktop.
2/3 files diff viewver
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Diff Viewer</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jsdiff/5.1.0/diff.min.js"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
added: { light: '#d4edda', dark: '#1e3a1e' },
removed: { light: '#f8d7da', dark: '#3a1e1e' },
modified: { light: '#fff3cd', dark: '#3a3a1e' },
padding: { light: '#e9ecef', dark: '#2d2d2d' }
}
}
}
}
</script>
<style>
* { box-sizing: border-box; }
body { margin: 0; padding: 0; }
.resizer {
width: 4px;
background: #ccc;
cursor: col-resize;
flex-shrink: 0;
}
.dark .resizer { background: #444; }
.resizer:hover { background: #999; }
.dark .resizer:hover { background: #666; }
.line-added { background-color: #d4edda; }
.dark .line-added { background-color: #1e3a1e; color: #fff; }
.line-removed { background-color: #f8d7da; }
.dark .line-removed { background-color: #3a1e1e; color: #fff; }
.line-modified { background-color: #fff3cd; }
.dark .line-modified { background-color: #3a3a1e; color: #fff; }
.line-padding { background-color: #e9ecef; }
.dark .line-padding { background-color: #2d2d2d; color: #fff; }
.dark .diff-line { color: #fff; }
.char-added { background-color: #28a745; color: #fff; }
.dark .char-added { background-color: #2ea043; }
.char-removed { background-color: #dc3545; color: #fff; }
.dark .char-removed { background-color: #d73a49; }
.diff-content {
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 13px;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-all;
}
.line-number {
min-width: 40px;
text-align: right;
padding-right: 8px;
color: #999;
user-select: none;
flex-shrink: 0;
}
.line-content {
flex: 1;
padding-left: 8px;
}
.diff-line {
display: flex;
min-height: 1.4em;
}
.diff-line.highlight {
outline: 2px solid #007bff;
outline-offset: -2px;
}
.dark .diff-line.highlight {
outline-color: #4da3ff;
}
textarea {
resize: none;
border: none;
outline: none;
}
.pane-container {
display: flex;
flex-direction: column;
min-width: 100px;
overflow: hidden;
}
.scroll-container {
overflow: auto;
flex: 1;
}
</style>
</head>
<body class="h-screen flex flex-col">
<header class="flex items-center justify-between px-3 py-2 border-b border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 flex-shrink-0">
<div class="flex items-center gap-3">
<h1 class="text-sm font-medium text-gray-800 dark:text-gray-200">Diff Viewer</h1>
<div class="flex items-center gap-1">
<button id="togglePane2" class="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
Center
</button>
<button id="togglePane1" class="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
Right
</button>
</div>
</div>
<div class="flex items-center gap-3">
<div class="flex items-center gap-1">
<button id="prevDiff" class="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
Prev
</button>
<span id="diffCounter" class="text-xs text-gray-600 dark:text-gray-400 min-w-[60px] text-center">0/0</span>
<button id="nextDiff" class="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
Next
</button>
</div>
<label class="flex items-center gap-1 text-xs text-gray-700 dark:text-gray-300">
<input type="checkbox" id="syncScroll" checked class="w-3 h-3">
Sync scroll
</label>
<button id="compareBtn" class="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 bg-blue-500 text-white hover:bg-blue-600">
Compare
</button>
<button id="editBtn" class="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hidden">
Edit
</button>
</div>
</header>
<main id="panesContainer" class="flex flex-1 overflow-hidden bg-gray-50 dark:bg-gray-800">
<div id="pane0" class="pane-container flex-1">
<div class="px-2 py-1 text-xs font-medium text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 border-b border-gray-300 dark:border-gray-600">
Base
</div>
<div class="flex-1 flex flex-col overflow-hidden">
<textarea id="input0" class="flex-1 p-2 text-sm font-mono bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200" placeholder="Paste base content here..."></textarea>
<div id="diff0" class="scroll-container hidden bg-white dark:bg-gray-900"></div>
</div>
</div>
<div class="resizer" data-resizer="0"></div>
<div id="pane1" class="pane-container flex-1">
<div class="px-2 py-1 text-xs font-medium text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 border-b border-gray-300 dark:border-gray-600">
Center
</div>
<div class="flex-1 flex flex-col overflow-hidden">
<textarea id="input1" class="flex-1 p-2 text-sm font-mono bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200" placeholder="Paste content to compare..."></textarea>
<div id="diff1" class="scroll-container hidden bg-white dark:bg-gray-900"></div>
</div>
</div>
<div class="resizer" data-resizer="1"></div>
<div id="pane2" class="pane-container flex-1">
<div class="px-2 py-1 text-xs font-medium text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 border-b border-gray-300 dark:border-gray-600">
Right
</div>
<div class="flex-1 flex flex-col overflow-hidden">
<textarea id="input2" class="flex-1 p-2 text-sm font-mono bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200" placeholder="Paste content to compare..."></textarea>
<div id="diff2" class="scroll-container hidden bg-white dark:bg-gray-900"></div>
</div>
</div>
</main>
<script>
const state = {
panes: [true, true, true],
syncScroll: true,
currentDiffIndex: -1,
diffPositions: [],
contents: ['', '', '']
};
const inputs = [
document.getElementById('input0'),
document.getElementById('input1'),
document.getElementById('input2')
];
const diffs = [
document.getElementById('diff0'),
document.getElementById('diff1'),
document.getElementById('diff2')
];
const panes = [
document.getElementById('pane0'),
document.getElementById('pane1'),
document.getElementById('pane2')
];
// Dark mode detection
function updateDarkMode() {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}
updateDarkMode();
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateDarkMode);
// Resizer functionality
const resizers = document.querySelectorAll('.resizer');
resizers.forEach(resizer => {
let startX, startWidths, paneIndices;
resizer.addEventListener('mousedown', (e) => {
e.preventDefault();
startX = e.clientX;
const resizerIndex = parseInt(resizer.dataset.resizer);
const visiblePanes = panes.filter((_, i) => state.panes[i]);
const visibleIndices = state.panes.map((v, i) => v ? i : -1).filter(i => i >= 0);
let leftPaneIdx = -1, rightPaneIdx = -1;
if (resizerIndex === 0) {
if (state.panes[0] && state.panes[1]) {
leftPaneIdx = 0; rightPaneIdx = 1;
} else if (state.panes[0] && state.panes[2]) {
leftPaneIdx = 0; rightPaneIdx = 2;
}
} else {
if (state.panes[1] && state.panes[2]) {
leftPaneIdx = 1; rightPaneIdx = 2;
} else if (state.panes[0] && state.panes[2] && !state.panes[1]) {
leftPaneIdx = 0; rightPaneIdx = 2;
}
}
if (leftPaneIdx === -1 || rightPaneIdx === -1) return;
paneIndices = [leftPaneIdx, rightPaneIdx];
startWidths = [panes[leftPaneIdx].offsetWidth, panes[rightPaneIdx].offsetWidth];
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
});
function onMouseMove(e) {
const dx = e.clientX - startX;
const newLeftWidth = startWidths[0] + dx;
const newRightWidth = startWidths[1] - dx;
if (newLeftWidth > 100 && newRightWidth > 100) {
panes[paneIndices[0]].style.flex = 'none';
panes[paneIndices[1]].style.flex = 'none';
panes[paneIndices[0]].style.width = newLeftWidth + 'px';
panes[paneIndices[1]].style.width = newRightWidth + 'px';
}
}
function onMouseUp() {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
}
});
// Toggle panes
document.getElementById('togglePane1').addEventListener('click', () => togglePane(2));
document.getElementById('togglePane2').addEventListener('click', () => togglePane(1));
function togglePane(index) {
const visibleCount = state.panes.filter(Boolean).length;
if (state.panes[index] && visibleCount <= 2) return;
state.panes[index] = !state.panes[index];
updatePaneVisibility();
computeDiffs();
}
function updatePaneVisibility() {
panes.forEach((pane, i) => {
pane.style.display = state.panes[i] ? 'flex' : 'none';
});
resizers[0].style.display = (state.panes[0] && (state.panes[1] || state.panes[2])) ? 'block' : 'none';
resizers[1].style.display = (state.panes[1] && state.panes[2]) ? 'block' : 'none';
document.getElementById('togglePane2').textContent = state.panes[1] ? 'Hide Center' : 'Show Center';
document.getElementById('togglePane1').textContent = state.panes[2] ? 'Hide Right' : 'Show Right';
panes.forEach((pane, i) => {
if (state.panes[i]) {
pane.style.flex = '1';
pane.style.width = 'auto';
}
});
}
// Sync scroll
document.getElementById('syncScroll').addEventListener('change', (e) => {
state.syncScroll = e.target.checked;
});
function setupSyncScroll() {
diffs.forEach((diff, i) => {
diff.addEventListener('scroll', () => {
if (!state.syncScroll) return;
diffs.forEach((otherDiff, j) => {
if (i !== j && state.panes[j]) {
otherDiff.scrollTop = diff.scrollTop;
otherDiff.scrollLeft = diff.scrollLeft;
}
});
});
});
}
setupSyncScroll();
// Diff navigation
document.getElementById('prevDiff').addEventListener('click', () => navigateDiff(-1));
document.getElementById('nextDiff').addEventListener('click', () => navigateDiff(1));
function navigateDiff(direction) {
if (state.diffPositions.length === 0) return;
state.currentDiffIndex += direction;
if (state.currentDiffIndex < 0) state.currentDiffIndex = state.diffPositions.length - 1;
if (state.currentDiffIndex >= state.diffPositions.length) state.currentDiffIndex = 0;
highlightCurrentDiff();
updateDiffCounter();
}
function highlightCurrentDiff() {
document.querySelectorAll('.diff-line.highlight').forEach(el => el.classList.remove('highlight'));
if (state.currentDiffIndex >= 0 && state.currentDiffIndex < state.diffPositions.length) {
const lineIndex = state.diffPositions[state.currentDiffIndex];
diffs.forEach((diff, i) => {
if (state.panes[i]) {
const line = diff.querySelector(`[data-line="${lineIndex}"]`);
if (line) {
line.classList.add('highlight');
line.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
});
}
}
function updateDiffCounter() {
const counter = document.getElementById('diffCounter');
if (state.diffPositions.length === 0) {
counter.textContent = '0/0';
} else {
counter.textContent = `${state.currentDiffIndex + 1}/${state.diffPositions.length}`;
}
}
// Compare button
document.getElementById('compareBtn').addEventListener('click', () => {
inputs.forEach((input, i) => {
state.contents[i] = input.value;
});
const filledPanes = state.panes.filter((visible, i) => visible && state.contents[i].length > 0);
if (filledPanes.length < 2) {
alert('At least 2 panes must have content to compare.');
return;
}
computeDiffs();
document.getElementById('compareBtn').classList.add('hidden');
document.getElementById('editBtn').classList.remove('hidden');
});
// Edit button
document.getElementById('editBtn').addEventListener('click', () => {
exitCompareMode();
document.getElementById('editBtn').classList.add('hidden');
document.getElementById('compareBtn').classList.remove('hidden');
});
function exitCompareMode() {
state.panes.forEach((visible, i) => {
if (visible) {
inputs[i].classList.remove('hidden');
inputs[i].value = state.contents[i];
diffs[i].classList.add('hidden');
}
});
state.diffPositions = [];
state.currentDiffIndex = -1;
updateDiffCounter();
}
function computeDiffs() {
const base = state.contents[0];
const hasContent = state.contents.some(c => c.length > 0);
if (!hasContent) {
inputs.forEach((input, i) => {
input.classList.remove('hidden');
diffs[i].classList.add('hidden');
});
state.diffPositions = [];
state.currentDiffIndex = -1;
updateDiffCounter();
return;
}
const baseLines = base.split('\n');
const alignedResults = computeAlignedDiffs(baseLines, state.contents, state.panes);
state.diffPositions = [];
diffs.forEach((diff, paneIndex) => {
if (!state.panes[paneIndex]) return;
inputs[paneIndex].classList.add('hidden');
diff.classList.remove('hidden');
const lines = alignedResults[paneIndex];
let html = '<div class="diff-content">';
lines.forEach((line, idx) => {
let className = 'diff-line';
if (line.type === 'added') className += ' line-added';
else if (line.type === 'removed') className += ' line-removed';
else if (line.type === 'modified') className += ' line-modified';
else if (line.type === 'padding') className += ' line-padding';
if (line.type !== 'unchanged' && line.type !== 'padding') {
if (!state.diffPositions.includes(idx)) {
state.diffPositions.push(idx);
}
}
const lineNum = line.lineNum !== null ? line.lineNum : '';
const content = line.html || escapeHtml(line.content) || ' ';
html += `<div class="${className}" data-line="${idx}">`;
html += `<span class="line-number">${lineNum}</span>`;
html += `<span class="line-content">${content}</span>`;
html += '</div>';
});
html += '</div>';
diff.innerHTML = html;
});
state.diffPositions.sort((a, b) => a - b);
state.currentDiffIndex = state.diffPositions.length > 0 ? 0 : -1;
updateDiffCounter();
}
function computeAlignedDiffs(baseLines, contents, visiblePanes) {
const aligned = [[], [], []];
const compareLines = [
baseLines,
contents[1].split('\n'),
contents[2].split('\n')
];
// For each comparison pane, compute line-level diff against base
const lineDiffs = [null, null, null];
visiblePanes.forEach((visible, paneIdx) => {
if (!visible || paneIdx === 0) return;
lineDiffs[paneIdx] = Diff.diffArrays(baseLines, compareLines[paneIdx]);
});
// Build aligned view for each pane
visiblePanes.forEach((visible, paneIdx) => {
if (!visible || paneIdx === 0) return;
const diff = lineDiffs[paneIdx];
if (!diff) return;
let alignIdx = 0;
let baseLineNum = 1;
let compareLineNum = 1;
// Process diff parts, looking ahead for removed+added pairs
for (let partIdx = 0; partIdx < diff.length; partIdx++) {
const part = diff[partIdx];
const nextPart = diff[partIdx + 1];
if (part.removed && nextPart && nextPart.added) {
// This is a modification: removed lines followed by added lines
const removedLines = part.value;
const addedLines = nextPart.value;
const maxPairs = Math.max(removedLines.length, addedLines.length);
for (let i = 0; i < maxPairs; i++) {
const baseLine = removedLines[i];
const compareLine = addedLines[i];
if (baseLine !== undefined && compareLine !== undefined) {
// Both exist - compute char diff
const charDiff = Diff.diffWords(baseLine, compareLine);
let baseHtml = '';
let compareHtml = '';
charDiff.forEach(p => {
const escaped = escapeHtml(p.value);
if (p.added) {
compareHtml += `<span class="char-added">${escaped}</span>`;
} else if (p.removed) {
baseHtml += `<span class="char-removed">${escaped}</span>`;
} else {
baseHtml += escaped;
compareHtml += escaped;
}
});
aligned[0][alignIdx] = aligned[0][alignIdx] || {
content: baseLine,
lineNum: baseLineNum++,
type: 'modified',
html: baseHtml || ' '
};
aligned[paneIdx][alignIdx] = {
content: compareLine,
lineNum: compareLineNum++,
type: 'modified',
html: compareHtml || ' '
};
} else if (baseLine !== undefined) {
// Only base line exists - pure removal
aligned[0][alignIdx] = aligned[0][alignIdx] || {
content: baseLine,
lineNum: baseLineNum++,
type: 'removed',
html: `<span class="char-removed">${escapeHtml(baseLine)}</span>`
};
aligned[paneIdx][alignIdx] = { content: '', lineNum: null, type: 'padding' };
} else {
// Only compare line exists - pure addition
if (!aligned[0][alignIdx]) {
aligned[0][alignIdx] = { content: '', lineNum: null, type: 'padding' };
}
aligned[paneIdx][alignIdx] = {
content: compareLine,
lineNum: compareLineNum++,
type: 'added',
html: `<span class="char-added">${escapeHtml(compareLine)}</span>`
};
}
alignIdx++;
}
partIdx++; // Skip the next part since we processed it
} else if (part.removed) {
// Pure removal (no following addition)
part.value.forEach(line => {
aligned[0][alignIdx] = aligned[0][alignIdx] || {
content: line,
lineNum: baseLineNum++,
type: 'removed',
html: `<span class="char-removed">${escapeHtml(line)}</span>`
};
aligned[paneIdx][alignIdx] = { content: '', lineNum: null, type: 'padding' };
alignIdx++;
});
} else if (part.added) {
// Pure addition (no preceding removal)
part.value.forEach(line => {
if (!aligned[0][alignIdx]) {
aligned[0][alignIdx] = { content: '', lineNum: null, type: 'padding' };
}
aligned[paneIdx][alignIdx] = {
content: line,
lineNum: compareLineNum++,
type: 'added',
html: `<span class="char-added">${escapeHtml(line)}</span>`
};
alignIdx++;
});
} else {
// Unchanged
part.value.forEach(line => {
aligned[0][alignIdx] = aligned[0][alignIdx] || {
content: line,
lineNum: baseLineNum++,
type: 'unchanged'
};
aligned[paneIdx][alignIdx] = {
content: line,
lineNum: compareLineNum++,
type: 'unchanged'
};
alignIdx++;
});
}
}
});
// Fill gaps
const totalLines = Math.max(aligned[0].length, aligned[1].length, aligned[2].length);
for (let i = 0; i < totalLines; i++) {
if (!aligned[0][i]) aligned[0][i] = { content: '', lineNum: null, type: 'padding' };
if (visiblePanes[1] && !aligned[1][i]) aligned[1][i] = { content: '', lineNum: null, type: 'padding' };
if (visiblePanes[2] && !aligned[2][i]) aligned[2][i] = { content: '', lineNum: null, type: 'padding' };
}
return aligned;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
updatePaneVisibility();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment