|
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