import React, { useState, useMemo, useRef, useCallback, useEffect } from 'react'; import { useStyles } from '../../core/hooks/useStyles'; import { useTheme } from '../../core/theme/ThemeProvider'; import { Slider } from '../Slider/Slider'; import { Stack } from '../Stack/Stack'; import { Text } from '../Text/Text'; import { parseColor, rgbToHsl, hslToRgb, rgbToHex, rgbToHsv, hsvToRgb } from '../../core/color/utils'; import { SegmentedControl } from '../SegmentedControl/SegmentedControl'; import { useDraggable } from '../../core/hooks/useInteractions'; import { Checkbox } from '../Checkbox/Checkbox'; import { Icon } from '../Icon/Icon'; import { TimesIcon, SquareIcon, TriangleIcon } from '../../icons'; import { IconButton } from '../IconButton/IconButton'; import { Input } from '../Input/Input'; declare global { namespace JSX { interface IntrinsicElements { conicGradient: React.SVGProps & { from?: string; at?: string; }; } } } type ColorModel = 'HEX' | 'HSL' | 'HSV' | 'RGB'; type HarmonyRule = 'complementary' | 'analogous' | 'triadic' | 'split'; type PickerShape = 'square' | 'triangle'; const EyedropperIcon: React.FC> = (props) => ; const GamutWarningIcon: React.FC> = (props) => ; interface ColorPickerProps { value: string; onChange: (color: string) => void; isOpen: boolean; onClose: () => void; className?: string; } export const ColorPicker: React.FC = ({ value, onChange, isOpen, onClose, className = '' }) => { const { theme } = useTheme(); const createStyle = useStyles('color-picker-pro'); const pickerRef = useRef(null); const handleRef = useRef(null); useDraggable(pickerRef, handleRef, { x: window.innerWidth / 2 - 140, y: 100 }); const [initialColor] = useState(value); const [colorModel, setColorModel] = useState('HEX'); const [harmony, setHarmony] = useState('complementary'); const [pickerShape, setPickerShape] = useState('square'); const hsv = useMemo(() => { try { const [r, g, b] = parseColor(value); return rgbToHsv(r, g, b); } catch { return { h: 0, s: 0, v: 0 }; } }, [value]); const hsl = useMemo(() => { try { const [r, g, b] = parseColor(value); return rgbToHsl(r, g, b); } catch { return { h: 0, s: 0, l: 0 }; } }, [value]); const handleHsvChange = (newHsv: { h: number, s: number, v: number }) => { const { r, g, b } = hsvToRgb(newHsv.h, newHsv.s, newHsv.v); onChange(rgbToHex(r, g, b)); }; const handleHslChange = (newHsl: { h: number, s: number, l: number }) => { const { r, g, b } = hslToRgb(newHsl.h, newHsl.s, newHsl.l); onChange(rgbToHex(r, g, b)); }; const containerClass = createStyle({ position: 'fixed', padding: '0', backgroundColor: '#3a3a3a', borderRadius: '8px', border: `1px solid #1e1e1e`, width: 'max-content', boxShadow: '0 8px 32px rgba(0, 0, 0, 0.5)', zIndex: 1000, userSelect: 'none', display: isOpen ? 'block' : 'none', }); const headerClass = createStyle({ padding: '6px 12px', backgroundColor: '#4a4a4a', borderBottom: '1px solid #1e1e1e', cursor: 'move', display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderTopLeftRadius: '8px', borderTopRightRadius: '8px', }); if (!isOpen) return null; return (
Color Picker
setPickerShape(s => s === 'square' ? 'triangle' : 'square')} aria-label="Toggle picker shape" variant="secondary" style={{ padding: '4px', height: 'auto', width: '24px' }} />
); }; // --- Sub-components for clarity --- const HarmonyHandle: React.FC<{ hue: number; radius: number; center: number; onClick: (hue: number) => void }> = ({ hue, radius, center, onClick }) => { const [isHovered, setIsHovered] = useState(false); const angle = hue * Math.PI / 180; const pos = { x: center + Math.cos(angle) * radius, y: center + Math.sin(angle) * radius, }; return onClick(hue)} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} style={{ cursor: 'pointer', transition: 'r 0.2s ease' }} />; } const VisualPicker: React.FC<{ hsv: {h: number, s: number, v: number}; hsl: {h: number, s: number, l: number}; onHsvChange: (hsv: any) => void, onHslChange: (hsl: any) => void, harmony: HarmonyRule, pickerShape: PickerShape }> = ({ hsv, hsl, onHsvChange, onHslChange, harmony, pickerShape }) => { const wheelSize = 200; const center = wheelSize / 2; const hueRadius = center - 10; const wheelRef = useRef(null); const handleHueChange = (newHue: number) => { if (pickerShape === 'square') { onHsvChange({ ...hsv, h: newHue }); } else { onHslChange({ ...hsl, h: newHue }); } }; const handleHueInteraction = (e: React.MouseEvent, isDragging: boolean) => { if (!wheelRef.current || (!e.buttons && isDragging)) return; const rect = wheelRef.current.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; const dx = x - center; const dy = y - center; const angle = Math.atan2(dy, dx) * 180 / Math.PI; handleHueChange((angle + 360) % 360); }; const hueAngle = (pickerShape === 'square' ? hsv.h : hsl.h) * Math.PI / 180; const hueHandlePos = { x: center + Math.cos(hueAngle) * hueRadius, y: center + Math.sin(hueAngle) * hueRadius, }; const harmonyHues = useMemo(() => { const currentHue = pickerShape === 'square' ? hsv.h : hsl.h; let hues: number[] = []; switch (harmony) { case 'complementary': hues = [(currentHue + 180) % 360]; break; case 'analogous': hues = [(currentHue + 330) % 360, (currentHue + 30) % 360]; break; case 'triadic': hues = [(currentHue + 120) % 360, (currentHue + 240) % 360]; break; case 'split': hues = [(currentHue + 150) % 360, (currentHue + 210) % 360]; break; } return hues; }, [hsv.h, hsl.h, harmony, pickerShape]); return ( handleHueInteraction(e, false)} onMouseMove={(e) => handleHueInteraction(e, true)} /> {pickerShape === 'square' ? : } {harmonyHues.map((hue, i) => ( ))} ); }; const SquarePicker: React.FC<{ hsv: any; onHsvChange: (hsv: any) => void }> = ({ hsv, onHsvChange }) => { const size = 130; const offset = (200 - size) / 2; const containerRef = useRef(null); const handleInteraction = (e: React.MouseEvent, isDragging: boolean) => { if (!containerRef.current || (!e.buttons && isDragging)) return; const rect = containerRef.current.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; const s = Math.max(0, Math.min(100, x / size * 100)); const v = Math.max(0, Math.min(100, (1 - y / size) * 100)); onHsvChange({ ...hsv, s, v }); }; const handlePos = { x: offset + hsv.s / 100 * size, y: offset + (1 - hsv.v / 100) * size }; return ( handleInteraction(e, false)} onMouseMove={(e) => handleInteraction(e, true)}> ); }; const TrianglePicker: React.FC<{ hsl: any; onHslChange: (hsl: any) => void }> = ({ hsl, onHslChange }) => { const containerRef = useRef(null); const size = 150; const center = 100; const h = (Math.sqrt(3)/2) * size; const v_white = { x: center, y: center - h/2 }; const v_black = { x: center - size/2, y: center + h/2 }; const v_hue = { x: center + size/2, y: center + h/2 }; const points = `${v_white.x},${v_white.y} ${v_black.x},${v_black.y} ${v_hue.x},${v_hue.y}`; const handleInteraction = (e: React.MouseEvent, isDragging: boolean) => { if (!containerRef.current || (!e.buttons && isDragging)) return; const rect = containerRef.current.getBoundingClientRect(); const px = e.clientX - rect.left; const py = e.clientY - rect.top; const denominator = ((v_black.y - v_hue.y) * (v_white.x - v_hue.x) + (v_hue.x - v_black.x) * (v_white.y - v_hue.y)); const w_white = ((v_black.y - v_hue.y) * (px - v_hue.x) + (v_hue.x - v_black.x) * (py - v_hue.y)) / denominator; const w_black = ((v_hue.y - v_white.y) * (px - v_hue.x) + (v_white.x - v_hue.x) * (py - v_hue.y)) / denominator; const w_hue = 1 - w_white - w_black; if (w_white >= 0 && w_black >= 0 && w_hue >= 0) { const l = w_white * 1 + w_black * 0 + w_hue * 0.5; const s = w_hue; onHslChange({ h: hsl.h, s: s * 100, l: l * 100 }); } }; // Calculate handle position from HSL const s_norm = hsl.s / 100; const l_norm = hsl.l / 100; const w_hue = s_norm; const w_white = l_norm - 0.5 * w_hue; const w_black = 1 - w_white - w_hue; const handlePos = { x: w_white * v_white.x + w_black * v_black.x + w_hue * v_hue.x, y: w_white * v_white.y + w_black * v_black.y + w_hue * v_hue.y, }; return ( handleInteraction(e, false)} onMouseMove={(e) => handleInteraction(e, true)}> ); }; const Swatches: React.FC<{initialColor: string; currentColor: string}> = ({initialColor, currentColor}) => { return (
New
Current
); }; const HarmonySelector: React.FC<{harmony: HarmonyRule, setHarmony: (h: HarmonyRule) => void}> = ({harmony, setHarmony}) => { return ( setHarmony(v as HarmonyRule)} options={[ {label: 'Comp', value: 'complementary'}, {label: 'Analog', value: 'analogous'}, {label: 'Triad', value: 'triadic'}, {label: 'Split', value: 'split'}, ]} /> ); } const ColorInputs: React.FC<{value: string, onChange: (hex: string) => void, colorModel: ColorModel, setColorModel: (m: ColorModel) => void}> = ({value, onChange, colorModel, setColorModel}) => { const [localHex, setLocalHex] = useState(value); useEffect(() => { setLocalHex(value); }, [value]); const { r, g, b } = useMemo(() => { const [r,g,b] = parseColor(value); return {r:Math.round(r), g:Math.round(g), b:Math.round(b)}; }, [value]); const { h: hsl_h, s: hsl_s, l: hsl_l } = useMemo(() => rgbToHsl(r, g, b), [r,g,b]); const hsv = useMemo(() => rgbToHsv(r, g, b), [r,g,b]); const handleLocalHexChange = (e: React.ChangeEvent) => { const newHex = e.target.value; setLocalHex(newHex); if (newHex.match(/^#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/)) { onChange(newHex); } }; const handleHslChange = (newHsl: {h?: number, s?: number, l?: number}) => { const finalHsl = {h: hsl_h, s: hsl_s, l: hsl_l, ...newHsl}; const newRgb = hslToRgb(finalHsl.h, finalHsl.s, finalHsl.l); onChange(rgbToHex(newRgb.r, newRgb.g, newRgb.b)); }; const handleHsvChange = (newHsv: {h?: number, s?: number, v?: number}) => { const finalHsv = {...hsv, ...newHsv}; const newRgb = hsvToRgb(finalHsv.h, finalHsv.s, finalHsv.v); onChange(rgbToHex(newRgb.r, newRgb.g, newRgb.b)); }; return ( setColorModel(v as ColorModel)} options={[ {label: 'HEX', value: 'HEX'}, {label: 'HSL', value: 'HSL'}, {label: 'HSV', value: 'HSV'}, {label: 'RGB', value: 'RGB'} ]}/> {colorModel === 'HEX' && ( )} {colorModel === 'RGB' && ( onChange(rgbToHex(Number(e.target.value), g, b))} /> onChange(rgbToHex(r, Number(e.target.value), b))} /> onChange(rgbToHex(r, g, Number(e.target.value)))} /> )} {colorModel === 'HSL' && ( handleHslChange({h: Number(e.target.value)})} /> handleHslChange({s: Number(e.target.value)})} /> handleHslChange({l: Number(e.target.value)})} /> )} {colorModel === 'HSV' && ( handleHsvChange({h: Number(e.target.value)})}/> handleHsvChange({s: Number(e.target.value)})}/> handleHsvChange({v: Number(e.target.value)})}/> )} ) }; export default ColorPicker;