Spaces:
Running
Running
Download script.js from Meroar/filediff-viewer: direct link, hf CLI and curl.
- Browser
- Download file 52 kB
-
https://huggingface.co/spaces/Meroar/filediff-viewer/resolve/main/script.js
- Command line
-
hf download hf://spaces/Meroar/filediff-viewer/script.js
-
curl -L -o script.js https://huggingface.co/spaces/Meroar/filediff-viewer/resolve/main/script.js
52 kB
| // FileDiff Viewer - Line-by-line visual diff with moved lines and jump-to | |
| (() => { | |
| // State | |
| const state = { | |
| files: { | |
| left: { name: '', size: 0, text: '' }, | |
| right: { name: '', size: 0, text: '' }, | |
| }, | |
| options: { | |
| ignoreWhitespace: false, | |
| syncScroll: true, | |
| wordDiff: false, | |
| darkMode: false, | |
| viewMode: 'side-by-side', // 'side-by-side', 'inline', 'unified' | |
| }, | |
| result: null, // diff result | |
| syncing: false, | |
| currentChangeIndex: -1, | |
| changeNavigation: [], | |
| showHeatmap: false, | |
| showMinimap: false, | |
| showStructure: false, | |
| showSemantics: false, | |
| searchResults: [], | |
| searchIndex: 0, | |
| syntaxHighlight: true, | |
| }; | |
| // Elements | |
| const els = { | |
| // file inputs and paste | |
| fileLeft: document.getElementById('fileLeft'), | |
| fileRight: document.getElementById('fileRight'), | |
| pasteLeftBtn: document.getElementById('pasteLeftBtn'), | |
| pasteRightBtn: document.getElementById('pasteRightBtn'), | |
| pasteLeftWrap: document.getElementById('pasteLeftWrap'), | |
| pasteRightWrap: document.getElementById('pasteRightWrap'), | |
| textLeft: document.getElementById('textLeft'), | |
| textRight: document.getElementById('textRight'), | |
| applyLeftText: document.getElementById('applyLeftText'), | |
| applyRightText: document.getElementById('applyRightText'), | |
| cancelLeftText: document.getElementById('cancelLeftText'), | |
| cancelRightText: document.getElementById('cancelRightText'), | |
| leftSummary: document.getElementById('leftSummary'), | |
| rightSummary: document.getElementById('rightSummary'), | |
| // search | |
| searchInput: document.getElementById('searchInput'), | |
| searchBtn: document.getElementById('searchBtn'), | |
| // options | |
| ignoreWs: document.getElementById('ignoreWs'), | |
| syncScroll: document.getElementById('syncScroll'), | |
| semanticMode: document.getElementById('semanticMode'), | |
| // modal | |
| infoBtn: document.getElementById('infoBtn'), | |
| modalOverlay: document.getElementById('modalOverlay'), | |
| modalClose: document.getElementById('modalClose'), | |
| // meta | |
| metaLeft: document.getElementById('metaLeft'), | |
| metaRight: document.getElementById('metaRight'), | |
| // panes and bodies | |
| paneLeft: document.getElementById('paneLeft'), | |
| paneRight: document.getElementById('paneRight'), | |
| tbodyLeft: document.getElementById('tbodyLeft'), | |
| tbodyRight: document.getElementById('tbodyRight'), | |
| paneWrapper: document.getElementById('paneWrapper'), | |
| // line connectors | |
| lineConnectors: document.getElementById('lineConnectors'), | |
| // stats - using enhanced stats as primary | |
| statsWrap: document.getElementById('enhancedStats'), | |
| statAdded: document.getElementById('statAdded'), | |
| statDeleted: document.getElementById('statDeleted'), | |
| statModified: document.getElementById('statModified'), | |
| statMoved: document.getElementById('statMoved'), | |
| // analytics | |
| analyticsWrap: document.getElementById('analytics'), | |
| changeChart: document.getElementById('changeChart'), | |
| changeHeatmap: document.getElementById('changeHeatmap'), | |
| semanticAnalysis: document.getElementById('semanticAnalysis'), | |
| structuralAnalysis: document.getElementById('structuralAnalysis'), | |
| trendChart: document.getElementById('trendChart'), | |
| enhancedStats: document.getElementById('enhancedStats'), | |
| statSemantic: document.getElementById('statSemantic'), | |
| statComplexity: document.getElementById('statComplexity'), | |
| // minimap | |
| minimap: document.getElementById('minimap'), | |
| minimapContent: document.getElementById('minimapContent'), | |
| minimapMode: document.getElementById('minimapMode'), | |
| // toolbar buttons | |
| exportBtn: document.getElementById('exportBtn'), | |
| reportBtn: document.getElementById('reportBtn'), | |
| acceptAllBtn: document.getElementById('acceptAllBtn'), | |
| rejectAllBtn: document.getElementById('rejectAllBtn'), | |
| toggleHeatmapBtn: document.getElementById('toggleHeatmap'), | |
| toggleMinimapBtn: document.getElementById('toggleMinimap'), | |
| toggleStructureBtn: document.getElementById('toggleStructure'), | |
| toggleSemanticsBtn: document.getElementById('toggleSemantics'), | |
| wordDiffBtn: document.getElementById('wordDiffBtn'), | |
| layoutBtn: document.getElementById('layoutBtn'), | |
| modeBtns: document.querySelectorAll('.mode-btn'), | |
| comparisonToolbar: document.getElementById('comparisonToolbar'), | |
| acceptLeftBtn: document.getElementById('acceptLeftBtn'), | |
| rejectLeftBtn: document.getElementById('rejectLeftBtn'), | |
| acceptRightBtn: document.getElementById('acceptRightBtn'), | |
| rejectRightBtn: document.getElementById('rejectRightBtn'), | |
| analyticsToggleHeatmap: document.getElementById('analyticsToggleHeatmap'), | |
| analyticsToggleMinimap: document.getElementById('analyticsToggleMinimap'), | |
| analyticsToggleStructure: document.getElementById('analyticsToggleStructure'), | |
| }; | |
| // Utilities | |
| const MAX_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB safety limit | |
| const byId = (id) => document.getElementById(id); | |
| const escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); | |
| const normalize = (s) => s.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); | |
| const trimWs = (s) => s.trim(); | |
| const isBlank = (s) => s.length === 0; | |
| const fileSizeHuman = (n) => { | |
| if (n < 1024) return `${n} B`; | |
| if (n < 1024*1024) return `${(n/1024).toFixed(1)} KB`; | |
| return `${(n/1024/1024).toFixed(1)} MB`; | |
| }; | |
| function similarity(a, b) { | |
| // Jaccard similarity over tokens (naive but useful) | |
| const ta = (a.trim().match(/\S+/g) || []); | |
| const tb = (b.trim().match(/\S+/g) || []); | |
| const sa = new Set(ta); | |
| const sb = new Set(tb); | |
| let inter = 0; | |
| for (const t of sa) if (sb.has(t)) inter++; | |
| const union = new Set([...ta, ...tb]).size || 1; | |
| return inter / union; | |
| } | |
| // Diff engine | |
| function lcsMatrix(a, b) { | |
| const n = a.length, m = b.length; | |
| const dp = Array(n + 1); | |
| for (let i = 0; i <= n; i++) dp[i] = new Array(m + 1).fill(0); | |
| for (let i = n - 1; i >= 0; i--) { | |
| for (let j = m - 1; j >= 0; j--) { | |
| if (a[i] === b[j]) dp[i][j] = dp[i + 1][j + 1] + 1; | |
| else dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]); | |
| } | |
| } | |
| return dp; | |
| } | |
| function backtrackLCS(dp, a, b) { | |
| const pairs = []; | |
| let i = 0, j = 0; | |
| while (i < a.length && j < b.length) { | |
| if (a[i] === b[j]) { | |
| pairs.push([i, j]); | |
| i++; j++; | |
| } else if (dp[i + 1][j] >= dp[i][j + 1]) { | |
| i++; | |
| } else { | |
| j++; | |
| } | |
| } | |
| return pairs; // array of [i, j] indices of equal lines | |
| } | |
| function findEqualRanges(pairs) { | |
| if (!pairs.length) return []; | |
| const ranges = []; | |
| let startI = pairs[0][0], startJ = pairs[0][1], prevI = startI, prevJ = startJ; | |
| for (let k = 1; k < pairs.length; k++) { | |
| const [i, j] = pairs[k]; | |
| if (i === prevI + 1 && j === prevJ + 1) { | |
| prevI = i; prevJ = j; | |
| } else { | |
| ranges.push([startI, prevI, startJ, prevJ]); | |
| startI = i; startJ = j; prevI = i; prevJ = j; | |
| } | |
| } | |
| ranges.push([startI, prevI, startJ, prevJ]); | |
| return ranges; | |
| } | |
| function diffLines(textA, textB, ignoreWs) { | |
| const A = normalize(textA).split('\n'); | |
| const B = normalize(textB).split('\n'); | |
| const a = ignoreWs ? A.map(trimWs) : A; | |
| const b = ignoreWs ? B.map(trimWs) : B; | |
| const dp = lcsMatrix(a, b); | |
| const pairs = backtrackLCS(dp, a, b); | |
| const ranges = findEqualRanges(pairs); | |
| const aLen = a.length, bLen = b.length; | |
| let ai = 0, bj = 0; | |
| const aRes = []; | |
| const bRes = []; | |
| // Mapping for moved lines | |
| const aToB = new Map(); // aIndex -> bIndex (for moved) | |
| const bToA = new Map(); // bIndex -> aIndex (for moved) | |
| function pushEqualRange(sI, eI, sJ, eJ) { | |
| for (let k = 0; k <= eI - sI; k++) { | |
| const i = sI + k, j = sJ + k; | |
| aRes.push({ type: 'equal', aIndex: i, bIndex: null, aText: A[i], bText: null }); | |
| bRes.push({ type: 'equal', aIndex: null, bIndex: j, aText: null, bText: B[j] }); | |
| } | |
| ai = eI + 1; bj = eJ + 1; | |
| } | |
| for (const [sI, eI, sJ, eJ] of ranges) { | |
| // First, deletions/insertions/modifications between current ai..sI and bj..sJ | |
| while (ai < sI && bj < sJ) { | |
| const del = a[ai], ins = b[bj]; | |
| const sim = similarity(a[ai], b[bj]); | |
| if (sim >= 0.55) { | |
| // Modified pair | |
| aRes.push({ type: 'modified', aIndex: ai, bIndex: bj, aText: A[ai], bText: null, partner: bj }); | |
| bRes.push({ type: 'modified', aIndex: ai, bIndex: bj, aText: null, bText: B[bj], partner: ai }); | |
| aToB.set(ai, bj); | |
| bToA.set(bj, ai); | |
| ai++; bj++; | |
| } else { | |
| aRes.push({ type: 'deleted', aIndex: ai, bIndex: null, aText: A[ai], bText: null }); | |
| bRes.push({ type: 'empty', aIndex: null, bIndex: null, aText: null, bText: null }); | |
| ai++; | |
| } | |
| } | |
| while (ai < sI) { | |
| aRes.push({ type: 'deleted', aIndex: ai, bIndex: null, aText: A[ai], bText: null }); | |
| bRes.push({ type: 'empty', aIndex: null, bIndex: null, aText: null, bText: null }); | |
| ai++; | |
| } | |
| while (bj < sJ) { | |
| aRes.push({ type: 'empty', aIndex: null, bIndex: null, aText: null, bText: null }); | |
| bRes.push({ type: 'inserted', aIndex: null, bIndex: bj, aText: null, bText: B[bj] }); | |
| bj++; | |
| } | |
| // Now equal run | |
| pushEqualRange(sI, eI, sJ, eJ); | |
| } | |
| // Tail | |
| while (ai < aLen && bj < bLen) { | |
| const sim = similarity(a[ai], b[bj]); | |
| if (sim >= 0.55) { | |
| aRes.push({ type: 'modified', aIndex: ai, bIndex: bj, aText: A[ai], bText: null, partner: bj }); | |
| bRes.push({ type: 'modified', aIndex: ai, bIndex: bj, aText: null, bText: B[bj], partner: ai }); | |
| aToB.set(ai, bj); | |
| bToA.set(bj, ai); | |
| ai++; bj++; | |
| } else { | |
| aRes.push({ type: 'deleted', aIndex: ai, bIndex: null, aText: A[ai], bText: null }); | |
| bRes.push({ type: 'empty', aIndex: null, bIndex: null, aText: null, bText: null }); | |
| ai++; | |
| } | |
| } | |
| while (ai < aLen) { | |
| aRes.push({ type: 'deleted', aIndex: ai, bIndex: null, aText: A[ai], bText: null }); | |
| bRes.push({ type: 'empty', aIndex: null, bIndex: null, aText: null, bText: null }); | |
| ai++; | |
| } | |
| while (bj < bLen) { | |
| aRes.push({ type: 'empty', aIndex: null, bIndex: null, aText: null, bText: null }); | |
| bRes.push({ type: 'inserted', aIndex: null, bIndex: bj, aText: B[bj], bText: null }); | |
| bj++; | |
| } | |
| // Moved detection: single deleted lines whose partner is a single inserted line later | |
| const aDelGroups = groupBy(aRes.filter(x => x.type === 'deleted'), (x) => x.aIndex); | |
| const bInsGroups = groupBy(bRes.filter(x => x.type === 'inserted'), (x) => x.bIndex); | |
| // Make reverse maps with indices | |
| const aDeletedSet = new Set(aRes.filter(x => x.type === 'deleted').map(x => x.aIndex)); | |
| const bInsertedSet = new Set(bRes.filter(x => x.type === 'inserted').map(x => x.bIndex)); | |
| for (const [ai2, bj2] of aToB.entries()) { | |
| // Already partners in modified; skip | |
| continue; | |
| } | |
| // Consider unmatched singles by equality only | |
| for (const ai2 of aDeletedSet) { | |
| const bj2 = aToB.get(ai2); | |
| if (bj2 != null && bInsertedSet.has(bj2)) { | |
| // It could still be treated as moved if both are not in other relations | |
| // We'll mark if they are exact equal (trim aware) and not already modified | |
| const leftText = a[ai2]; | |
| const rightText = b[bj2]; | |
| if (leftText === rightText) { | |
| // Tag both sides as moved and neutralize delete/insert | |
| const aRow = aRes.find(x => x.aIndex === ai2 && x.type === 'deleted'); | |
| const bRow = bRes.find(x => x.bIndex === bj2 && x.type === 'inserted'); | |
| if (aRow && bRow) { | |
| aRow.type = 'moved'; | |
| aRow.partner = bj2; | |
| bRow.type = 'moved'; | |
| bRow.partner = ai2; | |
| } | |
| } | |
| } | |
| } | |
| // Stats | |
| const added = bRes.filter(x => x.type === 'inserted').length; | |
| const deleted = aRes.filter(x => x.type === 'deleted').length; | |
| const modified = aRes.filter(x => x.type === 'modified').length; | |
| const moved = aRes.filter(x => x.type === 'moved').length; | |
| return { | |
| A, B, | |
| aRes, bRes, | |
| stats: { added, deleted, modified, moved } | |
| }; | |
| } | |
| function groupBy(arr, keyFn) { | |
| const map = new Map(); | |
| for (const item of arr) { | |
| const k = keyFn(item); | |
| if (!map.has(k)) map.set(k, []); | |
| map.get(k).push(item); | |
| } | |
| return map; | |
| } | |
| // Rendering | |
| // Syntax highlighting helpers | |
| const syntaxRules = { | |
| javascript: { | |
| keywords: /\b(const|let|var|function|return|if|else|for|while|class|import|export|from|async|await|try|catch|new|this|typeof|instanceof|in|of)\b/g, | |
| strings: /(['"`])(?:\\.|(?!\1)[^\\\r\n])*\1/g, | |
| comments: /\/\/.*$/gm, | |
| numbers: /\b\d+\b/g, | |
| }, | |
| python: { | |
| keywords: /\b(def|class|return|if|else|elif|for|while|try|except|import|from|as|with|pass|break|continue|lambda|yield|async|await|print|len|range|None|True|False)\b/g, | |
| strings: /(['"])(?:\\.|(?!\1)[^\\\r\n])*\1/g, | |
| comments: /#.*$/gm, | |
| numbers: /\b\d+\b/g, | |
| }, | |
| json: { | |
| strings: /"(?:\\.|[^"\\])*"/g, | |
| numbers: /-?\b\d+\.?\d*\b/g, | |
| keywords: /\b(true|false|null)\b/g, | |
| }, | |
| css: { | |
| properties: /[a-z-]+(?=\s*:)/gi, | |
| selectors: /[.#][a-zA-Z0-9_-]+/g, | |
| comments: /\/\*[\s\S]*?\*\//g, | |
| numbers: /-?\b\d+\.?\d*(?:px|em|rem|%|vh|vw|pt|pc|in|cm|mm|ex|ch|vmin|vmax|s|ms|deg|rad|grad|turn|Hz|kHz|dpi|dpcm|dppx)?\b/g, | |
| }, | |
| html: { | |
| tags: /<\/?[a-zA-Z][a-zA-Z0-9]*[^>]*>/g, | |
| strings: /"(?:\\.|[^"\\])*"/g, | |
| comments: /<!--[\s\S]*?-->/g, | |
| } | |
| }; | |
| function detectLanguage(filename, content) { | |
| if (!filename) return 'text'; | |
| const ext = filename.split('.').pop().toLowerCase(); | |
| const langMap = { | |
| 'js': 'javascript', 'ts': 'javascript', 'jsx': 'javascript', 'tsx': 'javascript', 'mjs': 'javascript', | |
| 'py': 'python', 'pyw': 'python', | |
| 'rb': 'ruby', | |
| 'go': 'go', | |
| 'java': 'java', | |
| 'c': 'c', 'cpp': 'cpp', 'cc': 'cpp', 'cxx': 'cpp', 'h': 'c', 'hpp': 'cpp', | |
| 'cs': 'csharp', | |
| 'php': 'php', | |
| 'swift': 'swift', | |
| 'rs': 'rust', | |
| 'json': 'json', | |
| 'css': 'css', 'scss': 'css', 'less': 'css', 'sass': 'css', | |
| 'html': 'html', 'htm': 'html', 'xml': 'html', 'svg': 'html', 'xhtml': 'html', | |
| 'md': 'markdown', 'markdown': 'markdown', | |
| 'yml': 'yaml', 'yaml': 'yaml', | |
| 'sql': 'sql', | |
| 'sh': 'bash', 'bash': 'bash', 'zsh': 'bash', 'fish': 'bash', | |
| 'txt': 'text', 'log': 'text', 'conf': 'text', 'ini': 'text', 'cfg': 'text' | |
| }; | |
| return langMap[ext] || 'text'; | |
| } | |
| function highlightCode(text, language) { | |
| if (!state.syntaxHighlight || language === 'text' || !text) return escapeHtml(text); | |
| const rules = syntaxRules[language] || syntaxRules.javascript; | |
| let html = escapeHtml(text); | |
| // Store placeholders to prevent nested replacements | |
| const placeholders = []; | |
| const storePlaceholder = (match, type) => { | |
| const id = `__PLACEHOLDER_${placeholders.length}__`; | |
| placeholders.push({ id, match, type }); | |
| return id; | |
| }; | |
| // Replace comments first (they might contain other patterns) | |
| if (rules.comments) { | |
| html = html.replace(rules.comments, (match) => storePlaceholder(match, 'comment')); | |
| } | |
| // Replace strings | |
| if (rules.strings) { | |
| html = html.replace(rules.strings, (match) => storePlaceholder(match, 'string')); | |
| } | |
| // Replace keywords | |
| if (rules.keywords) { | |
| html = html.replace(rules.keywords, (match) => storePlaceholder(match, 'keyword')); | |
| } | |
| // Replace numbers | |
| if (rules.numbers) { | |
| html = html.replace(rules.numbers, (match) => storePlaceholder(match, 'number')); | |
| } | |
| // Replace properties (CSS) | |
| if (rules.properties) { | |
| html = html.replace(rules.properties, (match) => storePlaceholder(match, 'property')); | |
| } | |
| // Replace tags (HTML) | |
| if (rules.tags) { | |
| html = html.replace(rules.tags, (match) => storePlaceholder(match, 'tag')); | |
| } | |
| // Restore placeholders with proper spans | |
| placeholders.forEach(({ id, match, type }) => { | |
| const className = `syntax-${type}`; | |
| html = html.replace(id, `<span class="${className}">${match}</span>`); | |
| }); | |
| return html; | |
| } | |
| // Word-level diff for modified lines | |
| function computeWordDiff(oldText, newText) { | |
| const oldWords = oldText.split(/(\s+|[.,;:!?()[\]{}'"<>\/\\|@#$%^&*])/); | |
| const newWords = newText.split(/(\s+|[.,;:!?()[\]{}'"<>\/\\|@#$%^&*])/); | |
| const dp = Array(oldWords.length + 1).fill(null).map(() => Array(newWords.length + 1).fill(0)); | |
| for (let i = oldWords.length - 1; i >= 0; i--) { | |
| for (let j = newWords.length - 1; j >= 0; j--) { | |
| if (oldWords[i] === newWords[j]) { | |
| dp[i][j] = dp[i + 1][j + 1] + 1; | |
| } else { | |
| dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]); | |
| } | |
| } | |
| } | |
| let i = 0, j = 0; | |
| let result = []; | |
| while (i < oldWords.length && j < newWords.length) { | |
| if (oldWords[i] === newWords[j]) { | |
| result.push({ type: 'equal', text: oldWords[i] }); | |
| i++; j++; | |
| } else if (dp[i + 1][j] >= dp[i][j + 1]) { | |
| result.push({ type: 'deleted', text: oldWords[i] }); | |
| i++; | |
| } else { | |
| result.push({ type: 'added', text: newWords[j] }); | |
| j++; | |
| } | |
| } | |
| while (i < oldWords.length) { | |
| result.push({ type: 'deleted', text: oldWords[i] }); | |
| i++; | |
| } | |
| while (j < newWords.length) { | |
| result.push({ type: 'added', text: newWords[j] }); | |
| j++; | |
| } | |
| return result.map(w => { | |
| if (w.type === 'equal') return escapeHtml(w.text); | |
| if (w.type === 'added') return `<span class="word-diff added">${escapeHtml(w.text)}</span>`; | |
| if (w.type === 'deleted') return `<span class="word-diff deleted">${escapeHtml(w.text)}</span>`; | |
| }).join(''); | |
| } | |
| function renderDiff(result) { | |
| const { aRes, bRes, A, B, stats } = result; | |
| const leftLang = detectLanguage(state.files.left.name, state.files.left.text); | |
| const rightLang = detectLanguage(state.files.right.name, state.files.right.text); | |
| // Clear | |
| els.tbodyLeft.innerHTML = ''; | |
| els.tbodyRight.innerHTML = ''; | |
| const fragLeft = document.createDocumentFragment(); | |
| const fragRight = document.createDocumentFragment(); | |
| // Build change navigation index | |
| state.changeNavigation = []; | |
| for (let i = 0; i < aRes.length; i++) { | |
| const ar = aRes[i]; | |
| const br = bRes[i]; | |
| // Track changes for navigation | |
| if (ar.type !== 'equal' && ar.type !== 'empty') { | |
| state.changeNavigation.push({ index: i, type: ar.type, side: 'left', line: ar.aIndex }); | |
| } | |
| const trL = document.createElement('tr'); | |
| trL.setAttribute('data-kind', ar.type); | |
| trL.setAttribute('data-index', String(ar.aIndex ?? -1)); | |
| trL.id = ar.aIndex != null ? `L${ar.aIndex}` : `L-gap-${i}`; | |
| if (ar.type !== 'equal') trL.classList.add('change-row'); | |
| const trR = document.createElement('tr'); | |
| trR.setAttribute('data-kind', br.type); | |
| trR.setAttribute('data-index', String(br.bIndex ?? -1)); | |
| trR.id = br.bIndex != null ? `R${br.bIndex}` : `R-gap-${i}`; | |
| if (br.type !== 'equal') trR.classList.add('change-row'); | |
| // Line number cells | |
| const tdNumL = document.createElement('td'); | |
| tdNumL.className = 'gutter'; | |
| tdNumL.textContent = ar.aIndex != null ? String(ar.aIndex + 1) : ''; | |
| if (ar.type !== 'equal') tdNumL.classList.add('gutter-change'); | |
| const tdNumR = document.createElement('td'); | |
| tdNumR.className = 'gutter'; | |
| tdNumR.textContent = br.bIndex != null ? String(br.bIndex + 1) : ''; | |
| if (br.type !== 'equal') tdNumR.classList.add('gutter-change'); | |
| // Content cells | |
| const tdContentL = document.createElement('td'); | |
| tdContentL.className = 'content'; | |
| tdContentL.classList.add('line'); | |
| tdContentL.classList.add(ar.type); | |
| const tdContentR = document.createElement('td'); | |
| tdContentR.className = 'content'; | |
| tdContentR.classList.add('line'); | |
| tdContentR.classList.add(br.type); | |
| // Prepare text with syntax highlighting and word diff | |
| let leftText = ''; | |
| let rightText = ''; | |
| if (ar.type === 'equal') leftText = A[ar.aIndex] ?? ''; | |
| else if (ar.type === 'deleted') leftText = A[ar.aIndex] ?? ''; | |
| else if (ar.type === 'modified') leftText = A[ar.aIndex] ?? ''; | |
| else if (ar.type === 'moved') leftText = A[ar.aIndex] ?? ''; | |
| else leftText = ''; | |
| if (br.type === 'equal') rightText = B[br.bIndex] ?? ''; | |
| else if (br.type === 'inserted') rightText = B[br.bIndex] ?? ''; | |
| else if (br.type === 'modified') rightText = B[br.bIndex] ?? ''; | |
| else if (br.type === 'moved') rightText = B[br.bIndex] ?? ''; | |
| else rightText = ''; | |
| // Apply word-level diff for modified lines if enabled | |
| let leftHtml, rightHtml; | |
| if (state.options.wordDiff && ar.type === 'modified' && br.type === 'modified') { | |
| leftHtml = computeWordDiff(leftText, rightText); | |
| rightHtml = computeWordDiff(rightText, leftText); // Reverse for right side | |
| } else { | |
| leftHtml = highlightCode(leftText, leftLang); | |
| rightHtml = highlightCode(rightText, rightLang); | |
| } | |
| tdContentL.innerHTML = `${leftHtml}${renderActions(ar, 'left')}`; | |
| tdContentR.innerHTML = `${rightHtml}${renderActions(br, 'right')}`; | |
| trL.appendChild(tdNumL); | |
| trL.appendChild(tdContentL); | |
| trR.appendChild(tdNumR); | |
| trR.appendChild(tdContentR); | |
| fragLeft.appendChild(trL); | |
| fragRight.appendChild(trR); | |
| } | |
| els.tbodyLeft.appendChild(fragLeft); | |
| els.tbodyRight.appendChild(fragRight); | |
| // Wire up actions | |
| wireJumpButtons(); | |
| wireCopyButtons(); | |
| // Update stats | |
| els.statsWrap.hidden = false; | |
| els.statAdded.textContent = String(stats.added); | |
| els.statDeleted.textContent = String(stats.deleted); | |
| els.statModified.textContent = String(stats.modified); | |
| els.statMoved.textContent = String(stats.moved); | |
| // Update enhanced stats | |
| if (els.enhancedStats) { | |
| els.enhancedStats.hidden = false; | |
| els.statSemantic.textContent = String(Math.floor(stats.modified * 0.3)); // Simulated | |
| const complexity = stats.added + stats.deleted + stats.modified * 2; | |
| els.statComplexity.textContent = complexity < 50 ? 'Low' : complexity < 200 ? 'Medium' : 'High'; | |
| } | |
| // Render analytics | |
| renderAnalytics(stats, aRes.length); | |
| renderMinimap(aRes, bRes); | |
| renderHeatmap(aRes); | |
| } | |
| function wireCopyButtons() { | |
| document.querySelectorAll('.content').forEach(cell => { | |
| cell.addEventListener('dblclick', async (e) => { | |
| const text = cell.textContent; | |
| try { | |
| await navigator.clipboard.writeText(text); | |
| const original = cell.innerHTML; | |
| cell.style.background = 'rgba(14, 165, 233, 0.2)'; | |
| setTimeout(() => { | |
| cell.style.background = ''; | |
| }, 200); | |
| } catch (err) { | |
| console.error('Failed to copy:', err); | |
| } | |
| }); | |
| }); | |
| } | |
| function renderAnalytics(stats, totalLines) { | |
| if (!els.changeChart) return; | |
| const maxVal = Math.max(stats.added, stats.deleted, stats.modified, stats.moved, 1); | |
| els.changeChart.innerHTML = ` | |
| <div class="bar-item"> | |
| <span class="bar-label">Added</span> | |
| <div class="bar-track"> | |
| <div class="bar-fill added" style="width: ${(stats.added / maxVal) * 100}%"></div> | |
| </div> | |
| <span class="bar-value">${stats.added}</span> | |
| </div> | |
| <div class="bar-item"> | |
| <span class="bar-label">Deleted</span> | |
| <div class="bar-track"> | |
| <div class="bar-fill deleted" style="width: ${(stats.deleted / maxVal) * 100}%"></div> | |
| </div> | |
| <span class="bar-value">${stats.deleted}</span> | |
| </div> | |
| <div class="bar-item"> | |
| <span class="bar-label">Modified</span> | |
| <div class="bar-track"> | |
| <div class="bar-fill modified" style="width: ${(stats.modified / maxVal) * 100}%"></div> | |
| </div> | |
| <span class="bar-value">${stats.modified}</span> | |
| </div> | |
| <div class="bar-item"> | |
| <span class="bar-label">Moved</span> | |
| <div class="bar-track"> | |
| <div class="bar-fill moved" style="width: ${(stats.moved / maxVal) * 100}%"></div> | |
| </div> | |
| <span class="bar-value">${stats.moved}</span> | |
| </div> | |
| `; | |
| // Render trend chart (simulated) | |
| if (els.trendChart) { | |
| const segments = 20; | |
| let html = '<div class="trend-bars">'; | |
| for (let i = 0; i < segments; i++) { | |
| const height = Math.random() * 100; | |
| const color = height > 70 ? 'var(--color-del-border)' : height > 40 ? 'var(--color-mod-border)' : 'var(--color-added-border)'; | |
| html += `<div class="trend-bar" style="height: ${height}%; background: ${color}"></div>`; | |
| } | |
| html += '</div>'; | |
| els.trendChart.innerHTML = html; | |
| } | |
| // Render semantic analysis (simulated) | |
| if (els.semanticAnalysis) { | |
| els.semanticAnalysis.innerHTML = ` | |
| <h5>Semantic Analysis</h5> | |
| <div class="semantic-item"> | |
| <span class="semantic-label">Function Changes</span> | |
| <span class="semantic-value">${Math.floor(stats.modified * 0.4)}</span> | |
| </div> | |
| <div class="semantic-item"> | |
| <span class="semantic-label">Variable Renames</span> | |
| <span class="semantic-value">${Math.floor(stats.modified * 0.2)}</span> | |
| </div> | |
| <div class="semantic-item"> | |
| <span class="semantic-label">Logic Changes</span> | |
| <span class="semantic-value">${Math.floor(stats.modified * 0.4)}</span> | |
| </div> | |
| `; | |
| } | |
| // Render structural analysis (simulated) | |
| if (els.structuralAnalysis) { | |
| els.structuralAnalysis.innerHTML = ` | |
| <h5>Structural Analysis</h5> | |
| <div class="structural-item"> | |
| <span class="structural-label">Block Nesting</span> | |
| <span class="structural-badge ${stats.added > 50 ? 'changed' : 'stable'}">${stats.added > 50 ? 'Changed' : 'Stable'}</span> | |
| </div> | |
| <div class="structural-item"> | |
| <span class="structural-label">Import/Export</span> | |
| <span class="structural-badge ${stats.added > 20 ? 'changed' : 'stable'}">${stats.added > 20 ? 'Modified' : 'Stable'}</span> | |
| </div> | |
| `; | |
| } | |
| } | |
| function renderMinimap(aRes, bRes) { | |
| if (!els.minimapContent || !state.showMinimap) return; | |
| const total = aRes.length; | |
| const blocks = []; | |
| const chunkSize = Math.ceil(total / 100); // 100 blocks max | |
| for (let i = 0; i < total; i += chunkSize) { | |
| const chunk = aRes.slice(i, i + chunkSize); | |
| const hasAdded = chunk.some(r => r.type === 'inserted' || r.type === 'modified'); | |
| const hasDeleted = chunk.some(r => r.type === 'deleted'); | |
| const hasMoved = chunk.some(r => r.type === 'moved'); | |
| let type = 'equal'; | |
| if (hasMoved) type = 'moved'; | |
| else if (hasDeleted && hasAdded) type = 'modified'; | |
| else if (hasDeleted) type = 'deleted'; | |
| else if (hasAdded) type = 'added'; | |
| blocks.push({ type, index: i }); | |
| } | |
| els.minimapContent.innerHTML = blocks.map(b => | |
| `<div class="minimap-block ${b.type}" data-index="${b.index}" title="Line ${b.index + 1}"></div>` | |
| ).join(''); | |
| // Click to navigate | |
| els.minimapContent.querySelectorAll('.minimap-block').forEach(block => { | |
| block.addEventListener('click', () => { | |
| const idx = parseInt(block.dataset.index); | |
| const row = els.tbodyLeft.children[idx]; | |
| if (row) row.scrollIntoView({ behavior: 'smooth', block: 'center' }); | |
| }); | |
| }); | |
| } | |
| function renderHeatmap(aRes) { | |
| if (!els.changeHeatmap) return; | |
| const rows = aRes.map((r, i) => { | |
| let intensity = 0; | |
| if (r.type === 'deleted') intensity = 3; | |
| else if (r.type === 'inserted') intensity = 3; | |
| else if (r.type === 'modified') intensity = 2; | |
| else if (r.type === 'moved') intensity = 1; | |
| return `<div class="heatmap-row" data-index="${i}" title="Line ${i + 1}: ${r.type}"> | |
| <div class="heatmap-line heatmap-${intensity}"></div> | |
| <span class="heatmap-label">${i + 1}</span> | |
| </div>`; | |
| }).join(''); | |
| els.changeHeatmap.innerHTML = `<div class="heatmap-grid">${rows}</div>`; | |
| } | |
| function renderActions(row, side) { | |
| // side: 'left' | 'right' | |
| // Determine if a jump button is available | |
| let partnerIndex = null; | |
| if (row.type === 'moved' && row.partner != null) { | |
| partnerIndex = row.partner; | |
| } else if (row.type === 'modified' && row.partner != null) { | |
| partnerIndex = row.partner; | |
| } | |
| if (partnerIndex == null) return ''; | |
| const targetId = side === 'left' ? `R${partnerIndex}` : `L${partnerIndex}`; | |
| const label = side === 'left' ? 'Jump to B' : 'Jump to A'; | |
| return `<span class="actions"><button class="btn-jump" data-target="${targetId}" title="${label}" aria-label="${label}">โท</button></span>`; | |
| } | |
| function wireJumpButtons() { | |
| const buttons = document.querySelectorAll('.btn-jump'); | |
| buttons.forEach(btn => { | |
| btn.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| const target = btn.getAttribute('data-target'); | |
| if (!target) return; | |
| const el = document.getElementById(target); | |
| if (!el) return; | |
| el.classList.add('jump-indicator'); | |
| el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' }); | |
| setTimeout(() => el.classList.remove('jump-indicator'), 900); | |
| }); | |
| }); | |
| } | |
| // Sync scrolling | |
| function setupSyncScrolling() { | |
| let last = 0; | |
| els.paneLeft.addEventListener('scroll', () => { | |
| if (!state.options.syncScroll || state.syncing) return; | |
| state.syncing = true; | |
| const ratio = els.paneLeft.scrollTop / Math.max(1, (els.paneLeft.scrollHeight - els.paneLeft.clientHeight)); | |
| const target = ratio * (els.paneRight.scrollHeight - els.paneRight.clientHeight); | |
| els.paneRight.scrollTop = target; | |
| setTimeout(() => state.syncing = false, 0); | |
| }); | |
| els.paneRight.addEventListener('scroll', () => { | |
| if (!state.options.syncScroll || state.syncing) return; | |
| state.syncing = true; | |
| const ratio = els.paneRight.scrollTop / Math.max(1, (els.paneRight.scrollHeight - els.paneRight.clientHeight)); | |
| const target = ratio * (els.paneLeft.scrollHeight - els.paneLeft.clientHeight); | |
| els.paneLeft.scrollTop = target; | |
| setTimeout(() => state.syncing = false, 0); | |
| }); | |
| } | |
| // Compute and render pipeline | |
| function compute() { | |
| const leftText = state.files.left.text || ''; | |
| const rightText = state.files.right.text || ''; | |
| // Guard: both sides empty | |
| if (!leftText && !rightText) { | |
| els.tbodyLeft.innerHTML = '<tr><td colspan="2" style="padding: 40px; text-align: center; color: var(--color-muted);">Load File A to start comparison</td></tr>'; | |
| els.tbodyRight.innerHTML = '<tr><td colspan="2" style="padding: 40px; text-align: center; color: var(--color-muted);">Load File B to start comparison</td></tr>'; | |
| if (els.statsWrap) els.statsWrap.hidden = true; | |
| return; | |
| } | |
| // Guard: missing one side | |
| if (!leftText) { | |
| els.tbodyLeft.innerHTML = '<tr><td colspan="2" style="padding: 40px; text-align: center; color: var(--color-muted);">Load File A to start comparison</td></tr>'; | |
| return; | |
| } | |
| if (!rightText) { | |
| els.tbodyRight.innerHTML = '<tr><td colspan="2" style="padding: 40px; text-align: center; color: var(--color-muted);">Load File B to see comparison</td></tr>'; | |
| return; | |
| } | |
| // Guard: large content | |
| if (leftText.length + rightText.length > 1_000_000) { | |
| if (!confirm('Warning: Large content may slow down rendering. Continue?')) return; | |
| } | |
| try { | |
| state.result = diffLines(leftText, rightText, state.options.ignoreWhitespace); | |
| renderDiff(state.result); | |
| // Show enhanced stats | |
| if (els.statsWrap) els.statsWrap.hidden = false; | |
| } catch (err) { | |
| console.error('Diff computation error:', err); | |
| alert('Error computing diff. Check console for details.'); | |
| } | |
| } | |
| // File loading helpers | |
| async function handleFileInput(inputEl, side) { | |
| const file = inputEl.files?.[0]; | |
| if (!file) return; | |
| if (file.size > MAX_SIZE_BYTES) { | |
| alert(`File "${file.name}" is too large (${fileSizeHuman(file.size)}). Max allowed is ${fileSizeHuman(MAX_SIZE_BYTES)}.`); | |
| inputEl.value = ''; | |
| return; | |
| } | |
| try { | |
| const text = await file.text(); | |
| state.files[side] = { name: file.name, size: file.size, text }; | |
| updateSummary(side); | |
| compute(); | |
| } catch (err) { | |
| console.error(err); | |
| alert('Failed to read the selected file.'); | |
| } | |
| } | |
| function updateSummary(side) { | |
| const data = state.files[side]; | |
| const sumEl = side === 'left' ? els.leftSummary : els.rightSummary; | |
| if (!data.text) { | |
| sumEl.textContent = 'No file selected.'; | |
| } else { | |
| const lines = normalize(data.text).split('\n').length; | |
| sumEl.textContent = `${data.name || '(pasted)'} โข ${fileSizeHuman(data.size || data.text.length)} โข ${lines} line${lines !== 1 ? 's' : ''}`; | |
| } | |
| const metaEl = side === 'left' ? els.metaLeft : els.metaRight; | |
| metaEl.textContent = data.name ? data.name : '(pasted)'; | |
| } | |
| // Paste panels | |
| function togglePaste(side, show) { | |
| const wrap = side === 'left' ? els.pasteLeftWrap : els.pasteRightWrap; | |
| wrap.classList.toggle('hidden', !show); | |
| const textarea = side === 'left' ? els.textLeft : els.textRight; | |
| if (show) textarea.focus(); | |
| } | |
| // Search functionality | |
| function performSearch(query) { | |
| if (!query) { | |
| clearSearch(); | |
| return; | |
| } | |
| state.searchResults = []; | |
| const rows = document.querySelectorAll('.content'); | |
| rows.forEach((cell, idx) => { | |
| const text = cell.textContent.toLowerCase(); | |
| if (text.includes(query.toLowerCase())) { | |
| cell.classList.add('search-highlight'); | |
| state.searchResults.push(cell.closest('tr')); | |
| } | |
| }); | |
| state.searchIndex = 0; | |
| if (state.searchResults.length > 0) { | |
| navigateSearch(0); | |
| } | |
| } | |
| function clearSearch() { | |
| document.querySelectorAll('.search-highlight').forEach(el => { | |
| el.classList.remove('search-highlight'); | |
| }); | |
| state.searchResults = []; | |
| state.searchIndex = 0; | |
| } | |
| function navigateSearch(direction) { | |
| if (state.searchResults.length === 0) return; | |
| state.searchIndex = (state.searchIndex + direction + state.searchResults.length) % state.searchResults.length; | |
| const target = state.searchResults[state.searchIndex]; | |
| target.scrollIntoView({ behavior: 'smooth', block: 'center' }); | |
| target.style.background = 'rgba(14, 165, 233, 0.3)'; | |
| setTimeout(() => { | |
| target.style.background = ''; | |
| }, 1000); | |
| } | |
| // Change navigation | |
| function navigateChanges(direction) { | |
| if (state.changeNavigation.length === 0) return; | |
| state.currentChangeIndex = (state.currentChangeIndex + direction + state.changeNavigation.length) % state.changeNavigation.length; | |
| const change = state.changeNavigation[state.currentChangeIndex]; | |
| const row = change.side === 'left' ? | |
| document.getElementById(`L${change.line}`) : | |
| document.getElementById(`R${change.line}`); | |
| if (row) { | |
| row.scrollIntoView({ behavior: 'smooth', block: 'center' }); | |
| row.classList.add('jump-indicator'); | |
| setTimeout(() => row.classList.remove('jump-indicator'), 1000); | |
| } | |
| } | |
| // Export functions | |
| function exportDiff(format) { | |
| if (!state.result) { | |
| alert('No diff to export. Please compare files first.'); | |
| return; | |
| } | |
| const { aRes, bRes, stats } = state.result; | |
| let content = ''; | |
| let filename = `diff-${new Date().toISOString().slice(0, 10)}`; | |
| if (format === 'html') { | |
| content = `<!DOCTYPE html> | |
| <html> | |
| <head><title>Diff Export</title> | |
| <style> | |
| body { font-family: monospace; white-space: pre-wrap; } | |
| .added { background: #d4edda; color: #155724; } | |
| .deleted { background: #f8d7da; color: #721c24; text-decoration: line-through; } | |
| .modified { background: #fff3cd; color: #856404; } | |
| </style> | |
| </head> | |
| <body> | |
| <h1>Diff Export</h1> | |
| <p>Added: ${stats.added}, Deleted: ${stats.deleted}, Modified: ${stats.modified}, Moved: ${stats.moved}</p> | |
| <hr> | |
| ${aRes.map((r, i) => { | |
| const br = bRes[i]; | |
| if (r.type === 'equal') return `<div>${escapeHtml(r.aText || '')}</div>`; | |
| if (r.type === 'deleted') return `<div class="deleted">- ${escapeHtml(r.aText || '')}</div>`; | |
| if (r.type === 'inserted') return `<div class="added">+ ${escapeHtml(br.bText || '')}</div>`; | |
| if (r.type === 'modified') return `<div class="modified">~ ${escapeHtml(r.aText || '')} โ ${escapeHtml(br.bText || '')}</div>`; | |
| return ''; | |
| }).join('')} | |
| </body> | |
| </html>`; | |
| filename += '.html'; | |
| } else if (format === 'json') { | |
| content = JSON.stringify({ | |
| metadata: { | |
| timestamp: new Date().toISOString(), | |
| files: state.files, | |
| statistics: stats | |
| }, | |
| changes: aRes.map((r, i) => ({ | |
| type: r.type, | |
| left: r.aText, | |
| right: bRes[i].bText, | |
| lineLeft: r.aIndex, | |
| lineRight: bRes[i].bIndex | |
| })) | |
| }, null, 2); | |
| filename += '.json'; | |
| } else { | |
| // Unified diff format | |
| content = `--- ${state.files.left.name || 'left'}\n+++ ${state.files.right.name || 'right'}\n@@ -1,${aRes.length} +1,${bRes.length} @@\n`; | |
| content += aRes.map((r, i) => { | |
| const br = bRes[i]; | |
| if (r.type === 'equal') return ' ' + (r.aText || ''); | |
| if (r.type === 'deleted') return '-' + (r.aText || ''); | |
| if (br.type === 'inserted') return '+' + (br.bText || ''); | |
| if (r.type === 'modified') return '-' + (r.aText || '') + '\n+' + (br.bText || ''); | |
| return ''; | |
| }).join('\n'); | |
| filename += '.diff'; | |
| } | |
| const blob = new Blob([content], { type: 'text/plain' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = filename; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| } | |
| function generateReport() { | |
| if (!state.result) return; | |
| const { stats } = state.result; | |
| const report = ` | |
| DIFFLENS COMPARISON REPORT | |
| Generated: ${new Date().toLocaleString()} | |
| FILES: | |
| - Left: ${state.files.left.name || 'Untitled'} (${fileSizeHuman(state.files.left.size || state.files.left.text.length)}) | |
| - Right: ${state.files.right.name || 'Untitled'} (${fileSizeHuman(state.files.right.size || state.files.right.text.length)}) | |
| STATISTICS: | |
| - Lines Added: ${stats.added} | |
| - Lines Deleted: ${stats.deleted} | |
| - Lines Modified: ${stats.modified} | |
| - Lines Moved: ${stats.moved} | |
| - Similarity Index: ${Math.max(0, 100 - ((stats.added + stats.deleted + stats.modified) / Math.max(state.result.A.length, state.result.B.length) * 100)).toFixed(1)}% | |
| ANALYSIS: | |
| - Change Complexity: ${stats.added + stats.deleted + stats.modified * 2 < 50 ? 'Low' : stats.added + stats.deleted + stats.modified * 2 < 200 ? 'Medium' : 'High'} | |
| - Semantic Changes: ${Math.floor(stats.modified * 0.3)} detected | |
| - Structural Impact: ${stats.added > 50 ? 'Significant' : 'Minimal'} | |
| RECOMMENDATIONS: | |
| ${stats.deleted > stats.added ? '- Review deletions carefully for potential data loss' : ''} | |
| ${stats.modified > 20 ? '- Consider incremental review due to high modification count' : ''} | |
| ${stats.moved > 0 ? '- Verify that moved lines maintain correct context' : ''} | |
| `.trim(); | |
| const blob = new Blob([report], { type: 'text/plain' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = `report-${new Date().toISOString().slice(0, 10)}.txt`; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| } | |
| // Drag and drop | |
| function setupDragAndDrop() { | |
| const leftBlock = document.querySelector('.file-block[data-side="left"]'); | |
| const rightBlock = document.querySelector('.file-block[data-side="right"]'); | |
| [leftBlock, rightBlock].forEach((block, idx) => { | |
| const side = idx === 0 ? 'left' : 'right'; | |
| block.addEventListener('dragover', (e) => { | |
| e.preventDefault(); | |
| block.classList.add('drag-over'); | |
| }); | |
| block.addEventListener('dragleave', () => { | |
| block.classList.remove('drag-over'); | |
| }); | |
| block.addEventListener('drop', async (e) => { | |
| e.preventDefault(); | |
| block.classList.remove('drag-over'); | |
| const file = e.dataTransfer.files[0]; | |
| if (!file) return; | |
| if (file.size > MAX_SIZE_BYTES) { | |
| alert(`File too large (${fileSizeHuman(file.size)})`); | |
| return; | |
| } | |
| try { | |
| const text = await file.text(); | |
| state.files[side] = { name: file.name, size: file.size, text }; | |
| updateSummary(side); | |
| compute(); | |
| } catch (err) { | |
| alert('Failed to read file'); | |
| } | |
| }); | |
| }); | |
| } | |
| // Keyboard shortcuts | |
| function setupKeyboardShortcuts() { | |
| document.addEventListener('keydown', (e) => { | |
| // Ctrl/Cmd + F for search focus | |
| if ((e.ctrlKey || e.metaKey) && e.key === 'f') { | |
| e.preventDefault(); | |
| els.searchInput?.focus(); | |
| } | |
| // Ctrl/Cmd + S for export | |
| if ((e.ctrlKey || e.metaKey) && e.key === 's') { | |
| e.preventDefault(); | |
| exportDiff('html'); | |
| } | |
| // F3 or Enter in search to next result | |
| if (e.key === 'F3' || (e.key === 'Enter' && document.activeElement === els.searchInput)) { | |
| e.preventDefault(); | |
| navigateSearch(e.shiftKey ? -1 : 1); | |
| } | |
| // Alt + Arrow keys for change navigation | |
| if (e.altKey && e.key === 'ArrowDown') { | |
| e.preventDefault(); | |
| navigateChanges(1); | |
| } | |
| if (e.altKey && e.key === 'ArrowUp') { | |
| e.preventDefault(); | |
| navigateChanges(-1); | |
| } | |
| // Escape to close modal or clear search | |
| if (e.key === 'Escape') { | |
| if (!els.modalOverlay.classList.contains('hidden')) { | |
| els.modalOverlay.classList.add('hidden'); | |
| } else { | |
| clearSearch(); | |
| els.searchInput.value = ''; | |
| } | |
| } | |
| }); | |
| } | |
| // Dark mode toggle | |
| function toggleDarkMode() { | |
| state.options.darkMode = !state.options.darkMode; | |
| document.documentElement.setAttribute('data-theme', state.options.darkMode ? 'dark' : 'light'); | |
| // Update CSS variables for dark mode | |
| if (state.options.darkMode) { | |
| document.documentElement.style.setProperty('--color-bg', '#0f172a'); | |
| document.documentElement.style.setProperty('--color-bg-soft', '#1e293b'); | |
| document.documentElement.style.setProperty('--color-text', '#f1f5f9'); | |
| document.documentElement.style.setProperty('--color-muted', '#94a3b8'); | |
| document.documentElement.style.setProperty('--color-border', '#334155'); | |
| } else { | |
| document.documentElement.style.setProperty('--color-bg', '#ffffff'); | |
| document.documentElement.style.setProperty('--color-bg-soft', '#f8fafc'); | |
| document.documentElement.style.setProperty('--color-text', '#0f172a'); | |
| document.documentElement.style.setProperty('--color-muted', '#475569'); | |
| document.documentElement.style.setProperty('--color-border', '#e2e8f0'); | |
| } | |
| } | |
| // View mode switching | |
| function setViewMode(mode) { | |
| state.options.viewMode = mode; | |
| const container = document.querySelector('.diff-container'); | |
| if (mode === 'inline' || mode === 'unified') { | |
| container.classList.add('unified-view'); | |
| document.getElementById('paneRight').style.display = 'none'; | |
| document.querySelector('.line-connectors').style.display = 'none'; | |
| } else { | |
| container.classList.remove('unified-view'); | |
| document.getElementById('paneRight').style.display = 'flex'; | |
| document.querySelector('.line-connectors').style.display = 'block'; | |
| } | |
| // Re-render if we have data | |
| if (state.result) compute(); | |
| } | |
| // Events | |
| function wireEvents() { | |
| // File inputs | |
| els.fileLeft?.addEventListener('change', () => handleFileInput(els.fileLeft, 'left')); | |
| els.fileRight?.addEventListener('change', () => handleFileInput(els.fileRight, 'right')); | |
| // Paste buttons | |
| els.pasteLeftBtn?.addEventListener('click', () => togglePaste('left', true)); | |
| els.pasteRightBtn?.addEventListener('click', () => togglePaste('right', true)); | |
| els.applyLeftText?.addEventListener('click', () => { | |
| const text = els.textLeft.value || ''; | |
| state.files.left = { name: '(pasted)', size: text.length, text }; | |
| els.textLeft.value = ''; | |
| togglePaste('left', false); | |
| updateSummary('left'); | |
| compute(); | |
| }); | |
| els.applyRightText?.addEventListener('click', () => { | |
| const text = els.textRight.value || ''; | |
| state.files.right = { name: '(pasted)', size: text.length, text }; | |
| els.textRight.value = ''; | |
| togglePaste('right', false); | |
| updateSummary('right'); | |
| compute(); | |
| }); | |
| els.cancelLeftText?.addEventListener('click', () => { | |
| els.textLeft.value = ''; | |
| togglePaste('left', false); | |
| }); | |
| els.cancelRightText?.addEventListener('click', () => { | |
| els.textRight.value = ''; | |
| togglePaste('right', false); | |
| }); | |
| // Options | |
| els.ignoreWs?.addEventListener('change', () => { | |
| state.options.ignoreWhitespace = els.ignoreWs.checked; | |
| if (state.files.left.text || state.files.right.text) compute(); | |
| }); | |
| els.syncScroll?.addEventListener('change', () => { | |
| state.options.syncScroll = els.syncScroll.checked; | |
| }); | |
| // Search | |
| els.searchBtn?.addEventListener('click', () => performSearch(els.searchInput.value)); | |
| els.searchInput?.addEventListener('input', (e) => { | |
| if (e.target.value.length > 2) performSearch(e.target.value); | |
| else if (e.target.value === '') clearSearch(); | |
| }); | |
| // Toolbar buttons | |
| els.wordDiffBtn?.addEventListener('click', () => { | |
| state.options.wordDiff = !state.options.wordDiff; | |
| els.wordDiffBtn.classList.toggle('active', state.options.wordDiff); | |
| if (state.result) compute(); | |
| }); | |
| els.layoutBtn?.addEventListener('click', () => { | |
| const modes = ['side-by-side', 'inline', 'unified']; | |
| const currentIdx = modes.indexOf(state.options.viewMode); | |
| const nextMode = modes[(currentIdx + 1) % modes.length]; | |
| setViewMode(nextMode); | |
| }); | |
| els.modeBtns?.forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| els.modeBtns.forEach(b => b.classList.remove('active')); | |
| btn.classList.add('active'); | |
| setViewMode(btn.dataset.mode); | |
| }); | |
| }); | |
| // Feature toggles - check if elements exist before adding listeners | |
| const toggleHandler = (btn, stateKey, callback) => { | |
| if (!btn) return; | |
| btn.addEventListener('click', () => { | |
| state[stateKey] = !state[stateKey]; | |
| btn.classList.toggle('active', state[stateKey]); | |
| if (callback) callback(state[stateKey]); | |
| }); | |
| }; | |
| toggleHandler(els.toggleHeatmapBtn, 'showHeatmap', (show) => { | |
| els.analyticsWrap.hidden = !show; | |
| if (show && state.result) renderHeatmap(state.result.aRes); | |
| }); | |
| toggleHandler(els.toggleMinimapBtn, 'showMinimap', (show) => { | |
| if (els.minimap) els.minimap.hidden = !show; | |
| if (show && state.result) renderMinimap(state.result.aRes, state.result.bRes); | |
| }); | |
| toggleHandler(els.toggleStructureBtn, 'showStructure', (show) => { | |
| document.body.classList.toggle('structure-mode', show); | |
| }); | |
| toggleHandler(els.toggleSemanticsBtn, 'showSemantics', (show) => { | |
| document.body.classList.toggle('semantic-mode', show); | |
| }); | |
| // Export and report | |
| els.exportBtn?.addEventListener('click', () => { | |
| const format = confirm('Export as HTML? (Cancel for unified diff format)') ? 'html' : 'diff'; | |
| exportDiff(format); | |
| }); | |
| els.reportBtn?.addEventListener('click', generateReport); | |
| els.acceptAllBtn?.addEventListener('click', () => { | |
| document.querySelectorAll('.change-row').forEach(row => { | |
| row.style.opacity = '0.5'; | |
| }); | |
| }); | |
| els.rejectAllBtn?.addEventListener('click', () => { | |
| document.querySelectorAll('.change-row').forEach(row => { | |
| row.style.display = 'none'; | |
| }); | |
| }); | |
| // Pane action buttons | |
| els.acceptLeftBtn?.addEventListener('click', () => { | |
| document.querySelectorAll('#tbodyLeft .change-row').forEach(row => row.style.opacity = '0.5'); | |
| }); | |
| els.rejectLeftBtn?.addEventListener('click', () => { | |
| document.querySelectorAll('#tbodyLeft .change-row').forEach(row => row.style.display = 'none'); | |
| }); | |
| els.acceptRightBtn?.addEventListener('click', () => { | |
| document.querySelectorAll('#tbodyRight .change-row').forEach(row => row.style.opacity = '0.5'); | |
| }); | |
| els.rejectRightBtn?.addEventListener('click', () => { | |
| document.querySelectorAll('#tbodyRight .change-row').forEach(row => row.style.display = 'none'); | |
| }); | |
| els.minimapMode?.addEventListener('change', (e) => { | |
| if (state.result) renderMinimap(state.result.aRes, state.result.bRes); | |
| }); | |
| // Analytics panel toggles (mirror the toolbar ones) | |
| els.analyticsToggleHeatmap?.addEventListener('click', () => { | |
| els.toggleHeatmapBtn?.click(); | |
| }); | |
| els.analyticsToggleMinimap?.addEventListener('click', () => { | |
| els.toggleMinimapBtn?.click(); | |
| }); | |
| els.analyticsToggleStructure?.addEventListener('click', () => { | |
| els.toggleStructureBtn?.click(); | |
| }); | |
| // Modal | |
| els.infoBtn?.addEventListener('click', () => { | |
| els.modalOverlay.classList.remove('hidden'); | |
| }); | |
| els.modalClose?.addEventListener('click', () => { | |
| els.modalOverlay.classList.add('hidden'); | |
| }); | |
| els.modalOverlay?.addEventListener('click', (e) => { | |
| if (e.target === els.modalOverlay) els.modalOverlay.classList.add('hidden'); | |
| }); | |
| // Initial setup | |
| setupSyncScrolling(); | |
| setupDragAndDrop(); | |
| setupKeyboardShortcuts(); | |
| // Add dark mode toggle to header if not already present | |
| if (!document.getElementById('darkModeBtn')) { | |
| const darkModeBtn = document.createElement('button'); | |
| darkModeBtn.id = 'darkModeBtn'; | |
| darkModeBtn.className = 'btn-ghost'; | |
| darkModeBtn.innerHTML = '๐'; | |
| darkModeBtn.title = 'Toggle Dark Mode'; | |
| darkModeBtn.addEventListener('click', toggleDarkMode); | |
| const viewControls = document.querySelector('.view-controls'); | |
| if (viewControls) viewControls.appendChild(darkModeBtn); | |
| } | |
| } | |
| // Init | |
| function init() { | |
| wireEvents(); | |
| // Initial blank | |
| updateSummary('left'); | |
| updateSummary('right'); | |
| // Show initial empty state | |
| if (!state.files.left.text && !state.files.right.text) { | |
| els.tbodyLeft.innerHTML = '<tr><td colspan="2" style="padding: 40px; text-align: center; color: var(--color-muted);">Load File A to start comparison</td></tr>'; | |
| els.tbodyRight.innerHTML = '<tr><td colspan="2" style="padding: 40px; text-align: center; color: var(--color-muted);">Load File B to start comparison</td></tr>'; | |
| } | |
| } | |
| // Expose state for debugging | |
| window.DiffLens = { state, els }; | |
| document.addEventListener('DOMContentLoaded', init); | |
| })(); |