1️⃣ 颜色空间选择:为什么用 HSV?
HSV vs RGB
diff
RGB(Red, Green, Blue)
- 直观:R=255, G=0, B=0 = 红色
- 问题:难以实现渐变选择器
例如:如何从 (255,0,0) 平滑过渡到 (255,255,0)?
HSV(Hue, Saturation, Value)
- H (色相):0-360°,决定是什么颜色(红橙黄绿青蓝紫)
- S (饱和度):0-100%,颜色的纯度(0%=灰色,100%=纯色)
- V (明度):0-100%,颜色的亮度(0%=黑色,100%=最亮)
✅ HSV 天然适合做选择器!
UI 映射逻辑
typescript
主色板 (200x200)
┌────────────────┐
│ 白 ← S → 纯色 │ 横轴 X = 饱和度 (0-100%)
│ │
│ ↑ │ 纵轴 Y = 明度 (100-0%)
│ V │
│ ↓ │ 背景色 = 当前色相的纯色
│ │
│ 黑 │
└────────────────┘
色相条 (竖条)
┌──┐
│红│ 0°
│黄│ 60°
│绿│ 120°
│青│ 180°
│蓝│ 240°
│紫│ 300°
│红│ 360°
└──┘
2️⃣ 核心数据流
状态管理策略
typescript
// 内部状态:HSV(便于UI操作)
const [hue, setHue] = useState(0) // 0-360
const [saturation, setSaturation] = useState(100) // 0-100
const [brightness, setBrightness] = useState(100) // 0-100
// 外部接口:HEX(便于使用)
props.value = "#ff5722"
props.onChange("#00ff00")
// 转换流程
外部 HEX → 内部 RGB → 内部 HSV → UI 显示
UI 操作 → 内部 HSV → 内部 RGB → 外部 HEX
数据流向图
scss
用户交互
↓
拖拽主色板 → 更新 S, V
拖拽色相条 → 更新 H
输入 RGB → 转换为 HSV
输入 HEX → 转换为 HSV
↓
updateColor(h, s, v)
↓
HSV → RGB → HEX
↓
onChange(hex)
↓
外部状态更新
↓
重新渲染
3️⃣ 关键实现细节
A. 主色板拖拽实现
typescript
// 核心思路:鼠标位置 → 百分比 → 颜色值
const handleSaturationMove = (e: MouseEvent) => {
const rect = saturationRef.current.getBoundingClientRect()
// 1. 获取鼠标相对于色板的位置
const x = e.clientX - rect.left // 0 ~ rect.width
const y = e.clientY - rect.top // 0 ~ rect.height
// 2. 限制范围
const clampedX = Math.max(0, Math.min(x, rect.width))
const clampedY = Math.max(0, Math.min(y, rect.height))
// 3. 转换为百分比
const newSaturation = (clampedX / rect.width) * 100 // 横向 = 饱和度
const newBrightness = 100 - (clampedY / rect.height) * 100 // 纵向 = 明度(反向)
// 4. 更新颜色
updateColor(hue, newSaturation, newBrightness)
}
为什么明度要反向?
ini
色板视觉效果:
┌────────┐
│ 亮色 │ ← 用户期望:上方 = 亮
│ │
│ 暗色 │ ← 用户期望:下方 = 暗
└────────┘
但 Y 坐标:
top = 0 ← clientY 小
bottom = 200 ← clientY 大
所以需要反转:brightness = 100 - (y%)
B. 全局拖拽跟踪
typescript
// 问题:鼠标快速移动会脱离元素
// 解决:监听全局 mousemove
const [isDragging, setIsDragging] = useState(false)
// 按下时开始拖拽
const handleMouseDown = (e) => {
setIsDragging(true)
handleMove(e)
}
// 全局监听
useEffect(() => {
if (isDragging) {
const handleMouseMove = (e) => handleMove(e)
const handleMouseUp = () => setIsDragging(false)
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
return () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
}
}, [isDragging])
C. 渐变背景实现
css
/* 主色板:三层渐变 */
.saturationPanel {
/* 底层:纯色(由 JS 动态设置) */
background-color: rgb(255, 0, 0); /* 当前色相 */
}
.saturationWhite {
/* 中层:白色到透明(左到右) */
background: linear-gradient(to right, #ffffff, transparent);
}
.saturationBlack {
/* 顶层:黑色到透明(下到上) */
background: linear-gradient(to top, #000000, transparent);
}
/* 叠加效果:
左上角 = 白色 + 纯色 = 浅色
右上角 = 透明 + 纯色 = 纯色
左下角 = 白色 + 黑色 = 灰色
右下角 = 透明 + 黑色 = 黑色
*/
D. 色相条实现
css
.hueSlider {
/* 彩虹渐变:红 → 黄 → 绿 → 青 → 蓝 → 紫 → 红 */
background: linear-gradient(
to bottom,
#ff0000 0%, /* 0° 红 */
#ffff00 17%, /* 60° 黄 */
#00ff00 33%, /* 120° 绿 */
#00ffff 50%, /* 180° 青 */
#0000ff 67%, /* 240° 蓝 */
#ff00ff 83%, /* 300° 紫 */
#ff0000 100% /* 360° 红 */
);
}
4️⃣ 颜色转换算法
RGB ↔ HSV 转换
typescript
// RGB → HSV(核心算法)
function rgbToHsv(r: number, g: number, b: number) {
r = r / 255
g = g / 255
b = b / 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
const d = max - min
// 计算色相 H
let h = 0
if (d !== 0) {
switch (max) {
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break
case g: h = ((b - r) / d + 2) / 6; break
case b: h = ((r - g) / d + 4) / 6; break
}
}
// 计算饱和度 S
const s = max === 0 ? 0 : d / max
// 计算明度 V
const v = max
return {
h: h * 360, // 转为角度
s: s * 100, // 转为百分比
v: v * 100 // 转为百分比
}
}
// HSV → RGB(逆向算法)
function hsvToRgb(h: number, s: number, v: number) {
h = h / 360
s = s / 100
v = v / 100
const i = Math.floor(h * 6)
const f = h * 6 - i
const p = v * (1 - s)
const q = v * (1 - f * s)
const t = v * (1 - (1 - f) * s)
let r, g, b
switch (i % 6) {
case 0: [r, g, b] = [v, t, p]; break
case 1: [r, g, b] = [q, v, p]; break
case 2: [r, g, b] = [p, v, t]; break
case 3: [r, g, b] = [p, q, v]; break
case 4: [r, g, b] = [t, p, v]; break
case 5: [r, g, b] = [v, p, q]; break
}
return {
r: Math.round(r * 255),
g: Math.round(g * 255),
b: Math.round(b * 255)
}
}
为什么需要这两个转换?
用户场景 需要的转换
────────────────────────────────────
拖拽色板 → 更新颜色 HSV → RGB → HEX
输入 RGB 值 → 更新色板 RGB → HSV
输入 HEX 值 → 更新色板 HEX → RGB → HSV
外部传入 value → 初始化 HEX → RGB → HSV
5️⃣ 受控与非受控模式
typescript
// 受控模式:外部管理状态
<ColorPicker
value={color} // 外部状态
onChange={setColor} // 外部更新
/>
// 非受控模式:内部管理状态
<ColorPicker
defaultValue="#ff0000" // 只设置初始值
onChange={handleChange} // 只通知外部
/>
// 实现
const [internalValue, setInternalValue] = useState(defaultValue)
const isControlled = value !== undefined
const currentColor = isControlled ? value : internalValue
const updateColor = (hex: string) => {
if (!isControlled) {
setInternalValue(hex) // 非受控:更新内部状态
}
onChange?.(hex) // 通知外部
}
6️⃣ 性能优化
避免不必要的重渲染
typescript
// 问题:每次拖拽都会调用 onChange
// 优化:使用 useCallback + 防抖
const updateColorDebounced = useCallback(
debounce((h, s, v) => {
const rgb = hsvToRgb(h, s, v)
const hex = rgbToHex(rgb.r, rgb.g, rgb.b)
onChange?.(hex)
}, 16), // 60fps
[]
)
// 或者:只在鼠标释放时才调用 onChange
const handleMouseUp = () => {
setIsDragging(false)
// 此时才触发最终的 onChange
updateColor(hue, saturation, brightness)
}
7️⃣ 完整交互流程
scss
用户操作:拖拽主色板
↓
onMouseDown → 设置 isDragging = true
↓
全局 mousemove → handleSaturationMove
↓
计算鼠标位置 → 转换为 S, V 百分比
↓
setState({ saturation, brightness })
↓
触发重渲染
↓
计算新的 RGB: hsvToRgb(H, S, V)
↓
转换为 HEX: rgbToHex(R, G, B)
↓
调用 onChange(hex)
↓
外部组件更新
↓
传入新的 value prop
↓
useEffect 同步:hexToRgb → rgbToHsv
↓
更新 HSV 状态
↓
更新 UI:指针位置、RGB输入框、HEX输入框
8️⃣ 设计亮点
✅ 1. 分离关注点
arduino
ColorPicker.tsx → UI 逻辑
ColorPickerField.tsx → 表单集成
colorUtils.ts → 颜色算法
types.ts → 类型定义
*.module.css → 样式隔离
✅ 2. 双向同步
拖拽 → HSV → RGB → HEX → 输入框
输入框 → HEX/RGB → HSV → 拖拽指针
✅ 3. 边界处理
typescript
// 限制范围
const clampedX = Math.max(0, Math.min(x, rect.width))
// 数值校验
const numValue = Math.max(0, Math.min(255, parseInt(value) || 0))
// 禁用状态
if (disabled) return
✅ 4. 用户体验
- 点击立即响应
- 拖拽平滑跟随
- 键盘输入实时同步
- Hover 反馈明确
- 预设颜色快速选择
🎯 总结
这个调色盘的核心思路是:
- 选择 HSV 颜色空间 → 天然适合 UI 交互
- 三层渐变叠加 → 实现主色板效果
- 位置映射为颜色 → 拖拽转百分比转颜色值
- 全局事件监听 → 解决拖拽脱离问题
- 双向颜色转换 → HSV ↔ RGB ↔ HEX
- 受控/非受控 → 灵活的状态管理
- 模块化设计 → 易于维护和扩展
这种设计既保证了交互的流畅性,又保持了代码的可维护性!🎨