|
1 | | -import { createPlaceholder } from '../_placeholder'; |
2 | | -export default createPlaceholder('diff-viewer', '该工具正在开发中'); |
| 1 | +import { useState, useMemo } from 'react'; |
| 2 | +import { ToolShell } from '../../shell/ToolShell'; |
| 3 | + |
| 4 | +type DiffLine = { type: 'equal' | 'add' | 'remove'; content: string; oldLine?: number; newLine?: number }; |
| 5 | + |
| 6 | +function computeLineDiff(oldText: string, newText: string, ignoreWhitespace: boolean): DiffLine[] { |
| 7 | + const oldLines = oldText.split('\n'); |
| 8 | + const newLines = newText.split('\n'); |
| 9 | + |
| 10 | + const normalize = (s: string) => ignoreWhitespace ? s.trim().replace(/\s+/g, ' ') : s; |
| 11 | + |
| 12 | + // LCS DP table |
| 13 | + const m = oldLines.length; |
| 14 | + const n = newLines.length; |
| 15 | + const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)); |
| 16 | + |
| 17 | + for (let i = 1; i <= m; i++) { |
| 18 | + for (let j = 1; j <= n; j++) { |
| 19 | + if (normalize(oldLines[i - 1]) === normalize(newLines[j - 1])) { |
| 20 | + dp[i][j] = dp[i - 1][j - 1] + 1; |
| 21 | + } else { |
| 22 | + dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); |
| 23 | + } |
| 24 | + } |
| 25 | + } |
| 26 | + |
| 27 | + // Backtrack to produce diff |
| 28 | + const result: DiffLine[] = []; |
| 29 | + let i = m, j = n; |
| 30 | + while (i > 0 || j > 0) { |
| 31 | + if (i > 0 && j > 0 && normalize(oldLines[i - 1]) === normalize(newLines[j - 1])) { |
| 32 | + result.unshift({ type: 'equal', content: oldLines[i - 1], oldLine: i, newLine: j }); |
| 33 | + i--; j--; |
| 34 | + } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) { |
| 35 | + result.unshift({ type: 'add', content: newLines[j - 1], newLine: j }); |
| 36 | + j--; |
| 37 | + } else { |
| 38 | + result.unshift({ type: 'remove', content: oldLines[i - 1], oldLine: i }); |
| 39 | + i--; |
| 40 | + } |
| 41 | + } |
| 42 | + |
| 43 | + return result; |
| 44 | +} |
| 45 | + |
| 46 | +function computeCharDiff(oldLine: string, newLine: string): { type: 'equal' | 'add' | 'remove'; text: string }[] { |
| 47 | + const a = oldLine; |
| 48 | + const b = newLine; |
| 49 | + const m = a.length; |
| 50 | + const n = b.length; |
| 51 | + |
| 52 | + // LCS for characters |
| 53 | + const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)); |
| 54 | + for (let i = 1; i <= m; i++) { |
| 55 | + for (let j = 1; j <= n; j++) { |
| 56 | + dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1]); |
| 57 | + } |
| 58 | + } |
| 59 | + |
| 60 | + const result: { type: 'equal' | 'add' | 'remove'; text: string }[] = []; |
| 61 | + let i = m, j = n; |
| 62 | + while (i > 0 || j > 0) { |
| 63 | + if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) { |
| 64 | + result.unshift({ type: 'equal', text: a[i - 1] }); |
| 65 | + i--; j--; |
| 66 | + } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) { |
| 67 | + result.unshift({ type: 'add', text: b[j - 1] }); |
| 68 | + j--; |
| 69 | + } else { |
| 70 | + result.unshift({ type: 'remove', text: a[i - 1] }); |
| 71 | + i--; |
| 72 | + } |
| 73 | + } |
| 74 | + |
| 75 | + return result; |
| 76 | +} |
| 77 | + |
| 78 | +export default function DiffViewer() { |
| 79 | + const [oldText, setOldText] = useState(''); |
| 80 | + const [newText, setNewText] = useState(''); |
| 81 | + const [viewMode, setViewMode] = useState<'split' | 'inline'>('split'); |
| 82 | + const [ignoreWhitespace, setIgnoreWhitespace] = useState(false); |
| 83 | + |
| 84 | + const diff = useMemo(() => { |
| 85 | + if (!oldText && !newText) return null; |
| 86 | + return computeLineDiff(oldText, newText, ignoreWhitespace); |
| 87 | + }, [oldText, newText, ignoreWhitespace]); |
| 88 | + |
| 89 | + const stats = useMemo(() => { |
| 90 | + if (!diff) return null; |
| 91 | + const added = diff.filter((d) => d.type === 'add').length; |
| 92 | + const removed = diff.filter((d) => d.type === 'remove').length; |
| 93 | + const unchanged = diff.filter((d) => d.type === 'equal').length; |
| 94 | + return { added, removed, unchanged, total: diff.length }; |
| 95 | + }, [diff]); |
| 96 | + |
| 97 | + const clear = () => { setOldText(''); setNewText(''); }; |
| 98 | + |
| 99 | + return ( |
| 100 | + <ToolShell title="文本 Diff 对比" description="行级/字符级差异对比,支持并排和内联视图"> |
| 101 | + <div className="tool-layout"> |
| 102 | + <div className="tool-panel"> |
| 103 | + <div className="panel-header"> |
| 104 | + 原始文本 |
| 105 | + <div className="panel-actions"> |
| 106 | + <button className="panel-btn" onClick={() => setOldText('')}>清空</button> |
| 107 | + </div> |
| 108 | + </div> |
| 109 | + <textarea |
| 110 | + className="tool-textarea" |
| 111 | + value={oldText} |
| 112 | + onChange={(e) => setOldText(e.target.value)} |
| 113 | + placeholder="粘贴原始文本…" |
| 114 | + /> |
| 115 | + </div> |
| 116 | + <div className="tool-panel"> |
| 117 | + <div className="panel-header"> |
| 118 | + 修改后文本 |
| 119 | + <div className="panel-actions"> |
| 120 | + <button className="panel-btn" onClick={() => setNewText('')}>清空</button> |
| 121 | + </div> |
| 122 | + </div> |
| 123 | + <textarea |
| 124 | + className="tool-textarea" |
| 125 | + value={newText} |
| 126 | + onChange={(e) => setNewText(e.target.value)} |
| 127 | + placeholder="粘贴修改后的文本…" |
| 128 | + /> |
| 129 | + </div> |
| 130 | + </div> |
| 131 | + |
| 132 | + {diff && ( |
| 133 | + <div className="tool-panel" style={{ marginTop: 16 }}> |
| 134 | + <div className="panel-header"> |
| 135 | + 差异结果 |
| 136 | + <div className="panel-actions"> |
| 137 | + <button className={`panel-btn${viewMode === 'split' ? ' accent' : ''}`} onClick={() => setViewMode('split')}>并排</button> |
| 138 | + <button className={`panel-btn${viewMode === 'inline' ? ' accent' : ''}`} onClick={() => setViewMode('inline')}>内联</button> |
| 139 | + <button className={`panel-btn${ignoreWhitespace ? ' accent' : ''}`} onClick={() => setIgnoreWhitespace(!ignoreWhitespace)}>忽略空白</button> |
| 140 | + <button className="panel-btn" onClick={clear}>清空</button> |
| 141 | + </div> |
| 142 | + </div> |
| 143 | + {stats && ( |
| 144 | + <div className="diff-stats"> |
| 145 | + <span className="diff-stat-add">+{stats.added}</span> |
| 146 | + <span className="diff-stat-remove">-{stats.removed}</span> |
| 147 | + <span className="diff-stat-unchanged">{stats.unchanged} unchanged</span> |
| 148 | + </div> |
| 149 | + )} |
| 150 | + {viewMode === 'split' ? ( |
| 151 | + <SplitView diff={diff} /> |
| 152 | + ) : ( |
| 153 | + <InlineView diff={diff} /> |
| 154 | + )} |
| 155 | + </div> |
| 156 | + )} |
| 157 | + </ToolShell> |
| 158 | + ); |
| 159 | +} |
| 160 | + |
| 161 | +function SplitView({ diff }: { diff: DiffLine[] }) { |
| 162 | + const leftLines: { type: 'equal' | 'remove' | 'empty'; content: string; line?: number }[] = []; |
| 163 | + const rightLines: { type: 'equal' | 'add' | 'empty'; content: string; line?: number }[] = []; |
| 164 | + |
| 165 | + for (const d of diff) { |
| 166 | + if (d.type === 'equal') { |
| 167 | + leftLines.push({ type: 'equal', content: d.content, line: d.oldLine }); |
| 168 | + rightLines.push({ type: 'equal', content: d.content, line: d.newLine }); |
| 169 | + } else if (d.type === 'remove') { |
| 170 | + leftLines.push({ type: 'remove', content: d.content, line: d.oldLine }); |
| 171 | + rightLines.push({ type: 'empty', content: '' }); |
| 172 | + } else { |
| 173 | + leftLines.push({ type: 'empty', content: '' }); |
| 174 | + rightLines.push({ type: 'add', content: d.content, line: d.newLine }); |
| 175 | + } |
| 176 | + } |
| 177 | + |
| 178 | + return ( |
| 179 | + <div className="diff-split"> |
| 180 | + <div className="diff-split-pane"> |
| 181 | + {leftLines.map((line, i) => ( |
| 182 | + <DiffLineRow key={i} lineNum={line.line} type={line.type} content={line.content} /> |
| 183 | + ))} |
| 184 | + </div> |
| 185 | + <div className="diff-split-pane"> |
| 186 | + {rightLines.map((line, i) => ( |
| 187 | + <DiffLineRow key={i} lineNum={line.line} type={line.type} content={line.content} /> |
| 188 | + ))} |
| 189 | + </div> |
| 190 | + </div> |
| 191 | + ); |
| 192 | +} |
| 193 | + |
| 194 | +function InlineView({ diff }: { diff: DiffLine[] }) { |
| 195 | + const lines: { type: string; content: string; line?: number }[] = []; |
| 196 | + for (const d of diff) { |
| 197 | + if (d.type === 'equal') { |
| 198 | + lines.push({ type: 'equal', content: d.content, line: d.oldLine }); |
| 199 | + } else if (d.type === 'remove') { |
| 200 | + lines.push({ type: 'remove', content: d.content, line: d.oldLine }); |
| 201 | + } else { |
| 202 | + lines.push({ type: 'add', content: d.content, line: d.newLine }); |
| 203 | + } |
| 204 | + } |
| 205 | + |
| 206 | + return ( |
| 207 | + <div className="diff-inline"> |
| 208 | + {lines.map((line, i) => ( |
| 209 | + <DiffLineRow key={i} lineNum={line.line} type={line.type as any} content={line.content} /> |
| 210 | + ))} |
| 211 | + </div> |
| 212 | + ); |
| 213 | +} |
| 214 | + |
| 215 | +function DiffLineRow({ lineNum, type, content }: { lineNum?: number; type: string; content: string }) { |
| 216 | + const prefix = type === 'add' ? '+' : type === 'remove' ? '-' : ' '; |
| 217 | + |
| 218 | + return ( |
| 219 | + <div className={`diff-line diff-line-${type}`}> |
| 220 | + <span className="diff-line-num">{lineNum ?? ''}</span> |
| 221 | + <span className="diff-line-prefix">{prefix}</span> |
| 222 | + <span className="diff-line-content">{content || ' '}</span> |
| 223 | + </div> |
| 224 | + ); |
| 225 | +} |
0 commit comments