import { type ComponentProps, forwardRef, useEffect, useRef, useState } from "react"; import { cn } from "@/utils/ui"; import { Input } from "./input"; import { Popover, PopoverClose, PopoverContent, PopoverTrigger, } from "./popover"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "./select"; import { Button } from "./button"; import { Cancel01Icon, ColorPickerIcon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { type ColorFormat, appendAlpha, extractColorFromText, formatColorValue, hexToHsv, hsvToHex, parseColorInput, parseHexAlpha, } from "@/utils/color"; const CHECKERBOARD_STYLE = { backgroundImage: ` linear-gradient(45deg, rgba(0,0,0,0.1) 25%, transparent 25%), linear-gradient(-45deg, rgba(0,0,0,0.1) 25%, transparent 25%), linear-gradient(45deg, transparent 75%, rgba(0,0,0,0.1) 75%), linear-gradient(-45deg, transparent 75%, rgba(0,0,0,0.1) 75%) `, backgroundSize: "8px 8px", backgroundPosition: "0 0, 0 4px, 4px -4px, -4px 0px", backgroundColor: "#fff", } as const; interface ColorPickerContentProps { value?: string; onChange?: (value: string) => void; onChangeEnd?: (value: string) => void; side?: ComponentProps["side"]; align?: ComponentProps["align"]; } function ColorPickerContent({ value = "FFFFFF", onChange, onChangeEnd, side = "left", align = "center", }: ColorPickerContentProps) { const [isDragging, setIsDragging] = useState< "saturation" | "hue" | "opacity" | null >(null); const [internalHue, setInternalHue] = useState(0); const [inputValue, setInputValue] = useState(value); const [colorFormat, setColorFormat] = useState("hex"); const saturationRef = useRef(null); const hueRef = useRef(null); const opacityRef = useRef(null); const latestDragColorRef = useRef(null); const isEyeDropperSupported = typeof window !== "undefined" && "EyeDropper" in window; const { rgb: rgbValue, alpha } = parseHexAlpha({ hex: value }); const [h, s, v] = hexToHsv({ hex: rgbValue }); const hueDiff = Math.abs(h - internalHue); const isSameHueWrapped = hueDiff < 1 || Math.abs(hueDiff - 360) < 1; const displayHue = s === 0 || isSameHueWrapped ? internalHue : h; useEffect(() => { setInputValue(formatColorValue({ hex: value, format: colorFormat })); }, [value, colorFormat]); useEffect(() => { const handleMouseMove = (event: MouseEvent) => { if (!isDragging) return; if (isDragging === "saturation" && saturationRef.current) { const rect = saturationRef.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), ); const newHex = appendAlpha({ rgbHex: hsvToHex({ h: displayHue, s: x, v: 1 - y }), alpha, }); latestDragColorRef.current = newHex; onChange?.(newHex); } if (isDragging === "hue" && hueRef.current) { const rect = hueRef.current.getBoundingClientRect(); const x = Math.max( 0, Math.min(1, (event.clientX - rect.left) / rect.width), ); const newH = x * 360; setInternalHue(newH); if (s > 0) { const newHex = appendAlpha({ rgbHex: hsvToHex({ h: newH, s, v }), alpha, }); latestDragColorRef.current = newHex; onChange?.(newHex); } } if (isDragging === "opacity" && opacityRef.current) { const rect = opacityRef.current.getBoundingClientRect(); const x = Math.max( 0, Math.min(1, (event.clientX - rect.left) / rect.width), ); const newHex = appendAlpha({ rgbHex: rgbValue, alpha: x }); latestDragColorRef.current = newHex; onChange?.(newHex); } }; const handleMouseUp = () => { if (latestDragColorRef.current !== null && onChangeEnd) { onChangeEnd(latestDragColorRef.current); latestDragColorRef.current = null; } setIsDragging(null); }; if (isDragging) { document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); return () => { document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); }; } }, [isDragging, displayHue, s, v, alpha, rgbValue, onChange, onChangeEnd]); const handleEyeDropper = async () => { if (!isEyeDropperSupported || !EyeDropper) return; try { const dropper = new EyeDropper(); const result = await dropper.open(); const hex = result.sRGBHex.replace("#", "").toLowerCase(); const finalHex = appendAlpha({ rgbHex: hex, alpha }); onChange?.(finalHex); onChangeEnd?.(finalHex); } catch { // user cancelled the picker } }; const handleSaturationMouseDown = (event: React.MouseEvent) => { event.preventDefault(); const saturationElement = saturationRef.current; if (!saturationElement) return; setIsDragging("saturation"); const rect = saturationElement.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)); const newHex = appendAlpha({ rgbHex: hsvToHex({ h: displayHue, s: x, v: 1 - y }), alpha, }); latestDragColorRef.current = newHex; onChange?.(newHex); }; const handleHueMouseDown = (event: React.MouseEvent) => { event.preventDefault(); const hueElement = hueRef.current; if (!hueElement) return; setIsDragging("hue"); const rect = hueElement.getBoundingClientRect(); const x = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)); const newH = x * 360; setInternalHue(newH); if (s > 0) { const newHex = appendAlpha({ rgbHex: hsvToHex({ h: newH, s, v }), alpha, }); latestDragColorRef.current = newHex; onChange?.(newHex); } }; const handleOpacityMouseDown = (event: React.MouseEvent) => { event.preventDefault(); const opacityElement = opacityRef.current; if (!opacityElement) return; setIsDragging("opacity"); const rect = opacityElement.getBoundingClientRect(); const x = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)); const newHex = appendAlpha({ rgbHex: rgbValue, alpha: x }); latestDragColorRef.current = newHex; onChange?.(newHex); }; const handleInputChange = (event: React.ChangeEvent) => { setInputValue( colorFormat === "hex" ? event.target.value.replace("#", "") : event.target.value, ); }; const commitInputValue = () => { const parsed = parseColorInput({ input: inputValue, format: colorFormat }); if (parsed) { const nextHex = appendAlpha({ rgbHex: parsed, alpha }); onChange?.(nextHex); onChangeEnd?.(nextHex); return; } const extracted = extractColorFromText({ text: inputValue }); if (extracted) { const hasExplicitAlpha = extracted.length > 6; const finalHex = hasExplicitAlpha ? extracted : appendAlpha({ rgbHex: extracted, alpha }); onChange?.(finalHex); onChangeEnd?.(finalHex); } }; const handleInputBlur = () => commitInputValue(); const handleInputKeyDown = (event: React.KeyboardEvent) => { if (event.key === "Enter") { commitInputValue(); event.currentTarget.blur(); } }; const handlePaste = (event: React.ClipboardEvent) => { const pastedText = event.clipboardData.getData("text"); const extractedHex = extractColorFromText({ text: pastedText }); if (!extractedHex) return; event.preventDefault(); const hasExplicitAlpha = extractedHex.length > 6; const finalHex = hasExplicitAlpha ? extractedHex : appendAlpha({ rgbHex: extractedHex, alpha }); onChange?.(finalHex); onChangeEnd?.(finalHex); }; const saturationStyle = { background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(${displayHue}, 100%, 50%))`, }; const hueStyle = { background: "linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)", }; return ( { event.preventDefault(); }} onCloseAutoFocus={(event) => { event.preventDefault(); }} onInteractOutside={(event) => { if (isDragging) event.preventDefault(); }} >
{isEyeDropperSupported && ( )}
); } interface ColorPickerProps { value?: string; onChange?: (value: string) => void; onChangeEnd?: (value: string) => void; className?: string; contentSide?: ComponentProps["side"]; contentAlign?: ComponentProps["align"]; } const ColorPicker = forwardRef( ( { className, value = "FFFFFF", onChange, onChangeEnd, contentSide, contentAlign, ...props }, ref, ) => { const { alpha } = parseHexAlpha({ hex: value }); const [inputValue, setInputValue] = useState(value); useEffect(() => { setInputValue(value); }, [value]); const commitInputValue = (raw: string) => { const input = raw.replace("#", ""); const parsed = parseColorInput({ input, format: "hex" }); if (parsed) { const nextHex = appendAlpha({ rgbHex: parsed, alpha }); onChange?.(nextHex); onChangeEnd?.(nextHex); return; } const extracted = extractColorFromText({ text: input }); if (extracted) { const hasExplicitAlpha = extracted.length > 6; const finalHex = hasExplicitAlpha ? extracted : appendAlpha({ rgbHex: extracted, alpha }); onChange?.(finalHex); onChangeEnd?.(finalHex); } }; const handleInputChange = (event: React.ChangeEvent) => { setInputValue(event.target.value.replace("#", "")); }; const handleInputBlur = () => commitInputValue(inputValue); const handleInputKeyDown = ( event: React.KeyboardEvent, ) => { if (event.key === "Enter") { commitInputValue(inputValue); event.currentTarget.blur(); } }; const handlePaste = (event: React.ClipboardEvent) => { const pastedText = event.clipboardData.getData("text"); const extractedHex = extractColorFromText({ text: pastedText }); if (!extractedHex) return; event.preventDefault(); const hasExplicitAlpha = extractedHex.length > 6; const finalHex = hasExplicitAlpha ? extractedHex : appendAlpha({ rgbHex: extractedHex, alpha }); onChange?.(finalHex); onChangeEnd?.(finalHex); }; return (
); }, ); ColorPicker.displayName = "ColorPicker"; const ColorCircle = ({ size, position, color, }: { size: "sm" | "md"; position: { left: string; top: string }; color?: string; }) => (
); export { ColorPicker, ColorPickerContent };