DevTools TextDiff: Side-by-Side Ansicht mit gepaarten Zeilen und Highlighting in Textareas
This commit is contained in:
@@ -944,25 +944,50 @@ function KeyGen({ toast }) {
|
||||
function diffLines(a, b) {
|
||||
const aLines = a.split('\n');
|
||||
const bLines = b.split('\n');
|
||||
// Myers diff (simple LCS-based)
|
||||
const m = aLines.length, n = bLines.length;
|
||||
// LCS dp
|
||||
const dp = Array.from({length: m+1}, () => new Array(n+1).fill(0));
|
||||
for (let i = m-1; i >= 0; i--)
|
||||
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]);
|
||||
|
||||
const result = [];
|
||||
// Build flat ops
|
||||
const ops = [];
|
||||
let i = 0, j = 0;
|
||||
while (i < m || j < n) {
|
||||
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])) {
|
||||
result.push({ type: 'add', text: bLines[j] }); j++;
|
||||
ops.push({type:'add', a:null, b:bLines[j]}); j++;
|
||||
} 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 }) {
|
||||
@@ -986,17 +1011,39 @@ function TextDiff({ mobile }) {
|
||||
e.target.value = '';
|
||||
}
|
||||
|
||||
const added = diff ? diff.filter(d=>d.type==='add').length : 0;
|
||||
const removed = diff ? diff.filter(d=>d.type==='del').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'||d.type==='change').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%',
|
||||
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 (
|
||||
<div>
|
||||
<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 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>
|
||||
@@ -1006,15 +1053,8 @@ function TextDiff({ mobile }) {
|
||||
style={{display:'none'}} onChange={e=>loadFile(e,setter)}/>
|
||||
</label>
|
||||
</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={{
|
||||
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>
|
||||
<LineNumCol text={val} changedSet={cset}/>
|
||||
<textarea style={{...ta, border:'none', borderRadius:0, background:'transparent', flex:1, resize:'vertical'}}
|
||||
value={val} placeholder="Text hier einfügen…"
|
||||
onChange={e=>{ setter(e.target.value); setDiff(null); }}/>
|
||||
@@ -1033,9 +1073,9 @@ function TextDiff({ mobile }) {
|
||||
{!!diff && (
|
||||
<div style={{display:'flex', gap:10, alignItems:'center', flexWrap:'wrap'}}>
|
||||
<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',
|
||||
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',
|
||||
fontSize:11, padding:'4px 10px', borderRadius:6}}>= {same} gleich</span>
|
||||
</div>
|
||||
@@ -1044,33 +1084,69 @@ function TextDiff({ mobile }) {
|
||||
|
||||
{!!diff && (
|
||||
<div style={{borderRadius:9, border:'1px solid rgba(255,255,255,0.08)',
|
||||
overflow:'auto', maxHeight:480, background:'rgba(0,0,0,0.2)'}}>
|
||||
<table style={{width:'100%', borderCollapse:'collapse', fontFamily:"'Courier New',monospace", fontSize:12}}>
|
||||
overflow:'auto', maxHeight:520, background:'rgba(0,0,0,0.2)'}}>
|
||||
{/* 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>
|
||||
{(()=>{
|
||||
let lnA=0, lnB=0;
|
||||
return diff.map((d,i)=>{
|
||||
if (d.type==='del') lnA++;
|
||||
else if (d.type==='add') lnB++;
|
||||
else { lnA++; lnB++; }
|
||||
const ln = d.type==='add' ? lnB : lnA;
|
||||
const isChange = d.type==='change';
|
||||
const isDel = d.type==='del';
|
||||
const isAdd = d.type==='add';
|
||||
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 (
|
||||
<tr key={i} style={{
|
||||
background: d.type==='add'?'rgba(78,205,196,0.1)':d.type==='del'?'rgba(255,107,157,0.1)':'transparent',
|
||||
borderBottom:'1px solid rgba(255,255,255,0.03)'}}>
|
||||
<td style={{width:36,textAlign:'right',padding:'2px 8px',userSelect:'none',
|
||||
color:'rgba(255,255,255,0.2)',fontFamily:"'Courier New',monospace",fontSize:11,
|
||||
borderRight:'1px solid rgba(255,255,255,0.05)'}}>
|
||||
{ln}
|
||||
<tr key={i} style={{borderBottom:'1px solid rgba(255,255,255,0.03)'}}>
|
||||
{/* Side A */}
|
||||
<td style={{background:bgA, 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}}>
|
||||
{(isDel||isChange||isSame) ? lnA : ''}
|
||||
</td>
|
||||
<td style={{width:24,textAlign:'center',padding:'2px 6px',userSelect:'none',
|
||||
color:d.type==='add'?'#4ecdc4':d.type==='del'?'#ff6b9d':'rgba(255,255,255,0.2)',
|
||||
fontWeight:700,fontSize:14,borderRight:'1px solid rgba(255,255,255,0.05)'}}>
|
||||
{d.type==='add'?'+':d.type==='del'?'−':''}
|
||||
<td style={{background:bgA, textAlign:'center', padding:'2px 3px', color:symColA,
|
||||
fontWeight:700, fontSize:13, userSelect:'none',
|
||||
borderRight:'1px solid rgba(255,255,255,0.06)'}}>
|
||||
{symA}
|
||||
</td>
|
||||
<td style={{padding:'3px 10px',whiteSpace:'pre-wrap',wordBreak:'break-all',
|
||||
color:d.type==='add'?'#a8f0eb':d.type==='del'?'#ffb3cc':'rgba(255,255,255,0.65)'}}>
|
||||
{d.text||' '}
|
||||
<td style={{background:bgA, padding:'3px 8px', whiteSpace:'pre-wrap', wordBreak:'break-all',
|
||||
color:colA, borderRight:'2px solid rgba(255,255,255,0.06)'}}>
|
||||
{(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>
|
||||
</tr>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user