Skip to content

Commit b9dae02

Browse files
committed
feat:add txt diff compare tool
1 parent 5b48735 commit b9dae02

8 files changed

Lines changed: 336 additions & 5 deletions

File tree

.github/workflows/deploy.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,10 @@ jobs:
2222

2323
- uses: actions/setup-node@v4
2424
with:
25-
node-version: 20
25+
node-version: 22
2626
cache: npm
2727

28-
- run: npm ci
28+
- run: npm install
2929
- run: npm run build
3030

3131
- uses: actions/upload-pages-artifact@v3

src/features/base64-codec/index.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useState } from 'react';
22
import { ToolShell } from '../../shell/ToolShell';
3+
import { useCleanup } from '../../shared/hooks/useCleanup';
34

45
export default function Base64Codec() {
56
const [input, setInput] = useState('');
@@ -8,6 +9,8 @@ export default function Base64Codec() {
89
const [mode, setMode] = useState<'encode' | 'decode'>('encode');
910
const [urlSafe, setUrlSafe] = useState(false);
1011

12+
useCleanup(() => { setInput(''); setOutput(''); });
13+
1114
const process = () => {
1215
const raw = input;
1316
if (!raw) { setOutput(''); setError(''); return; }

src/features/diff-viewer/index.tsx

Lines changed: 225 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,225 @@
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+
}

src/features/json-formatter/index.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useState } from 'react';
22
import { ToolShell } from '../../shell/ToolShell';
3+
import { useCleanup } from '../../shared/hooks/useCleanup';
34

45
const SAMPLE = JSON.stringify(
56
{ name: "Efficient Tools", version: "1.0.0", tools: [{ id: "json", status: "可用" }, { id: "regex", status: "可用" }], config: { theme: "flow", lang: "zh-CN" } },
@@ -12,6 +13,8 @@ export default function JsonFormatter() {
1213
const [output, setOutput] = useState('');
1314
const [error, setError] = useState('');
1415

16+
useCleanup(() => { setInput(''); setOutput(''); });
17+
1518
const format = () => {
1619
const raw = input.trim();
1720
if (!raw) { setOutput(''); setError(''); return; }

src/features/url-codec/index.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useState } from 'react';
22
import { ToolShell } from '../../shell/ToolShell';
3+
import { useCleanup } from '../../shared/hooks/useCleanup';
34

45
export default function UrlCodec() {
56
const [input, setInput] = useState('');
@@ -9,6 +10,8 @@ export default function UrlCodec() {
910
const [component, setComponent] = useState(false);
1011
const [params, setParams] = useState<{ key: string; value: string }[]>([]);
1112

13+
useCleanup(() => { setInput(''); setOutput(''); setParams([]); });
14+
1215
const process = () => {
1316
const raw = input;
1417
if (!raw) { setOutput(''); setError(''); setParams([]); return; }

src/registry.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ export const tools: ToolMeta[] = [
104104
path: '/diff',
105105
component: () => import('./features/diff-viewer'),
106106
keywords: ['diff', 'compare', '对比', '差异'],
107-
status: '开发中',
107+
status: '可用',
108108
},
109109
{
110110
id: 'hash',

src/shared/hooks/useCleanup.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { useEffect, useRef } from 'react';
2+
3+
/**
4+
* Registers a cleanup function that runs when the component unmounts.
5+
* Use to release large resources (big strings, buffers, workers, etc.)
6+
*/
7+
export function useCleanup(cleanup: () => void) {
8+
const ref = useRef(cleanup);
9+
ref.current = cleanup;
10+
11+
useEffect(() => {
12+
return () => ref.current();
13+
}, []);
14+
}

0 commit comments

Comments
 (0)