1+ import React , { useState , useRef , useEffect } from 'react' ;
2+ import { motion , AnimatePresence } from 'framer-motion' ;
3+ import { EyedropperIcon , XIcon } from '@phosphor-icons/react' ;
4+
5+ const PRESET_COLORS = [
6+ { name : 'Pure Void' , hex : '#050505' } ,
7+ { name : 'Paper White' , hex : '#F5F5F5' } ,
8+ { name : 'Emerald Flux' , hex : '#10b981' } ,
9+ { name : 'Salmon Signal' , hex : '#FA8072' } ,
10+ { name : 'Cyber Cyan' , hex : '#00FFFF' } ,
11+ { name : 'Neon Violet' , hex : '#a855f7' } ,
12+ { name : 'Amber Warning' , hex : '#f59e0b' } ,
13+ { name : 'Royal Gold' , hex : '#D4AF37' } ,
14+ { name : 'Crimson Data' , hex : '#ef4444' } ,
15+ { name : 'Cobalt Core' , hex : '#3b82f6' } ,
16+ { name : 'Deep Slate' , hex : '#1e293b' } ,
17+ { name : 'Ghost Gray' , hex : '#94a3b8' } ,
18+ ] ;
19+
20+ const hexToHsv = ( hex ) => {
21+ let r = 0 , g = 0 , b = 0 ;
22+ if ( hex . length === 4 ) {
23+ r = parseInt ( hex [ 1 ] + hex [ 1 ] , 16 ) ;
24+ g = parseInt ( hex [ 2 ] + hex [ 2 ] , 16 ) ;
25+ b = parseInt ( hex [ 3 ] + hex [ 3 ] , 16 ) ;
26+ } else if ( hex . length === 7 ) {
27+ r = parseInt ( hex . substring ( 1 , 3 ) , 16 ) ;
28+ g = parseInt ( hex . substring ( 3 , 5 ) , 16 ) ;
29+ b = parseInt ( hex . substring ( 5 , 7 ) , 16 ) ;
30+ }
31+
32+ r /= 255 ; g /= 255 ; b /= 255 ;
33+ const max = Math . max ( r , g , b ) , min = Math . min ( r , g , b ) ;
34+ let h , s , v = max ;
35+ const d = max - min ;
36+ s = max === 0 ? 0 : d / max ;
37+
38+ if ( max === min ) {
39+ h = 0 ;
40+ } else {
41+ switch ( max ) {
42+ case r : h = ( g - b ) / d + ( g < b ? 6 : 0 ) ; break ;
43+ case g : h = ( b - r ) / d + 2 ; break ;
44+ case b : h = ( r - g ) / d + 4 ; break ;
45+ default : break ;
46+ }
47+ h /= 6 ;
48+ }
49+ return { h, s, v } ;
50+ } ;
51+
52+ const hsvToHex = ( h , s , v ) => {
53+ let r , g , b ;
54+ const i = Math . floor ( h * 6 ) ;
55+ const f = h * 6 - i ;
56+ const p = v * ( 1 - s ) ;
57+ const q = v * ( 1 - f * s ) ;
58+ const t = v * ( 1 - ( 1 - f ) * s ) ;
59+ switch ( i % 6 ) {
60+ case 0 : r = v ; g = t ; b = p ; break ;
61+ case 1 : r = q ; g = v ; b = p ; break ;
62+ case 2 : r = p ; g = v ; b = t ; break ;
63+ case 3 : r = p ; g = q ; b = v ; break ;
64+ case 4 : r = t ; g = p ; b = v ; break ;
65+ case 5 : r = v ; g = p ; b = q ; break ;
66+ default : break ;
67+ }
68+ const toHex = ( x ) => Math . round ( x * 255 ) . toString ( 16 ) . padStart ( 2 , '0' ) ;
69+ return `#${ toHex ( r ) } ${ toHex ( g ) } ${ toHex ( b ) } ` ;
70+ } ;
71+
72+ const CustomColorPicker = ( { value, onChange, label } ) => {
73+ const [ isOpen , setIsOpen ] = useState ( false ) ;
74+ const [ hsv , setHsv ] = useState ( { h : 0 , s : 0 , v : 0 } ) ;
75+ const [ inputValue , setInputValue ] = useState ( value ) ;
76+ const containerRef = useRef ( null ) ;
77+ const satRef = useRef ( null ) ;
78+ const hueRef = useRef ( null ) ;
79+
80+ useEffect ( ( ) => {
81+ try {
82+ setHsv ( hexToHsv ( value ) ) ;
83+ setInputValue ( value ) ;
84+ } catch ( e ) {
85+ setHsv ( { h : 0 , s : 0 , v : 0 } ) ;
86+ }
87+ } , [ value ] ) ;
88+
89+ useEffect ( ( ) => {
90+ const handleClickOutside = ( event ) => {
91+ if ( containerRef . current && ! containerRef . current . contains ( event . target ) ) {
92+ setIsOpen ( false ) ;
93+ }
94+ } ;
95+ document . addEventListener ( 'mousedown' , handleClickOutside ) ;
96+ return ( ) => document . removeEventListener ( 'mousedown' , handleClickOutside ) ;
97+ } , [ ] ) ;
98+
99+ const handleSatMouseDown = ( e ) => {
100+ const handleMouseMove = ( moveEvent ) => {
101+ const rect = satRef . current . getBoundingClientRect ( ) ;
102+ let s = ( moveEvent . clientX - rect . left ) / rect . width ;
103+ let v = 1 - ( moveEvent . clientY - rect . top ) / rect . height ;
104+ s = Math . max ( 0 , Math . min ( 1 , s ) ) ;
105+ v = Math . max ( 0 , Math . min ( 1 , v ) ) ;
106+ onChange ( hsvToHex ( hsv . h , s , v ) ) ;
107+ } ;
108+
109+ const handleMouseUp = ( ) => {
110+ window . removeEventListener ( 'mousemove' , handleMouseMove ) ;
111+ window . removeEventListener ( 'mouseup' , handleMouseUp ) ;
112+ } ;
113+
114+ window . addEventListener ( 'mousemove' , handleMouseMove ) ;
115+ window . addEventListener ( 'mouseup' , handleMouseUp ) ;
116+ handleMouseMove ( e ) ;
117+ } ;
118+
119+ const handleHueMouseDown = ( e ) => {
120+ const handleMouseMove = ( moveEvent ) => {
121+ const rect = hueRef . current . getBoundingClientRect ( ) ;
122+ let h = ( moveEvent . clientX - rect . left ) / rect . width ;
123+ h = Math . max ( 0 , Math . min ( 1 , h ) ) ;
124+ onChange ( hsvToHex ( h , hsv . s , hsv . v ) ) ;
125+ } ;
126+
127+ const handleMouseUp = ( ) => {
128+ window . removeEventListener ( 'mousemove' , handleMouseMove ) ;
129+ window . removeEventListener ( 'mouseup' , handleMouseUp ) ;
130+ } ;
131+
132+ window . addEventListener ( 'mousemove' , handleMouseMove ) ;
133+ window . addEventListener ( 'mouseup' , handleMouseUp ) ;
134+ handleMouseMove ( e ) ;
135+ } ;
136+
137+ return (
138+ < div className = "space-y-2" ref = { containerRef } >
139+ { label && (
140+ < label className = "block font-mono text-[9px] uppercase text-gray-600 tracking-widest" >
141+ { label }
142+ </ label >
143+ ) }
144+
145+ < div className = "relative" >
146+ < button
147+ onClick = { ( ) => setIsOpen ( ! isOpen ) }
148+ className = "w-full flex items-center gap-3 p-2 bg-black/40 border border-white/10 hover:border-white/30 transition-all rounded-sm group"
149+ >
150+ < div
151+ className = "w-6 h-6 rounded-sm border border-white/20 shadow-inner shrink-0"
152+ style = { { backgroundColor : value } }
153+ />
154+ < span className = "font-mono text-[10px] uppercase tracking-widest text-gray-400 group-hover:text-white transition-colors" >
155+ { value . toUpperCase ( ) }
156+ </ span >
157+ < EyedropperIcon className = "ml-auto text-gray-600 group-hover:text-emerald-500 transition-colors" size = { 14 } />
158+ </ button >
159+
160+ < AnimatePresence >
161+ { isOpen && (
162+ < motion . div
163+ initial = { { opacity : 0 , y : 5 , scale : 0.95 } }
164+ animate = { { opacity : 1 , y : 0 , scale : 1 } }
165+ exit = { { opacity : 0 , y : 5 , scale : 0.95 } }
166+ className = "absolute z-50 top-full mt-2 w-64 bg-[#0a0a0a] border border-white/20 p-4 shadow-[0_20px_50px_rgba(0,0,0,0.8)] rounded-sm"
167+ >
168+ < div className = "flex items-center justify-between mb-4 pb-2 border-b border-white/5" >
169+ < span className = "font-mono text-[9px] font-bold text-emerald-500 uppercase tracking-widest" >
170+ Color_Matrix
171+ </ span >
172+ < button onClick = { ( ) => setIsOpen ( false ) } className = "text-gray-600 hover:text-white" >
173+ < XIcon size = { 12 } />
174+ </ button >
175+ </ div >
176+
177+ { /* Saturation / Value Picker */ }
178+ < div
179+ ref = { satRef }
180+ onMouseDown = { handleSatMouseDown }
181+ className = "relative w-full aspect-video mb-4 cursor-crosshair rounded-sm overflow-hidden border border-white/10"
182+ style = { { backgroundColor : hsvToHex ( hsv . h , 1 , 1 ) } }
183+ >
184+ < div className = "absolute inset-0 bg-gradient-to-r from-white to-transparent" />
185+ < div className = "absolute inset-0 bg-gradient-to-t from-black to-transparent" />
186+ < div
187+ className = "absolute w-3 h-3 border-2 border-white rounded-full -translate-x-1/2 -translate-y-1/2 shadow-[0_0_5px_rgba(0,0,0,0.5)] pointer-events-none"
188+ style = { { left : `${ hsv . s * 100 } %` , top : `${ ( 1 - hsv . v ) * 100 } %` } }
189+ />
190+ </ div >
191+
192+ { /* Hue Slider */ }
193+ < div
194+ ref = { hueRef }
195+ onMouseDown = { handleHueMouseDown }
196+ className = "relative w-full h-4 mb-6 cursor-pointer rounded-sm border border-white/10"
197+ style = { { background : 'linear-gradient(to right, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%)' } }
198+ >
199+ < div
200+ className = "absolute top-0 bottom-0 w-1.5 bg-white border border-black/40 -translate-x-1/2 shadow-md pointer-events-none"
201+ style = { { left : `${ hsv . h * 100 } %` } }
202+ />
203+ </ div >
204+
205+ < div className = "grid grid-cols-6 gap-1.5 mb-4" >
206+ { PRESET_COLORS . map ( ( c ) => (
207+ < button
208+ key = { c . hex }
209+ onClick = { ( ) => {
210+ onChange ( c . hex ) ;
211+ } }
212+ className = { `w-full aspect-square rounded-sm border transition-all ${
213+ value . toLowerCase ( ) === c . hex . toLowerCase ( )
214+ ? 'border-emerald-500 scale-110 z-10'
215+ : 'border-white/10 hover:border-white/40'
216+ } `}
217+ style = { { backgroundColor : c . hex } }
218+ title = { c . name }
219+ />
220+ ) ) }
221+ </ div >
222+
223+ < div className = "flex items-center gap-2" >
224+ < div className = "relative w-full bg-black/40 border border-white/10 rounded-sm overflow-hidden flex items-center px-3 h-8" >
225+ < input
226+ type = "text"
227+ value = { inputValue . toUpperCase ( ) }
228+ onChange = { ( e ) => {
229+ const val = e . target . value ;
230+ setInputValue ( val ) ;
231+
232+ let hex = val ;
233+ if ( ! hex . startsWith ( '#' ) && hex . length > 0 ) hex = '#' + hex ;
234+
235+ // Validate 3, 4, 6, or 8 digit hex
236+ if ( / ^ # ( [ 0 - 9 A - F ] { 3 } ) { 1 , 2 } $ / i. test ( hex ) || / ^ # ( [ 0 - 9 A - F ] { 4 } ) { 1 , 2 } $ / i. test ( hex ) ) {
237+ onChange ( hex ) ;
238+ }
239+ } }
240+ onBlur = { ( ) => setInputValue ( value ) }
241+ className = "w-full bg-transparent font-mono text-[10px] text-white outline-none uppercase tracking-widest"
242+ />
243+ < div className = "w-4 h-4 rounded-sm border border-white/10 shrink-0" style = { { backgroundColor : value } } />
244+ </ div >
245+ </ div >
246+ </ motion . div >
247+ ) }
248+ </ AnimatePresence >
249+ </ div >
250+ </ div >
251+ ) ;
252+ } ;
253+
254+ export default CustomColorPicker ;
0 commit comments