| import { useRef, useState, useCallback } from "react"; |
| import { OKLCH_L, OKLCH_C } from "#shared/theme"; |
|
|
| const COLOR_NAMES = [ |
| { name: "Candy Apple Red", hue: 0 }, |
| { name: "Boiling Magma", hue: 15 }, |
| { name: "Aerospace Orange", hue: 25 }, |
| { name: "Burtuqali Orange", hue: 33 }, |
| { name: "American Orange", hue: 40 }, |
| { name: "Cheese", hue: 48 }, |
| { name: "Amber", hue: 55 }, |
| { name: "Demonic Yellow", hue: 65 }, |
| { name: "Bat-Signal", hue: 72 }, |
| { name: "Bright Yellow Green", hue: 85 }, |
| { name: "Lasting Lime", hue: 95 }, |
| { name: "Bright Green", hue: 110 }, |
| { name: "Chlorophyll Green", hue: 120 }, |
| { name: "Green Screen", hue: 130 }, |
| { name: "Cathode Green", hue: 148 }, |
| { name: "Booger Buster", hue: 160 }, |
| { name: "Enthusiasm", hue: 170 }, |
| { name: "Ice Ice Baby", hue: 180 }, |
| { name: "Vivid Sky Blue", hue: 195 }, |
| { name: "Azure", hue: 214 }, |
| { name: "Blue Ribbon", hue: 225 }, |
| { name: "Icelandic Water", hue: 238 }, |
| { name: "Blue Pencil", hue: 250 }, |
| { name: "Electric Ultramarine", hue: 260 }, |
| { name: "Purple Climax", hue: 275 }, |
| { name: "Amethyst Ganzstar", hue: 285 }, |
| { name: "Electric Purple", hue: 295 }, |
| { name: "Phlox", hue: 305 }, |
| { name: "Brusque Pink", hue: 315 }, |
| { name: "Bright Magenta", hue: 325 }, |
| { name: "Pink", hue: 335 }, |
| { name: "Hot Flamingoes", hue: 345 }, |
| { name: "Carmine Red", hue: 355 }, |
| ]; |
|
|
| function getColorName(hue: number): string { |
| let best = COLOR_NAMES[0].name; |
| let bestDist = 361; |
| for (const c of COLOR_NAMES) { |
| const d = Math.abs(c.hue - hue); |
| const dist = Math.min(d, 360 - d); |
| if (dist < bestDist) { |
| bestDist = dist; |
| best = c.name; |
| } |
| } |
| return best; |
| } |
|
|
| function oklchToHex(l: number, c: number, h: number): string { |
| const hRad = (h * Math.PI) / 180; |
| const a = c * Math.cos(hRad); |
| const b = c * Math.sin(hRad); |
|
|
| const l_ = l + 0.3963377774 * a + 0.2158037573 * b; |
| const m_ = l - 0.1055613458 * a - 0.0638541728 * b; |
| const s_ = l - 0.0894841775 * a - 1.291485548 * b; |
|
|
| const ll = l_ * l_ * l_; |
| const mm = m_ * m_ * m_; |
| const ss = s_ * s_ * s_; |
|
|
| const toSrgb = (u: number) => |
| u <= 0.0031308 ? 12.92 * u : 1.055 * Math.pow(Math.max(0, u), 1 / 2.4) - 0.055; |
|
|
| const r = toSrgb(+4.0767416621 * ll - 3.3077115913 * mm + 0.2309699292 * ss); |
| const g = toSrgb(-1.2684380046 * ll + 2.6097574011 * mm - 0.3413193965 * ss); |
| const bVal = toSrgb(-0.0041960863 * ll - 0.7034186147 * mm + 1.707614701 * ss); |
|
|
| const clamp = (v: number) => Math.max(0, Math.min(255, Math.round(v * 255))); |
| const hex = (v: number) => clamp(v).toString(16).padStart(2, "0"); |
| return `#${hex(r)}${hex(g)}${hex(bVal)}`; |
| } |
|
|
| interface HueSliderProps { |
| hue: number; |
| onChange: (hue: number) => void; |
| } |
|
|
| export function HueSlider({ hue, onChange }: HueSliderProps) { |
| const sliderRef = useRef<HTMLDivElement>(null); |
| const [dragging, setDragging] = useState(false); |
|
|
| const hexColor = oklchToHex(OKLCH_L, OKLCH_C, hue); |
| const colorName = getColorName(hue); |
|
|
| const hueFromClientX = useCallback((clientX: number) => { |
| const rect = sliderRef.current?.getBoundingClientRect(); |
| if (!rect) return hue; |
| const x = Math.max(0, Math.min(clientX - rect.left, rect.width)); |
| return (x / rect.width) * 360; |
| }, [hue]); |
|
|
| const handlePointerDown = useCallback((e: React.PointerEvent) => { |
| e.preventDefault(); |
| (e.target as HTMLElement).setPointerCapture(e.pointerId); |
| setDragging(true); |
| onChange(hueFromClientX(e.clientX)); |
| }, [hueFromClientX, onChange]); |
|
|
| const handlePointerMove = useCallback((e: React.PointerEvent) => { |
| if (!dragging) return; |
| onChange(hueFromClientX(e.clientX)); |
| }, [dragging, hueFromClientX, onChange]); |
|
|
| const handlePointerUp = useCallback(() => { |
| setDragging(false); |
| }, []); |
|
|
| const handleKeyDown = useCallback((e: React.KeyboardEvent) => { |
| const step = e.shiftKey ? 10 : 2; |
| if (e.key === "ArrowLeft") { e.preventDefault(); onChange(((hue - step) % 360 + 360) % 360); } |
| if (e.key === "ArrowRight") { e.preventDefault(); onChange((hue + step) % 360); } |
| }, [hue, onChange]); |
|
|
| return ( |
| <div style={{ display: "flex", flexDirection: "column", gap: 12 }}> |
| <div style={{ display: "flex", alignItems: "center", gap: 12 }}> |
| <div |
| style={{ |
| width: 44, |
| height: 44, |
| borderRadius: 8, |
| backgroundColor: hexColor, |
| border: "1px solid var(--ed-border)", |
| flexShrink: 0, |
| }} |
| /> |
| <div style={{ minWidth: 0 }}> |
| <div style={{ fontWeight: 700, fontSize: "0.8rem", lineHeight: 1.3, color: "var(--ed-text)" }}> |
| {colorName} |
| </div> |
| <div style={{ color: "var(--ed-text-secondary)", fontSize: "0.7rem", fontFamily: "monospace" }}> |
| {hexColor.toUpperCase()} - {Math.round(hue)}° |
| </div> |
| </div> |
| </div> |
| |
| <div |
| ref={sliderRef} |
| role="slider" |
| tabIndex={0} |
| aria-label="Hue" |
| aria-valuemin={0} |
| aria-valuemax={360} |
| aria-valuenow={Math.round(hue)} |
| onPointerDown={handlePointerDown} |
| onPointerMove={handlePointerMove} |
| onPointerUp={handlePointerUp} |
| onKeyDown={handleKeyDown} |
| style={{ |
| position: "relative", |
| height: 16, |
| borderRadius: 10, |
| border: "1px solid var(--ed-border)", |
| background: "linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)", |
| cursor: "ew-resize", |
| touchAction: "none", |
| }} |
| > |
| <div |
| style={{ |
| position: "absolute", |
| top: "50%", |
| left: `${(hue / 360) * 100}%`, |
| width: 14, |
| height: 14, |
| borderRadius: "50%", |
| border: "2px solid #fff", |
| transform: "translate(-50%, -50%)", |
| backgroundColor: hexColor, |
| boxShadow: "0 0 0 1px rgba(0,0,0,0.15), 0 1px 3px rgba(0,0,0,0.3)", |
| pointerEvents: "none", |
| }} |
| /> |
| </div> |
| </div> |
| ); |
| } |
|
|
| export { oklchToHex }; |
|
|