mirror of https://github.com/kortix-ai/suna.git
enhance color picker
This commit is contained in:
parent
47238cba3b
commit
a445ef4611
File diff suppressed because it is too large
Load Diff
|
@ -33,10 +33,10 @@
|
||||||
"@radix-ui/react-progress": "^1.1.6",
|
"@radix-ui/react-progress": "^1.1.6",
|
||||||
"@radix-ui/react-radio-group": "^1.3.3",
|
"@radix-ui/react-radio-group": "^1.3.3",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.4",
|
"@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-separator": "^1.1.3",
|
||||||
"@radix-ui/react-slider": "^1.3.2",
|
"@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-switch": "^1.2.4",
|
||||||
"@radix-ui/react-tabs": "^1.1.8",
|
"@radix-ui/react-tabs": "^1.1.8",
|
||||||
"@radix-ui/react-tooltip": "^1.2.3",
|
"@radix-ui/react-tooltip": "^1.2.3",
|
||||||
|
@ -64,6 +64,7 @@
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^0.2.1",
|
"cmdk": "^0.2.1",
|
||||||
"cobe": "^0.6.3",
|
"cobe": "^0.6.3",
|
||||||
|
"color": "^5.0.0",
|
||||||
"color-bits": "^1.1.0",
|
"color-bits": "^1.1.0",
|
||||||
"colorthief": "^2.6.0",
|
"colorthief": "^2.6.0",
|
||||||
"comment-json": "^4.2.5",
|
"comment-json": "^4.2.5",
|
||||||
|
@ -92,7 +93,10 @@
|
||||||
"postcss": "8.4.33",
|
"postcss": "8.4.33",
|
||||||
"posthog-js": "^1.258.6",
|
"posthog-js": "^1.258.6",
|
||||||
"posthog-node": "^5.6.0",
|
"posthog-node": "^5.6.0",
|
||||||
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
|
"react-color-palette": "^7.3.1",
|
||||||
|
"react-colorful": "^5.6.1",
|
||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
"react-diff-viewer-continued": "^3.4.0",
|
"react-diff-viewer-continued": "^3.4.0",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
|
@ -122,6 +126,7 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
"@tailwindcss/postcss": "^4.1.4",
|
"@tailwindcss/postcss": "^4.1.4",
|
||||||
|
"@types/color": "^4.2.0",
|
||||||
"@types/colorthief": "^2.6.0",
|
"@types/colorthief": "^2.6.0",
|
||||||
"@types/diff": "^7.0.2",
|
"@types/diff": "^7.0.2",
|
||||||
"@types/jju": "^1.4.5",
|
"@types/jju": "^1.4.5",
|
||||||
|
|
|
@ -667,3 +667,36 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
|
@ -10,14 +10,17 @@ import {
|
||||||
DialogFooter
|
DialogFooter
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { IconPicker } from './icon-picker';
|
import { IconPicker } from './icon-picker';
|
||||||
import { AgentIconAvatar } from './agent-icon-avatar';
|
import { AgentIconAvatar } from './agent-icon-avatar';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
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 {
|
interface ProfilePictureDialogProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
@ -64,16 +67,150 @@ export function ProfilePictureDialog({
|
||||||
}, [selectedIcon, iconColor, backgroundColor, onIconUpdate, onImageUpdate, onClose]);
|
}, [selectedIcon, iconColor, backgroundColor, onIconUpdate, onImageUpdate, onClose]);
|
||||||
|
|
||||||
const presetColors = [
|
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: '#6366F1', icon: '#FFFFFF', name: 'Indigo' },
|
||||||
{ bg: '#10B981', icon: '#FFFFFF', name: 'Emerald' },
|
{ 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: '#EF4444', icon: '#FFFFFF', name: 'Red' },
|
||||||
{ bg: '#8B5CF6', icon: '#FFFFFF', name: 'Purple' },
|
{ bg: '#8B5CF6', icon: '#FFFFFF', name: 'Purple' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const ColorControls = () => (
|
const ColorControls = () => (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
||||||
<div className="flex flex-col items-center space-y-3 py-4">
|
<div className="flex flex-col items-center space-y-3 py-4">
|
||||||
<AgentIconAvatar
|
<AgentIconAvatar
|
||||||
iconName={selectedIcon}
|
iconName={selectedIcon}
|
||||||
|
@ -81,72 +218,53 @@ export function ProfilePictureDialog({
|
||||||
backgroundColor={backgroundColor}
|
backgroundColor={backgroundColor}
|
||||||
agentName={agentName}
|
agentName={agentName}
|
||||||
size={100}
|
size={100}
|
||||||
className="rounded-3xl border"
|
className="rounded-3xl border shadow-lg"
|
||||||
/>
|
/>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="font-medium">{agentName || 'Agent'}</p>
|
<p className="font-medium">{agentName || 'Agent'}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<ColorPickerField
|
||||||
<Label htmlFor="icon-color" className="text-sm mb-2 block">Icon Color</Label>
|
label="Icon Color"
|
||||||
<div className="flex gap-2">
|
color={iconColor}
|
||||||
<Input
|
onChange={setIconColor}
|
||||||
id="icon-color"
|
/>
|
||||||
type="color"
|
|
||||||
value={iconColor}
|
<ColorPickerField
|
||||||
onChange={(e) => setIconColor(e.target.value)}
|
label="Background Color"
|
||||||
className="w-16 h-10 p-1 cursor-pointer"
|
color={backgroundColor}
|
||||||
/>
|
onChange={setBackgroundColor}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<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">
|
<div className="grid grid-cols-5 gap-2">
|
||||||
{presetColors.map((preset) => (
|
{presetThemes.map((preset) => (
|
||||||
<button
|
<button
|
||||||
key={preset.name}
|
key={preset.name}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIconColor(preset.icon);
|
setIconColor(preset.icon);
|
||||||
setBackgroundColor(preset.bg);
|
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 }}
|
style={{ backgroundColor: preset.bg }}
|
||||||
title={preset.name}
|
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>
|
<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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -21,10 +21,11 @@ function PopoverContent({
|
||||||
className,
|
className,
|
||||||
align = 'center',
|
align = 'center',
|
||||||
sideOffset = 4,
|
sideOffset = 4,
|
||||||
|
container,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
}: React.ComponentProps<typeof PopoverPrimitive.Content> & { container?: HTMLElement }) {
|
||||||
return (
|
return (
|
||||||
<PopoverPrimitive.Portal>
|
<PopoverPrimitive.Portal container={container}>
|
||||||
<PopoverPrimitive.Content
|
<PopoverPrimitive.Content
|
||||||
data-slot="popover-content"
|
data-slot="popover-content"
|
||||||
align={align}
|
align={align}
|
||||||
|
|
|
@ -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;
|
||||||
|
};
|
Loading…
Reference in New Issue