239 lines
7.5 KiB
TypeScript
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;
|