import { IconColorPicker } from "@tabler/icons-react"; import { useEffect, useId, useRef, useState, type KeyboardEvent, type PointerEvent, type ReactNode, } from "react"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover.js"; import { cn } from "../utils.js"; export type VisualControlValue = string | number | boolean; export interface VisualControlOption { label: string; value: string; color?: string; } export interface VisualTweakDefinition { id: string; label: string; type: "color-swatch" | "color-swatches" | "segment" | "slider" | "toggle"; options?: VisualControlOption[]; min?: number; max?: number; step?: number; defaultValue: VisualControlValue; cssVar?: string; unit?: string; } function clampNumber(value: number, min?: number, max?: number) { let next = value; if (typeof min === "number") next = Math.max(min, next); if (typeof max === "number") next = Math.min(max, next); return next; } function formatNumber(value: number, unit?: string) { const rounded = Number.isInteger(value) ? value : Number(value.toFixed(2)); return unit ? `${rounded}${unit}` : String(rounded); } function parseDraftNumber(value: string, fallback: number) { const match = value.trim().match(/-?\d+(?:\.\d+)?/); if (!match) return fallback; const next = Number(match[0]); return Number.isFinite(next) ? next : fallback; } interface RgbaColor { r: number; g: number; b: number; a: number; } interface HsvaColor { h: number; s: number; v: number; a: number; } const CHECKERBOARD_IMAGE = "linear-gradient(45deg, #d4d4d4 25%, transparent 25%), linear-gradient(-45deg, #d4d4d4 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #d4d4d4 75%), linear-gradient(-45deg, transparent 75%, #d4d4d4 75%)"; const FALLBACK_RGBA: RgbaColor = { r: 0, g: 0, b: 0, a: 1 }; function normalizeRgba(color: RgbaColor): RgbaColor { return { r: clamp(color.r, 0, 255), g: clamp(color.g, 0, 255), b: clamp(color.b, 0, 255), a: clampFloat(color.a, 0, 1), }; } function normalizeHexColor(value: string): string | null { const trimmed = value.trim(); if (/^#[0-9a-f]{6}$/i.test(trimmed)) return trimmed.toUpperCase(); if (/^[0-9a-f]{6}$/i.test(trimmed)) return `#${trimmed}`.toUpperCase(); if (/^#[0-9a-f]{3}$/i.test(trimmed)) { return `#${trimmed .slice(1) .split("") .map((char) => char + char) .join("")}`.toUpperCase(); } if (/^[0-9a-f]{3}$/i.test(trimmed)) { return `#${trimmed .split("") .map((char) => char + char) .join("")}`.toUpperCase(); } return null; } function parseCssColor(value: string): RgbaColor | null { const trimmed = value.trim(); if (!trimmed) return null; const normalizedHex = normalizeHexColor(trimmed); if (normalizedHex) { return normalizeRgba({ r: Number.parseInt(normalizedHex.slice(1, 3), 16), g: Number.parseInt(normalizedHex.slice(3, 5), 16), b: Number.parseInt(normalizedHex.slice(5, 7), 16), a: 1, }); } const rgb = trimmed.match( /^rgba?\(\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)(?:\s*,\s*([0-9.]+%?))?/i, ); if (rgb) { return normalizeRgba({ r: Number(rgb[1]), g: Number(rgb[2]), b: Number(rgb[3]), a: parseAlpha(rgb[4]), }); } const lower = trimmed.toLowerCase(); if (lower === "transparent" || lower === "rgba(0, 0, 0, 0)") { return { r: 0, g: 0, b: 0, a: 0 }; } return null; } function channelToHex(value: number): string { return clamp(value, 0, 255).toString(16).padStart(2, "0"); } function rgbaToHex(color: RgbaColor): string { const normalized = normalizeRgba(color); return `#${channelToHex(normalized.r)}${channelToHex(normalized.g)}${channelToHex(normalized.b)}`.toUpperCase(); } function rgbaToCss(color: RgbaColor): string { const normalized = normalizeRgba(color); if (normalized.a >= 1) return rgbaToHex(normalized); return `rgba(${normalized.r}, ${normalized.g}, ${normalized.b}, ${trimNumber(normalized.a)})`; } function parseAlpha(value: string | undefined): number { if (!value) return 1; if (value.endsWith("%")) return Number(value.slice(0, -1)) / 100; return Number(value); } function trimNumber(value: number) { return Number(value.toFixed(2)).toString(); } function rgbaToHsv(color: RgbaColor): HsvaColor { const normalized = normalizeRgba(color); const r = normalized.r / 255; const g = normalized.g / 255; const b = normalized.b / 255; const max = Math.max(r, g, b); const min = Math.min(r, g, b); const delta = max - min; let h = 0; if (delta !== 0) { if (max === r) h = ((g - b) / delta) % 6; else if (max === g) h = (b - r) / delta + 2; else h = (r - g) / delta + 4; h *= 60; if (h < 0) h += 360; } return { h: Math.round(h), s: max === 0 ? 0 : Math.round((delta / max) * 100), v: Math.round(max * 100), a: normalized.a, }; } function hsvToRgba(color: HsvaColor): RgbaColor { const h = ((color.h % 360) + 360) % 360; const s = clampFloat(color.s, 0, 100) / 100; const v = clampFloat(color.v, 0, 100) / 100; const chroma = v * s; const x = chroma * (1 - Math.abs(((h / 60) % 2) - 1)); const m = v - chroma; let r = 0; let g = 0; let b = 0; if (h < 60) [r, g, b] = [chroma, x, 0]; else if (h < 120) [r, g, b] = [x, chroma, 0]; else if (h < 180) [r, g, b] = [0, chroma, x]; else if (h < 240) [r, g, b] = [0, x, chroma]; else if (h < 300) [r, g, b] = [x, 0, chroma]; else [r, g, b] = [chroma, 0, x]; return normalizeRgba({ r: Math.round((r + m) * 255), g: Math.round((g + m) * 255), b: Math.round((b + m) * 255), a: clampFloat(color.a, 0, 1), }); } function clamp(value: number, min: number, max: number): number { if (!Number.isFinite(value)) return min; return Math.max(min, Math.min(max, Math.round(value))); } function clampFloat(value: number, min: number, max: number): number { if (!Number.isFinite(value)) return min; return Math.max(min, Math.min(max, value)); } function swatchBackground(value: string) { if (value === "transparent" || value === "rgba(0, 0, 0, 0)") { return { background: CHECKERBOARD_IMAGE, backgroundPosition: "0 0, 0 4px, 4px -4px, -4px 0", backgroundSize: "8px 8px", }; } return { background: value }; } export function VisualInspectorPanel({ title, subtitle, children, className, headerAction, }: { title: ReactNode; subtitle?: ReactNode; children: ReactNode; className?: string; headerAction?: ReactNode; }) { return ( ); } export function VisualInspectorSection({ title, children, className, }: { title: ReactNode; children: ReactNode; className?: string; }) { return (
{title}
{children}
); } export function VisualControlRow({ label, children, className, }: { label: ReactNode; children: ReactNode; className?: string; }) { return (
{label} {children}
); } export function VisualSwatchControl({ options, value, onChange, columns = 8, className, }: { options: VisualControlOption[]; value: string; onChange: (value: string) => void; columns?: number; className?: string; }) { return (
{options.map((option) => { const swatch = option.color ?? option.value; const isTransparent = swatch === "transparent"; return (
); } export function VisualColorPicker({ label, value, onChange, documentColors = [], allowTransparent = false, hexLabel = "Hex", documentColorsLabel = "Document colors", transparentLabel = "Transparent", className, }: { label: string; value: string; onChange: (value: string) => void; documentColors?: string[]; allowTransparent?: boolean; hexLabel?: string; documentColorsLabel?: string; transparentLabel?: string; className?: string; }) { const [open, setOpen] = useState(false); const color = parseCssColor(value) ?? FALLBACK_RGBA; const colorHex = rgbaToHex(color); const [draft, setDraft] = useState(colorHex.replace(/^#/, "")); const [picking, setPicking] = useState(false); const hasEyeDropper = typeof window !== "undefined" && "EyeDropper" in window; useEffect(() => { if (!open) setDraft(colorHex.replace(/^#/, "")); }, [colorHex, open]); const commitDraft = (nextDraft = draft) => { const normalized = normalizeHexColor(nextDraft); if (!normalized) { setDraft(colorHex.replace(/^#/, "")); return; } setDraft(normalized.replace(/^#/, "")); onChange(normalized); }; const emitHsv = (nextHsv: HsvaColor) => { const nextColor = hsvToRgba({ ...nextHsv, a: color.a }); const nextValue = rgbaToCss(nextColor); setDraft(rgbaToHex(nextColor).replace(/^#/, "")); onChange(nextValue); }; const pickScreenColor = async () => { if (!hasEyeDropper || picking) return; try { setPicking(true); const EyeDropperCtor = ( window as unknown as { EyeDropper?: new () => { open: () => Promise<{ sRGBHex: string }> }; } ).EyeDropper; if (!EyeDropperCtor) return; const result = await new EyeDropperCtor().open(); if (result?.sRGBHex) { const next = normalizeHexColor(result.sRGBHex); if (next) { setDraft(next.replace(/^#/, "")); onChange(next); } } } finally { setPicking(false); } }; const uniqueDocumentColors = Array.from( new Set( documentColors .map((color) => color === "transparent" ? color : normalizeHexColor(color), ) .filter(Boolean) as string[], ), ).slice(0, 16); const displayValue = value === "transparent" || color.a === 0 ? transparentLabel : colorHex.replace(/^#/, ""); const hsv = rgbaToHsv(color); return ( event.preventDefault()} >
emitHsv({ ...hsv, h: nextHue === 360 ? 0 : nextHue }) } /> { const nextColor = normalizeRgba({ ...color, a: nextOpacity / 100, }); const nextValue = rgbaToCss(nextColor); setDraft(rgbaToHex(nextColor).replace(/^#/, "")); onChange(nextValue); }} />
{hexLabel} setDraft(event.currentTarget.value)} onFocus={(event) => event.currentTarget.select()} onBlur={() => commitDraft()} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); commitDraft(); event.currentTarget.blur(); } if (event.key === "Escape") { event.preventDefault(); setDraft(colorHex.replace(/^#/, "")); event.currentTarget.blur(); } }} className="h-6 min-w-0 rounded-md border border-input bg-background/70 px-2 text-right text-[11px] uppercase tabular-nums outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring" />
{(allowTransparent || uniqueDocumentColors.length > 0) && (
{documentColorsLabel}
{allowTransparent && (
)}
); } function VisualSaturationBrightnessField({ hsv, label, onChange, }: { hsv: HsvaColor; label: string; onChange: (value: HsvaColor) => void; }) { const fieldRef = useRef(null); const draggingRef = useRef(false); const hueColor = rgbaToCss(hsvToRgba({ h: hsv.h, s: 100, v: 100, a: 1 })); const updateFromPointer = (event: PointerEvent) => { const rect = fieldRef.current?.getBoundingClientRect(); if (!rect) return; onChange({ ...hsv, s: clamp(((event.clientX - rect.left) / rect.width) * 100, 0, 100), v: clamp(100 - ((event.clientY - rect.top) / rect.height) * 100, 0, 100), }); }; const stepWithKeyboard = (event: KeyboardEvent) => { const step = event.shiftKey ? 10 : 1; if (event.key === "ArrowRight") { event.preventDefault(); onChange({ ...hsv, s: clamp(hsv.s + step, 0, 100) }); } if (event.key === "ArrowLeft") { event.preventDefault(); onChange({ ...hsv, s: clamp(hsv.s - step, 0, 100) }); } if (event.key === "ArrowUp") { event.preventDefault(); onChange({ ...hsv, v: clamp(hsv.v + step, 0, 100) }); } if (event.key === "ArrowDown") { event.preventDefault(); onChange({ ...hsv, v: clamp(hsv.v - step, 0, 100) }); } }; return (
{ draggingRef.current = true; event.currentTarget.setPointerCapture(event.pointerId); updateFromPointer(event); }} onPointerMove={(event) => { if (!draggingRef.current) return; updateFromPointer(event); }} onPointerUp={(event) => { draggingRef.current = false; if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } }} onPointerCancel={() => { draggingRef.current = false; }} onKeyDown={stepWithKeyboard} className="relative h-40 w-full cursor-crosshair overflow-hidden rounded-t-md outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset active:cursor-grabbing" style={{ backgroundImage: `linear-gradient(to top, #000 0%, transparent 100%), linear-gradient(to right, #fff 0%, ${hueColor} 100%)`, }} >
); } function VisualColorTrack({ label, value, min, max, backgroundImage, backgroundSize, backgroundPosition, onChange, }: { label: string; value: number; min: number; max: number; backgroundImage: string; backgroundSize?: string; backgroundPosition?: string; onChange: (value: number) => void; }) { const trackRef = useRef(null); const draggingRef = useRef(false); const percent = ((value - min) / (max - min)) * 100; const updateFromPointer = (event: PointerEvent) => { const rect = trackRef.current?.getBoundingClientRect(); if (!rect) return; const next = min + ((event.clientX - rect.left) / rect.width) * (max - min); onChange(clamp(next, min, max)); }; const stepWithKeyboard = (event: KeyboardEvent) => { const step = event.shiftKey ? 10 : 1; if (event.key === "ArrowRight" || event.key === "ArrowUp") { event.preventDefault(); onChange(clamp(value + step, min, max)); } if (event.key === "ArrowLeft" || event.key === "ArrowDown") { event.preventDefault(); onChange(clamp(value - step, min, max)); } if (event.key === "Home") { event.preventDefault(); onChange(min); } if (event.key === "End") { event.preventDefault(); onChange(max); } }; return (
{ draggingRef.current = true; event.currentTarget.setPointerCapture(event.pointerId); updateFromPointer(event); }} onPointerMove={(event) => { if (!draggingRef.current) return; updateFromPointer(event); }} onPointerUp={(event) => { draggingRef.current = false; if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } }} onPointerCancel={() => { draggingRef.current = false; }} className="relative h-3.5 cursor-pointer rounded-full border border-border/60 outline-none ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 active:cursor-grabbing" style={{ backgroundImage, backgroundSize, backgroundPosition }} >
); } export function VisualSegmentedControl({ options, value, onChange, className, }: { options: VisualControlOption[]; value: string; onChange: (value: string) => void; className?: string; }) { return (
{options.map((option) => ( ))}
); } export function VisualToggleControl({ checked, onChange, label, }: { checked: boolean; onChange: (checked: boolean) => void; label?: string; }) { return ( ); } export function VisualSliderControl({ value, onChange, min = 0, max = 100, step = 1, unit, }: { value: number; onChange: (value: number) => void; min?: number; max?: number; step?: number; unit?: string; }) { const safeValue = clampNumber(Number.isFinite(value) ? value : min, min, max); return (
onChange(Number(event.currentTarget.value))} className="min-w-0 flex-1 cursor-pointer accent-foreground" /> {formatNumber(safeValue, unit)}
); } export function VisualScrubInput({ label, value, onChange, min, max, step = 1, unit, disabled = false, }: { label: string; value: number; onChange: (value: number) => void; min?: number; max?: number; step?: number; unit?: string; disabled?: boolean; }) { const id = useId(); const [draft, setDraft] = useState(() => formatNumber(value, unit)); const [focused, setFocused] = useState(false); const dragRef = useRef<{ pointerId: number; prevX: number; dragged: boolean; } | null>(null); useEffect(() => { if (!focused) setDraft(formatNumber(value, unit)); }, [focused, unit, value]); const commit = (nextDraft = draft) => { const parsed = parseDraftNumber(nextDraft, value); const next = clampNumber(parsed, min, max); onChange(next); setDraft(formatNumber(next, unit)); }; const setNext = (next: number) => { const clamped = clampNumber(next, min, max); onChange(clamped); setDraft(formatNumber(clamped, unit)); }; const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Enter") { event.preventDefault(); commit(); event.currentTarget.blur(); return; } if (event.key === "Escape") { event.preventDefault(); setDraft(formatNumber(value, unit)); event.currentTarget.blur(); return; } if (event.key === "ArrowUp" || event.key === "ArrowDown") { event.preventDefault(); const mult = event.shiftKey || event.metaKey ? 10 : event.altKey ? 0.1 : 1; const direction = event.key === "ArrowUp" ? 1 : -1; const base = parseDraftNumber(draft, value); setNext(base + direction * step * mult); } }; const onPointerDown = (event: PointerEvent) => { if (disabled || event.button !== 0) return; event.preventDefault(); dragRef.current = { pointerId: event.pointerId, prevX: event.clientX, dragged: false, }; event.currentTarget.setPointerCapture(event.pointerId); }; const onPointerMove = (event: PointerEvent) => { const drag = dragRef.current; if (!drag || drag.pointerId !== event.pointerId) return; const delta = event.clientX - drag.prevX; if (delta === 0) return; drag.prevX = event.clientX; drag.dragged = true; const mult = event.shiftKey || event.metaKey ? 10 : event.altKey ? 0.1 : 1; setNext(value + delta * step * mult); }; const onPointerUp = (event: PointerEvent) => { const drag = dragRef.current; if (!drag || drag.pointerId !== event.pointerId) return; event.currentTarget.releasePointerCapture(event.pointerId); dragRef.current = null; if (!drag.dragged) { document.getElementById(id)?.focus(); } }; return (
setFocused(true)} onBlur={() => { setFocused(false); commit(); }} onChange={(event) => setDraft(event.currentTarget.value)} onKeyDown={onKeyDown} className="h-7 min-w-0 flex-1 rounded-md border border-input bg-background/70 px-2 text-right text-[11px] tabular-nums text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring disabled:opacity-50" />
); } export function VisualTweakControl({ tweak, value, onChange, className, }: { tweak: VisualTweakDefinition; value: VisualControlValue; onChange: (value: VisualControlValue) => void; className?: string; }) { if (tweak.type === "toggle") { return (
{tweak.label}
); } const numericValue = typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number(tweak.defaultValue); return ( {((tweak.type as string) === "color-swatch" || (tweak.type as string) === "color-swatches") && ( option.color ?? option.value, )} onChange={onChange} /> )} {tweak.type === "segment" && ( )} {tweak.type === "slider" && ( )} ); }