479 lines
14 KiB
TypeScript
479 lines
14 KiB
TypeScript
"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 = Omit<HTMLAttributes<HTMLDivElement>, "onChange"> & {
|
|
value?: Parameters<typeof Color>[0]
|
|
defaultValue?: Parameters<typeof Color>[0]
|
|
onChange?: (value: Parameters<typeof Color.rgb>[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<typeof channelsRef.current>) => {
|
|
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 (
|
|
<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} />
|
|
</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 { 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 (
|
|
<div
|
|
className={cn("relative size-full cursor-crosshair rounded", className)}
|
|
onPointerDown={event => {
|
|
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}
|
|
>
|
|
<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"
|
|
type="button"
|
|
variant="outline"
|
|
{...props}
|
|
>
|
|
<PipetteIcon />
|
|
</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={value => {
|
|
if (value) setMode(value)
|
|
}}
|
|
value={mode}
|
|
>
|
|
<SelectTrigger className={cn("h-8 w-20 shrink-0 text-xs", className)} {...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}%)`}
|
|
/>
|
|
</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
|
|
}
|
|
|
|
// Demo
|
|
export function Demo() {
|
|
return (
|
|
<div className="fixed inset-0 flex items-center justify-center p-8">
|
|
<ColorPicker defaultValue="#6366f1" className="h-auto w-64">
|
|
<ColorPickerSelection className="h-40 rounded-lg" />
|
|
<ColorPickerHue />
|
|
<ColorPickerAlpha />
|
|
<div className="flex items-center gap-2">
|
|
<ColorPickerEyeDropper />
|
|
<ColorPickerOutput />
|
|
<ColorPickerFormat />
|
|
</div>
|
|
</ColorPicker>
|
|
</div>
|
|
)
|
|
}
|