166 lines
5.5 KiB
TypeScript
166 lines
5.5 KiB
TypeScript
/**
|
|
* 보조지표 설정 — 색상 + 투명도 (스와치 클릭 시 팔레트·고급 선택기)
|
|
*/
|
|
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { parsePlotColor, formatPlotColor, plotColorCss } from '../utils/plotColorUtils';
|
|
import { getOpacityPercentSpec } from '../utils/indicatorParamSpec';
|
|
import NumericParamInput from './NumericParamInput';
|
|
import ColorPickerPanel from './ColorPickerPanel';
|
|
|
|
export interface ColorInputProps {
|
|
value: string;
|
|
onChange: (v: string) => void;
|
|
disabled?: boolean;
|
|
/** false — 인라인 네이티브 color input 숨김(설정 화면: OS 피커만 뜨는 문제 방지) */
|
|
showCompactNative?: boolean;
|
|
/** false — 바깥 클릭으로 닫지 않음(확인·✕·Esc 로만 닫기) */
|
|
closeOnClickOutside?: boolean;
|
|
}
|
|
|
|
const ColorInput: React.FC<ColorInputProps> = ({
|
|
value,
|
|
onChange,
|
|
disabled = false,
|
|
showCompactNative = true,
|
|
closeOnClickOutside = false,
|
|
}) => {
|
|
const { hex6, alpha } = parsePlotColor(value);
|
|
const [open, setOpen] = useState(false);
|
|
const anchorRef = useRef<HTMLDivElement>(null);
|
|
const popoverRef = useRef<HTMLDivElement>(null);
|
|
const [pos, setPos] = useState({ top: 0, left: 0 });
|
|
|
|
const updatePosition = useCallback(() => {
|
|
const el = anchorRef.current;
|
|
if (!el) return;
|
|
const rect = el.getBoundingClientRect();
|
|
const popW = 280;
|
|
let left = rect.left;
|
|
if (left + popW > window.innerWidth - 12) {
|
|
left = window.innerWidth - popW - 12;
|
|
}
|
|
setPos({ top: rect.bottom + 6, left: Math.max(8, left) });
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
updatePosition();
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') setOpen(false);
|
|
};
|
|
const onScroll = () => updatePosition();
|
|
window.addEventListener('keydown', onKey);
|
|
window.addEventListener('resize', updatePosition);
|
|
window.addEventListener('scroll', onScroll, true);
|
|
return () => {
|
|
window.removeEventListener('keydown', onKey);
|
|
window.removeEventListener('resize', updatePosition);
|
|
window.removeEventListener('scroll', onScroll, true);
|
|
};
|
|
}, [open, updatePosition]);
|
|
|
|
useEffect(() => {
|
|
if (!open || !closeOnClickOutside) return;
|
|
const onDocPointer = (e: Event) => {
|
|
const t = e.target as Node;
|
|
if (anchorRef.current?.contains(t) || popoverRef.current?.contains(t)) return;
|
|
setOpen(false);
|
|
};
|
|
document.addEventListener('mousedown', onDocPointer, true);
|
|
document.addEventListener('pointerdown', onDocPointer, true);
|
|
return () => {
|
|
document.removeEventListener('mousedown', onDocPointer, true);
|
|
document.removeEventListener('pointerdown', onDocPointer, true);
|
|
};
|
|
}, [open, closeOnClickOutside]);
|
|
|
|
const patchHex = (h: string) => onChange(formatPlotColor(h, alpha));
|
|
const patchAlpha = (a: number) => onChange(formatPlotColor(hex6, a));
|
|
|
|
const closePopover = useCallback(() => setOpen(false), []);
|
|
|
|
const popover = open && typeof document !== 'undefined' ? (
|
|
<div
|
|
ref={popoverRef}
|
|
className="ism-color-popover"
|
|
style={{ top: pos.top, left: pos.left }}
|
|
role="dialog"
|
|
aria-label="색상 선택"
|
|
onMouseDown={e => e.stopPropagation()}
|
|
onPointerDown={e => e.stopPropagation()}
|
|
>
|
|
<div className="ism-color-popover-header">
|
|
<span className="ism-color-popover-title">색상 선택</span>
|
|
<button
|
|
type="button"
|
|
className="gc-popup-close ism-color-popover-close"
|
|
onClick={closePopover}
|
|
aria-label="색상 선택 닫기"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
{showCompactNative && (
|
|
<div className="ism-color-popover-native">
|
|
<label className="ism-color-native-label">
|
|
<span className="ism-style-label">직접 선택</span>
|
|
<input
|
|
type="color"
|
|
className="ism-color-picker"
|
|
value={hex6}
|
|
onChange={e => patchHex(e.target.value)}
|
|
/>
|
|
</label>
|
|
</div>
|
|
)}
|
|
<ColorPickerPanel hex6={hex6} onHexChange={patchHex} />
|
|
<div className="ism-color-popover-footer">
|
|
<button type="button" className="plsp-popup-ok" onClick={closePopover}>
|
|
확인
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : null;
|
|
|
|
return (
|
|
<div className={`ism-color-wrap${disabled ? ' ism-color-wrap--disabled' : ''}`} ref={anchorRef}>
|
|
<button
|
|
type="button"
|
|
className="ism-color-swatch-btn"
|
|
title="색상 선택"
|
|
aria-expanded={open}
|
|
disabled={disabled}
|
|
onClick={() => {
|
|
if (disabled) return;
|
|
setOpen(v => !v);
|
|
if (!open) requestAnimationFrame(updatePosition);
|
|
}}
|
|
>
|
|
<span className="ism-color-swatch-preview" style={{ background: plotColorCss(value) }} />
|
|
</button>
|
|
{showCompactNative && (
|
|
<input
|
|
type="color"
|
|
className="ism-color-picker ism-color-picker--compact"
|
|
value={hex6}
|
|
title="브라우저 색상 선택"
|
|
disabled={disabled}
|
|
onChange={e => patchHex(e.target.value)}
|
|
/>
|
|
)}
|
|
<NumericParamInput
|
|
className="ism-input ism-alpha-input"
|
|
value={alpha}
|
|
spec={getOpacityPercentSpec(alpha)}
|
|
disabled={disabled}
|
|
onChange={patchAlpha}
|
|
/>
|
|
<span className="ism-alpha-label">%</span>
|
|
{popover ? createPortal(popover, document.body) : null}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ColorInput;
|