mirror of https://github.com/kortix-ai/suna.git
Merge pull request #1451 from escapade-mckv/agent-icons
enhance color picker
This commit is contained in:
commit
a189e25a82
File diff suppressed because it is too large
Load Diff
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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