Skip to content

Commit 826dde6

Browse files
committed
feat:add rgb transform tool & hash tool
1 parent b9dae02 commit 826dde6

4 files changed

Lines changed: 626 additions & 6 deletions

File tree

Lines changed: 288 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,288 @@
1-
import { createPlaceholder } from '../_placeholder';
2-
export default createPlaceholder('color-converter', '该工具正在开发中');
1+
import { useState, useMemo } from 'react';
2+
import { ToolShell } from '../../shell/ToolShell';
3+
4+
interface RGBA { r: number; g: number; b: number; a: number }
5+
6+
function parseHex(hex: string): RGBA | null {
7+
const m = hex.replace('#', '').match(/^([0-9a-f]{3,8})$/i);
8+
if (!m) return null;
9+
let h = m[1];
10+
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
11+
if (h.length === 4) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2] + h[3] + h[3];
12+
if (h.length < 6) return null;
13+
// 8位格式为 #AARRGGBB(ARGB)
14+
if (h.length === 8) {
15+
const a = parseInt(h.slice(0, 2), 16) / 255;
16+
const r = parseInt(h.slice(2, 4), 16);
17+
const g = parseInt(h.slice(4, 6), 16);
18+
const b = parseInt(h.slice(6, 8), 16);
19+
return { r, g, b, a };
20+
}
21+
const r = parseInt(h.slice(0, 2), 16);
22+
const g = parseInt(h.slice(2, 4), 16);
23+
const b = parseInt(h.slice(4, 6), 16);
24+
return { r, g, b, a: 1 };
25+
}
26+
27+
function toHex(r: number, g: number, b: number, a: number): string {
28+
const rgb = [r, g, b].map((v) => Math.round(v).toString(16).padStart(2, '0')).join('');
29+
if (a < 1) {
30+
// ARGB 格式: #AARRGGBB
31+
return '#' + Math.round(a * 255).toString(16).padStart(2, '0') + rgb;
32+
}
33+
return '#' + rgb;
34+
}
35+
36+
function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } {
37+
r /= 255; g /= 255; b /= 255;
38+
const max = Math.max(r, g, b), min = Math.min(r, g, b);
39+
const l = (max + min) / 2;
40+
if (max === min) return { h: 0, s: 0, l: l * 100 };
41+
const d = max - min;
42+
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
43+
let h = 0;
44+
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
45+
else if (max === g) h = ((b - r) / d + 2) / 6;
46+
else h = ((r - g) / d + 4) / 6;
47+
return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) };
48+
}
49+
50+
function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } {
51+
h /= 360; s /= 100; l /= 100;
52+
if (s === 0) { const v = Math.round(l * 255); return { r: v, g: v, b: v }; }
53+
const hue2rgb = (p: number, q: number, t: number) => {
54+
if (t < 0) t += 1;
55+
if (t > 1) t -= 1;
56+
if (t < 1 / 6) return p + (q - p) * 6 * t;
57+
if (t < 1 / 2) return q;
58+
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
59+
return p;
60+
};
61+
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
62+
const p = 2 * l - q;
63+
return {
64+
r: Math.round(hue2rgb(p, q, h + 1 / 3) * 255),
65+
g: Math.round(hue2rgb(p, q, h) * 255),
66+
b: Math.round(hue2rgb(p, q, h - 1 / 3) * 255),
67+
};
68+
}
69+
70+
function relativeLuminance(r: number, g: number, b: number): number {
71+
const [rs, gs, bs] = [r, g, b].map((v) => {
72+
v /= 255;
73+
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
74+
});
75+
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
76+
}
77+
78+
function contrastRatio(c1: RGBA, c2: RGBA): number {
79+
const l1 = relativeLuminance(c1.r, c1.g, c1.b);
80+
const l2 = relativeLuminance(c2.r, c2.g, c2.b);
81+
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
82+
}
83+
84+
function toCssColor(c: RGBA): string {
85+
if (c.a < 1) return `rgba(${c.r}, ${c.g}, ${c.b}, ${c.a.toFixed(4)})`;
86+
return `rgb(${c.r}, ${c.g}, ${c.b})`;
87+
}
88+
89+
export default function ColorConverter() {
90+
const [hexInput, setHexInput] = useState('#5b7fff');
91+
const [rInput, setRInput] = useState('91');
92+
const [gInput, setGInput] = useState('127');
93+
const [bInput, setBInput] = useState('255');
94+
const [aInput, setAInput] = useState('100');
95+
const [hInput, setHInput] = useState('225');
96+
const [sInput, setSInput] = useState('100');
97+
const [lInput, setLInput] = useState('68');
98+
const [bgHex, setBgHex] = useState('#ffffff');
99+
100+
const parsed = useMemo(() => parseHex(hexInput), [hexInput]);
101+
102+
const syncFromColor = (c: RGBA) => {
103+
setRInput(String(c.r));
104+
setGInput(String(c.g));
105+
setBInput(String(c.b));
106+
setAInput(String(Math.round(c.a * 100)));
107+
const hsl = rgbToHsl(c.r, c.g, c.b);
108+
setHInput(String(hsl.h));
109+
setSInput(String(hsl.s));
110+
setLInput(String(hsl.l));
111+
};
112+
113+
const updateFromHex = (hex: string) => {
114+
setHexInput(hex);
115+
const c = parseHex(hex);
116+
if (c) syncFromColor(c);
117+
};
118+
119+
const updateFromRgb = (r: string, g: string, b: string, a: string) => {
120+
setRInput(r); setGInput(g); setBInput(b); setAInput(a);
121+
const ri = parseInt(r), gi = parseInt(g), bi = parseInt(b), ai = parseInt(a);
122+
if (isNaN(ri) || isNaN(gi) || isNaN(bi) || isNaN(ai)) return;
123+
const alpha = Math.max(0, Math.min(100, ai)) / 100;
124+
setHexInput(toHex(ri, gi, bi, alpha));
125+
const hsl = rgbToHsl(ri, gi, bi);
126+
setHInput(String(hsl.h));
127+
setSInput(String(hsl.s));
128+
setLInput(String(hsl.l));
129+
};
130+
131+
const updateFromHsl = (h: string, s: string, l: string) => {
132+
setHInput(h); setSInput(s); setLInput(l);
133+
const hi = parseInt(h), si = parseInt(s), li = parseInt(l);
134+
if (isNaN(hi) || isNaN(si) || isNaN(li)) return;
135+
const rgb = hslToRgb(hi, si, li);
136+
setRInput(String(rgb.r));
137+
setGInput(String(rgb.g));
138+
setBInput(String(rgb.b));
139+
const alpha = parseInt(aInput) / 100;
140+
setHexInput(toHex(rgb.r, rgb.g, rgb.b, isNaN(alpha) ? 1 : alpha));
141+
};
142+
143+
const contrast = useMemo(() => {
144+
if (!parsed) return null;
145+
const bg = parseHex(bgHex);
146+
if (!bg) return null;
147+
return contrastRatio(parsed, bg);
148+
}, [parsed, bgHex]);
149+
150+
const alpha = parsed?.a ?? 1;
151+
const hasAlpha = alpha < 1;
152+
const copy = (text: string) => navigator.clipboard.writeText(text);
153+
154+
return (
155+
<ToolShell title="颜色格式转换" description="HEX / RGBA / HSL 互转,支持透明度,对比度检查">
156+
<div className="tool-layout">
157+
<div className="tool-panel">
158+
<div className="panel-header">HEX</div>
159+
<div className="color-input-row">
160+
<div
161+
className="color-preview"
162+
style={{ background: parsed ? toCssColor(parsed) : hexInput, backgroundImage: hasAlpha ? `linear-gradient(45deg, #ccc 25%, transparent 25%), linear-gradient(-45deg, #ccc 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #ccc 75%), linear-gradient(-45deg, transparent 75%, #ccc 75%)` : undefined, backgroundSize: hasAlpha ? '8px 8px' : undefined, backgroundPosition: hasAlpha ? '0 0, 0 4px, 4px -4px, -4px 0' : undefined }}
163+
/>
164+
<input
165+
type="text"
166+
className="tool-input"
167+
value={hexInput}
168+
onChange={(e) => updateFromHex(e.target.value)}
169+
placeholder="#5b7fff 或 #AARRGGBB"
170+
/>
171+
<button className="panel-btn" onClick={() => copy(hexInput)}>复制</button>
172+
</div>
173+
174+
<div className="panel-header">RGBA</div>
175+
<div className="color-input-row color-rgb-row">
176+
<div className="color-field">
177+
<label>R</label>
178+
<input type="number" min="0" max="255" value={rInput} onChange={(e) => updateFromRgb(e.target.value, gInput, bInput, aInput)} />
179+
</div>
180+
<div className="color-field">
181+
<label>G</label>
182+
<input type="number" min="0" max="255" value={gInput} onChange={(e) => updateFromRgb(rInput, e.target.value, bInput, aInput)} />
183+
</div>
184+
<div className="color-field">
185+
<label>B</label>
186+
<input type="number" min="0" max="255" value={bInput} onChange={(e) => updateFromRgb(rInput, gInput, e.target.value, aInput)} />
187+
</div>
188+
<div className="color-field">
189+
<label>A%</label>
190+
<input type="number" min="0" max="100" value={aInput} onChange={(e) => updateFromRgb(rInput, gInput, bInput, e.target.value)} />
191+
</div>
192+
<button className="panel-btn" onClick={() => copy(`rgba(${rInput}, ${gInput}, ${bInput}, ${(parseInt(aInput) / 100).toFixed(2)})`)}>复制</button>
193+
</div>
194+
195+
<div className="panel-header">HSL</div>
196+
<div className="color-input-row color-rgb-row">
197+
<div className="color-field">
198+
<label>H</label>
199+
<input type="number" min="0" max="360" value={hInput} onChange={(e) => updateFromHsl(e.target.value, sInput, lInput)} />
200+
</div>
201+
<div className="color-field">
202+
<label>S%</label>
203+
<input type="number" min="0" max="100" value={sInput} onChange={(e) => updateFromHsl(hInput, e.target.value, lInput)} />
204+
</div>
205+
<div className="color-field">
206+
<label>L%</label>
207+
<input type="number" min="0" max="100" value={lInput} onChange={(e) => updateFromHsl(hInput, sInput, e.target.value)} />
208+
</div>
209+
<button className="panel-btn" onClick={() => copy(`hsl(${hInput}, ${sInput}%, ${lInput}%)`)}>复制</button>
210+
</div>
211+
212+
<div className="panel-header">CSS 输出</div>
213+
<div className="color-css-output">
214+
<code>{hasAlpha
215+
? `background: rgba(${rInput}, ${gInput}, ${bInput}, ${(parseInt(aInput) / 100).toFixed(2)});`
216+
: `background: ${hexInput};`
217+
}</code>
218+
<button className="panel-btn" onClick={() => copy(hasAlpha
219+
? `rgba(${rInput}, ${gInput}, ${bInput}, ${(parseInt(aInput) / 100).toFixed(2)})`
220+
: hexInput
221+
)}>复制</button>
222+
</div>
223+
</div>
224+
225+
<div className="tool-panel">
226+
<div className="panel-header">预览</div>
227+
<div className="color-preview-checker">
228+
<div className="color-preview-large" style={{ background: parsed ? toCssColor(parsed) : hexInput }}>
229+
<span style={{ color: (contrast && contrast >= 4.5) ? '#fff' : '#000' }}>Aa</span>
230+
</div>
231+
</div>
232+
233+
<div className="panel-header">对比度检查</div>
234+
<div className="color-contrast">
235+
<div className="color-contrast-bg">
236+
<label>背景色</label>
237+
<div className="color-input-row">
238+
<div className="color-preview-sm" style={{ background: bgHex }} />
239+
<input
240+
type="text"
241+
className="tool-input"
242+
value={bgHex}
243+
onChange={(e) => setBgHex(e.target.value)}
244+
placeholder="#ffffff"
245+
/>
246+
</div>
247+
</div>
248+
{contrast !== null && (
249+
<div className="color-contrast-result">
250+
<div className="color-contrast-ratio">{contrast.toFixed(2)}:1</div>
251+
<div className="color-contrast-badges">
252+
<span className={`contrast-badge ${contrast >= 4.5 ? 'pass' : 'fail'}`}>
253+
AA {contrast >= 4.5 ? '✓' : '✗'}
254+
</span>
255+
<span className={`contrast-badge ${contrast >= 7 ? 'pass' : 'fail'}`}>
256+
AAA {contrast >= 7 ? '✓' : '✗'}
257+
</span>
258+
</div>
259+
<div className="color-contrast-preview" style={{ background: bgHex, color: parsed ? toCssColor(parsed) : hexInput }}>
260+
The quick brown fox jumps over the lazy dog.
261+
</div>
262+
</div>
263+
)}
264+
</div>
265+
266+
<div className="panel-header">调色板</div>
267+
<div className="color-palette">
268+
{[10, 20, 30, 40, 50, 60, 70, 80, 90].map((l) => {
269+
const h = parseInt(hInput) || 0;
270+
const s = parseInt(sInput) || 0;
271+
const rgb = hslToRgb(h, s, l);
272+
const hex = toHex(rgb.r, rgb.g, rgb.b, 1);
273+
return (
274+
<div
275+
key={l}
276+
className="color-palette-swatch"
277+
style={{ background: hex }}
278+
title={hex}
279+
onClick={() => updateFromHex(hex)}
280+
/>
281+
);
282+
})}
283+
</div>
284+
</div>
285+
</div>
286+
</ToolShell>
287+
);
288+
}

0 commit comments

Comments
 (0)