Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
251 changes: 209 additions & 42 deletions components/ui/color-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -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<HTMLDivElement>(null);
const hueRef = React.useRef<HTMLDivElement>(null);
const alphaRef = React.useRef<HTMLDivElement>(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) => {
Expand All @@ -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 = () => {
Expand All @@ -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 = () => {
Expand All @@ -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 = () => {
Expand All @@ -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 (
<Popover open={isOpen} onOpenChange={setIsOpen}>
Expand All @@ -215,13 +294,13 @@ export function ColorPicker({ color, onChange, className }: ColorPickerProps) {
<PopoverContent
className="w-auto p-3 border-2 border-primary/20"
align="start"
side="bottom"
side="right"
>
<div className="space-y-3">
{/* Saturation/Brightness picker */}
<div
ref={saturationRef}
className="w-52 h-44 rounded-lg cursor-crosshair relative overflow-hidden"
className="w-72 h-56 rounded-lg cursor-crosshair relative overflow-hidden"
style={{
background: `linear-gradient(to bottom, transparent, black), linear-gradient(to right, white, ${hueColor})`,
}}
Expand All @@ -240,10 +319,98 @@ export function ColorPicker({ color, onChange, className }: ColorPickerProps) {
/>
</div>

{/* Hex input */}
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-muted-foreground shrink-0 w-5">HEX</span>
<Input
value={hexInput}
onChange={(e) => {
setHexInput(e.target.value);
if (hexError) setHexError(false);
}}
onBlur={handleHexCommit}
onKeyDown={handleHexKeyDown}
aria-invalid={hexError}
className="h-7 text-xs font-mono"
/>
</div>

{/* RGBA inputs */}
<div className="grid w-72 grid-cols-4 gap-1.5">
<div className="flex min-w-0 flex-col items-center gap-0.5">
<span className="text-[10px] font-medium text-muted-foreground">R</span>
<Input
type="number"
min={0}
max={255}
step={1}
aria-label="Red"
value={currentRgb[0]}
onChange={(e) => {
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"
/>
</div>
<div className="flex min-w-0 flex-col items-center gap-0.5">
<span className="text-[10px] font-medium text-muted-foreground">G</span>
<Input
type="number"
min={0}
max={255}
step={1}
aria-label="Green"
value={currentRgb[1]}
onChange={(e) => {
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"
/>
</div>
<div className="flex min-w-0 flex-col items-center gap-0.5">
<span className="text-[10px] font-medium text-muted-foreground">B</span>
<Input
type="number"
min={0}
max={255}
step={1}
aria-label="Blue"
value={currentRgb[2]}
onChange={(e) => {
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"
/>
</div>
<div className="flex min-w-0 flex-col items-center gap-0.5">
<span className="text-[10px] font-medium text-muted-foreground">A</span>
<Input
type="number"
min={0}
max={1}
step={0.01}
aria-label="Alpha"
value={alphaInput}
onChange={(e) => {
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"
/>
</div>
</div>

{/* Hue slider */}
<div
ref={hueRef}
className="w-52 h-4 rounded-full cursor-pointer relative overflow-hidden"
className="w-72 h-4 rounded-full cursor-pointer relative overflow-hidden"
style={{
background: 'linear-gradient(to right, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000)',
}}
Expand All @@ -264,7 +431,7 @@ export function ColorPicker({ color, onChange, className }: ColorPickerProps) {
{/* Alpha slider */}
<div
ref={alphaRef}
className="w-52 h-4 rounded-full cursor-pointer relative overflow-hidden"
className="w-72 h-4 rounded-full cursor-pointer relative overflow-hidden"
style={{
background: `linear-gradient(to right, transparent, ${currentHex}), repeating-conic-gradient(#808080 0% 25%, #fff 0% 50%) 50% / 8px 8px`,
}}
Expand Down