DevTools: Farb-Rechner mit HEX/RGB/RGBA/HSL/HSV/CMYK, Color Picker, Alpha, WCAG-Kontrast

This commit is contained in:
2026-06-01 15:41:21 +02:00
parent 57bf7e6944
commit b698818791

View File

@@ -2360,6 +2360,325 @@ function JsonTool({ toast, mobile }) {
// ──────────────────────────────────────────────────────────── Farb-Rechner ──
// ── Konvertierungsfunktionen ──
function hexToRgb(hex) {
hex = hex.replace(/^#/, '');
if (hex.length === 3) hex = hex.split('').map(c=>c+c).join('');
if (hex.length === 4) hex = hex.split('').map(c=>c+c).join(''); // #rgba
const n = parseInt(hex.slice(0,6), 16);
const a = hex.length === 8 ? parseInt(hex.slice(6,8),16)/255 : 1;
return { r:(n>>16)&255, g:(n>>8)&255, b:n&255, a };
}
function rgbToHex({r,g,b,a=1}) {
const h = [r,g,b].map(v=>Math.round(v).toString(16).padStart(2,'0')).join('');
const ah = a < 1 ? Math.round(a*255).toString(16).padStart(2,'0') : '';
return '#' + h + ah;
}
function rgbToHsl({r,g,b,a=1}) {
r/=255; g/=255; b/=255;
const max=Math.max(r,g,b), min=Math.min(r,g,b);
let h=0,s=0,l=(max+min)/2;
if (max!==min) {
const d=max-min;
s=l>0.5?d/(2-max-min):d/(max+min);
switch(max){
case r: h=((g-b)/d+(g<b?6:0))/6; break;
case g: h=((b-r)/d+2)/6; break;
case b: h=((r-g)/d+4)/6; break;
}
}
return { h:Math.round(h*360), s:Math.round(s*100), l:Math.round(l*100), a };
}
function hslToRgb({h,s,l,a=1}) {
s/=100; l/=100;
const k=n=>(n+h/30)%12;
const c=s*Math.min(l,1-l);
const f=n=>l-c*Math.max(-1,Math.min(k(n)-3,Math.min(9-k(n),1)));
return {r:Math.round(f(0)*255), g:Math.round(f(8)*255), b:Math.round(f(4)*255), a};
}
function rgbToHsv({r,g,b,a=1}) {
r/=255; g/=255; b/=255;
const max=Math.max(r,g,b), min=Math.min(r,g,b), d=max-min;
let h=0;
if (d!==0){
switch(max){
case r: h=((g-b)/d)%6; break;
case g: h=(b-r)/d+2; break;
case b: h=(r-g)/d+4; break;
}
h=Math.round(h*60); if(h<0) h+=360;
}
return {h, s:max===0?0:Math.round(d/max*100), v:Math.round(max*100), a};
}
function rgbToCmyk({r,g,b}) {
r/=255; g/=255; b/=255;
const k=1-Math.max(r,g,b);
if (k===1) return {c:0,m:0,y:0,k:100};
return {
c:Math.round((1-r-k)/(1-k)*100),
m:Math.round((1-g-k)/(1-k)*100),
y:Math.round((1-b-k)/(1-k)*100),
k:Math.round(k*100)
};
}
// Relative luminance (WCAG)
function luminance({r,g,b}) {
const c = [r,g,b].map(v => {
v/=255;
return v<=0.03928 ? v/12.92 : Math.pow((v+0.055)/1.055,2.4);
});
return 0.2126*c[0]+0.7152*c[1]+0.0722*c[2];
}
function contrastRatio(lum1, lum2) {
const [l,d] = lum1>lum2 ? [lum1,lum2] : [lum2,lum1];
return (l+0.05)/(d+0.05);
}
// Parse any color string input → {r,g,b,a} or null
function parseColor(raw) {
if (!raw) return null;
raw = raw.trim();
// hex
if (/^#?[0-9a-fA-F]{3,8}$/.test(raw)) {
try { return hexToRgb(raw.startsWith('#')?raw:'#'+raw); } catch {}
}
// rgb / rgba
const rgbM = raw.match(/rgba?\(\s*([\d.]+)\s*[,\s]\s*([\d.]+)\s*[,\s]\s*([\d.]+)(?:\s*[,\/]\s*([\d.]+%?))?\s*\)/i);
if (rgbM) {
const a = rgbM[4] ? (rgbM[4].includes('%') ? parseFloat(rgbM[4])/100 : parseFloat(rgbM[4])) : 1;
return {r:+rgbM[1],g:+rgbM[2],b:+rgbM[3],a};
}
// hsl / hsla
const hslM = raw.match(/hsla?\(\s*([\d.]+)\s*[,\s]\s*([\d.]+)%?\s*[,\s]\s*([\d.]+)%?(?:\s*[,\/]\s*([\d.]+%?))?\s*\)/i);
if (hslM) {
const a = hslM[4] ? (hslM[4].includes('%')?parseFloat(hslM[4])/100:parseFloat(hslM[4])) : 1;
return hslToRgb({h:+hslM[1],s:+hslM[2],l:+hslM[3],a});
}
// CSS color names (basic set)
const NAMES = {red:[255,0,0],green:[0,128,0],blue:[0,0,255],white:[255,255,255],black:[0,0,0],
yellow:[255,255,0],cyan:[0,255,255],magenta:[255,0,255],orange:[255,165,0],purple:[128,0,128],
pink:[255,192,203],gray:[128,128,128],grey:[128,128,128],lime:[0,255,0],navy:[0,0,128],
teal:[0,128,128],silver:[192,192,192],maroon:[128,0,0],olive:[128,128,0]};
const name = NAMES[raw.toLowerCase()];
if (name) return {r:name[0],g:name[1],b:name[2],a:1};
return null;
}
function fmt2(n) { return Math.round(n*100)/100; }
function FarbRechner({ toast, mobile }) {
const [input, setInput] = useState('');
const [pickerHex,setPickerHex]= useState('#4ecdc4');
const [color, setColor] = useState(null); // {r,g,b,a}
const [copied, setCopied] = useState('');
function applyColor(rgba) {
setColor(rgba);
// sync picker (ignore alpha for picker)
setPickerHex(rgbToHex({...rgba,a:1}));
}
function handleInput(val) {
setInput(val);
const c = parseColor(val);
if (c) applyColor(c);
else if (!val.trim()) setColor(null);
}
function handlePicker(hex) {
setPickerHex(hex);
const c = hexToRgb(hex);
// preserve alpha from current color
const a = color?.a ?? 1;
setInput(hex);
applyColor({...c, a});
setInput(hex);
}
async function copy(text, key) {
try { await navigator.clipboard.writeText(text); setCopied(key); setTimeout(()=>setCopied(''),1800); }
catch { toast('Kopieren fehlgeschlagen'); }
}
const inp = {...S.inp, fontSize:13, padding:'7px 9px', boxSizing:'border-box'};
// Derived formats
const formats = color ? (() => {
const {r,g,b,a} = color;
const hsl = rgbToHsl(color);
const hsv = rgbToHsv(color);
const cmyk = rgbToCmyk(color);
const hex6 = rgbToHex({r,g,b,a:1});
const hex8 = rgbToHex({r,g,b,a});
const hasAlpha = a < 1;
const lum = luminance({r,g,b});
const contrastWhite = fmt2(contrastRatio(lum, 1));
const contrastBlack = fmt2(contrastRatio(lum, 0));
const wcagWhite = contrastWhite>=7?'AAA':contrastWhite>=4.5?'AA':contrastWhite>=3?'AA (groß)':'✗';
const wcagBlack = contrastBlack>=7?'AAA':contrastBlack>=4.5?'AA':contrastBlack>=3?'AA (groß)':'✗';
const textColor = lum > 0.179 ? '#000' : '#fff';
return [
{key:'hex6', label:'HEX', value: hex6, copy: hex6},
{key:'hex8', label:'HEX + Alpha', value: hex8, copy: hex8, hide: !hasAlpha},
{key:'rgb', label:'RGB', value: `rgb(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)})`, copy: `rgb(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)})`},
{key:'rgba', label:'RGBA', value: `rgba(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)}, ${fmt2(a)})`, copy: `rgba(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)}, ${fmt2(a)})`},
{key:'hsl', label:'HSL', value: `hsl(${hsl.h}, ${hsl.s}%, ${hsl.l}%)`, copy: `hsl(${hsl.h}, ${hsl.s}%, ${hsl.l}%)`},
{key:'hsla', label:'HSLA', value: `hsla(${hsl.h}, ${hsl.s}%, ${hsl.l}%, ${fmt2(a)})`, copy: `hsla(${hsl.h}, ${hsl.s}%, ${hsl.l}%, ${fmt2(a)})`},
{key:'hsv', label:'HSV / HSB', value: `hsv(${hsv.h}°, ${hsv.s}%, ${hsv.v}%)`, copy: `hsv(${hsv.h}, ${hsv.s}%, ${hsv.v}%)`},
{key:'cmyk', label:'CMYK', value: `cmyk(${cmyk.c}%, ${cmyk.m}%, ${cmyk.y}%, ${cmyk.k}%)`, copy: `cmyk(${cmyk.c}%, ${cmyk.m}%, ${cmyk.y}%, ${cmyk.k}%)`},
{key:'css', label:'CSS custom property', value: `--color: ${hex6};`, copy: `--color: ${hex6};`},
{key:'r', label:'R (0255)', value: String(Math.round(r)), copy: String(Math.round(r)), muted:true},
{key:'g', label:'G (0255)', value: String(Math.round(g)), copy: String(Math.round(g)), muted:true},
{key:'b', label:'B (0255)', value: String(Math.round(b)), copy: String(Math.round(b)), muted:true},
{key:'a', label:'Alpha (01)', value: fmt2(a), copy: String(fmt2(a)), muted:true},
{key:'lum', label:'Relative Luminanz', value: fmt2(lum), copy: String(fmt2(lum)), muted:true},
{key:'cw', label:'Kontrast zu Weiß', value: `${contrastWhite}:1 — WCAG: ${wcagWhite}`, copy: `${contrastWhite}:1`, muted:true},
{key:'cb', label:'Kontrast zu Schwarz', value: `${contrastBlack}:1 — WCAG: ${wcagBlack}`, copy: `${contrastBlack}:1`, muted:true},
].filter(f=>!f.hide);
})() : [];
const previewBg = color ? `rgb(${Math.round(color.r)},${Math.round(color.g)},${Math.round(color.b)})` : 'rgba(255,255,255,0.05)';
const previewText = color ? (luminance(color) > 0.179 ? '#000' : '#fff') : 'rgba(255,255,255,0.3)';
// Gradient bar stops for hue slider background
const hueBar = 'linear-gradient(to right,#f00,#ff0,#0f0,#0ff,#00f,#f0f,#f00)';
return (
<div>
{/* Eingabe-Zeile */}
<div style={{display:'flex', gap:10, alignItems:'center', marginBottom:16, flexWrap:'wrap'}}>
<div style={{flex:1, minWidth:160}}>
<div style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:10,marginBottom:4}}>
Farbe eingeben (HEX, RGB, HSL, Farbname)
</div>
<input style={{...inp, width:'100%',
borderColor: input && !color ? 'rgba(255,107,157,0.5)' : undefined}}
placeholder="#4ecdc4 oder rgb(78,205,196) oder hsl(177,55%,55%)"
value={input} onChange={e=>handleInput(e.target.value)}/>
</div>
{/* Color Picker */}
<div>
<div style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:10,marginBottom:4}}>
Color Picker
</div>
<div style={{position:'relative', width:42, height:36}}>
<div style={{width:42,height:36,borderRadius:7,background:pickerHex,
border:'2px solid rgba(255,255,255,0.15)',cursor:'pointer',overflow:'hidden'}}>
<input type="color" value={pickerHex}
onChange={e=>handlePicker(e.target.value)}
style={{opacity:0,position:'absolute',inset:0,width:'100%',height:'100%',cursor:'pointer',border:'none',padding:0}}/>
</div>
</div>
</div>
{/* Alpha Slider */}
{!!color && (
<div style={{flex:'0 0 140px'}}>
<div style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:10,marginBottom:4}}>
Alpha / Transparenz: {Math.round((color.a??1)*100)}%
</div>
<input type="range" min={0} max={1} step={0.01}
value={color?.a??1}
onChange={e=>applyColor({...color, a:parseFloat(e.target.value)})}
style={{width:'100%', accentColor:'#4ecdc4'}}/>
</div>
)}
{!!input && <button style={{...S.btn('#ff6b9d',false),fontSize:11,padding:'7px 14px'}}
onClick={()=>{setInput('');setColor(null);}}> Leeren</button>}
</div>
{/* Vorschau */}
{color && (
<div style={{borderRadius:10,overflow:'hidden',marginBottom:16,border:'1px solid rgba(255,255,255,0.08)'}}>
{/* Checkerboard + Farbe */}
<div style={{
height:80,
background:`linear-gradient(${previewBg},${previewBg}),
repeating-conic-gradient(rgba(255,255,255,0.1) 0% 25%, transparent 0% 50%) 0 0 / 16px 16px`,
display:'flex',alignItems:'center',justifyContent:'center'}}>
<div style={{
background: color.a < 1
? `rgba(${Math.round(color.r)},${Math.round(color.g)},${Math.round(color.b)},${color.a})`
: previewBg,
width:'100%', height:'100%', display:'flex',alignItems:'center',justifyContent:'center'}}>
<span style={{color:previewText, fontFamily:'monospace', fontSize:13, fontWeight:700,
textShadow: luminance(color)>0.5?'0 1px 2px rgba(0,0,0,0.2)':'0 1px 2px rgba(0,0,0,0.5)'}}>
{rgbToHex({...color,a:1}).toUpperCase()}
</span>
</div>
</div>
{/* Komplementärfarbe Vorschau */}
<div style={{display:'flex',height:32}}>
{[0,30,60,90,120,150,180,210,240,270,300,330].map(dh=>{
const hsl = rgbToHsl(color);
const c2 = hslToRgb({...hsl, h:(hsl.h+dh)%360, a:1});
return <div key={dh} style={{flex:1,background:`rgb(${c2.r},${c2.g},${c2.b})`}} title={`+${dh}°`}/>;
})}
</div>
<div style={{padding:'4px 10px',background:'rgba(0,0,0,0.2)',
color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:9}}>
Farbrad-Vorschau (Töne in 30°-Schritten)
</div>
</div>
)}
{/* Format-Ausgaben */}
{!!formats.length && (
<div style={{display:'flex',flexDirection:'column',gap:4}}>
{formats.map(f=>(
<div key={f.key} onClick={()=>copy(f.copy, f.key)}
style={{display:'flex',justifyContent:'space-between',alignItems:'center',
padding:'7px 10px',borderRadius:7,cursor:'pointer',userSelect:'none',
background:'rgba(255,255,255,0.03)',border:'1px solid rgba(255,255,255,0.05)',
transition:'background 0.15s'}}
onMouseEnter={e=>e.currentTarget.style.background='rgba(255,255,255,0.07)'}
onMouseLeave={e=>e.currentTarget.style.background='rgba(255,255,255,0.03)'}>
<span style={{color:'rgba(255,255,255,0.45)',fontFamily:'monospace',fontSize:11,minWidth:120}}>{f.label}</span>
<code style={{color: f.muted?'rgba(255,255,255,0.55)':'#4ecdc4',
fontFamily:"'Courier New',monospace",fontSize:12,fontWeight:700,
flex:1,textAlign:'right',marginLeft:8,wordBreak:'break-all'}}>
{f.value}
</code>
<span style={{color:copied===f.key?'#4ecdc4':'rgba(255,255,255,0.2)',
fontSize:14,marginLeft:10,flexShrink:0,transition:'color 0.2s'}}>
{copied===f.key?'✓':'⎘'}
</span>
</div>
))}
</div>
)}
{!color && !input && (
<div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:11,
padding:'20px 0',textAlign:'center'}}>
Farbe eingeben oder im Color Picker auswählen alle Formate werden berechnet
</div>
)}
{!!input && !color && (
<div style={{color:'#ff6b9d',fontFamily:'monospace',fontSize:11,padding:'8px 0'}}>
Format nicht erkannt · Beispiele: <code>#ff6b9d</code> · <code>rgb(255, 107, 157)</code> · <code>hsl(340, 100%, 71%)</code>
</div>
)}
</div>
);
}
const TOOL_DEFS = [ const TOOL_DEFS = [
{id:'cron', label:'⏱ Crontab', desc:'Crontab erklären und erstellen', ready:true, {id:'cron', label:'⏱ Crontab', desc:'Crontab erklären und erstellen', ready:true,
tabs:[{id:'explain',label:'Erklären'},{id:'build',label:'Erstellen'}], tabs:[{id:'explain',label:'Erklären'},{id:'build',label:'Erstellen'}],
@@ -2370,7 +2689,7 @@ const TOOL_DEFS = [
{id:'diff', label:'± Text Diff', desc:'Zwei Texte vergleichen', ready:true, component:TextDiff}, {id:'diff', label:'± Text Diff', desc:'Zwei Texte vergleichen', ready:true, component:TextDiff},
{id:'netzwerk', label:'🌐 Netzwerk', desc:'IP, Subnetz, Geschwindigkeit, CIDR', ready:true, component:NetzwerkRechner}, {id:'netzwerk', label:'🌐 Netzwerk', desc:'IP, Subnetz, Geschwindigkeit, CIDR', ready:true, component:NetzwerkRechner},
{id:'datetime', label:'📅 Datetime', desc:'Zeiten umrechnen · Geburtstag · Differenz', ready:true, component:DatetimeRechner}, {id:'datetime', label:'📅 Datetime', desc:'Zeiten umrechnen · Geburtstag · Differenz', ready:true, component:DatetimeRechner},
{id:'keygen', label:'🔐 Key-Gen', desc:'Sichere Keys & Secrets generieren', ready:true, component:KeyGen}, {id:'farben', label:'🎨 Farben', desc:'HEX, RGB, HSL, OKLCH umrechnen', ready:true, component:FarbRechner},
]; ];
export default function DevTools({ toast, mobile }) { export default function DevTools({ toast, mobile }) {