213 lines
8.2 KiB
TypeScript
213 lines
8.2 KiB
TypeScript
import { useEffect, useState, type CSSProperties, type InputHTMLAttributes, type PointerEvent } from "react";
|
||
import { cn } from "./cn.js";
|
||
import { Dropdown } from "./Dropdown.js";
|
||
|
||
export interface RangeControlProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange"> {
|
||
label: string;
|
||
value: number;
|
||
min: number;
|
||
max: number;
|
||
formatValue?: (value: number) => string;
|
||
onChange: (value: number) => void;
|
||
}
|
||
|
||
export function RangeControl({
|
||
label,
|
||
value,
|
||
min,
|
||
max,
|
||
step,
|
||
formatValue = String,
|
||
onChange,
|
||
className,
|
||
...props
|
||
}: RangeControlProps) {
|
||
const safeMax = max === min ? min + 1 : max;
|
||
const progress = Math.max(0, Math.min(100, ((value - min) / (safeMax - min)) * 100));
|
||
const style = { "--nodedc-range-progress": `${progress}%` } as CSSProperties;
|
||
return (
|
||
<label className={cn("nodedc-range", className)} style={style}>
|
||
<input
|
||
type="range"
|
||
value={value}
|
||
min={min}
|
||
max={safeMax}
|
||
step={step}
|
||
aria-label={label}
|
||
onChange={(event) => onChange(Number(event.target.value))}
|
||
{...props}
|
||
/>
|
||
<span className="nodedc-range__label">{label}</span>
|
||
<span className="nodedc-range__value">{formatValue(value)}</span>
|
||
<span className="nodedc-range__fill-text" aria-hidden="true">
|
||
<span className="nodedc-range__label">{label}</span>
|
||
<span className="nodedc-range__value">{formatValue(value)}</span>
|
||
</span>
|
||
</label>
|
||
);
|
||
}
|
||
|
||
export interface ColorFieldProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "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<Hsv>(() => 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<HTMLDivElement>) => {
|
||
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 (
|
||
<div className={cn("nodedc-color-field", className)} style={style}>
|
||
<Dropdown
|
||
className="nodedc-color-field__dropdown"
|
||
placement="bottom-start"
|
||
minWidth={296}
|
||
width={296}
|
||
disabled={disabled}
|
||
surfaceRole="dialog"
|
||
surfaceClassName="nodedc-color-palette-surface"
|
||
trigger={({ open, toggle, setTriggerRef, surfaceId }) => (
|
||
<button
|
||
ref={setTriggerRef}
|
||
type="button"
|
||
className="nodedc-color-field__picker"
|
||
aria-label={`Выбрать ${label}`}
|
||
aria-haspopup="dialog"
|
||
aria-controls={surfaceId}
|
||
aria-expanded={open}
|
||
data-open={open ? "true" : undefined}
|
||
disabled={disabled}
|
||
onClick={toggle}
|
||
><span aria-hidden="true" /></button>
|
||
)}
|
||
>
|
||
<div className="nodedc-color-palette" aria-label={`Палитра: ${label}`}>
|
||
<div
|
||
className="nodedc-color-palette__spectrum"
|
||
role="slider"
|
||
tabIndex={disabled ? -1 : 0}
|
||
aria-label={`${label}: насыщенность и яркость`}
|
||
aria-valuetext={hsvToHex(hsv)}
|
||
style={{ "--nodedc-color-hue": hsvToHex({ h: hsv.h, s: 100, v: 100 }), "--nodedc-color-s": `${hsv.s}%`, "--nodedc-color-v": `${100 - hsv.v}%` } as CSSProperties}
|
||
onPointerDown={(event) => {
|
||
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 }); }
|
||
}}
|
||
><span className="nodedc-color-palette__marker" aria-hidden="true" /></div>
|
||
<label className="nodedc-color-palette__hue">
|
||
<span>Оттенок</span>
|
||
<input type="range" min={0} max={360} value={Math.round(hsv.h)} aria-label={`${label}: оттенок`} disabled={disabled} onChange={(event) => commit({ ...hsv, h: Number(event.target.value) })} />
|
||
</label>
|
||
<div className="nodedc-color-palette__presets" aria-label="Быстрые цвета">
|
||
{palettePresets.map((preset) => (
|
||
<button
|
||
key={preset}
|
||
type="button"
|
||
aria-label={`Выбрать ${preset}`}
|
||
aria-pressed={hsvToHex(hsv).toLowerCase() === preset}
|
||
style={{ "--nodedc-palette-swatch": preset } as CSSProperties}
|
||
onClick={() => {
|
||
const next = hexToHsv(preset);
|
||
if (next) commit(next);
|
||
}}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</Dropdown>
|
||
<input
|
||
className="nodedc-color-field__text"
|
||
value={value}
|
||
aria-label={`${label}: HEX`}
|
||
disabled={disabled}
|
||
spellCheck={false}
|
||
onChange={(event) => onChange(event.target.value)}
|
||
{...props}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|