Created
June 23, 2026 14:44
-
-
Save axhamre/1087a343ba4754fc0be0e6041c4258e1 to your computer and use it in GitHub Desktop.
Reusable HTML diff template
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <title>Reusable text diff template</title> | |
| <style> | |
| :root { | |
| --bg:#f7f7f4; --paper:#ffffff; --ink:#17202a; --muted:#667085; --line:#d0d5dd; | |
| --add:#d8f5df; --add-border:#56b870; --del:#ffe0e0; --del-border:#e15b64; --chg:#fff2b8; --chg-border:#e0b400; | |
| } | |
| * { box-sizing: border-box; } | |
| body { margin:0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; background:var(--bg); color:var(--ink); } | |
| header { position:sticky; top:0; z-index:10; background:rgba(255,255,255,.96); border-bottom:1px solid var(--line); padding:18px 24px 12px; backdrop-filter: blur(6px); } | |
| h1 { font-size:22px; margin:0 0 8px; } | |
| .meta { display:flex; gap:16px; flex-wrap:wrap; color:var(--muted); font-size:13px; } | |
| .badge { border:1px solid var(--line); border-radius:999px; padding:4px 9px; background:#fff; } | |
| .legend { display:flex; gap:10px; flex-wrap:wrap; margin-top:10px; font-size:13px; } | |
| .key { padding:4px 8px; border-radius:6px; border:1px solid var(--line); } | |
| .key.add { background:var(--add); border-color:var(--add-border); } | |
| .key.del { background:var(--del); border-color:var(--del-border); } | |
| .key.chg { background:var(--chg); border-color:var(--chg-border); } | |
| .canvas-wrap { height:130px; position:relative; background:#fbfbfa; border-top:1px solid #eee; margin-top:12px; border-radius:10px; overflow:hidden; } | |
| #diffCanvas { width:100%; height:130px; display:block; } | |
| main { max-width:1500px; margin:18px auto 60px; padding:0 18px; position:relative; } | |
| .columns { display:grid; grid-template-columns:1fr 1fr; gap:14px; position:sticky; top:126px; z-index:5; margin-bottom:8px; } | |
| .col-title { background:#111827; color:#fff; padding:10px 14px; border-radius:10px; font-weight:700; } | |
| .diff { background:var(--paper); border:1px solid var(--line); border-radius:14px; overflow:hidden; box-shadow:0 1px 2px rgba(16,24,40,.05); } | |
| .diff-row { display:grid; grid-template-columns:1fr 1fr; min-height:38px; border-top:1px solid #eef0f3; scroll-margin-top:190px; } | |
| .diff-row:first-child { border-top:0; } | |
| .pane { padding:10px 14px; line-height:1.48; font-size:14.5px; white-space:pre-wrap; overflow-wrap:anywhere; } | |
| .pane.old { border-right:1px solid #eef0f3; } | |
| .diff-row.equal .block { color:#30363d; } | |
| .diff-row.insert .new { background:linear-gradient(90deg, rgba(86,184,112,.16), transparent 72%); } | |
| .diff-row.delete .old { background:linear-gradient(90deg, rgba(225,91,100,.16), transparent 72%); } | |
| .diff-row.replace .old, .diff-row.replace .new { background:linear-gradient(90deg, rgba(224,180,0,.13), transparent 72%); } | |
| .add-word { background:var(--add); border-bottom:2px solid var(--add-border); border-radius:3px; padding:0 2px; } | |
| .del-word { background:var(--del); border-bottom:2px solid var(--del-border); border-radius:3px; padding:0 2px; text-decoration: line-through; text-decoration-thickness:1px; } | |
| .diff-row.replace .add-word, .diff-row.replace .del-word { background:var(--chg); border-bottom-color:var(--chg-border); text-decoration:none; } | |
| .diff-row[data-change-id] { cursor:pointer; } | |
| .diff-row[data-change-id]:hover { outline:2px solid #98a2b3; outline-offset:-2px; } | |
| .block:empty::after { content:'∅'; color:#98a2b3; } | |
| footer { max-width:1500px; margin:0 auto 40px; padding:0 18px; color:var(--muted); font-size:12px; } | |
| textarea.source { display:none; } | |
| @media (max-width:900px) { .columns,.diff-row { grid-template-columns:1fr; } .pane.old { border-right:0; border-bottom:1px solid #eef0f3; } .columns { display:none; } } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- | |
| How to reuse: | |
| 1. Paste your original text between <textarea id="oldText"> and </textarea>. | |
| 2. Paste your revised text between <textarea id="newText"> and </textarea>. | |
| 3. Optionally edit DIFF_TITLE, OLD_LABEL, and NEW_LABEL below. | |
| 4. Open this HTML file in a browser. | |
| Tip: Do not escape HTML characters in your pasted text. Paste normal text as-is. | |
| --> | |
| <textarea id="oldText" class="source"> | |
| PASTE ORIGINAL TEXT HERE | |
| </textarea> | |
| <textarea id="newText" class="source"> | |
| PASTE REVISED TEXT HERE | |
| </textarea> | |
| <script> | |
| const DIFF_TITLE = "Reusable text diff"; | |
| const OLD_LABEL = "Original"; | |
| const NEW_LABEL = "Revised"; | |
| </script> | |
| <header> | |
| <h1 id="title">Reusable text diff</h1> | |
| <div class="meta"> | |
| <span class="badge" id="oldBadge">Old: original text</span> | |
| <span class="badge" id="newBadge">New: revised text</span> | |
| <span class="badge" id="changedBadge">Changed blocks: 0</span> | |
| <span class="badge" id="addedBadge">Added blocks: 0</span> | |
| <span class="badge" id="deletedBadge">Deleted blocks: 0</span> | |
| </div> | |
| <div class="legend"> | |
| <span class="key add">Green = added</span> | |
| <span class="key del">Red = deleted</span> | |
| <span class="key chg">Yellow = modified wording</span> | |
| </div> | |
| <div class="canvas-wrap" aria-label="Canvas diff overview"><canvas id="diffCanvas"></canvas></div> | |
| </header> | |
| <main> | |
| <div class="columns"><div class="col-title" id="oldTitle">Original</div><div class="col-title" id="newTitle">Revised</div></div> | |
| <section class="diff" id="diff"></section> | |
| </main> | |
| <footer> | |
| Canvas guide: each vertical mark represents a changed block in document order. Click a mark or a highlighted row to navigate. Curved connectors show old-to-new paired changes where both sides exist. | |
| </footer> | |
| <script> | |
| function escapeHtml(str) { | |
| return str.replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); | |
| } | |
| function tokenize(str) { | |
| return str.match(/\w+|[^\w\s]+|\s+/gu) || []; | |
| } | |
| function splitBlocks(text) { | |
| return text.trim().split(/\n\s*\n/g).flatMap(raw => { | |
| const lines = raw.trim().split('\n').map(s => s.trim()).filter(Boolean); | |
| return lines.length > 1 ? lines : lines; | |
| }).filter(Boolean); | |
| } | |
| function sequenceMatcher(a, b) { | |
| // Small, dependency-free LCS matcher. Good for text/docs; very large files may be slower. | |
| const n = a.length, m = b.length; | |
| const dp = Array.from({length:n+1}, () => new Uint32Array(m+1)); | |
| for (let i=n-1; i>=0; i--) for (let j=m-1; j>=0; j--) dp[i][j] = a[i] === b[j] ? dp[i+1][j+1] + 1 : Math.max(dp[i+1][j], dp[i][j+1]); | |
| const ops = []; | |
| let i=0, j=0; | |
| function push(tag, ai, bj) { | |
| const last = ops[ops.length-1]; | |
| if (last && last.tag === tag) { last.a.push(ai); last.b.push(bj); } | |
| else ops.push({tag, a:[ai], b:[bj]}); | |
| } | |
| while (i<n && j<m) { | |
| if (a[i] === b[j]) { push('equal', i++, j++); } | |
| else if (dp[i+1][j] >= dp[i][j+1]) { push('delete', i++, null); } | |
| else { push('insert', null, j++); } | |
| } | |
| while (i<n) push('delete', i++, null); | |
| while (j<m) push('insert', null, j++); | |
| // Coalesce adjacent delete+insert or insert+delete into replace. | |
| const out = []; | |
| for (let k=0; k<ops.length; k++) { | |
| const cur = ops[k], next = ops[k+1]; | |
| if (next && ((cur.tag === 'delete' && next.tag === 'insert') || (cur.tag === 'insert' && next.tag === 'delete'))) { | |
| out.push({tag:'replace', a:cur.a.concat(next.a).filter(x => x !== null), b:cur.b.concat(next.b).filter(x => x !== null)}); | |
| k++; | |
| } else out.push(cur); | |
| } | |
| return out; | |
| } | |
| function wordDiff(a, b, side) { | |
| const at = tokenize(a), bt = tokenize(b); | |
| const ops = sequenceMatcher(at, bt); | |
| let out = ''; | |
| for (const op of ops) { | |
| if (op.tag === 'equal') out += escapeHtml((side === 'old' ? op.a.map(i => at[i]) : op.b.map(i => bt[i])).join('')); | |
| else if ((op.tag === 'delete' || op.tag === 'replace') && side === 'old') out += `<span class="del-word">${escapeHtml(op.a.map(i => at[i]).join(''))}</span>`; | |
| else if ((op.tag === 'insert' || op.tag === 'replace') && side === 'new') out += `<span class="add-word">${escapeHtml(op.b.map(i => bt[i]).join(''))}</span>`; | |
| } | |
| return out; | |
| } | |
| function buildDiff() { | |
| document.getElementById('title').textContent = DIFF_TITLE; | |
| document.getElementById('oldTitle').textContent = OLD_LABEL; | |
| document.getElementById('newTitle').textContent = NEW_LABEL; | |
| document.getElementById('oldBadge').textContent = `Old: ${OLD_LABEL.toLowerCase()}`; | |
| document.getElementById('newBadge').textContent = `New: ${NEW_LABEL.toLowerCase()}`; | |
| const oldBlocks = splitBlocks(document.getElementById('oldText').value); | |
| const newBlocks = splitBlocks(document.getElementById('newText').value); | |
| const ops = sequenceMatcher(oldBlocks, newBlocks); | |
| const rows = []; | |
| const stats = {added:0, deleted:0, changed:0, unchanged:0}; | |
| let changeId = 0; | |
| for (const op of ops) { | |
| if (op.tag === 'equal') { | |
| for (let k=0; k<op.a.length; k++) { stats.unchanged++; rows.push({type:'equal', cid:null, oldHtml:escapeHtml(oldBlocks[op.a[k]]), newHtml:escapeHtml(newBlocks[op.b[k]])}); } | |
| } else if (op.tag === 'delete') { | |
| for (const i of op.a) { changeId++; stats.deleted++; rows.push({type:'delete', cid:changeId, oldHtml:`<span class="del-word">${escapeHtml(oldBlocks[i])}</span>`, newHtml:''}); } | |
| } else if (op.tag === 'insert') { | |
| for (const j of op.b) { changeId++; stats.added++; rows.push({type:'insert', cid:changeId, oldHtml:'', newHtml:`<span class="add-word">${escapeHtml(newBlocks[j])}</span>`}); } | |
| } else if (op.tag === 'replace') { | |
| const len = Math.max(op.a.length, op.b.length); | |
| for (let k=0; k<len; k++) { | |
| const oldVal = op.a[k] != null ? oldBlocks[op.a[k]] : ''; | |
| const newVal = op.b[k] != null ? newBlocks[op.b[k]] : ''; | |
| changeId++; | |
| if (oldVal && newVal) { stats.changed++; rows.push({type:'replace', cid:changeId, oldHtml:wordDiff(oldVal, newVal, 'old'), newHtml:wordDiff(oldVal, newVal, 'new')}); } | |
| else if (oldVal) { stats.deleted++; rows.push({type:'delete', cid:changeId, oldHtml:`<span class="del-word">${escapeHtml(oldVal)}</span>`, newHtml:''}); } | |
| else { stats.added++; rows.push({type:'insert', cid:changeId, oldHtml:'', newHtml:`<span class="add-word">${escapeHtml(newVal)}</span>`}); } | |
| } | |
| } | |
| } | |
| document.getElementById('changedBadge').textContent = `Changed blocks: ${stats.changed}`; | |
| document.getElementById('addedBadge').textContent = `Added blocks: ${stats.added}`; | |
| document.getElementById('deletedBadge').textContent = `Deleted blocks: ${stats.deleted}`; | |
| document.getElementById('diff').innerHTML = rows.map(r => ` | |
| <div class="diff-row ${r.type}"${r.cid ? ` data-change-id="${r.cid}"` : ''}> | |
| <div class="pane old"><div class="block">${r.oldHtml}</div></div> | |
| <div class="pane new"><div class="block">${r.newHtml}</div></div> | |
| </div>`).join(''); | |
| setupCanvas(); | |
| } | |
| function setupCanvas() { | |
| const canvas = document.getElementById('diffCanvas'); | |
| const ctx = canvas.getContext('2d'); | |
| const rows = Array.from(document.querySelectorAll('.diff-row[data-change-id]')); | |
| function typeColor(row) { | |
| if (row.classList.contains('insert')) return '#56b870'; | |
| if (row.classList.contains('delete')) return '#e15b64'; | |
| return '#e0b400'; | |
| } | |
| function draw() { | |
| const w = canvas.clientWidth, h = canvas.clientHeight; | |
| ctx.clearRect(0,0,w,h); | |
| ctx.font = '12px system-ui, sans-serif'; | |
| ctx.fillStyle = '#667085'; | |
| ctx.fillText('Diff canvas overview - click markers to jump', 16, 20); | |
| const baseY = 74; | |
| ctx.strokeStyle = '#d0d5dd'; ctx.lineWidth = 1; | |
| ctx.beginPath(); ctx.moveTo(24, baseY); ctx.lineTo(w-24, baseY); ctx.stroke(); | |
| const n = rows.length || 1; | |
| rows.forEach((row, idx) => { | |
| const x = 24 + (w-48) * (idx/(Math.max(n-1,1))); | |
| const color = typeColor(row); | |
| ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = 3; | |
| const top = row.classList.contains('insert') ? baseY - 34 : baseY - 18; | |
| const bottom = row.classList.contains('delete') ? baseY + 34 : baseY + 18; | |
| ctx.beginPath(); ctx.moveTo(x, top); ctx.lineTo(x, bottom); ctx.stroke(); | |
| if (row.classList.contains('replace')) { | |
| ctx.globalAlpha = .35; | |
| ctx.beginPath(); ctx.moveTo(x-8, baseY+26); ctx.bezierCurveTo(x-4, baseY+42, x+4, baseY-42, x+8, baseY-26); ctx.stroke(); | |
| ctx.globalAlpha = 1; | |
| } | |
| }); | |
| const progress = Math.min(1, Math.max(0, scrollY / (document.body.scrollHeight - innerHeight || 1))); | |
| const px = 24 + (w-48) * progress; | |
| ctx.strokeStyle = '#111827'; ctx.lineWidth = 2; | |
| ctx.beginPath(); ctx.moveTo(px, 36); ctx.lineTo(px, h-18); ctx.stroke(); | |
| } | |
| function resize() { | |
| const r = canvas.getBoundingClientRect(); | |
| canvas.width = Math.max(1, Math.floor(r.width * devicePixelRatio)); | |
| canvas.height = Math.max(1, Math.floor(r.height * devicePixelRatio)); | |
| ctx.setTransform(devicePixelRatio,0,0,devicePixelRatio,0,0); | |
| draw(); | |
| } | |
| canvas.onclick = e => { | |
| const rect = canvas.getBoundingClientRect(); | |
| const n = rows.length || 1; | |
| const idx = Math.max(0, Math.min(n-1, Math.round((e.clientX - rect.left - 24)/(rect.width-48) * (n-1)))); | |
| rows[idx]?.scrollIntoView({behavior:'smooth', block:'center'}); | |
| }; | |
| rows.forEach(row => row.onclick = () => row.scrollIntoView({behavior:'smooth', block:'center'})); | |
| window.onresize = resize; | |
| window.onscroll = draw; | |
| resize(); | |
| } | |
| buildDiff(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment