import { useState, useRef, useEffect, useMemo, useCallback } from 'react' import { createPortal } from 'react-dom' interface ColorPickerProps { value: string onChange: (color: string) => void label: string description?: string disabled?: boolean } // Check if running in Telegram WebApp const isTelegramWebApp = (): boolean => { return !!(window as unknown as { Telegram?: { WebApp?: unknown } }).Telegram?.WebApp } // Convert hex to RGB const hexToRgb = (hex: string): { r: number; g: number; b: number } => { const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex) return result ? { r: parseInt(result[1], 16), g: parseInt(result[2], 16), b: parseInt(result[3], 16), } : { r: 0, g: 0, b: 0 } } // Convert RGB to hex const rgbToHex = (r: number, g: number, b: number): string => { return '#' + [r, g, b].map(x => x.toString(16).padStart(2, '0')).join('') } // Convert RGB to HSL const rgbToHsl = (r: number, g: number, b: number): { h: number; s: number; l: number } => { r /= 255; g /= 255; b /= 255 const max = Math.max(r, g, b), min = Math.min(r, g, b) let h = 0, s = 0 const 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) } } // Convert HSL to RGB const hslToRgb = (h: number, s: number, l: number): { r: number; g: number; b: number } => { h /= 360; s /= 100; l /= 100 let r, g, b if (s === 0) { r = g = b = l } else { const hue2rgb = (p: number, q: number, t: number) => { if (t < 0) t += 1 if (t > 1) t -= 1 if (t < 1/6) return p + (q - p) * 6 * t if (t < 1/2) return q if (t < 2/3) return p + (q - p) * (2/3 - t) * 6 return p } const q = l < 0.5 ? l * (1 + s) : l + s - l * s const p = 2 * l - q r = hue2rgb(p, q, h + 1/3) g = hue2rgb(p, q, h) b = hue2rgb(p, q, h - 1/3) } return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255) } } const PRESET_COLORS = [ '#3b82f6', '#ef4444', '#22c55e', '#f59e0b', '#8b5cf6', '#ec4899', '#06b6d4', '#14b8a6', '#84cc16', '#f97316', '#6366f1', '#a855f7', ] export function ColorPicker({ value, onChange, label, description, disabled }: ColorPickerProps) { const [isOpen, setIsOpen] = useState(false) const [localValue, setLocalValue] = useState(value) const [hsl, setHsl] = useState(() => { const rgb = hexToRgb(value) return rgbToHsl(rgb.r, rgb.g, rgb.b) }) const [pickerPosition, setPickerPosition] = useState<{ top: number; left: number; openUp: boolean }>({ top: 0, left: 0, openUp: false }) const buttonRef = useRef(null) const pickerRef = useRef(null) const colorInputRef = useRef(null) const isTelegram = useMemo(() => isTelegramWebApp(), []) // Sync with external value useEffect(() => { setLocalValue(value) const rgb = hexToRgb(value) setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b)) }, [value]) // Calculate picker position const updatePosition = useCallback(() => { if (!buttonRef.current) return const rect = buttonRef.current.getBoundingClientRect() const pickerHeight = 320 const pickerWidth = 280 const padding = 12 // Check if there's space below const spaceBelow = window.innerHeight - rect.bottom const spaceAbove = rect.top const openUp = spaceBelow < pickerHeight + padding && spaceAbove > spaceBelow // Calculate left position (ensure it stays in viewport) let left = rect.left if (left + pickerWidth > window.innerWidth - padding) { left = window.innerWidth - pickerWidth - padding } if (left < padding) left = padding setPickerPosition({ top: openUp ? rect.top - pickerHeight - 8 : rect.bottom + 8, left, openUp }) }, []) // Open picker const handleOpen = useCallback(() => { if (disabled) return updatePosition() setIsOpen(true) }, [disabled, updatePosition]) // Close picker const handleClose = useCallback(() => { setIsOpen(false) }, []) // Handle click outside useEffect(() => { if (!isOpen) return const handleClickOutside = (e: MouseEvent) => { if ( pickerRef.current && !pickerRef.current.contains(e.target as Node) && buttonRef.current && !buttonRef.current.contains(e.target as Node) ) { handleClose() } } const handleScroll = () => handleClose() const handleResize = () => updatePosition() document.addEventListener('mousedown', handleClickOutside) document.addEventListener('touchstart', handleClickOutside as EventListener) window.addEventListener('scroll', handleScroll, true) window.addEventListener('resize', handleResize) return () => { document.removeEventListener('mousedown', handleClickOutside) document.removeEventListener('touchstart', handleClickOutside as EventListener) window.removeEventListener('scroll', handleScroll, true) window.removeEventListener('resize', handleResize) } }, [isOpen, handleClose, updatePosition]) // Update color from HSL const updateColorFromHsl = useCallback((newHsl: { h: number; s: number; l: number }) => { const rgb = hslToRgb(newHsl.h, newHsl.s, newHsl.l) const hex = rgbToHex(rgb.r, rgb.g, rgb.b) setHsl(newHsl) setLocalValue(hex) onChange(hex) }, [onChange]) // Handle hue change const handleHueChange = useCallback((e: React.ChangeEvent) => { updateColorFromHsl({ ...hsl, h: parseInt(e.target.value) }) }, [hsl, updateColorFromHsl]) // Handle saturation change const handleSaturationChange = useCallback((e: React.ChangeEvent) => { updateColorFromHsl({ ...hsl, s: parseInt(e.target.value) }) }, [hsl, updateColorFromHsl]) // Handle lightness change const handleLightnessChange = useCallback((e: React.ChangeEvent) => { updateColorFromHsl({ ...hsl, l: parseInt(e.target.value) }) }, [hsl, updateColorFromHsl]) // Handle native color input const handleColorInputChange = useCallback((e: React.ChangeEvent) => { const newColor = e.target.value setLocalValue(newColor) const rgb = hexToRgb(newColor) setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b)) onChange(newColor) }, [onChange]) // Handle hex input const handleHexInputChange = useCallback((e: React.ChangeEvent) => { let newValue = e.target.value if (newValue && !newValue.startsWith('#')) { newValue = '#' + newValue } if (newValue === '' || newValue.match(/^#[0-9A-Fa-f]{0,6}$/)) { setLocalValue(newValue) if (newValue.match(/^#[0-9A-Fa-f]{6}$/)) { const rgb = hexToRgb(newValue) setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b)) onChange(newValue) } } }, [onChange]) // Handle preset click const handlePresetClick = useCallback((color: string) => { setLocalValue(color) const rgb = hexToRgb(color) setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b)) onChange(color) handleClose() }, [onChange, handleClose]) // Picker content const pickerContent = isOpen ? (
e.stopPropagation()} > {/* Color preview header */}
{/* Controls */}
{/* Hue slider */}
Hue {hsl.h}°
{/* Saturation slider */}
Saturation {hsl.s}%
{/* Lightness slider */}
Lightness {hsl.l}%
{/* Hex input */}
HEX
{/* Presets */}
Presets
{PRESET_COLORS.map((preset) => (
) : null return (
{description &&

{description}

}
{/* Color preview button */} )}
{/* Render picker in portal */} {typeof document !== 'undefined' && createPortal(pickerContent, document.body)}
) }