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 (
);
}
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) => (
onChange(option.value)}
className={cn(
"min-w-0 flex-1 cursor-pointer px-2 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-accent/70 hover:text-foreground",
value === option.value && "bg-accent text-foreground",
)}
>
{option.label}
))}
);
}
export function VisualToggleControl({
checked,
onChange,
label,
}: {
checked: boolean;
onChange: (checked: boolean) => void;
label?: string;
}) {
return (
onChange(!checked)}
className={cn(
"relative h-5 w-9 cursor-pointer rounded-full border border-border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
checked ? "bg-primary/35" : "bg-muted",
)}
>
);
}
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" && (
)}
);
}