diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx index 9dbefd1..2f015ea 100644 --- a/src/components/ColorPicker.tsx +++ b/src/components/ColorPicker.tsx @@ -1,4 +1,5 @@ import { useState, useRef, useEffect, useMemo, useCallback } from 'react' +import { createPortal } from 'react-dom' interface ColorPickerProps { value: string @@ -8,7 +9,7 @@ interface ColorPickerProps { disabled?: boolean } -// Check if running in Telegram WebApp (native color picker causes crash) +// Check if running in Telegram WebApp const isTelegramWebApp = (): boolean => { return !!(window as unknown as { Telegram?: { WebApp?: unknown } }).Telegram?.WebApp } @@ -30,109 +31,330 @@ 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', // Blue - '#ef4444', // Red - '#22c55e', // Green - '#f59e0b', // Amber - '#8b5cf6', // Violet - '#ec4899', // Pink - '#06b6d4', // Cyan - '#14b8a6', // Teal - '#84cc16', // Lime - '#f97316', // Orange - '#6366f1', // Indigo - '#a855f7', // Purple - '#ffffff', // White - '#64748b', // Slate - '#1e293b', // Dark - '#000000', // Black + '#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 [rgb, setRgb] = useState(() => hexToRgb(value)) - const containerRef = useRef(null) + 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) - // Memoize Telegram check to avoid recalculating const isTelegram = useMemo(() => isTelegramWebApp(), []) + // Sync with external value useEffect(() => { setLocalValue(value) - setRgb(hexToRgb(value)) + const rgb = hexToRgb(value) + setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b)) }, [value]) - // Handle RGB slider change - const handleRgbChange = useCallback((channel: 'r' | 'g' | 'b', val: number) => { - const newRgb = { ...rgb, [channel]: val } - setRgb(newRgb) - const hex = rgbToHex(newRgb.r, newRgb.g, newRgb.b) - setLocalValue(hex) - onChange(hex) - }, [rgb, onChange]) + // Calculate picker position + const updatePosition = useCallback(() => { + if (!buttonRef.current) return - // Close on outside click - useEffect(() => { - function handleClickOutside(event: MouseEvent) { - if (containerRef.current && !containerRef.current.contains(event.target as Node)) { - setIsOpen(false) - } + 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 } - document.addEventListener('mousedown', handleClickOutside) - return () => document.removeEventListener('mousedown', handleClickOutside) + if (left < padding) left = padding + + setPickerPosition({ + top: openUp ? rect.top - pickerHeight - 8 : rect.bottom + 8, + left, + openUp + }) }, []) - const handleColorInputChange = (e: React.ChangeEvent) => { + // 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) - setRgb(hexToRgb(newColor)) + const rgb = hexToRgb(newColor) + setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b)) onChange(newColor) - } + }, [onChange]) - const handleHexInputChange = (e: React.ChangeEvent) => { + // Handle hex input + const handleHexInputChange = useCallback((e: React.ChangeEvent) => { let newValue = e.target.value - - // Add # if not present if (newValue && !newValue.startsWith('#')) { newValue = '#' + newValue } - - // Validate hex format if (newValue === '' || newValue.match(/^#[0-9A-Fa-f]{0,6}$/)) { setLocalValue(newValue) - - // Only trigger onChange and update RGB for valid complete hex if (newValue.match(/^#[0-9A-Fa-f]{6}$/)) { - setRgb(hexToRgb(newValue)) + const rgb = hexToRgb(newValue) + setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b)) onChange(newValue) } } - } + }, [onChange]) - const handlePresetClick = (color: string) => { + // Handle preset click + const handlePresetClick = useCallback((color: string) => { setLocalValue(color) - setRgb(hexToRgb(color)) + const rgb = hexToRgb(color) + setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b)) onChange(color) - setIsOpen(false) - } + 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 - min 44px for touch accessibility */} + {/* Color preview button */} )}
- {/* Dropdown with presets and RGB sliders */} - {isOpen && ( -
- {/* RGB Sliders - always visible on all devices */} -
-
RGB
-
- {/* Red */} -
- R - handleRgbChange('r', parseInt(e.target.value))} - className="flex-1 h-3 sm:h-2 rounded-full appearance-none cursor-pointer touch-pan-x" - style={{ - background: `linear-gradient(to right, rgb(0,${rgb.g},${rgb.b}), rgb(255,${rgb.g},${rgb.b}))`, - }} - /> - handleRgbChange('r', Math.min(255, Math.max(0, parseInt(e.target.value) || 0)))} - className="w-14 sm:w-12 text-xs text-center bg-dark-700 border border-dark-600 rounded px-1 py-1 text-dark-200" - /> -
- {/* Green */} -
- G - handleRgbChange('g', parseInt(e.target.value))} - className="flex-1 h-3 sm:h-2 rounded-full appearance-none cursor-pointer touch-pan-x" - style={{ - background: `linear-gradient(to right, rgb(${rgb.r},0,${rgb.b}), rgb(${rgb.r},255,${rgb.b}))`, - }} - /> - handleRgbChange('g', Math.min(255, Math.max(0, parseInt(e.target.value) || 0)))} - className="w-14 sm:w-12 text-xs text-center bg-dark-700 border border-dark-600 rounded px-1 py-1 text-dark-200" - /> -
- {/* Blue */} -
- B - handleRgbChange('b', parseInt(e.target.value))} - className="flex-1 h-3 sm:h-2 rounded-full appearance-none cursor-pointer touch-pan-x" - style={{ - background: `linear-gradient(to right, rgb(${rgb.r},${rgb.g},0), rgb(${rgb.r},${rgb.g},255))`, - }} - /> - handleRgbChange('b', Math.min(255, Math.max(0, parseInt(e.target.value) || 0)))} - className="w-14 sm:w-12 text-xs text-center bg-dark-700 border border-dark-600 rounded px-1 py-1 text-dark-200" - /> -
-
-
- -
Preset colors
-
- {PRESET_COLORS.map((preset) => ( -
-
- )} + {/* Render picker in portal */} + {typeof document !== 'undefined' && createPortal(pickerContent, document.body)}
) }