enhance color picker

This commit is contained in:
Saumya 2025-08-25 12:01:55 +05:30
parent 47238cba3b
commit a445ef4611
6 changed files with 1360 additions and 183 deletions

File diff suppressed because it is too large Load Diff

View File

@ -33,10 +33,10 @@
"@radix-ui/react-progress": "^1.1.6",
"@radix-ui/react-radio-group": "^1.3.3",
"@radix-ui/react-scroll-area": "^1.2.4",
"@radix-ui/react-select": "^2.1.7",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.3",
"@radix-ui/react-slider": "^1.3.2",
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.8",
"@radix-ui/react-tooltip": "^1.2.3",
@ -64,6 +64,7 @@
"clsx": "^2.1.1",
"cmdk": "^0.2.1",
"cobe": "^0.6.3",
"color": "^5.0.0",
"color-bits": "^1.1.0",
"colorthief": "^2.6.0",
"comment-json": "^4.2.5",
@ -92,7 +93,10 @@
"postcss": "8.4.33",
"posthog-js": "^1.258.6",
"posthog-node": "^5.6.0",
"radix-ui": "^1.4.3",
"react": "^18",
"react-color-palette": "^7.3.1",
"react-colorful": "^5.6.1",
"react-day-picker": "^8.10.1",
"react-diff-viewer-continued": "^3.4.0",
"react-dom": "^18",
@ -122,6 +126,7 @@
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4.1.4",
"@types/color": "^4.2.0",
"@types/colorthief": "^2.6.0",
"@types/diff": "^7.0.2",
"@types/jju": "^1.4.5",

View File

@ -666,4 +666,37 @@
background-position: calc(100% + var(--shiny-width)) 0;
}
}
}
}
.react-colorful {
width: 100% !important;
height: 100% !important;
}
.react-colorful__saturation {
position: relative !important;
flex-grow: 1 !important;
border-radius: 8px 8px 0 0 !important;
border-bottom: none !important;
overflow: hidden !important;
min-height: 150px !important;
}
.react-colorful__hue {
position: relative !important;
height: 20px !important;
border-radius: 0 0 8px 8px !important;
}
.react-colorful__saturation-pointer,
.react-colorful__hue-pointer {
width: 20px !important;
height: 20px !important;
border: 2px solid white !important;
box-shadow: 0 2px 4px rgba(0,0,0,0.2), inset 0 0 0 1px rgba(0,0,0,0.1) !important;
}
.react-colorful__interactive:focus .react-colorful__pointer {
transform: scale(1.1);
}

View File

