Skip to content

Commit a37d5c7

Browse files
committed
feat:impl for iamge format transform & compress
1 parent 5d6eb3d commit a37d5c7

8 files changed

Lines changed: 413 additions & 218 deletions

File tree

‎TOOLS_ANALYSIS.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -324,14 +324,14 @@
324324
| 6 | **正则表达式测试器** | 文本 | ✅ 已实现。SO 2024: 52% 开发者每周使用正则 |
325325
| 7 | **URL 编解码** | 编解码 | ✅ 已实现。SO 2024: Web 开发 #2 高频操作 |
326326
| 8 | **颜色格式转换** | 图片 | ✅ 已实现。SO 2024: 前端开发 Top 5 需求 |
327-
| 9 | **YAML 格式化/验证** | 格式化 | npm js-yaml: 周 3500 万+;K8s/GitHub Actions 核心格式 |
327+
| 9 | **YAML 格式化/验证** | 格式化 | ✅ 已实现。npm js-yaml: 周 3500 万+;K8s/GitHub Actions 核心格式 |
328328
| 10 | **文本 Diff 对比** | 文本 | ✅ 已实现。SO 2024: 代码审查 #1 工具需求 |
329329
| 11 | **UUID/GUID 生成器** | 生成器 | ✅ 已实现。npm uuid: 周 5000 万+ |
330-
| 12 | **随机密码生成器** | 加密 | SO 2024: 安全工具 #2 需求 |
331-
| 13 | **图片压缩器** | 图片 | SO 2024: 性能优化 #1 工具需求;Squoosh 停维后市场空缺 |
330+
| 12 | **随机密码生成器** | 加密 | ✅ 已实现。SO 2024: 安全工具 #2 需求 |
331+
| 13 | **图片压缩器** | 图片 | ✅ 已实现。SO 2024: 性能优化 #1 工具需求;Squoosh 停维后市场空缺 |
332332
| 14 | **Markdown 预览** | 文本 | ✅ 已实现。SO 2024: 文档编写 #1 工具 |
333333
| 15 | **Unicode 编解码** | 编解码 | SO 2024: 国际化开发 Top 5 需求 |
334-
| 16 | **图片格式转换** | 图片 | SO 2024: WebP/AVIF 渗透率从 2021 年 5% 升至 2024 年 40% |
334+
| 16 | **图片格式转换** | 图片 | ✅ 已实现。SO 2024: WebP/AVIF 渗透率从 2021 年 5% 升至 2024 年 40% |
335335
| 17 | **JSON Schema 验证器** | 格式化 | npm zod: 周 1500 万+ (2023 年起爆发);API 契约测试核心 |
336336
| 18 | **JSON ↔ CSV 转换** | 格式化 | SO 2024: 数据处理 #1 转换需求 |
337337
| 19 | **Cron 表达式解析** | 时间日期 | ✅ 已实现。npm cron-parser: 周 200 万+;CI/CD 必备 |
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import { useState, useRef, useCallback } from 'react';
2+
import { ToolShell } from '../../shell/ToolShell';
3+
import { useI18n, useToolI18n } from '../../shared/context/I18nContext';
4+
import { HelpSection } from '../../shared/components/HelpSection';
5+
6+
interface ImageInfo {
7+
name: string;
8+
width: number;
9+
height: number;
10+
originalSize: number;
11+
compressedSize: number;
12+
compressedUrl: string;
13+
compressedBlob: Blob;
14+
format: string;
15+
}
16+
17+
function formatBytes(bytes: number): string {
18+
if (bytes < 1024) return bytes + ' B';
19+
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
20+
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
21+
}
22+
23+
export default function ImageCompressor() {
24+
const { t } = useI18n();
25+
const { name, desc, ui, help } = useToolI18n('imgCompress');
26+
const [quality, setQuality] = useState(75);
27+
const [format, setFormat] = useState<'image/jpeg' | 'image/webp' | 'image/png'>('image/jpeg');
28+
const [maxWidth, setMaxWidth] = useState(0);
29+
const [result, setResult] = useState<ImageInfo | null>(null);
30+
const [processing, setProcessing] = useState(false);
31+
const [sourceFile, setSourceFile] = useState<File | null>(null);
32+
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
33+
const fileInputRef = useRef<HTMLInputElement>(null);
34+
35+
const handleFile = useCallback((file: File) => {
36+
setSourceFile(file);
37+
setResult(null);
38+
if (previewUrl) URL.revokeObjectURL(previewUrl);
39+
setPreviewUrl(URL.createObjectURL(file));
40+
}, [previewUrl]);
41+
42+
const compress = useCallback(() => {
43+
if (!sourceFile) return;
44+
setProcessing(true);
45+
const img = new Image();
46+
const url = URL.createObjectURL(sourceFile);
47+
img.onload = () => {
48+
URL.revokeObjectURL(url);
49+
let w = img.width, h = img.height;
50+
if (maxWidth > 0 && w > maxWidth) {
51+
h = Math.round(h * (maxWidth / w));
52+
w = maxWidth;
53+
}
54+
const canvas = document.createElement('canvas');
55+
canvas.width = w;
56+
canvas.height = h;
57+
const ctx = canvas.getContext('2d')!;
58+
if (format === 'image/jpeg') {
59+
ctx.fillStyle = '#ffffff';
60+
ctx.fillRect(0, 0, w, h);
61+
}
62+
ctx.drawImage(img, 0, 0, w, h);
63+
canvas.toBlob((blob) => {
64+
if (!blob) { setProcessing(false); return; }
65+
setResult({
66+
name: sourceFile.name,
67+
width: w, height: h,
68+
originalSize: sourceFile.size,
69+
compressedSize: blob.size,
70+
compressedUrl: URL.createObjectURL(blob),
71+
compressedBlob: blob,
72+
format: format.split('/')[1].toUpperCase(),
73+
});
74+
setProcessing(false);
75+
}, format, format === 'image/png' ? undefined : quality / 100);
76+
};
77+
img.src = url;
78+
}, [sourceFile, quality, format, maxWidth]);
79+
80+
const handleFileInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
81+
const file = e.target.files?.[0];
82+
if (file) handleFile(file);
83+
}, [handleFile]);
84+
85+
const handleDrop = useCallback((e: React.DragEvent) => {
86+
e.preventDefault();
87+
const file = e.dataTransfer.files[0];
88+
if (file && file.type.startsWith('image/')) handleFile(file);
89+
}, [handleFile]);
90+
91+
const download = () => {
92+
if (!result) return;
93+
const ext = format.split('/')[1];
94+
const baseName = result.name.replace(/\.[^.]+$/, '');
95+
const link = document.createElement('a');
96+
link.download = `${baseName}-compressed.${ext}`;
97+
link.href = result.compressedUrl;
98+
link.click();
99+
};
100+
101+
const ratio = result ? Math.round((1 - result.compressedSize / result.originalSize) * 100) : 0;
102+
103+
return (
104+
<ToolShell title={name} description={desc}>
105+
<div className="tool-layout">
106+
<div className="tool-panel">
107+
<div className="panel-header">{t('common.settings')}</div>
108+
<div className="uuid-config">
109+
<div className="uuid-config-row">
110+
<label>{ui.format}</label>
111+
<div className="panel-actions">
112+
{(['image/jpeg', 'image/webp', 'image/png'] as const).map((f) => (
113+
<button key={f} className={`panel-btn panel-btn-sm${format === f ? ' accent' : ''}`} onClick={() => setFormat(f)}>{f.split('/')[1].toUpperCase()}</button>
114+
))}
115+
</div>
116+
</div>
117+
{format !== 'image/png' && (
118+
<div className="uuid-config-row">
119+
<label>{ui.quality}</label>
120+
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1 }}>
121+
<input type="range" min={10} max={100} value={quality} onChange={(e) => setQuality(parseInt(e.target.value))} style={{ flex: 1 }} />
122+
<span style={{ fontSize: 14, fontFamily: 'var(--font-mono)', minWidth: 36, textAlign: 'right' }}>{quality}%</span>
123+
</div>
124+
</div>
125+
)}
126+
{format === 'image/png' && (
127+
<div style={{ fontSize: 12, color: 'var(--amber)', padding: '4px 0' }}>{ui.pngTip}</div>
128+
)}
129+
<div className="uuid-config-row">
130+
<label>{ui.maxWidth}</label>
131+
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1 }}>
132+
<input type="range" min={0} max={3840} step={100} value={maxWidth} onChange={(e) => setMaxWidth(parseInt(e.target.value))} style={{ flex: 1 }} />
133+
<span style={{ fontSize: 14, fontFamily: 'var(--font-mono)', minWidth: 48, textAlign: 'right' }}>{maxWidth === 0 ? ui.noLimit : maxWidth + 'px'}</span>
134+
</div>
135+
</div>
136+
</div>
137+
<input ref={fileInputRef} type="file" accept="image/*" onChange={handleFileInput} style={{ display: 'none' }} />
138+
<div className="qr-dropzone" onDragOver={(e) => e.preventDefault()} onDrop={handleDrop} onClick={() => fileInputRef.current?.click()} style={{ margin: '12px 16px', padding: '24px 16px', cursor: 'pointer' }}>
139+
<div style={{ color: 'var(--muted)', fontSize: 13, textAlign: 'center' }}>
140+
{sourceFile ? `${sourceFile.name} (${formatBytes(sourceFile.size)})` : ui.dropzone}
141+
</div>
142+
</div>
143+
{sourceFile && (
144+
<button className="panel-btn accent" onClick={compress} disabled={processing} style={{ margin: '0 16px 12px', width: 'calc(100% - 32px)' }}>
145+
{processing ? ui.processing : ui.compress}
146+
</button>
147+
)}
148+
</div>
149+
<div className="tool-panel">
150+
<div className="panel-header">{t('common.output')}</div>
151+
{result ? (
152+
<div style={{ padding: 16 }}>
153+
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: 16, background: 'var(--surface-2)', borderRadius: 8, padding: 8 }}>
154+
<img src={result.compressedUrl} alt="" style={{ maxWidth: '100%', maxHeight: 300, borderRadius: 4 }} />
155+
</div>
156+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, fontSize: 13 }}>
157+
<div><span style={{ color: 'var(--muted)' }}>{ui.originalSize}: </span>{formatBytes(result.originalSize)}</div>
158+
<div><span style={{ color: 'var(--muted)' }}>{ui.compressedSize}: </span>{formatBytes(result.compressedSize)}</div>
159+
<div><span style={{ color: 'var(--muted)' }}>{ui.dimensions}: </span>{result.width}×{result.height}</div>
160+
<div><span style={{ color: 'var(--muted)' }}>{ui.reduction}: </span><span style={{ color: ratio > 0 ? 'var(--green)' : 'var(--red)', fontWeight: 600 }}>{ratio > 0 ? '-' : '+'}{Math.abs(ratio)}%</span></div>
161+
</div>
162+
{ratio < 0 && <div style={{ fontSize: 12, color: 'var(--amber)', marginTop: 8 }}>{ui.sizeIncreased}</div>}
163+
<button className="panel-btn accent" onClick={download} style={{ marginTop: 16, width: '100%' }}>{t('common.download')}</button>
164+
</div>
165+
) : previewUrl ? (
166+
<div style={{ padding: 16 }}>
167+
<div style={{ display: 'flex', justifyContent: 'center', background: 'var(--surface-2)', borderRadius: 8, padding: 8 }}>
168+
<img src={previewUrl} alt="" style={{ maxWidth: '100%', maxHeight: 300, borderRadius: 4 }} />
169+
</div>
170+
<div style={{ textAlign: 'center', color: 'var(--muted)', fontSize: 12, marginTop: 8 }}>{ui.adjustHint}</div>
171+
</div>
172+
) : (
173+
<div style={{ padding: 48, textAlign: 'center', color: 'var(--muted)', fontSize: 13 }}>{t('common.waiting')}</div>
174+
)}
175+
</div>
176+
</div>
177+
{help && <HelpSection title={help.title} features={help.features} usage={help.usage} params={help.params} />}
178+
</ToolShell>
179+
);
180+
}
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import { useState, useRef, useCallback } from 'react';
2+
import { ToolShell } from '../../shell/ToolShell';
3+
import { useI18n, useToolI18n } from '../../shared/context/I18nContext';
4+
import { HelpSection } from '../../shared/components/HelpSection';
5+
6+
type OutputFormat = 'image/png' | 'image/jpeg' | 'image/webp';
7+
8+
interface ConvertResult {
9+
name: string;
10+
width: number;
11+
height: number;
12+
originalSize: number;
13+
convertedSize: number;
14+
convertedUrl: string;
15+
convertedBlob: Blob;
16+
fromFormat: string;
17+
toFormat: string;
18+
}
19+
20+
function formatBytes(bytes: number): string {
21+
if (bytes < 1024) return bytes + ' B';
22+
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
23+
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
24+
}
25+
26+
const FORMAT_EXT: Record<string, string> = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp' };
27+
28+
export default function ImageConverter() {
29+
const { t } = useI18n();
30+
const { name, desc, ui, help } = useToolI18n('imgConvert');
31+
const [targetFormat, setTargetFormat] = useState<OutputFormat>('image/webp');
32+
const [quality, setQuality] = useState(92);
33+
const [result, setResult] = useState<ConvertResult | null>(null);
34+
const [processing, setProcessing] = useState(false);
35+
const [sourceFile, setSourceFile] = useState<File | null>(null);
36+
const [sourcePreviewUrl, setSourcePreviewUrl] = useState<string | null>(null);
37+
const fileInputRef = useRef<HTMLInputElement>(null);
38+
39+
const handleFile = useCallback((file: File) => {
40+
setSourceFile(file);
41+
setResult(null);
42+
if (sourcePreviewUrl) URL.revokeObjectURL(sourcePreviewUrl);
43+
setSourcePreviewUrl(URL.createObjectURL(file));
44+
}, [sourcePreviewUrl]);
45+
46+
const convert = useCallback(() => {
47+
if (!sourceFile) return;
48+
setProcessing(true);
49+
const fromFormat = sourceFile.type || 'unknown';
50+
const imgUrl = URL.createObjectURL(sourceFile);
51+
const img = new Image();
52+
img.onload = () => {
53+
URL.revokeObjectURL(imgUrl);
54+
const canvas = document.createElement('canvas');
55+
canvas.width = img.width;
56+
canvas.height = img.height;
57+
const ctx = canvas.getContext('2d')!;
58+
if (targetFormat === 'image/jpeg') {
59+
ctx.fillStyle = '#ffffff';
60+
ctx.fillRect(0, 0, img.width, img.height);
61+
}
62+
ctx.drawImage(img, 0, 0);
63+
canvas.toBlob((blob) => {
64+
if (!blob) { setProcessing(false); return; }
65+
setResult({
66+
name: sourceFile.name,
67+
width: img.width, height: img.height,
68+
originalSize: sourceFile.size,
69+
convertedSize: blob.size,
70+
convertedUrl: URL.createObjectURL(blob),
71+
convertedBlob: blob,
72+
fromFormat: fromFormat.replace('image/', '').toUpperCase() || '?',
73+
toFormat: targetFormat.split('/')[1].toUpperCase(),
74+
});
75+
setProcessing(false);
76+
}, targetFormat, targetFormat === 'image/png' ? undefined : quality / 100);
77+
};
78+
img.src = imgUrl;
79+
}, [sourceFile, targetFormat, quality]);
80+
81+
const handleFileInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
82+
const file = e.target.files?.[0];
83+
if (file) handleFile(file);
84+
}, [handleFile]);
85+
86+
const handleDrop = useCallback((e: React.DragEvent) => {
87+
e.preventDefault();
88+
const file = e.dataTransfer.files[0];
89+
if (file && file.type.startsWith('image/')) handleFile(file);
90+
}, [handleFile]);
91+
92+
const download = () => {
93+
if (!result) return;
94+
const ext = FORMAT_EXT[targetFormat];
95+
const baseName = result.name.replace(/\.[^.]+$/, '');
96+
const link = document.createElement('a');
97+
link.download = `${baseName}.${ext}`;
98+
link.href = result.convertedUrl;
99+
link.click();
100+
};
101+
102+
const formats: OutputFormat[] = ['image/png', 'image/jpeg', 'image/webp'];
103+
104+
return (
105+
<ToolShell title={name} description={desc}>
106+
<div className="tool-layout">
107+
<div className="tool-panel">
108+
<div className="panel-header">{t('common.settings')}</div>
109+
<div className="uuid-config">
110+
<div className="uuid-config-row">
111+
<label>{ui.targetFormat}</label>
112+
<div className="panel-actions">
113+
{formats.map((f) => (
114+
<button key={f} className={`panel-btn panel-btn-sm${targetFormat === f ? ' accent' : ''}`} onClick={() => setTargetFormat(f)}>{f.split('/')[1].toUpperCase()}</button>
115+
))}
116+
</div>
117+
</div>
118+
{targetFormat !== 'image/png' && (
119+
<div className="uuid-config-row">
120+
<label>{ui.quality}</label>
121+
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1 }}>
122+
<input type="range" min={10} max={100} value={quality} onChange={(e) => setQuality(parseInt(e.target.value))} style={{ flex: 1 }} />
123+
<span style={{ fontSize: 14, fontFamily: 'var(--font-mono)', minWidth: 36, textAlign: 'right' }}>{quality}%</span>
124+
</div>
125+
</div>
126+
)}
127+
</div>
128+
<input ref={fileInputRef} type="file" accept="image/*" onChange={handleFileInput} style={{ display: 'none' }} />
129+
<div className="qr-dropzone" onDragOver={(e) => e.preventDefault()} onDrop={handleDrop} onClick={() => fileInputRef.current?.click()} style={{ margin: '12px 16px', padding: '24px 16px', cursor: 'pointer' }}>
130+
<div style={{ color: 'var(--muted)', fontSize: 13, textAlign: 'center' }}>
131+
{sourceFile ? `${sourceFile.name} (${formatBytes(sourceFile.size)})` : ui.dropzone}
132+
</div>
133+
</div>
134+
{sourceFile && (
135+
<button className="panel-btn accent" onClick={convert} disabled={processing} style={{ margin: '0 16px 12px', width: 'calc(100% - 32px)' }}>
136+
{processing ? ui.processing : ui.convert}
137+
</button>
138+
)}
139+
</div>
140+
<div className="tool-panel">
141+
<div className="panel-header">{t('common.output')}</div>
142+
{result ? (
143+
<div style={{ padding: 16 }}>
144+
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: 16, background: 'var(--surface-2)', borderRadius: 8, padding: 8 }}>
145+
<img src={result.convertedUrl} alt="" style={{ maxWidth: '100%', maxHeight: 300, borderRadius: 4 }} />
146+
</div>
147+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, fontSize: 13 }}>
148+
<div><span style={{ color: 'var(--muted)' }}>{ui.from}: </span>{result.fromFormat}</div>
149+
<div><span style={{ color: 'var(--muted)' }}>{ui.to}: </span>{result.toFormat}</div>
150+
<div><span style={{ color: 'var(--muted)' }}>{ui.originalSize}: </span>{formatBytes(result.originalSize)}</div>
151+
<div><span style={{ color: 'var(--muted)' }}>{ui.convertedSize}: </span>{formatBytes(result.convertedSize)}</div>
152+
<div><span style={{ color: 'var(--muted)' }}>{ui.dimensions}: </span>{result.width}×{result.height}</div>
153+
</div>
154+
<button className="panel-btn accent" onClick={download} style={{ marginTop: 16, width: '100%' }}>{t('common.download')}</button>
155+
</div>
156+
) : sourcePreviewUrl ? (
157+
<div style={{ padding: 16 }}>
158+
<div style={{ display: 'flex', justifyContent: 'center', background: 'var(--surface-2)', borderRadius: 8, padding: 8 }}>
159+
<img src={sourcePreviewUrl} alt="" style={{ maxWidth: '100%', maxHeight: 300, borderRadius: 4 }} />
160+
</div>
161+
<div style={{ textAlign: 'center', color: 'var(--muted)', fontSize: 12, marginTop: 8 }}>{ui.adjustHint}</div>
162+
</div>
163+
) : (
164+
<div style={{ padding: 48, textAlign: 'center', color: 'var(--muted)', fontSize: 13 }}>{t('common.waiting')}</div>
165+
)}
166+
</div>
167+
</div>
168+
{help && <HelpSection title={help.title} features={help.features} usage={help.usage} params={help.params} />}
169+
</ToolShell>
170+
);
171+
}

‎src/features/yaml-formatter/index.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ export default function YamlFormatter() {
6666

6767
const handleInput = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
6868
setInput(e.target.value);
69-
autoResize(e.target);
69+
requestAnimationFrame(() => autoResize(e.target));
7070
}, [autoResize]);
7171

7272
const setExample = useCallback(() => {
@@ -129,7 +129,7 @@ export default function YamlFormatter() {
129129
<button className="panel-btn" onClick={() => { setInput(''); setOutput(''); setError(''); }}>{t('common.clear')}</button>
130130
</div>
131131
</div>
132-
<textarea ref={textareaRef} className="tool-textarea" style={{ height: 'auto', overflow: 'hidden' }} value={input} onInput={handleInput} placeholder={ui.placeholder} />
132+
<textarea ref={textareaRef} className="tool-textarea" style={{ height: 'auto', overflow: 'hidden' }} value={input} onChange={handleInput} placeholder={ui.placeholder} />
133133
</div>
134134
<div className="tool-panel">
135135
<div className="panel-header">

0 commit comments

Comments
 (0)