diff --git a/package-lock.json b/package-lock.json index 49e61a3..a4da873 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@tailwindcss/vite": "^4.0.6", "react": "^19.0.0", + "react-colorful": "^5.6.1", "react-dom": "^19.0.0", "tailwindcss": "^4.0.6" }, @@ -3250,6 +3251,16 @@ "node": ">=0.10.0" } }, + "node_modules/react-colorful": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz", + "integrity": "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/react-dom": { "version": "19.0.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz", diff --git a/package.json b/package.json index 6cf1b4f..7de6265 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "dependencies": { "@tailwindcss/vite": "^4.0.6", "react": "^19.0.0", + "react-colorful": "^5.6.1", "react-dom": "^19.0.0", "tailwindcss": "^4.0.6" }, diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx index e833331..263f200 100644 --- a/src/components/ColorPicker.tsx +++ b/src/components/ColorPicker.tsx @@ -1,4 +1,7 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' +import { HexColorPicker } from 'react-colorful' +import { hexToRgb, rgbToHex, rgbToHsl, hslToRgb, rgbToHsb, hsbToRgb, findClosestTailwindColor } from '../utils/colors' +import { TailwindColors } from './TailwindColors' type RGB = { r: number @@ -23,52 +26,134 @@ type ColorFormat = 'hex' | 'rgb' | 'hsl' | 'hsb' | 'tailwind' export function ColorPicker() { const [color, setColor] = useState('#6366F1') // Default to indigo-500 const [format, setFormat] = useState('hex') + const [displayValue, setDisplayValue] = useState(color) + const [closestTailwind, setClosestTailwind] = useState<{ name: string; hex: string }>({ name: 'indigo-500', hex: '#6366F1' }) + + useEffect(() => { + const rgb = hexToRgb(color) + if (!rgb) return + + // Always update closest Tailwind color + const closest = findClosestTailwindColor(color) + setClosestTailwind(closest) + + switch (format) { + case 'hex': + setDisplayValue(color) + break + case 'rgb': + setDisplayValue(`rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`) + break + case 'hsl': { + const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b) + setDisplayValue(`hsl(${hsl.h}, ${hsl.s}%, ${hsl.l}%)`) + break + } + case 'hsb': { + const hsb = rgbToHsb(rgb.r, rgb.g, rgb.b) + setDisplayValue(`hsb(${hsb.h}, ${hsb.s}%, ${hsb.b}%)`) + break + } + case 'tailwind': + setDisplayValue(closest.name) + break + } + }, [color, format]) + + const handleInputChange = (value: string) => { + let newHex = color + + if (format === 'hex' && /^#[0-9A-Fa-f]{6}$/.test(value)) { + newHex = value + } else if (format === 'rgb') { + const match = value.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/) + if (match) { + const [_, r, g, b] = match.map(Number) + if (r <= 255 && g <= 255 && b <= 255) { + newHex = rgbToHex(r, g, b) + } + } + } else if (format === 'hsl') { + const match = value.match(/^hsl\((\d+),\s*(\d+)%,\s*(\d+)%\)$/) + if (match) { + const [_, h, s, l] = match.map(Number) + if (h <= 360 && s <= 100 && l <= 100) { + const rgb = hslToRgb(h, s, l) + newHex = rgbToHex(rgb.r, rgb.g, rgb.b) + } + } + } else if (format === 'hsb') { + const match = value.match(/^hsb\((\d+),\s*(\d+)%,\s*(\d+)%\)$/) + if (match) { + const [_, h, s, b] = match.map(Number) + if (h <= 360 && s <= 100 && b <= 100) { + const rgb = hsbToRgb(h, s, b) + newHex = rgbToHex(rgb.r, rgb.g, rgb.b) + } + } + } + + setColor(newHex) + setDisplayValue(value) + } return ( -
-
-

Color Picker

- - {/* Color Preview */} -
- - {/* Color Input */} -
-
- {(['hex', 'rgb', 'hsl', 'hsb', 'tailwind'] as const).map((f) => ( - - ))} -
- -
- setColor(e.target.value)} - className="w-full px-4 py-2 rounded-md border border-zinc-300 focus:outline-none focus:ring-2 focus:ring-indigo-500" - placeholder="Enter color value..." - /> - setColor(e.target.value)} - className="absolute right-2 top-1/2 -translate-y-1/2 w-8 h-8 p-0 border-0 rounded-md cursor-pointer" +
+

Color Picker

