Files
goldenChart/frontend/src/components/AdvancedColorPicker.tsx
T
2026-05-23 15:11:48 +09:00

239 lines
7.5 KiB
TypeScript

/**
* 채도·명도 영역 + Hue 슬라이더 + 스포이드 + RGB 입력 (TradingView 스타일)
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
hexToHsv,
hexToRgb,
hsvToHex,
hsvToRgb,
normalizeHex6,
rgbToHex,
rgbToHsv,
svPanelBackground,
HUE_SLIDER_BG,
type Hsv,
} from '../utils/colorSpaceUtils';
export interface AdvancedColorPickerProps {
hex6: string;
onChange: (hex6: string) => void;
className?: string;
}
function clamp01(n: number): number {
return Math.max(0, Math.min(1, n));
}
function useHueDrag(onHue: (h: number) => void) {
const dragging = useRef(false);
const onPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
dragging.current = true;
e.currentTarget.setPointerCapture(e.pointerId);
const rect = e.currentTarget.getBoundingClientRect();
const t = rect.width > 0 ? (e.clientX - rect.left) / rect.width : 0;
onHue(clamp01(t) * 360);
}, [onHue]);
const onPointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (!dragging.current) return;
const rect = e.currentTarget.getBoundingClientRect();
const t = rect.width > 0 ? (e.clientX - rect.left) / rect.width : 0;
onHue(clamp01(t) * 360);
}, [onHue]);
const onPointerUp = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
dragging.current = false;
try { e.currentTarget.releasePointerCapture(e.pointerId); } catch { /* noop */ }
}, []);
return { onPointerDown, onPointerMove, onPointerUp };
}
const AdvancedColorPicker: React.FC<AdvancedColorPickerProps> = ({
hex6,
onChange,
className = '',
}) => {
const [hsv, setHsv] = useState<Hsv>(() => hexToHsv(normalizeHex6(hex6)));
const [rgb, setRgb] = useState(() => hexToRgb(normalizeHex6(hex6)));
const [colorMode, setColorMode] = useState<'rgb' | 'hex'>('rgb');
const skipSync = useRef(false);
useEffect(() => {
if (skipSync.current) {
skipSync.current = false;
return;
}
const h = hexToHsv(normalizeHex6(hex6));
setHsv(h);
setRgb(hexToRgb(normalizeHex6(hex6)));
}, [hex6]);
const emitHsv = useCallback((next: Hsv) => {
const h = { h: next.h, s: clamp01(next.s), v: clamp01(next.v) };
setHsv(h);
const r = hsvToRgb(h);
setRgb(r);
skipSync.current = true;
onChange(hsvToHex(h));
}, [onChange]);
const emitRgb = useCallback((r: number, g: number, b: number) => {
const next = { r, g, b };
setRgb(next);
setHsv(rgbToHsv(next));
skipSync.current = true;
onChange(rgbToHex(r, g, b));
}, [onChange]);
const hueDrag = useHueDrag(h => emitHsv({ ...hsv, h }));
const handleSvPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
const el = e.currentTarget;
const update = (cx: number, cy: number) => {
const rect = el.getBoundingClientRect();
const s = clamp01(rect.width > 0 ? (cx - rect.left) / rect.width : 0);
const v = clamp01(rect.height > 0 ? 1 - (cy - rect.top) / rect.height : 0);
emitHsv({ ...hsv, s, v });
};
update(e.clientX, e.clientY);
const onMove = (ev: PointerEvent) => update(ev.clientX, ev.clientY);
const onUp = () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
};
const pickEyedropper = async () => {
const EyeDropperCtor = (window as Window & { EyeDropper?: new () => { open: () => Promise<{ sRGBHex: string }> } }).EyeDropper;
if (!EyeDropperCtor) return;
try {
const result = await new EyeDropperCtor().open();
if (result?.sRGBHex) {
const hex = normalizeHex6(result.sRGBHex);
emitHsv(hexToHsv(hex));
}
} catch {
/* 사용자 취소 */
}
};
const pointerX = `${hsv.s * 100}%`;
const pointerY = `${(1 - hsv.v) * 100}%`;
const hueLeft = `${(hsv.h / 360) * 100}%`;
return (
<div className={`acp-root ${className}`.trim()}>
<div
className="acp-sv-panel"
style={{ background: svPanelBackground(hsv.h) }}
onPointerDown={handleSvPointerDown}
role="presentation"
>
<span
className="acp-sv-pointer"
style={{ left: pointerX, top: pointerY }}
aria-hidden
/>
</div>
<div className="acp-controls-row">
<button
type="button"
className="acp-eyedropper"
title="스포이드"
aria-label="스포이드로 색상 선택"
onClick={() => void pickEyedropper()}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden>
<path
d="M20.71 5.63l-2.34-2.34a1 1 0 00-1.42 0l-3.12 3.12-1.93-1.91a1 1 0 00-1.41 0l-1.83 1.83a1 1 0 000 1.41l1.91 1.91-6.36 6.36a1 1 0 000 1.41l2.12 2.12a1 1 0 001.41 0l6.36-6.36 1.91 1.91a1 1 0 001.41 0l1.83-1.83a1 1 0 000-1.41l-1.92-1.9 3.12-3.12a1 1 0 000-1.42z"
stroke="currentColor"
strokeWidth="1.4"
strokeLinejoin="round"
/>
</svg>
</button>
<span
className="acp-preview"
style={{ background: normalizeHex6(hex6) }}
aria-hidden
/>
<div
className="acp-hue-track"
style={{ background: HUE_SLIDER_BG }}
onPointerDown={hueDrag.onPointerDown}
onPointerMove={hueDrag.onPointerMove}
onPointerUp={hueDrag.onPointerUp}
role="slider"
aria-label="색상(Hue)"
aria-valuemin={0}
aria-valuemax={360}
aria-valuenow={Math.round(hsv.h)}
>
<span className="acp-hue-thumb" style={{ left: hueLeft }} aria-hidden />
</div>
</div>
<div className="acp-rgb-row">
{colorMode === 'rgb' ? (
<>
{(['r', 'g', 'b'] as const).map(ch => (
<label key={ch} className="acp-rgb-field">
<input
type="number"
className="acp-rgb-input"
min={0}
max={255}
value={rgb[ch]}
onChange={e => {
const v = Number(e.target.value);
if (Number.isNaN(v)) return;
emitRgb(
ch === 'r' ? v : rgb.r,
ch === 'g' ? v : rgb.g,
ch === 'b' ? v : rgb.b,
);
}}
/>
<span className="acp-rgb-label">{ch.toUpperCase()}</span>
</label>
))}
</>
) : (
<label className="acp-rgb-field acp-hex-field">
<input
type="text"
className="acp-rgb-input acp-hex-input"
value={normalizeHex6(hex6).slice(1)}
maxLength={6}
onChange={e => {
const raw = e.target.value.replace(/[^0-9A-Fa-f]/g, '').slice(0, 6);
if (raw.length === 6) {
emitHsv(hexToHsv('#' + raw));
}
}}
/>
<span className="acp-rgb-label">HEX</span>
</label>
)}
<button
type="button"
className="acp-mode-toggle"
title={colorMode === 'rgb' ? 'HEX 입력' : 'RGB 입력'}
aria-label="색상 입력 방식 전환"
onClick={() => setColorMode(m => (m === 'rgb' ? 'hex' : 'rgb'))}
>
</button>
</div>
</div>
);
};
export default AdvancedColorPicker;