goldenChat base source add
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* 채도·명도 영역 + 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;
|
||||
Reference in New Issue
Block a user