DevTools TextDiff: Side-by-Side Ansicht mit gepaarten Zeilen und Highlighting in Textareas

This commit is contained in:
2026-06-01 11:32:04 +02:00
parent ef4ab5fc80
commit 9995571573

View File

@@ -944,25 +944,50 @@ function KeyGen({ toast }) {
function diffLines(a, b) { function diffLines(a, b) {
const aLines = a.split('\n'); const aLines = a.split('\n');
const bLines = b.split('\n'); const bLines = b.split('\n');
// Myers diff (simple LCS-based)
const m = aLines.length, n = bLines.length; const m = aLines.length, n = bLines.length;
// LCS dp
const dp = Array.from({length: m+1}, () => new Array(n+1).fill(0)); const dp = Array.from({length: m+1}, () => new Array(n+1).fill(0));
for (let i = m-1; i >= 0; i--) for (let i = m-1; i >= 0; i--)
for (let j = n-1; j >= 0; j--) for (let j = n-1; j >= 0; j--)
dp[i][j] = aLines[i] === bLines[j] ? dp[i+1][j+1]+1 : Math.max(dp[i+1][j], dp[i][j+1]); dp[i][j] = aLines[i] === bLines[j] ? dp[i+1][j+1]+1 : Math.max(dp[i+1][j], dp[i][j+1]);
// Build flat ops
const result = []; const ops = [];
let i = 0, j = 0; let i = 0, j = 0;
while (i < m || j < n) { while (i < m || j < n) {
if (i < m && j < n && aLines[i] === bLines[j]) { if (i < m && j < n && aLines[i] === bLines[j]) {
result.push({ type: 'same', text: aLines[i] }); i++; j++; ops.push({type:'same', a:aLines[i], b:bLines[j]}); i++; j++;
} else if (j < n && (i >= m || dp[i][j+1] >= dp[i+1][j])) { } else if (j < n && (i >= m || dp[i][j+1] >= dp[i+1][j])) {
result.push({ type: 'add', text: bLines[j] }); j++; ops.push({type:'add', a:null, b:bLines[j]}); j++;
} else { } else {
result.push({ type: 'del', text: aLines[i] }); i++; ops.push({type:'del', a:aLines[i], b:null}); i++;
} }
} }
return result; // Pair up consecutive del+add into 'change' rows
const paired = [];
let k = 0;
while (k < ops.length) {
if (ops[k].type==='del' && k+1 < ops.length && ops[k+1].type==='add') {
paired.push({type:'change', a:ops[k].a, b:ops[k+1].b}); k+=2;
} else {
paired.push(ops[k]); k++;
}
}
return paired;
}
// Compute set of changed line numbers (1-based) for a given side
function changedLineNums(pairs, side) {
const set = new Set();
let ln = 0;
pairs.forEach(p => {
if (p.type==='same') { ln++; }
else if (p.type==='change') { ln++; set.add(ln); }
else if (p.type==='del' && side==='a') { ln++; set.add(ln); }
else if (p.type==='add' && side==='b') { ln++; set.add(ln); }
else if (p.type==='del' && side==='b') { /* b has no line here */ }
else if (p.type==='add' && side==='a') { /* a has no line here */ }
});
return set;
} }
function TextDiff({ mobile }) { function TextDiff({ mobile }) {
@@ -986,17 +1011,39 @@ function TextDiff({ mobile }) {
e.target.value = ''; e.target.value = '';
} }
const added = diff ? diff.filter(d=>d.type==='add').length : 0; const added = diff ? diff.filter(d=>d.type==='add'||d.type==='change').length : 0;
const removed = diff ? diff.filter(d=>d.type==='del').length : 0; const removed = diff ? diff.filter(d=>d.type==='del'||d.type==='change').length : 0;
const same = diff ? diff.filter(d=>d.type==='same').length : 0; const same = diff ? diff.filter(d=>d.type==='same').length : 0;
// Which lines are changed in each textarea (for background highlight)
const changedA = diff ? changedLineNums(diff, 'a') : new Set();
const changedB = diff ? changedLineNums(diff, 'b') : new Set();
const ta = { ...S.inp, fontSize:12, fontFamily:"'Courier New',monospace", width:'100%', const ta = { ...S.inp, fontSize:12, fontFamily:"'Courier New',monospace", width:'100%',
boxSizing:'border-box', minHeight:180, resize:'vertical', lineHeight:1.6 }; boxSizing:'border-box', minHeight:180, resize:'vertical', lineHeight:'1.6', padding:'7px 8px' };
function LineNumCol({ text, changedSet }) {
const lines = (text||' ').split('\n');
return (
<div style={{
padding:'7px 6px', minWidth:36, textAlign:'right', userSelect:'none',
fontFamily:"'Courier New',monospace", fontSize:12, lineHeight:'1.6',
background:'rgba(0,0,0,0.2)', borderRight:'1px solid rgba(255,255,255,0.07)',
whiteSpace:'pre', flexShrink:0}}>
{lines.map((_, i) => (
<div key={i} style={{
color: changedSet.has(i+1) ? '#ff6b9d' : 'rgba(255,255,255,0.25)',
background: changedSet.has(i+1) ? 'rgba(255,107,157,0.12)' : 'transparent',
}}>{i+1}</div>
))}
</div>
);
}
return ( return (
<div> <div>
<div style={{display:'grid', gridTemplateColumns: mobile?'1fr':'1fr 1fr', gap:12, marginBottom:12}}> <div style={{display:'grid', gridTemplateColumns: mobile?'1fr':'1fr 1fr', gap:12, marginBottom:12}}>
{[['left','Text A (Original)', left, setLeft], ['right','Text B (Neu)', right, setRight]].map(([side, label, val, setter])=>( {([['left','Text A (Original)', left, setLeft, changedA], ['right','Text B (Neu)', right, setRight, changedB]]).map(([side, label, val, setter, cset])=>(
<div key={side}> <div key={side}>
<div style={{display:'flex', alignItems:'center', gap:8, marginBottom:6}}> <div style={{display:'flex', alignItems:'center', gap:8, marginBottom:6}}>
<span style={{color:'rgba(255,255,255,0.55)', fontFamily:'monospace', fontSize:10, flex:1}}>{label}</span> <span style={{color:'rgba(255,255,255,0.55)', fontFamily:'monospace', fontSize:10, flex:1}}>{label}</span>
@@ -1006,15 +1053,8 @@ function TextDiff({ mobile }) {
style={{display:'none'}} onChange={e=>loadFile(e,setter)}/> style={{display:'none'}} onChange={e=>loadFile(e,setter)}/>
</label> </label>
</div> </div>
{/* Textarea mit Zeilennummern */}
<div style={{display:'flex', borderRadius:6, overflow:'hidden', border:'1px solid rgba(255,255,255,0.1)', background:'rgba(255,255,255,0.04)'}}> <div style={{display:'flex', borderRadius:6, overflow:'hidden', border:'1px solid rgba(255,255,255,0.1)', background:'rgba(255,255,255,0.04)'}}>
<div style={{ <LineNumCol text={val} changedSet={cset}/>
padding:'7px 6px', minWidth:36, textAlign:'right', userSelect:'none',
fontFamily:"'Courier New',monospace", fontSize:12, lineHeight:'1.6',
color:'rgba(255,255,255,0.25)', background:'rgba(0,0,0,0.2)',
borderRight:'1px solid rgba(255,255,255,0.07)', whiteSpace:'pre'}}>
{(val||' ').split('\n').map((_,i)=>i+1).join('\n')}
</div>
<textarea style={{...ta, border:'none', borderRadius:0, background:'transparent', flex:1, resize:'vertical'}} <textarea style={{...ta, border:'none', borderRadius:0, background:'transparent', flex:1, resize:'vertical'}}
value={val} placeholder="Text hier einfügen…" value={val} placeholder="Text hier einfügen…"
onChange={e=>{ setter(e.target.value); setDiff(null); }}/> onChange={e=>{ setter(e.target.value); setDiff(null); }}/>
@@ -1033,9 +1073,9 @@ function TextDiff({ mobile }) {
{!!diff && ( {!!diff && (
<div style={{display:'flex', gap:10, alignItems:'center', flexWrap:'wrap'}}> <div style={{display:'flex', gap:10, alignItems:'center', flexWrap:'wrap'}}>
<span style={{background:'rgba(255,107,157,0.15)', color:'#ff6b9d', fontFamily:'monospace', <span style={{background:'rgba(255,107,157,0.15)', color:'#ff6b9d', fontFamily:'monospace',
fontSize:11, padding:'4px 10px', borderRadius:6}}> {removed} entfernt</span> fontSize:11, padding:'4px 10px', borderRadius:6}}> {removed} entfernt/geändert</span>
<span style={{background:'rgba(78,205,196,0.15)', color:'#4ecdc4', fontFamily:'monospace', <span style={{background:'rgba(78,205,196,0.15)', color:'#4ecdc4', fontFamily:'monospace',
fontSize:11, padding:'4px 10px', borderRadius:6}}>+ {added} hinzugefügt</span> fontSize:11, padding:'4px 10px', borderRadius:6}}>+ {added} hinzugefügt/geändert</span>
<span style={{background:'rgba(255,255,255,0.05)', color:'rgba(255,255,255,0.45)', fontFamily:'monospace', <span style={{background:'rgba(255,255,255,0.05)', color:'rgba(255,255,255,0.45)', fontFamily:'monospace',
fontSize:11, padding:'4px 10px', borderRadius:6}}>= {same} gleich</span> fontSize:11, padding:'4px 10px', borderRadius:6}}>= {same} gleich</span>
</div> </div>
@@ -1044,33 +1084,69 @@ function TextDiff({ mobile }) {
{!!diff && ( {!!diff && (
<div style={{borderRadius:9, border:'1px solid rgba(255,255,255,0.08)', <div style={{borderRadius:9, border:'1px solid rgba(255,255,255,0.08)',
overflow:'auto', maxHeight:480, background:'rgba(0,0,0,0.2)'}}> overflow:'auto', maxHeight:520, background:'rgba(0,0,0,0.2)'}}>
<table style={{width:'100%', borderCollapse:'collapse', fontFamily:"'Courier New',monospace", fontSize:12}}> {/* Header */}
<div style={{display:'grid', gridTemplateColumns:'36px 16px 1fr 36px 16px 1fr',
borderBottom:'1px solid rgba(255,255,255,0.1)', background:'rgba(255,255,255,0.04)'}}>
<div style={{gridColumn:'1/4', padding:'5px 10px', color:'rgba(255,255,255,0.4)', fontFamily:'monospace', fontSize:10, borderRight:'1px solid rgba(255,255,255,0.08)'}}>Text A (Original)</div>
<div style={{gridColumn:'4/7', padding:'5px 10px', color:'rgba(255,255,255,0.4)', fontFamily:'monospace', fontSize:10}}>Text B (Neu)</div>
</div>
<table style={{width:'100%', borderCollapse:'collapse', fontFamily:"'Courier New',monospace", fontSize:12, tableLayout:'fixed'}}>
<colgroup>
<col style={{width:36}}/><col style={{width:16}}/><col/>
<col style={{width:36}}/><col style={{width:16}}/><col/>
</colgroup>
<tbody> <tbody>
{(()=>{ {(()=>{
let lnA=0, lnB=0; let lnA=0, lnB=0;
return diff.map((d,i)=>{ return diff.map((d,i)=>{
if (d.type==='del') lnA++; const isChange = d.type==='change';
else if (d.type==='add') lnB++; const isDel = d.type==='del';
else { lnA++; lnB++; } const isAdd = d.type==='add';
const ln = d.type==='add' ? lnB : lnA; const isSame = d.type==='same';
if (isSame) { lnA++; lnB++; }
if (isChange) { lnA++; lnB++; }
if (isDel) lnA++;
if (isAdd) lnB++;
const bgA = (isDel||isChange) ? 'rgba(255,107,157,0.1)' : 'transparent';
const bgB = (isAdd||isChange) ? 'rgba(78,205,196,0.1)' : 'transparent';
const colA = isDel||isChange ? '#ffb3cc' : 'rgba(255,255,255,0.65)';
const colB = isAdd||isChange ? '#a8f0eb' : 'rgba(255,255,255,0.65)';
const symA = isDel?'': isChange?'~':'';
const symB = isAdd?'+': isChange?'~':'';
const symColA = isDel?'#ff6b9d': isChange?'#ffe66d':'rgba(255,255,255,0.15)';
const symColB = isAdd?'#4ecdc4': isChange?'#ffe66d':'rgba(255,255,255,0.15)';
return ( return (
<tr key={i} style={{ <tr key={i} style={{borderBottom:'1px solid rgba(255,255,255,0.03)'}}>
background: d.type==='add'?'rgba(78,205,196,0.1)':d.type==='del'?'rgba(255,107,157,0.1)':'transparent', {/* Side A */}
borderBottom:'1px solid rgba(255,255,255,0.03)'}}> <td style={{background:bgA, textAlign:'right', padding:'2px 6px', color:'rgba(255,255,255,0.2)',
<td style={{width:36,textAlign:'right',padding:'2px 8px',userSelect:'none', borderRight:'1px solid rgba(255,255,255,0.05)', userSelect:'none', fontSize:11}}>
color:'rgba(255,255,255,0.2)',fontFamily:"'Courier New',monospace",fontSize:11, {(isDel||isChange||isSame) ? lnA : ''}
borderRight:'1px solid rgba(255,255,255,0.05)'}}>
{ln}
</td> </td>
<td style={{width:24,textAlign:'center',padding:'2px 6px',userSelect:'none', <td style={{background:bgA, textAlign:'center', padding:'2px 3px', color:symColA,
color:d.type==='add'?'#4ecdc4':d.type==='del'?'#ff6b9d':'rgba(255,255,255,0.2)', fontWeight:700, fontSize:13, userSelect:'none',
fontWeight:700,fontSize:14,borderRight:'1px solid rgba(255,255,255,0.05)'}}> borderRight:'1px solid rgba(255,255,255,0.06)'}}>
{d.type==='add'?'+':d.type==='del'?'':''} {symA}
</td> </td>
<td style={{padding:'3px 10px',whiteSpace:'pre-wrap',wordBreak:'break-all', <td style={{background:bgA, padding:'3px 8px', whiteSpace:'pre-wrap', wordBreak:'break-all',
color:d.type==='add'?'#a8f0eb':d.type==='del'?'#ffb3cc':'rgba(255,255,255,0.65)'}}> color:colA, borderRight:'2px solid rgba(255,255,255,0.06)'}}>
{d.text||' '} {(isDel||isChange||isSame) ? (d.a ?? d.text ?? ' ') : ''}
</td>
{/* Side B */}
<td style={{background:bgB, textAlign:'right', padding:'2px 6px', color:'rgba(255,255,255,0.2)',
borderRight:'1px solid rgba(255,255,255,0.05)', userSelect:'none', fontSize:11}}>
{(isAdd||isChange||isSame) ? lnB : ''}
</td>
<td style={{background:bgB, textAlign:'center', padding:'2px 3px', color:symColB,
fontWeight:700, fontSize:13, userSelect:'none',
borderRight:'1px solid rgba(255,255,255,0.06)'}}>
{symB}
</td>
<td style={{background:bgB, padding:'3px 8px', whiteSpace:'pre-wrap', wordBreak:'break-all',
color:colB}}>
{(isAdd||isChange||isSame) ? (d.b ?? d.text ?? ' ') : ''}
</td> </td>
</tr> </tr>
); );