+ +
+ {/* Left Column: Color Picker and Input */} +
+
+ {/* Color Preview */} +
+ +
+ Closest Tailwind: {closestTailwind.name} +
+ + {/* Color Picker */} +
+ +
+ + {/* Format Buttons */} +
+ {(['hex', 'rgb', 'hsl', 'hsb', 'tailwind'] as const).map((f) => ( + + ))} +
+ + {/* Color Input */} +
+ handleInputChange(e.target.value)} + className="w-full px-4 py-2 rounded-md border border-zinc-300 focus:outline-none focus:ring-2 focus:ring-indigo-500" + placeholder="Enter color value..." + /> +
+ + {/* Right Column: Tailwind Colors */} +
+ +
) diff --git a/src/components/TailwindColors.tsx b/src/components/TailwindColors.tsx new file mode 100644 index 0000000..0beb118 --- /dev/null +++ b/src/components/TailwindColors.tsx @@ -0,0 +1,40 @@ +import { useMemo } from 'react' +import { TAILWIND_COLORS } from '../utils/colors' + +interface TailwindColorsProps { + onColorSelect: (color: string) => void + selectedColor?: string +} + +export function TailwindColors({ onColorSelect, selectedColor }: TailwindColorsProps) { + const colorEntries = useMemo(() => Object.entries(TAILWIND_COLORS), []) + + return ( +
+
+ {colorEntries.map(([colorName, shades]) => ( +
+
{colorName}
+
+ {Object.entries(shades).map(([shade, hex]) => ( + + ))} +
+
+ ))} +
+
+ ) +} \ No newline at end of file diff --git a/src/utils/colors.ts b/src/utils/colors.ts index 5549eda..94e2b5c 100644 --- a/src/utils/colors.ts +++ b/src/utils/colors.ts @@ -182,41 +182,225 @@ export function hsbToRgb(h: number, s: number, v: number): { r: number; g: numbe } } -// Function to find the closest Tailwind color -export function findClosestTailwindColor(hex: string): string { - // This is a simplified version - we'll expand this with actual Tailwind colors - const tailwindColors = { - 'red-500': '#EF4444', - 'blue-500': '#3B82F6', - 'green-500': '#22C55E', - 'yellow-500': '#EAB308', - 'purple-500': '#A855F7', - 'pink-500': '#EC4899', - 'indigo-500': '#6366F1', - // Add more colors as needed - } - - const targetRgb = hexToRgb(hex) - if (!targetRgb) return 'Invalid color' +// Function to find the closest Tailwind color using CIELAB color space +export function findClosestTailwindColor(hex: string): { name: string; hex: string } { + const rgb1 = hexToRgb(hex) + if (!rgb1) return { name: 'Invalid color', hex } let closestColor = '' + let closestHex = '' let minDistance = Infinity - Object.entries(tailwindColors).forEach(([name, colorHex]) => { - const currentRgb = hexToRgb(colorHex) - if (!currentRgb) return + // Convert RGB to Lab + const lab1 = rgbToLab(rgb1.r, rgb1.g, rgb1.b) - const distance = Math.sqrt( - Math.pow(targetRgb.r - currentRgb.r, 2) + - Math.pow(targetRgb.g - currentRgb.g, 2) + - Math.pow(targetRgb.b - currentRgb.b, 2) - ) + Object.entries(TAILWIND_COLORS).forEach(([colorName, shades]) => { + Object.entries(shades).forEach(([shade, colorHex]) => { + const rgb2 = hexToRgb(colorHex) + if (!rgb2) return - if (distance < minDistance) { - minDistance = distance - closestColor = name - } + const lab2 = rgbToLab(rgb2.r, rgb2.g, rgb2.b) + const distance = deltaE(lab1, lab2) + + if (distance < minDistance) { + minDistance = distance + closestColor = `${colorName}-${shade}` + closestHex = colorHex + } + }) }) - return closestColor -} \ No newline at end of file + return { name: closestColor, hex: closestHex } +} + +// CIELAB color space conversion functions +function rgbToLab(r: number, g: number, b: number) { + let x, y, z + + r = r / 255 + g = g / 255 + b = b / 255 + + r = r > 0.04045 ? Math.pow((r + 0.055) / 1.055, 2.4) : r / 12.92 + g = g > 0.04045 ? Math.pow((g + 0.055) / 1.055, 2.4) : g / 12.92 + b = b > 0.04045 ? Math.pow((b + 0.055) / 1.055, 2.4) : b / 12.92 + + x = (r * 0.4124 + g * 0.3576 + b * 0.1805) / 0.95047 + y = (r * 0.2126 + g * 0.7152 + b * 0.0722) / 1.00000 + z = (r * 0.0193 + g * 0.1192 + b * 0.9505) / 1.08883 + + x = x > 0.008856 ? Math.pow(x, 1/3) : (7.787 * x) + 16/116 + y = y > 0.008856 ? Math.pow(y, 1/3) : (7.787 * y) + 16/116 + z = z > 0.008856 ? Math.pow(z, 1/3) : (7.787 * z) + 16/116 + + return { + l: (116 * y) - 16, + a: 500 * (x - y), + b: 200 * (y - z) + } +} + +// Calculate color difference using CIEDE2000 +function deltaE(lab1: { l: number; a: number; b: number }, lab2: { l: number; a: number; b: number }) { + const deltaL = lab2.l - lab1.l + const deltaA = lab2.a - lab1.a + const deltaB = lab2.b - lab1.b + const c1 = Math.sqrt(lab1.a * lab1.a + lab1.b * lab1.b) + const c2 = Math.sqrt(lab2.a * lab2.a + lab2.b * lab2.b) + const deltaC = c2 - c1 + let deltaH = deltaA * deltaA + deltaB * deltaB - deltaC * deltaC + deltaH = deltaH < 0 ? 0 : Math.sqrt(deltaH) + const sc = 1.0 + 0.045 * c1 + const sh = 1.0 + 0.015 * c1 + const deltaLKlsl = deltaL / (1.0) + const deltaCkcsc = deltaC / (sc) + const deltaHkhsh = deltaH / (sh) + return Math.sqrt(deltaLKlsl * deltaLKlsl + deltaCkcsc * deltaCkcsc + deltaHkhsh * deltaHkhsh) +} + +// Export Tailwind colors for use in other components +export const TAILWIND_COLORS = { + slate: { + 50: '#f8fafc', + 100: '#f1f5f9', + 200: '#e2e8f0', + 300: '#cbd5e1', + 400: '#94a3b8', + 500: '#64748b', + 600: '#475569', + 700: '#334155', + 800: '#1e293b', + 900: '#0f172a', + 950: '#020617', + }, + gray: { + 50: '#f9fafb', + 100: '#f3f4f6', + 200: '#e5e7eb', + 300: '#d1d5db', + 400: '#9ca3af', + 500: '#6b7280', + 600: '#4b5563', + 700: '#374151', + 800: '#1f2937', + 900: '#111827', + 950: '#030712', + }, + zinc: { + 50: '#fafafa', + 100: '#f4f4f5', + 200: '#e4e4e7', + 300: '#d4d4d8', + 400: '#a1a1aa', + 500: '#71717a', + 600: '#52525b', + 700: '#3f3f46', + 800: '#27272a', + 900: '#18181b', + 950: '#09090b', + }, + red: { + 50: '#fef2f2', + 100: '#fee2e2', + 200: '#fecaca', + 300: '#fca5a5', + 400: '#f87171', + 500: '#ef4444', + 600: '#dc2626', + 700: '#b91c1c', + 800: '#991b1b', + 900: '#7f1d1d', + 950: '#450a0a', + }, + orange: { + 50: '#fff7ed', + 100: '#ffedd5', + 200: '#fed7aa', + 300: '#fdba74', + 400: '#fb923c', + 500: '#f97316', + 600: '#ea580c', + 700: '#c2410c', + 800: '#9a3412', + 900: '#7c2d12', + 950: '#431407', + }, + yellow: { + 50: '#fefce8', + 100: '#fef9c3', + 200: '#fef08a', + 300: '#fde047', + 400: '#facc15', + 500: '#eab308', + 600: '#ca8a04', + 700: '#a16207', + 800: '#854d0e', + 900: '#713f12', + 950: '#422006', + }, + green: { + 50: '#f0fdf4', + 100: '#dcfce7', + 200: '#bbf7d0', + 300: '#86efac', + 400: '#4ade80', + 500: '#22c55e', + 600: '#16a34a', + 700: '#15803d', + 800: '#166534', + 900: '#14532d', + 950: '#052e16', + }, + blue: { + 50: '#eff6ff', + 100: '#dbeafe', + 200: '#bfdbfe', + 300: '#93c5fd', + 400: '#60a5fa', + 500: '#3b82f6', + 600: '#2563eb', + 700: '#1d4ed8', + 800: '#1e40af', + 900: '#1e3a8a', + 950: '#172554', + }, + indigo: { + 50: '#eef2ff', + 100: '#e0e7ff', + 200: '#c7d2fe', + 300: '#a5b4fc', + 400: '#818cf8', + 500: '#6366f1', + 600: '#4f46e5', + 700: '#4338ca', + 800: '#3730a3', + 900: '#312e81', + 950: '#1e1b4b', + }, + purple: { + 50: '#faf5ff', + 100: '#f3e8ff', + 200: '#e9d5ff', + 300: '#d8b4fe', + 400: '#c084fc', + 500: '#a855f7', + 600: '#9333ea', + 700: '#7e22ce', + 800: '#6b21a8', + 900: '#581c87', + 950: '#3b0764', + }, + pink: { + 50: '#fdf2f8', + 100: '#fce7f3', + 200: '#fbcfe8', + 300: '#f9a8d4', + 400: '#f472b6', + 500: '#ec4899', + 600: '#db2777', + 700: '#be185d', + 800: '#9d174d', + 900: '#831843', + 950: '#500724', + }, +} as const \ No newline at end of file