Skip to main content

颜色选择器组件的实现

一、实现效果

点击颜色预览块,弹出操作面板,其中包括:

  • 颜色面板
  • 取色器
  • 色相柱(即色阶柱)
  • 透明度柱
  • 颜色类型选择器
  • 各颜色值输入框
  • 预设颜色组

二、实现过程

1、点击弹出操作

先实现一个 Popup 弹窗组件,组件包裹颜色预览块,操作面板作为弹窗内容。

接下来是操作面板中各个部分的实现。

2、颜色面板的展示

颜色面板由面板底色及拖拽滑块组成,这里先来看面板底色。

面板底色由三层背景色叠加,分别为:

  • 色相颜色层
  • 白色渐变层
  • 黑色渐变层

结构如下:

<section className="i-color-panel-block">
<div className="i-color-panel-block__white" />
<div className="i-color-panel-block__black" />
</section>

其中,父级可直接作为色相颜色层,颜色只由色相柱来控制,因此颜色值需为 HSL(色相、饱和度、亮度)类型,其中 H(色相)是变量,S(饱和度)和 L(亮度)是固定值:

<section
className="i-color-panel-block"
style={{ background: `hsl(${h}, 100%, 50%)` }}
>
...
</section>

黑白两色渐变层的样式如下:

.i-color-panel-block__white {
background: linear-gradient(90deg, #fff, rgba(255, 255, 255, 0));
}

.i-color-panel-block__black {
background: linear-gradient(0deg, #000, transparent);
}

以上是颜色面板的面板底色样式原理,接下来需要实现色相柱,以控制颜色面板的色相层颜色。

3、色相柱的实现

色相柱由柱形底色及拖拽滑块组成:

<div className="i-color-panel-bar__rgb">
<div className="i-color-picker__cursor" />
</div>

其中父级作为柱形底色,是红橙黄绿青蓝紫七种颜色组成的彩虹渐变色,样式如下:

.i-color-panel-bar__rgb {
background: linear-gradient(
90deg,
#f00 0,
#ff0 17%,
#0f0 33%,
#0ff 50%,
#00f 67%,
#f0f 83%,
#f00
);
}

以上是色相柱的柱形底色样式原理,接下来需要实现色相柱的功能,主要为:

  1. 拖拽滑块自身计算出对应的色相
  2. 点击色相柱任一位置会自动定位滑块并计算色相
  3. 点击色相柱任一位置后进行拖拽可模拟滑块的拖拽过程

首先需要将色相与滑块的位置联系起来,而色相的取值范围是 0 - 360(其中红色为 0° / 360°,黄色为 60°,绿色为 120°,青色为 180°,蓝色为 240°,品红色为 300°),因此当滑块在最左侧时,对应色相的 ,在最右侧时对应色相的 360°,只需要拿滑块在柱形中所在位置的比例去乘 360,即可得到对应位置的色相值。

有了基本思路后,就是滑块的实现了:

  1. 拖拽滑块自身计算出对应的色相
    • 监听滑块的点击事件计算开始位置,监听滑块的移动事件计算变化值来更新滑块位置,实现滑块拖拽功能,然后再将实时位置与柱形位置宽度联系起来,计算出所需的位置比例。
  2. 点击色相柱任一位置会自动定位滑块并计算色相
    • 监听色相柱的点击事件计算开始位置,传给滑块并更新滑块位置,从而计算出位置比例。
  3. 点击色相柱任一位置后进行拖拽可模拟滑块的拖拽过程
    • 监听色相柱的点击及移动事件,将实时位置数据传给滑块并更新滑块位置,从而计算出位置比例。

上述实现过程可以省略第一步,只对色相柱进行事件监听,而滑块自身禁用任意事件,从而通过色相柱的点击和移动事件模拟出滑块自身的拖拽。

其中对滑块的禁用事件样式处理如下:

.i-color-picker__cursor {
pointer-events: none;
}

到这里,已经实现了色相柱的点击拖拽计算位置比例功能,拿到位置比例后,乘以 360,即可得到色相值。

拿到色相值(H)后,将色相值传给颜色面板,即可计算出颜色面板的色相层颜色:

<section
className="i-color-panel-block"
style={{ background: `hsl(${h}, 100%, 50%)` }}
>
<div className="i-color-panel-block__white" />
<div className="i-color-panel-block__black" />
</section>

以上是色相柱的实现,而透明度柱的实现原理与色相柱相似,下面继续实现透明度柱。

4、透明度柱的实现

透明度柱也由柱形底色及拖拽滑块组成,其中柱形底色包括:

  • 色相渐变层
  • 透明格子背景层

结构如下:

<div className="i-color-panel-bar__a">
<div className="i-color-picker__cursor" />
<section className="i-color-panel-bar__a-color" />
<section className="i-color-panel-bar__a-bg" />
</div>

其中,色相渐变层的颜色只由 H(色相)控制:

<section
className="i-color-panel-bar__a-color"
style={{ background: `linear-gradient(90deg, rgba(255, 0, 0, 0) 0%, hsl(${h}, 100%, 50%) 100%)` }}
/>

透明格子背景层的样式如下:

.i-color-panel-bar__a-bg {
background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAIAAADZF8uwAAAAGUlEQVQYV2M4gwH+YwCGIasIUwhT25BVBADtzYNYrHvv4gAAAABJRU5ErkJggg==');
}

注意这里需要设置一下 z-index 层级顺序,透明格子背景层在色相渐变层下方。

以上是透明度柱的柱形底色样式原理,接下来需要实现其功能,实现方式与色相柱相似,最后计算出滑块的位置比例,即可直接作为透明度值使用。

实现了色相柱和透明度柱,接下来继续完善颜色面板的功能。

5、颜色面板的功能实现

上面已经实现了颜色面板的展示效果,接下来是功能的实现。

其中,滑块的拖拽与色相柱相似,可通过点击及移动滑块所在面板来模拟滑块自身的拖拽功能,注意上面色相柱和透明度柱的滑块只需要计算 x 轴方向的位置比例,而颜色面板需要同时计算 x、y 轴上的位置比例。

实现了滑块的拖拽并拿到所在的位置比例后,就需要将 x、y 轴上的位置比例与实际颜色值联系起来,这里就需要用到 HSV 类型的颜色值。

什么是 HSV


HSVHSB,其 H(色相)与 HSL 的 H(色相)一致,而 SL 和 SB 的区别如下:


  • HSL 中的 S 是饱和度比例;
  • HSL 中的 L 是亮度比例。
  • HSB 中的 S 控制纯色中混入白色比,值越大,颜色越纯,白色越少;
  • HSB 中的 B 控制纯色中混入黑色比,值越大,明度越高,黑色越少。

假设滑块在 x、y 轴上的位置比例分别为 x、y,则 HSV 的计算如下:

  • H = 色相柱提供的色相值
  • S = 100x
  • V = 100(1 - y)

通过滑块的 x、y 轴位置比例计算得到 HSV 颜色值后,注意 HSV 格式无法直接在 CSS 中使用,需要转换成 RGB / HEX / HSL 等格式的有效值,这里就需要用到 HSV 的转换函数,点击查看具体转换公式

网上有很多转换函数可以参考,但考虑到后面的颜色合法值判断、类型切换等功能,建议直接使用 tinycolor2 颜色值转换库。

使用方式很简单,拿到 HSV 后直接调用即可:

const result = tinycolor(hsv)

result 将输出以下格式:

{
"_originalInput": "hsv(224, 35%, 60%)",
"_r": 99.45,
"_g": 113.73,
"_b": 153,
"_a": 1,
"_roundA": 1,
"_format": "hsv",
"_ok": true,
"_tc_id": 352
}

可以继续调用 tinycolor 提供的转换函数,转为 CSS 可用的 RGB 等格式:

// 转 RGB
tinycolor(hsv).toRgbString() // rgb(80, 104, 171)

// 转 HEX
tinycolor(hsv).toHexString() // #5068ab

注意以上结果无法体现颜色透明度,因此转换前需要对 result 的透明度进行单独设置:

result.setAlpha(a)

// 转 RGB
tinycolor(hsv).toRgbString() // rgba(83, 101, 153, 0.62)

// 转 HEX,注意这里需要用 HEX8 输出才能体现透明度
tinycolor(hsv).toHex8String() // #5365999e

到这里,就可以通过颜色面板的滑块拖拽来计算出最终颜色值了。

6、取色器

取色器是整个颜色选择器中最复杂的部分,实现思路如下:

  1. 对整个页面进行截图;
  2. 将截图绘制到 canvas,并获取指定区域像素点颜色值;
  3. 获取鼠标定位,显示放大镜和颜色值;

另一种实现思路是直接使用原生的 EyeDropper 类:

const handleClickDropper = async () => {
const eyeDropper = new EyeDropper();
const result = await eyeDropper.open();
const color = result.sRGBHex // #ffffff
}

注意这种方式的兼容性较差:

使用时需要做 !!window.EyeDropper 的判断,否则会报错。

7、其它功能

操作面板其它功能如下:

  • 颜色类型选择器
  • 各颜色值输入框
  • 预设颜色组

这几种功能相对简单,在各自独立组件的基础上结合 tinycolor 即可实现。

三、踩坑记录

1、滑块拖拽

  • 全局滚动后打开操作面板,滑块的定位错位。

可通过 window.scrollXwindow.scrollY 的计算加以矫正。

  • 滑块在滑动过程中进行滚轮滚动导致的错位。

可在打开操作面板时设置 document.documentElement.style.overflowY = 'hidden',关闭时还原的方式来解决。

2、滑块的边界处理

颜色面板滑块的边界为面板宽高额外加上滑块宽高:

色相柱和透明度柱的边界即为柱形的宽度:

因此,滑块对传入的位置比例需做以下处理:

if (色阶柱 || 透明度柱) {
translate.x = x * (parentWidth - cursorWidth)
} else {
translate.x = (x * parentWidth) - (cursorWidth / 2)
translate.y = (y * parentHeight) - (cursorWidth / 2)
}

3、操作面板的性能问题

操作面板作为 Popup 弹窗组件的弹窗内容,大致结构如下:

const [innerValue, setInnerValue] = useState(value)

const handleChange = (val: string) => {
setInnerValue(val)
}

return (
<Popup
visible={visible}
content={
<ColorPanel
value={innerValue}
colorList={colorList}
onChange={handleChange}
onClose={handleClose}
/>
}
>
<ColorBlock style={{ background: innerValue }} />
</Popup>
)

操作面板每次触发颜色更新时,会更新操作面板组件外层的颜色值 innerValue,使得操作面板重新渲染,这样会出现以下问题:

每次拖拽滑块时,操作面板内部颜色值在频繁更新,而作为弹窗内容的操作面板由于 innerValue 在同步变化,导致操作面板自身也在同步的频繁渲染,导致卡顿。解决方式如下:

使用 useMemo 控制当操作面板弹出时才对其进行渲染,避免 innerValue 变化时导致不必要的渲染:

const [innerValue, setInnerValue] = useState(value)

const handleChange = (val: string) => {
setInnerValue(val)
}

const colorPanel = useMemo(() =>
<ColorPanel
value={innerValue}
colorList={colorList}
onChange={handleChange}
onClose={handleClose}
/>, [visible]
)

return (
<Popup
visible={visible}
content={
<ColorPanel
value={innerValue}
colorList={colorList}
onChange={handleChange}
onClose={handleClose}
/>
}
content={colorPanel}
>
<ColorBlock style={{ background: innerValue }} />
</Popup>
)