"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(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 = Omit, "onChange"> & { value?: Parameters[0] defaultValue?: Parameters[0] onChange?: (value: Parameters[0]) => void } export const ColorPicker = ({ value, defaultValue = "#000000", onChange, className, ...props }: ColorPickerProps) => { const selectedColor = Color(value ?? defaultValue) const [hue, setHueState] = useState(selectedColor.hue() || 0) const [saturation, setSaturationState] = useState(selectedColor.saturationl()) const [lightness, setLightnessState] = useState(selectedColor.lightness()) const [alpha, setAlphaState] = useState(selectedColor.alpha() * 100) const [mode, setMode] = useState("hex") const channelsRef = useRef({ hue, saturation, lightness, alpha }) const updateColor = useCallback( (patch: Partial) => { const next = { ...channelsRef.current, ...patch } channelsRef.current = next setHueState(next.hue) setSaturationState(next.saturation) setLightnessState(next.lightness) setAlphaState(next.alpha) if (onChange) { const rgba = Color.hsl(next.hue, next.saturation, next.lightness) .alpha(next.alpha / 100) .rgb() .array() onChange([rgba[0], rgba[1], rgba[2], next.alpha / 100]) } }, [onChange, setHueState, setSaturationState, setLightnessState, setAlphaState], ) const setHue = useCallback((next: number) => updateColor({ hue: next }), [updateColor]) const setSaturation = useCallback( (next: number) => updateColor({ saturation: next }), [updateColor], ) const setLightness = useCallback( (next: number) => updateColor({ lightness: next }), [updateColor], ) const setAlpha = useCallback( (next: number) => updateColor({ alpha: next }), [updateColor], ) // Update color when controlled value changes useEffect(() => { if (value) { const color = Color(value).hsl() const next = { hue: color.hue() || 0, saturation: color.saturationl(), lightness: color.lightness(), alpha: color.alpha() * 100, } channelsRef.current = next // Synchronize the internal channels when the controlled value changes. // eslint-disable-next-line react-hooks/set-state-in-effect setHueState(next.hue) setSaturationState(next.saturation) setLightnessState(next.lightness) setAlphaState(next.alpha) } }, [value]) return (
) } export type ColorPickerSelectionProps = HTMLAttributes export const ColorPickerSelection = memo(({ className, ...props }: ColorPickerSelectionProps) => { const containerRef = useRef(null) const [isDragging, setIsDragging] = useState(false) const { hue, saturation, lightness, setSaturation, setLightness } = useColorPicker() const positionX = saturation / 100 const topLightness = 100 - positionX * 50 const positionY = Math.max(0, Math.min(1, 1 - lightness / topLightness)) 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 updateFromPointer = useCallback( (event: { clientX: number; clientY: number }) => { if (!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)) setSaturation(x * 100) const nextTopLightness = 100 - x * 50 const nextLightness = nextTopLightness * (1 - y) setLightness(nextLightness) }, [setSaturation, setLightness], ) return (
{ event.preventDefault() event.currentTarget.setPointerCapture(event.pointerId) setIsDragging(true) updateFromPointer(event) }} onPointerMove={event => { if (isDragging) updateFromPointer(event) }} onPointerUp={event => { event.currentTarget.releasePointerCapture(event.pointerId) setIsDragging(false) }} onPointerCancel={() => setIsDragging(false)} ref={containerRef} style={{ background: backgroundGradient, }} {...props} >
) }) ColorPickerSelection.displayName = "ColorPickerSelection" export type ColorPickerHueProps = ComponentProps export const ColorPickerHue = ({ className, ...props }: ColorPickerHueProps) => { const { hue, setHue } = useColorPicker() return ( setHue(hue)} step={1} value={[hue]} {...props} > ) } export type ColorPickerAlphaProps = ComponentProps export const ColorPickerAlpha = ({ className, ...props }: ColorPickerAlphaProps) => { const { alpha, setAlpha } = useColorPicker() return ( setAlpha(alpha)} step={1} value={[alpha]} {...props} >
) } export type ColorPickerEyeDropperProps = ComponentProps 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 ( ) } export type ColorPickerOutputProps = ComponentProps const formats = ["hex", "rgb", "css", "hsl"] export const ColorPickerOutput = ({ className, ...props }: ColorPickerOutputProps) => { const { mode, setMode } = useColorPicker() return ( ) } type PercentageInputProps = ComponentProps const PercentageInput = ({ className, ...props }: PercentageInputProps) => { return (
%
) } export type ColorPickerFormatProps = HTMLAttributes 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 (
) } if (mode === "rgb") { const rgb = color .rgb() .array() .map(value => Math.round(value)) return (
{rgb.map((value, index) => ( ))}
) } if (mode === "css") { const rgb = color .rgb() .array() .map(value => Math.round(value)) return (
) } if (mode === "hsl") { const hsl = color .hsl() .array() .map(value => Math.round(value)) return (
{hsl.map((value, index) => ( ))}
) } return null } // Demo export function Demo() { return (
) }