"use client"; import Color from "color"; import { Slider } from "radix-ui"; import { type ComponentProps, createContext, type HTMLAttributes, memo, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react"; import { useTranslation } from "react-i18next"; import { LuPipette } from "react-icons/lu"; 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< HTMLAttributes, "onChange" > & { value?: Parameters[0]; defaultValue?: Parameters[0]; onColorChange?: (value: [number, number, number, number]) => void; }; export const ColorPicker = ({ value, defaultValue = "#000000", onColorChange, className, children, ...props }: ColorPickerProps) => { const selectedColor = Color(value ?? defaultValue); const defaultColor = Color(defaultValue); const initialHue = Number.isFinite(selectedColor.hue()) ? selectedColor.hue() : Number.isFinite(defaultColor.hue()) ? defaultColor.hue() : 0; const initialSaturation = Number.isFinite(selectedColor.saturationl()) ? selectedColor.saturationl() : Number.isFinite(defaultColor.saturationl()) ? defaultColor.saturationl() : 100; const initialLightness = Number.isFinite(selectedColor.lightness()) ? selectedColor.lightness() : Number.isFinite(defaultColor.lightness()) ? defaultColor.lightness() : 50; const initialAlpha = Number.isFinite(selectedColor.alpha()) ? Math.round(selectedColor.alpha() * 100) : Math.round(defaultColor.alpha() * 100); const [hue, setHue] = useState(initialHue); const [saturation, setSaturation] = useState(initialSaturation); const [lightness, setLightness] = useState(initialLightness); const [alpha, setAlpha] = useState(initialAlpha); const [mode, setMode] = useState("hex"); const lastEmittedRef = useRef( `${Math.round(initialHue)}|${Math.round(initialSaturation)}|${Math.round(initialLightness)}|${Math.round(initialAlpha)}`, ); // Update color when controlled value changes useEffect(() => { if (value !== undefined) { const c = Color(value).hsl(); const nextHue = Number.isFinite(c.hue()) ? c.hue() : 0; const nextSat = Number.isFinite(c.saturationl()) ? c.saturationl() : 0; const nextLight = Number.isFinite(c.lightness()) ? c.lightness() : 0; const nextAlpha = Math.round( (Number.isFinite(c.alpha()) ? c.alpha() : 1) * 100, ); // Update internal state unconditionally when value prop changes setHue(nextHue); setSaturation(nextSat); setLightness(nextLight); setAlpha(nextAlpha); } }, [value]); // Remove state values from dependency array to prevent infinite loop // Notify parent of changes useEffect(() => { if (onColorChange) { const key = `${Math.round(hue)}|${Math.round(saturation)}|${Math.round(lightness)}|${Math.round(alpha)}`; if (key === lastEmittedRef.current) { return; } lastEmittedRef.current = key; const color = Color.hsl(hue, saturation, lightness).alpha(alpha / 100); const rgba = color.rgb().array(); onColorChange([rgba[0], rgba[1], rgba[2], alpha / 100]); } }, [hue, saturation, lightness, alpha, onColorChange]); return (
{children}
); }; export type ColorPickerSelectionProps = HTMLAttributes; export const ColorPickerSelection = memo( ({ className, ...props }: ColorPickerSelectionProps) => { const containerRef = useRef(null); const [isDragging, setIsDragging] = useState(false); const [positionX, setPositionX] = useState(0); const [positionY, setPositionY] = useState(0); const { hue, saturation, lightness, 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]); // Update position indicators when saturation/lightness change externally useEffect(() => { if (!isDragging) { const x = saturation / 100; const topLightness = x < 0.01 ? 100 : 50 + 50 * (1 - x); const y = topLightness > 0 ? 1 - lightness / topLightness : 0; setPositionX(x); setPositionY(Math.max(0, Math.min(1, y))); } }, [saturation, lightness, isDragging]); 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 (
{ e.preventDefault(); setIsDragging(true); handlePointerMove(e.nativeEvent); }} 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: _className, ...props }: ColorPickerOutputProps) => { const { t } = useTranslation(); 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; };