调色盘组件实现

74 阅读6分钟

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 反馈明确
  • 预设颜色快速选择

🎯 总结

这个调色盘的核心思路是:

  1. 选择 HSV 颜色空间 → 天然适合 UI 交互
  2. 三层渐变叠加 → 实现主色板效果
  3. 位置映射为颜色 → 拖拽转百分比转颜色值
  4. 全局事件监听 → 解决拖拽脱离问题
  5. 双向颜色转换 → HSV ↔ RGB ↔ HEX
  6. 受控/非受控 → 灵活的状态管理
  7. 模块化设计 → 易于维护和扩展

这种设计既保证了交互的流畅性,又保持了代码的可维护性!🎨