diff --git a/components/ui/color-picker.tsx b/components/ui/color-picker.tsx index f4621e5..a5d3181 100644 --- a/components/ui/color-picker.tsx +++ b/components/ui/color-picker.tsx @@ -2,6 +2,8 @@ import * as React from 'react'; import { Popover, PopoverContent, PopoverTrigger } from './popover'; +import { Input } from './input'; +import { z } from 'zod'; import { cn } from '@/lib/utils'; interface ColorPickerProps { @@ -10,6 +12,15 @@ interface ColorPickerProps { className?: string; } +interface HsvaColor { + h: number; + s: number; + v: number; + alpha: number; +} + +const defaultColor = { r: 125, g: 212, b: 173, alpha: 1 }; + // Convert HSV to RGB function hsvToRgb(h: number, s: number, v: number): [number, number, number] { const c = v * s; @@ -87,42 +98,69 @@ function rgbToHex(r: number, g: number, b: number): string { return '#' + [r, g, b].map(x => x.toString(16).padStart(2, '0')).join(''); } -export function ColorPicker({ color, onChange, className }: ColorPickerProps) { - const [isOpen, setIsOpen] = React.useState(false); +// Parse the comma-separated rgba(...) format emitted by this picker. +function rgbaToColor(color: string): { r: number; g: number; b: number; alpha: number } | null { + // Capture integer red, green, blue channels and a decimal alpha channel. + const result = /^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d*\.?\d+)\s*\)$/i.exec(color); + if (!result) return null; + + const r = Number(result[1]); + const g = Number(result[2]); + const b = Number(result[3]); + const alpha = Number(result[4]); + if (r > 255 || g > 255 || b > 255 || alpha > 1) return null; - // Parse current color to HSV - const rgb = hexToRgb(color) || [125, 212, 173]; // default to #7dd4ad - const [hue, saturation, value] = rgbToHsv(rgb[0], rgb[1], rgb[2]); + return { r, g, b, alpha }; +} + +function colorToHsva(color: string): HsvaColor { + const rgb = hexToRgb(color); + const parsed = rgb + ? { r: rgb[0], g: rgb[1], b: rgb[2], alpha: 1 } + : rgbaToColor(color) || defaultColor; + const [h, s, v] = rgbToHsv(parsed.r, parsed.g, parsed.b); + + return { h, s, v, alpha: parsed.alpha }; +} + +const hexColorSchema = z.string() + .trim() + .regex(/^#?[0-9a-fA-F]{6}$/, 'Invalid hex color') + .transform((value) => value.startsWith('#') ? value : `#${value}`); - const [h, setH] = React.useState(hue); - const [s, setS] = React.useState(saturation); - const [v, setV] = React.useState(value); - const [alpha, setAlpha] = React.useState(1); +export function ColorPicker({ color, onChange, className }: ColorPickerProps) { + const [isOpen, setIsOpen] = React.useState(false); + const { h, s, v, alpha } = colorToHsva(color); + const currentRgb = hsvToRgb(h, s, v); + const currentHex = rgbToHex(currentRgb[0], currentRgb[1], currentRgb[2]); + const hueColor = rgbToHex(...hsvToRgb(h, 1, 1)); const saturationRef = React.useRef(null); const hueRef = React.useRef(null); const alphaRef = React.useRef(null); - // Update internal state when color prop changes + const [hexInput, setHexInput] = React.useState(currentHex); + const [hexError, setHexError] = React.useState(false); + const alphaValue = alpha === 1 ? '1' : alpha.toFixed(2); + const [alphaInput, setAlphaInput] = React.useState(alphaValue); + React.useEffect(() => { - const rgb = hexToRgb(color); - if (rgb) { - const [newH, newS, newV] = rgbToHsv(rgb[0], rgb[1], rgb[2]); - setH(newH); - setS(newS); - setV(newV); - } - }, [color]); - - // Update color when HSV changes - const updateColor = React.useCallback((newH: number, newS: number, newV: number, newAlpha: number) => { - const [r, g, b] = hsvToRgb(newH, newS, newV); - if (newAlpha < 1) { - onChange(`rgba(${r}, ${g}, ${b}, ${newAlpha.toFixed(2)})`); - } else { - onChange(rgbToHex(r, g, b)); + setHexInput(currentHex); + setHexError(false); + }, [currentHex]); + + React.useEffect(() => { + setAlphaInput(alphaValue); + }, [alphaValue]); + + const updateColor = (newColor: HsvaColor) => { + const [r, g, b] = hsvToRgb(newColor.h, newColor.s, newColor.v); + if (newColor.alpha < 1) { + onChange(`rgba(${r}, ${g}, ${b}, ${newColor.alpha.toFixed(2)})`); + return; } - }, [onChange]); + onChange(rgbToHex(r, g, b)); + }; // Handle saturation/brightness picker drag const handleSaturationMouseDown = (e: React.MouseEvent) => { @@ -132,9 +170,7 @@ export function ColorPicker({ color, onChange, className }: ColorPickerProps) { const rect = saturationRef.current.getBoundingClientRect(); const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)); - setS(x); - setV(1 - y); - updateColor(h, x, 1 - y, alpha); + updateColor({ h, s: x, v: 1 - y, alpha }); }; const handleUp = () => { @@ -154,9 +190,7 @@ export function ColorPicker({ color, onChange, className }: ColorPickerProps) { if (!hueRef.current) return; const rect = hueRef.current.getBoundingClientRect(); const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); - const newH = x * 360; - setH(newH); - updateColor(newH, s, v, alpha); + updateColor({ h: x * 360, s, v, alpha }); }; const handleUp = () => { @@ -176,8 +210,7 @@ export function ColorPicker({ color, onChange, className }: ColorPickerProps) { if (!alphaRef.current) return; const rect = alphaRef.current.getBoundingClientRect(); const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); - setAlpha(x); - updateColor(h, s, v, x); + updateColor({ h, s, v, alpha: Math.round(x * 100) / 100 }); }; const handleUp = () => { @@ -190,9 +223,55 @@ export function ColorPicker({ color, onChange, className }: ColorPickerProps) { window.addEventListener('mouseup', handleUp); }; - const currentRgb = hsvToRgb(h, s, v); - const currentHex = rgbToHex(currentRgb[0], currentRgb[1], currentRgb[2]); - const hueColor = rgbToHex(...hsvToRgb(h, 1, 1)); + const handleHexCommit = () => { + const result = hexColorSchema.safeParse(hexInput); + if (!result.success) { + setHexError(true); + return; + } + + const rgb = hexToRgb(result.data); + if (!rgb) { + setHexError(true); + return; + } + + const [newH, newS, newV] = rgbToHsv(rgb[0], rgb[1], rgb[2]); + setHexInput(result.data.toLowerCase()); + setHexError(false); + updateColor({ h: newH, s: newS, v: newV, alpha }); + }; + + const handleHexKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + (e.target as HTMLInputElement).blur(); + } + if (e.key === 'Escape') { + setHexInput(currentHex); + setHexError(false); + } + }; + + const handleAlphaCommit = () => { + const value = Number(alphaInput); + if (!Number.isFinite(value)) { + setAlphaInput(alphaValue); + return; + } + + const nextAlpha = Math.round(Math.max(0, Math.min(1, value)) * 100) / 100; + setAlphaInput(nextAlpha === 1 ? '1' : nextAlpha.toFixed(2)); + updateColor({ h, s, v, alpha: nextAlpha }); + }; + + const handleAlphaKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + (e.target as HTMLInputElement).blur(); + } + if (e.key === 'Escape') { + setAlphaInput(alphaValue); + } + }; return ( @@ -215,13 +294,13 @@ export function ColorPicker({ color, onChange, className }: ColorPickerProps) {
{/* Saturation/Brightness picker */}
+ {/* Hex input */} +
+ HEX + { + setHexInput(e.target.value); + if (hexError) setHexError(false); + }} + onBlur={handleHexCommit} + onKeyDown={handleHexKeyDown} + aria-invalid={hexError} + className="h-7 text-xs font-mono" + /> +
+ + {/* RGBA inputs */} +
+
+ R + { + const r = Math.max(0, Math.min(255, Math.trunc(Number(e.target.value) || 0))); + const [newH, newS, newV] = rgbToHsv(r, currentRgb[1], currentRgb[2]); + updateColor({ h: newH, s: newS, v: newV, alpha }); + }} + className="h-7 w-full text-center text-xs font-mono [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" + /> +
+
+ G + { + const g = Math.max(0, Math.min(255, Math.trunc(Number(e.target.value) || 0))); + const [newH, newS, newV] = rgbToHsv(currentRgb[0], g, currentRgb[2]); + updateColor({ h: newH, s: newS, v: newV, alpha }); + }} + className="h-7 w-full text-center text-xs font-mono [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" + /> +
+
+ B + { + const b = Math.max(0, Math.min(255, Math.trunc(Number(e.target.value) || 0))); + const [newH, newS, newV] = rgbToHsv(currentRgb[0], currentRgb[1], b); + updateColor({ h: newH, s: newS, v: newV, alpha }); + }} + className="h-7 w-full text-center text-xs font-mono [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" + /> +
+
+ A + { + setAlphaInput(e.target.value); + }} + onBlur={handleAlphaCommit} + onKeyDown={handleAlphaKeyDown} + className="h-7 w-full text-center text-xs font-mono [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" + /> +
+
+ {/* Hue slider */}