import { useEffect, useRef, useState, type CSSProperties, type InputHTMLAttributes, type KeyboardEvent, type PointerEvent } from "react"; import { cn } from "./cn.js"; import { Dropdown } from "./Dropdown.js"; export interface RangeControlProps extends Omit, "type" | "value" | "onChange"> { label: string; value: number; min: number; max: number; formatValue?: (value: number) => string; onChange: (value: number) => void; } const clampRangeValue = (value: number, min: number, max: number) => ( Math.max(min, Math.min(max, value)) ); const decimalPlaces = (value: number) => { const [coefficient, exponentText] = String(value).toLowerCase().split("e"); const coefficientDecimals = coefficient?.split(".")[1]?.length ?? 0; if (exponentText === undefined) return coefficientDecimals; const exponent = Number.parseInt(exponentText, 10); if (!Number.isFinite(exponent)) return coefficientDecimals; return Math.max(0, coefficientDecimals - exponent); }; const normalizeEditedRangeValue = ( value: number, min: number, max: number, step: RangeControlProps["step"], ) => { const clamped = clampRangeValue(value, min, max); if (step === "any") return clamped; const numericStep = step === undefined ? 1 : Number(step); if (!Number.isFinite(numericStep) || numericStep <= 0) return clamped; const precision = Math.min(12, Math.max(decimalPlaces(min), decimalPlaces(numericStep))); const aligned = min + Math.round((clamped - min) / numericStep) * numericStep; return clampRangeValue(Number(aligned.toFixed(precision)), min, max); }; const editableNumber = (value: number) => ( Number.isInteger(value) ? String(value) : String(Number(value.toFixed(12))) ); export function RangeControl({ label, value, min, max, step, formatValue = String, onChange, className, disabled = false, tabIndex, ...props }: RangeControlProps) { const rangeRef = useRef(null); const editorRef = useRef(null); const cancelNextBlurRef = useRef(false); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(() => editableNumber(value)); const safeMax = max === min ? min + 1 : max; const progress = Math.max(0, Math.min(100, ((value - min) / (safeMax - min)) * 100)); const displayValue = formatValue(value); const editorCharacters = Math.max( 1, Math.min(14, editing ? draft.length || editableNumber(value).length : displayValue.length), ); const style = { "--nodedc-range-progress": `${progress}%`, "--nodedc-range-editor-characters": editorCharacters, } as CSSProperties; useEffect(() => { if (!editing) setDraft(editableNumber(value)); }, [editing, value]); const finishEditing = (commit: boolean, restoreRangeFocus: boolean) => { if (commit) { const parsed = Number(draft.trim().replace(",", ".")); if (Number.isFinite(parsed)) onChange(normalizeEditedRangeValue(parsed, min, max, step)); } setEditing(false); if (restoreRangeFocus) requestAnimationFrame(() => rangeRef.current?.focus()); }; const handleEditorKeyDown = (event: KeyboardEvent) => { event.stopPropagation(); if (event.key === "Enter") { event.preventDefault(); event.currentTarget.blur(); } if (event.key === "Escape") { event.preventDefault(); cancelNextBlurRef.current = true; setDraft(editableNumber(value)); setEditing(false); event.currentTarget.blur(); requestAnimationFrame(() => rangeRef.current?.focus()); } }; return (
onChange(Number(event.target.value))} /> {label} {displayValue} { if (!editing) { setDraft(editableNumber(value)); setEditing(true); } }} onFocus={() => { if (!editing) { setDraft(editableNumber(value)); setEditing(true); } }} onChange={(event) => setDraft(event.target.value)} onKeyDown={handleEditorKeyDown} onBlur={() => { if (cancelNextBlurRef.current) { cancelNextBlurRef.current = false; return; } finishEditing(true, false); }} />
); } export interface ColorFieldProps extends Omit, "type" | "value" | "onChange"> { value: string; onChange: (value: string) => void; label?: string; } type Hsv = { h: number; s: number; v: number }; const palettePresets = ["#ff2f92", "#ff6caf", "#d746ff", "#7a3cff", "#4ea4ff", "#35d7c1", "#c3ff66", "#ffffff", "#404040", "#111116"]; function clamp(value: number, min: number, max: number) { return Math.max(min, Math.min(max, value)); } function parseHexColor(value: string): [number, number, number] | null { const match = /^#?([0-9a-f]{6})$/i.exec(value.trim()); if (!match) return null; const hex = match[1]; return [ Number.parseInt(hex.slice(0, 2), 16), Number.parseInt(hex.slice(2, 4), 16), Number.parseInt(hex.slice(4, 6), 16), ]; } function hexToHsv(value: string): Hsv | null { const rgb = parseHexColor(value); if (!rgb) return null; const [red, green, blue] = rgb.map((channel) => channel / 255) as [number, number, number]; const max = Math.max(red, green, blue); const min = Math.min(red, green, blue); const delta = max - min; let hue = 0; if (delta) { if (max === red) hue = 60 * (((green - blue) / delta) % 6); else if (max === green) hue = 60 * ((blue - red) / delta + 2); else hue = 60 * ((red - green) / delta + 4); } return { h: (hue + 360) % 360, s: max === 0 ? 0 : (delta / max) * 100, v: max * 100 }; } function hsvToHex({ h, s, v }: Hsv): string { const hue = ((h % 360) + 360) % 360; const saturation = clamp(s, 0, 100) / 100; const value = clamp(v, 0, 100) / 100; const chroma = value * saturation; const component = (hue / 60) % 2; const match = chroma * (1 - Math.abs(component - 1)); const base = hue < 60 ? [chroma, match, 0] : hue < 120 ? [match, chroma, 0] : hue < 180 ? [0, chroma, match] : hue < 240 ? [0, match, chroma] : hue < 300 ? [match, 0, chroma] : [chroma, 0, match]; const offset = value - chroma; return `#${base.map((channel) => Math.round((channel + offset) * 255).toString(16).padStart(2, "0")).join("")}`; } export function ColorField({ value, onChange, label = "Цвет", className, disabled = false, ...props }: ColorFieldProps) { const validColor = hexToHsv(value); const [hsv, setHsv] = useState(() => validColor ?? { h: 330, s: 82, v: 100 }); const style = { "--nodedc-color-value": validColor ? hsvToHex(validColor) : hsvToHex(hsv) } as CSSProperties; useEffect(() => { if (validColor) setHsv(validColor); }, [value, validColor?.h, validColor?.s, validColor?.v]); const commit = (next: Hsv) => { const normalized = { h: clamp(next.h, 0, 360), s: clamp(next.s, 0, 100), v: clamp(next.v, 0, 100) }; setHsv(normalized); onChange(hsvToHex(normalized)); }; const updateSpectrum = (event: PointerEvent) => { const rect = event.currentTarget.getBoundingClientRect(); commit({ ...hsv, s: ((event.clientX - rect.left) / rect.width) * 100, v: 100 - ((event.clientY - rect.top) / rect.height) * 100 }); }; return (
( )} >
{ if (disabled) return; event.currentTarget.setPointerCapture(event.pointerId); updateSpectrum(event); }} onPointerMove={(event) => { if (event.currentTarget.hasPointerCapture(event.pointerId)) updateSpectrum(event); }} onPointerUp={(event) => { if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); }} onKeyDown={(event) => { const delta = event.shiftKey ? 10 : 2; if (event.key === "ArrowLeft") { event.preventDefault(); commit({ ...hsv, s: hsv.s - delta }); } if (event.key === "ArrowRight") { event.preventDefault(); commit({ ...hsv, s: hsv.s + delta }); } if (event.key === "ArrowDown") { event.preventDefault(); commit({ ...hsv, v: hsv.v - delta }); } if (event.key === "ArrowUp") { event.preventDefault(); commit({ ...hsv, v: hsv.v + delta }); } }} >
{palettePresets.map((preset) => (
onChange(event.target.value)} {...props} />
); }