@ -10,14 +10,17 @@ import {
DialogFooter
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { toast } from 'sonner';
import { IconPicker } from './icon-picker';
import { AgentIconAvatar } from './agent-icon-avatar';
import { Separator } from '@/components/ui/separator';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { cn } from '@/lib/utils';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { HexColorPicker } from 'react-colorful';
interface ProfilePictureDialogProps {
isOpen: boolean;
@ -64,16 +67,150 @@ export function ProfilePictureDialog({
}, [selectedIcon, iconColor, backgroundColor, onIconUpdate, onImageUpdate, onClose]);
const presetColors = [
'#000000', '#FFFFFF', '#6366F1', '#10B981', '#F59E0B',
'#EF4444', '#8B5CF6', '#EC4899', '#14B8A6', '#F97316',
'#06B6D4', '#84CC16', '#F43F5E', '#A855F7', '#3B82F6'
];
const ColorPickerField = ({
label,
color,
onChange,
}: {
label: string;
color: string;
onChange: (color: string) => void;
}) => {
const [isOpen, setIsOpen] = useState(false);
const [isInteracting, setIsInteracting] = useState(false);
return (
<div className="space-y-2">
<Label className="text-sm font-medium">{label}</Label>
<div className="flex items-center gap-3">
<Popover open={isOpen} onOpenChange={(open) => {
if (!open || !isInteracting) {
setIsOpen(open);
}
}}>
<PopoverTrigger asChild>
<button
type="button"
className="h-10 w-12 rounded-md border cursor-pointer hover:border-primary/50 transition-colors"
style={{ backgroundColor: color }}
aria-label={`${label} color`}
/>
</PopoverTrigger>
<PopoverContent
className="w-auto p-3"
align="start"
side="bottom"
sideOffset={5}
onInteractOutside={(e) => {
if (isInteracting) {
e.preventDefault();
return;
}
const target = e.target as HTMLElement;
const isColorPickerElement = target.closest('[class*="react-colorful"]') ||
target.className.includes('react-colorful') ||
target.closest('.react-colorful-container');
if (isColorPickerElement) {
e.preventDefault();
}
}}
>
<div className="space-y-3">
<div
className="react-colorful-container"
onMouseDown={() => setIsInteracting(true)}
onMouseUp={() => setIsInteracting(false)}
onTouchStart={() => setIsInteracting(true)}
onTouchEnd={() => setIsInteracting(false)}
>
<HexColorPicker
color={color}
onChange={(newColor) => {
onChange(newColor);
}}
style={{ width: '200px', height: '150px' }}
/>
</div>
<div className="flex items-center gap-2">
<Input
type="text"
value={color}
onChange={(e) => {
const hex = e.target.value;
if (/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/.test(hex) || hex.startsWith('#')) {
onChange(hex.toUpperCase());
}
}}
placeholder="#000000"
className="font-mono text-sm flex-1"
maxLength={7}
/>
<Button
size="sm"
variant="outline"
onClick={() => {
setIsInteracting(false);
setIsOpen(false);
}}
>
Done
</Button>
</div>
</div>
</PopoverContent>
</Popover>
<Input
type="text"
value={color}
onChange={(e) => {
const hex = e.target.value;
if (/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/.test(hex) || hex.startsWith('#')) {
onChange(hex.toUpperCase());
}
}}
placeholder="#000000"
className="font-mono text-sm"
maxLength={7}
/>
</div>
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">Presets</Label>
<div className="grid grid-cols-8 gap-1">
{presetColors.map((presetColor) => (
<button
key={presetColor}
onClick={() => onChange(presetColor)}
className={cn(
"w-7 h-7 rounded border-2 transition-all hover:scale-110",
color === presetColor ? "border-primary ring-2 ring-primary/20" : "border-transparent"
)}
style={{ backgroundColor: presetColor }}
title={presetColor}
/>
))}
</div>
</div>
</div>
);
};
const presetThemes = [
{ bg: '#6366F1', icon: '#FFFFFF', name: 'Indigo' },
{ bg: '#10B981', icon: '#FFFFFF', name: 'Emerald' },
{ bg: '#F59E0B', icon: '#FFFFFF', name: 'Amber' },
{ bg: '#F59E0B', icon: '#1F2937', name: 'Amber' },
{ bg: '#EF4444', icon: '#FFFFFF', name: 'Red' },
{ bg: '#8B5CF6', icon: '#FFFFFF', name: 'Purple' },
];
const ColorControls = () => (
<div className="space-y-6">
<div className="flex flex-col items-center space-y-3 py-4">
<AgentIconAvatar
iconName={selectedIcon}
@ -81,72 +218,53 @@ export function ProfilePictureDialog({
backgroundColor={backgroundColor}
agentName={agentName}
size={100}
className="rounded-3xl border"
className="rounded-3xl border shadow-lg"
/>
<div className="text-center">
<p className="font-medium">{agentName || 'Agent'}</p>
</div>
</div>
<div className="space-y-4">
<div>
<Label htmlFor="icon-color" className="text-sm mb-2 block">Icon Color</Label>
<div className="flex gap-2">
<Input
id="icon-color"
type="color"
value={iconColor}
onChange={(e) => setIconColor(e.target.value)}
className="w-16 h-10 p-1 cursor-pointer"
/>
<Input
type="text"
value={iconColor}
onChange={(e) => setIconColor(e.target.value)}
placeholder="#000000"
className="flex-1"
maxLength={7}
/>
</div>
</div>
<div>
<Label htmlFor="bg-color" className="text-sm mb-2 block">Background Color</Label>
<div className="flex gap-2">
<Input
id="bg-color"
type="color"
value={backgroundColor}
onChange={(e) => setBackgroundColor(e.target.value)}
className="w-16 h-10 p-1 cursor-pointer"
/>
<Input
type="text"
value={backgroundColor}
onChange={(e) => setBackgroundColor(e.target.value)}
placeholder="#F3F4F6"
className="flex-1"
maxLength={7}
/>
</div>
</div>
<ColorPickerField
label="Icon Color"
color={iconColor}
onChange={setIconColor}
/>
<ColorPickerField
label="Background Color"
color={backgroundColor}
onChange={setBackgroundColor}
/>
</div>
<div className="space-y-3">
<Label className="text-sm">Quick Presets</Label>
<Label className="text-sm font-medium">Quick Themes</Label>
<div className="grid grid-cols-5 gap-2">
{presetColors.map((preset) => (
{presetThemes.map((preset) => (
<button
key={preset.name}
onClick={() => {
setIconColor(preset.icon);
setBackgroundColor(preset.bg);
}}
className="group relative h-10 w-full rounded-lg border-2 border-border hover:border-primary transition-all hover:scale-105"
className={cn(
"group relative h-12 w-full rounded-xl border-2 transition-all hover:scale-105",
backgroundColor === preset.bg && iconColor === preset.icon
? "border-primary shadow-md"
: "border-border hover:border-primary/60"
)}
style={{ backgroundColor: preset.bg }}
title={preset.name}
>
<span className="absolute inset-0 flex items-center justify-center">
<Sparkles
className="w-4 h-4"
style={{ color: preset.icon }}
/>
</span>
<span className="sr-only">{preset.name}</span>
{backgroundColor === preset.bg && iconColor === preset.icon && (
<div className="absolute inset-0 rounded-lg ring-2 ring-primary ring-offset-2" />
)}
</button>
))}
</div>

View File

@ -21,10 +21,11 @@ function PopoverContent({
className,
align = 'center',
sideOffset = 4,
container,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
}: React.ComponentProps<typeof PopoverPrimitive.Content> & { container?: HTMLElement }) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Portal container={container}>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}

View File

@ -0,0 +1,469 @@
'use client';
import Color from 'color';
import { PipetteIcon } from 'lucide-react';
import { Slider } from 'radix-ui';
import {
type ComponentProps,
createContext,
type HTMLAttributes,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
interface ColorPickerContextValue {
hue: number;
saturation: number;
lightness: number;
alpha: number;
mode: string;
setHue: (hue: number) => void;
setSaturation: (saturation: number) => void;
setLightness: (lightness: number) => void;
setAlpha: (alpha: number) => void;
setMode: (mode: string) => void;
}
const ColorPickerContext = createContext<ColorPickerContextValue | undefined>(
undefined
);
export const useColorPicker = () => {
const context = useContext(ColorPickerContext);
if (!context) {
throw new Error('useColorPicker must be used within a ColorPickerProvider');
}
return context;
};
export type ColorPickerProps = HTMLAttributes<HTMLDivElement> & {
value?: Parameters<typeof Color>[0];
defaultValue?: Parameters<typeof Color>[0];
onChange?: (value: Parameters<typeof Color.rgb>[0]) => void;
children?: React.ReactNode;
};
export const ColorPicker = ({
value,
defaultValue = '#000000',
onChange,
className,
children,
...props
}: ColorPickerProps) => {
const initialColor = value ? Color(value) : Color(defaultValue);
const [hue, setHue] = useState(initialColor.hue());
const [saturation, setSaturation] = useState(initialColor.saturationl());
const [lightness, setLightness] = useState(initialColor.lightness());
const [alpha, setAlpha] = useState(initialColor.alpha() * 100);
const [mode, setMode] = useState('hex');
useEffect(() => {
if (value) {
try {
const color = Color(value);
setHue(color.hue());
setSaturation(color.saturationl());
setLightness(color.lightness());
setAlpha(color.alpha() * 100);
} catch (error) {
console.error('Invalid color value:', value);
}
}
}, [value]);
useEffect(() => {
if (onChange) {
const color = Color.hsl(hue, saturation, lightness).alpha(alpha / 100);
const rgba = color.rgb().array();
onChange([rgba[0], rgba[1], rgba[2], alpha / 100]);
}
}, [hue, saturation, lightness, alpha, onChange]);
return (
<ColorPickerContext.Provider
value={{
hue,
saturation,
lightness,
alpha,
mode,
setHue,
setSaturation,
setLightness,
setAlpha,
setMode,
}}
>
<div
className={cn('flex size-full flex-col gap-4', className)}
{...props}
>
{children}
</div>
</ColorPickerContext.Provider>
);
};
export type ColorPickerSelectionProps = HTMLAttributes<HTMLDivElement>;
export const ColorPickerSelection = memo(
({ className, ...props }: ColorPickerSelectionProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [positionX, setPositionX] = useState(0);
const [positionY, setPositionY] = useState(0);
const { hue, setSaturation, setLightness } = useColorPicker();
const backgroundGradient = useMemo(() => {
return `linear-gradient(0deg, rgba(0,0,0,1), rgba(0,0,0,0)),
linear-gradient(90deg, rgba(255,255,255,1), rgba(255,255,255,0)),
hsl(${hue}, 100%, 50%)`;
}, [hue]);
const handlePointerMove = useCallback(
(event: PointerEvent) => {
if (!(isDragging && containerRef.current)) {
return;
}
const rect = containerRef.current.getBoundingClientRect();
const x = Math.max(
0,
Math.min(1, (event.clientX - rect.left) / rect.width)
);
const y = Math.max(
0,
Math.min(1, (event.clientY - rect.top) / rect.height)
);
setPositionX(x);
setPositionY(y);
setSaturation(x * 100);
const topLightness = x < 0.01 ? 100 : 50 + 50 * (1 - x);
const lightness = topLightness * (1 - y);
setLightness(lightness);
},
[isDragging, setSaturation, setLightness]
);
useEffect(() => {
const handlePointerUp = () => setIsDragging(false);
if (isDragging) {
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
}
return () => {
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
};
}, [isDragging, handlePointerMove]);
return (
<div
className={cn('relative size-full cursor-crosshair rounded', className)}
onPointerDown={(e) => {
e.preventDefault();
setIsDragging(true);
handlePointerMove(e.nativeEvent);
}}
ref={containerRef}
style={{
background: backgroundGradient,
}}
{...props}
>
<div
className="-translate-x-1/2 -translate-y-1/2 pointer-events-none absolute h-4 w-4 rounded-full border-2 border-white"
style={{
left: `${positionX * 100}%`,
top: `${positionY * 100}%`,
boxShadow: '0 0 0 1px rgba(0,0,0,0.5)',
}}
/>
</div>
);
}
);
ColorPickerSelection.displayName = 'ColorPickerSelection';
export type ColorPickerHueProps = ComponentProps<typeof Slider.Root>;
export const ColorPickerHue = ({
className,
...props
}: ColorPickerHueProps) => {
const { hue, setHue } = useColorPicker();
return (
<Slider.Root
className={cn('relative flex h-4 w-full touch-none', className)}
max={360}
onValueChange={([hue]) => setHue(hue)}
step={1}
value={[hue]}
{...props}
>
<Slider.Track className="relative my-0.5 h-3 w-full grow rounded-full bg-[linear-gradient(90deg,#FF0000,#FFFF00,#00FF00,#00FFFF,#0000FF,#FF00FF,#FF0000)]">
<Slider.Range className="absolute h-full" />
</Slider.Track>
<Slider.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
</Slider.Root>
);
};
export type ColorPickerAlphaProps = ComponentProps<typeof Slider.Root>;
export const ColorPickerAlpha = ({
className,
...props
}: ColorPickerAlphaProps) => {
const { alpha, setAlpha } = useColorPicker();
return (
<Slider.Root
className={cn('relative flex h-4 w-full touch-none', className)}
max={100}
onValueChange={([alpha]) => setAlpha(alpha)}
step={1}
value={[alpha]}
{...props}
>
<Slider.Track
className="relative my-0.5 h-3 w-full grow rounded-full"
style={{
background:
'url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==") left center',
}}
>
<div className="absolute inset-0 rounded-full bg-gradient-to-r from-transparent to-black/50" />
<Slider.Range className="absolute h-full rounded-full bg-transparent" />
</Slider.Track>
<Slider.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
</Slider.Root>
);
};
export type ColorPickerEyeDropperProps = ComponentProps<typeof Button>;
export const ColorPickerEyeDropper = ({
className,
...props
}: ColorPickerEyeDropperProps) => {
const { setHue, setSaturation, setLightness, setAlpha } = useColorPicker();
const handleEyeDropper = async () => {
try {
// @ts-expect-error - EyeDropper API is experimental
const eyeDropper = new EyeDropper();
const result = await eyeDropper.open();
const color = Color(result.sRGBHex);
const [h, s, l] = color.hsl().array();
setHue(h);
setSaturation(s);
setLightness(l);
setAlpha(100);
} catch (error) {
console.error('EyeDropper failed:', error);
}
};
return (
<Button
className={cn('shrink-0 text-muted-foreground', className)}
onClick={handleEyeDropper}
size="icon"
variant="outline"
type="button"
{...props}
>
<PipetteIcon size={16} />
</Button>
);
};
export type ColorPickerOutputProps = ComponentProps<typeof SelectTrigger>;
const formats = ['hex', 'rgb', 'css', 'hsl'];
export const ColorPickerOutput = ({
className,
...props
}: ColorPickerOutputProps) => {
const { mode, setMode } = useColorPicker();
return (
<Select onValueChange={setMode} value={mode}>
<SelectTrigger className="h-8 w-20 shrink-0 text-xs" {...props}>
<SelectValue placeholder="Mode" />
</SelectTrigger>
<SelectContent>
{formats.map((format) => (
<SelectItem className="text-xs" key={format} value={format}>
{format.toUpperCase()}
</SelectItem>
))}
</SelectContent>
</Select>
);
};
type PercentageInputProps = ComponentProps<typeof Input>;
const PercentageInput = ({ className, ...props }: PercentageInputProps) => {
return (
<div className="relative">
<Input
readOnly
type="text"
{...props}
className={cn(
'h-8 w-[3.25rem] rounded-l-none bg-secondary px-2 text-xs shadow-none',
className
)}
/>
<span className="-translate-y-1/2 absolute top-1/2 right-2 text-muted-foreground text-xs">
%
</span>
</div>
);
};
export type ColorPickerFormatProps = HTMLAttributes<HTMLDivElement>;
export const ColorPickerFormat = ({
className,
...props
}: ColorPickerFormatProps) => {
const { hue, saturation, lightness, alpha, mode } = useColorPicker();
const color = Color.hsl(hue, saturation, lightness, alpha / 100);
if (mode === 'hex') {
const hex = color.hex();
return (
<div
className={cn(
'-space-x-px relative flex w-full items-center rounded-md shadow-sm',
className
)}
{...props}
>
<Input
className="h-8 rounded-r-none bg-secondary px-2 text-xs shadow-none"
readOnly
type="text"
value={hex}
/>
<PercentageInput value={alpha} />
</div>
);
}
if (mode === 'rgb') {
const rgb = color
.rgb()
.array()
.map((value) => Math.round(value));
return (
<div
className={cn(
'-space-x-px flex items-center rounded-md shadow-sm',
className
)}
{...props}
>
{rgb.map((value, index) => (
<Input
className={cn(
'h-8 rounded-r-none bg-secondary px-2 text-xs shadow-none',
index && 'rounded-l-none',
className
)}
key={index}
readOnly
type="text"
value={value}
/>
))}
<PercentageInput value={alpha} />
</div>
);
}
if (mode === 'css') {
const rgb = color
.rgb()
.array()
.map((value) => Math.round(value));
return (
<div className={cn('w-full rounded-md shadow-sm', className)} {...props}>
<Input
className="h-8 w-full bg-secondary px-2 text-xs shadow-none"
readOnly
type="text"
value={`rgba(${rgb.join(', ')}, ${alpha}%)`}
{...props}
/>
</div>
);
}
if (mode === 'hsl') {
const hsl = color
.hsl()
.array()
.map((value) => Math.round(value));
return (
<div
className={cn(
'-space-x-px flex items-center rounded-md shadow-sm',
className
)}
{...props}
>
{hsl.map((value, index) => (
<Input
className={cn(
'h-8 rounded-r-none bg-secondary px-2 text-xs shadow-none',
index && 'rounded-l-none',
className
)}
key={index}
readOnly
type="text"
value={value}
/>
))}
<PercentageInput value={alpha} />
</div>
);
}
return null;
};