外观
第35章—组件实战:ColorPicker颜色选择器(二)
4549字约15分钟
2024-09-19
这节我们开始写 ColorPicker 组件。
看下 antd 的 ColorPicker 组件:
可以分成这两部分:
上面是一个 ColorPickerPanel,可以通过滑块选择颜色,调整色相、饱和度。
下面是 ColorInput,可以通过输入框修改颜色,可以切换 RGB、HEX 等色彩模式。
我们先写 ColorPickerPanel 的部分:
这部分分为上面的调色板 Palette,下面的 Slider 滑动条。
这样一拆解,是不是思路就清晰了呢?
新建个项目:
npx create-react-app --template=typescript color-picker-component
新建 ColorPicker 目录,然后创建 ColorPickerPanel 组件:
import { CSSProperties } from "react";
import cs from 'classnames';
import './index.scss';
export interface ColorPickerProps {
className?: string;
style?: CSSProperties
}
function ColorPickerPanel(props: ColorPickerProps) {
const {
className,
style
} = props;
const classNames = cs("color-picker", className);
return <div className={classNames} style={style}>
</div>
}
export default ColorPickerPanel;
安装用到的 classnames 包:
npm install --save classnames
style 和 className 这俩 props 就不用解释了。
然后添加 value 和 onChange 的参数:
interface ColorPickerProps {
className?: string;
style?: CSSProperties;
value?: string;
onChange?: (color: string) => void;
}
这里颜色用 string 类型不大好,最好是有专门的 Color 类,可以用来切换 RGB、HSL、HEX 等颜色格式。
直接用 @ctrl/tinycolor 这个包就行。
npm install --save @ctrl/tinycolor
先试一下这个包:
创建 index.js
const { TinyColor } = require("@ctrl/tinycolor");
let color = new TinyColor('red');
console.log(color.toHex());
console.log(color.toHsl());
console.log(color.toRgb());
console.log();
color = new TinyColor('#00ff00');
console.log(color.toHex());
console.log(color.toHsl());
console.log(color.toRgb());
console.log();
color = new TinyColor({ r: 0, g: 0, b: 255});
console.log(color.toHex());
console.log(color.toHsl());
console.log(color.toRgb());
console.log();
跑一下:
可以看到,TinyColor 能识别出颜色的格式,并且在 hex、hsl、rgb 之间进行转换。
然后添加 ColorPicker/color.ts
import { TinyColor } from '@ctrl/tinycolor';
export class Color extends TinyColor {
}
那 value 直接写 Color 类型么?
也不好,这样用起来得 new 一个 Color 对象才行,不方便。
所以我们类型要这样写:
创建 ColorPicker/interface.ts
import type { Color } from './color';
export interface HSL {
h: number | string;
s: number | string;
l: number | string;
}
export interface RGB {
r: number | string;
g: number | string;
b: number | string;
}
export interface HSLA extends HSL {
a: number;
}
export interface RGBA extends RGB {
a: number;
}
export type ColorType =
| string
| number
| RGB
| RGBA
| HSL
| HSLA
| Color;
支持 string 还有 number 还有 rgb、hsl、rgba、hsla 这几种格式,或者直接传一个 Color 对象。
在组件里判断下 value 类型,如果不是 Color,那就创建一个 Color 对象,传入 Palette:
import { CSSProperties, useState } from "react";
import cs from 'classnames';
import { ColorType } from "./interface";
import { Color } from "./color";
import Palette from "./Palette";
import './index.scss';
export interface ColorPickerProps {
className?: string;
style?: CSSProperties;
value?: ColorType;
onChange?: (color: Color) => void;
}
function ColorPickerPanel(props: ColorPickerProps) {
const {
className,
style,
value,
onChange
} = props;
const [colorValue, setColorValue] = useState<Color>(() => {
if (value instanceof Color) {
return value;
}
return new Color(value);
});
const classNames = cs("color-picker", className);
return <div className={classNames} style={style}>
<Palette color={colorValue}></Palette>
</div>
}
export default ColorPickerPanel;
接下来写 Palette 组件:
src/Palette.tsx
import type { FC } from 'react';
import { Color } from './color';
const Palette: FC<{
color: Color
}> = ({ color }) => {
return (
<div className="color-picker-panel-palette" >
<div
className="color-picker-panel-palette-main"
style={{
backgroundColor: `hsl(${color.toHsl().h},100%, 50%)`,
backgroundImage:
'linear-gradient(0deg, #000, transparent),linear-gradient(90deg, #fff, hsla(0, 0%, 100%, 0))',
}}
/>
</div>
);
};
export default Palette;
拿到 color 的 hsl 值中的色相,然后加一个横向和纵向的渐变就好了。
我们写下样式 ColorPicker/index.scss:
.color-picker {
width: 300px;
&-panel {
&-palette {
position: relative;
min-height: 160px;
&-main {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
}
}
}
安装用到的包:
npm install --save-dev sass
跑一下:
npm run start
调色板出来了。
还要实现上面的滑块,这个封装个组件,因为 Slider 也会用到:
创建 ColorPicker/Handler.tsx:
import classNames from 'classnames';
import type { FC } from 'react';
type HandlerSize = 'default' | 'small';
interface HandlerProps {
size?: HandlerSize;
color?: string;
};
const Handler: FC<HandlerProps> = ({ size = 'default', color }) => {
return (
<div
className={classNames(`color-picker-panel-palette-handler`, {
[`color-picker-panel-palette-handler-sm`]: size === 'small',
})}
style={{
backgroundColor: color,
}}
/>
);
};
export default Handler;
有 size 和 color 两个参数。
size 是 default 和 small 两个取值,因为这俩滑块是不一样大的:
加一下两种滑块的样式:
&-handler {
box-sizing: border-box;
width: 16px;
height: 16px;
border: 2px solid #fff;
border-radius: 50%;
box-shadow: 0 0 1px 1px rgba(0, 0, 0, 0);
}
&-handler-sm {
width: 12px;
height: 12px;
}
在 Palette 引入下:
<Handler color={color.toRgbString()}/>
刷新下页面,确实是有的:
只是现在看不到。
加一下 zindex 就好了:
但是不建议写在这里。
为什么呢?
因为这里写了 position: absolute 那是不是 Handler 组件也得加上 x、y 的参数。
这样它就不纯粹了,复用性会变差。
所以可以把定位的样式抽离成一个单独的 Transform 组件:
创建 Transform:
import React, { forwardRef } from 'react';
export interface TransformOffset {
x: number;
y: number;
};
interface TransformProps {
offset?: TransformOffset;
children?: React.ReactNode;
}
const Transform = forwardRef<HTMLDivElement, TransformProps>((props, ref) => {
const { children, offset } = props;
return (
<div
ref={ref}
style={{
position: 'absolute',
left: offset?.x ?? 0,
top: offset?.y ?? 0,
zIndex: 1,
}}
>
{children}
</div>
);
});
export default Transform;
import { useRef, type FC } from 'react';
import { Color } from './color';
import Handler from './Handler';
import Transform from './Transform';
const Palette: FC<{
color: Color
}> = ({ color }) => {
const transformRef = useRef<HTMLDivElement>(null);
return (
<div className="color-picker-panel-palette" >
<Transform ref={transformRef} offset={{x: 50, y: 50}}>
<Handler color={color.toRgbString()}/>
</Transform>
<div
className={`color-picker-panel-palette-main`}
style={{
backgroundColor: `hsl(${color.toHsl().h},100%, 50%)`,
backgroundImage:
'linear-gradient(0deg, #000, transparent),linear-gradient(90deg, #fff, hsla(0, 0%, 100%, 0))',
}}
/>
</div>
);
};
export default Palette;
看下效果:
如果不单独分 Transform 这个组件呢?
那就是把这段样式写在 Hanlder 组件里,然后加上俩参数:
功能是一样的,但是不如拆分出来复用性好。
然后我们加上拖拽功能。
拖拽就是给元素绑定 mousedown、mousemove、mouseup 事件,在 mousemove 的时候改变 x、y。
这部分逻辑比较复杂,我们封装一个自定义 hook 来做。
创建 ColorPicker/useColorDrag.ts
import { useEffect, useRef, useState } from 'react';
import { TransformOffset } from './Transform';
type EventType =
| MouseEvent
| React.MouseEvent<Element, MouseEvent>
type EventHandle = (e: EventType) => void;
interface useColorDragProps {
offset?: TransformOffset;
containerRef: React.RefObject<HTMLDivElement>;
targetRef: React.RefObject<HTMLDivElement>;
direction?: 'x' | 'y';
onDragChange?: (offset: TransformOffset) => void;
}
function useColorDrag(
props: useColorDragProps,
): [TransformOffset, EventHandle] {
const {
offset,
targetRef,
containerRef,
direction,
onDragChange,
} = props;
const [offsetValue, setOffsetValue] = useState(offset || { x: 0, y: 0 });
const dragRef = useRef({
flag: false
});
useEffect(() => {
document.removeEventListener('mousemove', onDragMove);
document.removeEventListener('mouseup', onDragStop);
}, []);
const updateOffset: EventHandle = e => {
};
const onDragStop: EventHandle = e => {
document.removeEventListener('mousemove', onDragMove);
document.removeEventListener('mouseup', onDragStop);
dragRef.current.flag = false;
};
const onDragMove: EventHandle = e => {
e.preventDefault();
updateOffset(e);
};
const onDragStart: EventHandle = e => {
document.addEventListener('mousemove', onDragMove);
document.addEventListener('mouseup', onDragStop);
dragRef.current.flag = true;
};
return [offsetValue, onDragStart];
}
export default useColorDrag;
代码比较多,从上到下来看:
MouseEvent 是 ts 内置的原生鼠标事件类型,而 React.MouseEvent 是 react 提供鼠标事件类型。
是因为 react 里的事件是被 react 处理过的,和原生事件不一样。
直接给 document 绑定事件,这时候 event 是 MouseEvent 类型:
而在 jsx 里绑定事件,这时候 event 是 React.MouseEvent 类型:
我们都要支持:
这两个一个是保存 offset 的,一个是保存是否在拖动中的标记的:
然后先把之前的事件监听器去掉:
在 mousedown 的时候绑定 mousemove 和 mouseup 事件:
mousemove 的时候根据 event 修改 offset。
mouseup 的时候去掉事件监听器。
这个过程中还要修改记录拖动状态的 flag 的值。
然后实现拖动过程中的 offset 的计算:
const updateOffset: EventHandle = e => {
const scrollXOffset = document.documentElement.scrollLeft || document.body.scrollLeft;
const scrollYOffset = document.documentElement.scrollTop || document.body.scrollTop;
const pageX = e.pageX - scrollXOffset;
const pageY = e.pageY - scrollYOffset;
const {
x: rectX,
y: rectY,
width,
height
} = containerRef.current!.getBoundingClientRect();
const {
width: targetWidth,
height: targetHeight
} = targetRef.current!.getBoundingClientRect();
const centerOffsetX = targetWidth / 2;
const centerOffsetY = targetHeight / 2;
const offsetX = Math.max(0, Math.min(pageX - rectX, width)) - centerOffsetX;
const offsetY = Math.max(0, Math.min(pageY - rectY, height)) - centerOffsetY;
const calcOffset = {
x: offsetX,
y: direction === 'x' ? offsetValue.y : offsetY,
};
setOffsetValue(calcOffset);
onDragChange?.(calcOffset);
};
首先 e.pageX 和 e.pageY 是距离页面顶部和左边的距离。
减去 scrollLeft 和 scrollTop 之后就是离可视区域顶部和左边的距离了。
然后减去 handler 圆点的半径。
这样算出来的就是按住 handler 圆点的中心拖动的效果。
但是拖动不能超出 container 的区域,所以用 Math.max 来限制在 0 到 width、height 之间拖动。
这里如果传入的 direction 参数是 x,那么就只能横向拖动,是为了下面的 Slider 准备的:
我们来试下效果:
import { useRef, type FC } from 'react';
import { Color } from './color';
import Handler from './Handler';
import Transform from './Transform';
import useColorDrag from './useColorDrag';
const Palette: FC<{
color: Color
}> = ({ color }) => {
const transformRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [offset, dragStartHandle] = useColorDrag({
containerRef,
targetRef: transformRef,
onDragChange: offsetValue => {
console.log(offsetValue);
}
});
return (
<div
ref={containerRef}
className="color-picker-panel-palette"
onMouseDown={dragStartHandle}
>
<Transform ref={transformRef} offset={{x: offset.x, y: offset.y}}>
<Handler color={color.toRgbString()}/>
</Transform>
<div
className={`color-picker-panel-palette-main`}
style={{
backgroundColor: `hsl(${color.toHsl().h},100%, 50%)`,
backgroundImage:
'linear-gradient(0deg, #000, transparent),linear-gradient(90deg, #fff, hsla(0, 0%, 100%, 0))',
}}
/>
</div>
);
};
export default Palette;
可以看到,滑块可以拖动了,并且只能在容器范围内拖动。
只是颜色没有变化,这个需要根据 x、y 的值来算出当前的颜色。
我们封装个工具方法:
新建 ColorPicker/utils.ts
import { TransformOffset } from "./Transform";
import { Color } from "./color";
export const calculateColor = (props: {
offset: TransformOffset;
containerRef: React.RefObject<HTMLDivElement>;
targetRef: React.RefObject<HTMLDivElement>;
color: Color;
}): Color => {
const { offset, targetRef, containerRef, color } = props;
const { width, height } = containerRef.current!.getBoundingClientRect();
const {
width: targetWidth,
height: targetHeight
} = targetRef.current!.getBoundingClientRect();
const centerOffsetX = targetWidth / 2;
const centerOffsetY = targetHeight / 2;
const saturation = (offset.x + centerOffsetX) / width;
const lightness = 1 - (offset.y + centerOffsetY) / height;
const hsv = color.toHsv();
return new Color({
h: hsv.h,
s: saturation <= 0 ? 0 : saturation,
v: lightness >= 1 ? 1 : lightness,
a: hsv.a,
});
}
这块逻辑就是用 x/width 用 y/height 求出一个比例来。
当然,x、y 还要加上圆点的半径,这样才是中心点位置。
根据比例设置 hsv 的值,这样就算出了拖动位置的颜色。
然后在 onDragChange 里根据 offset 计算当前的颜色,并且通过 onChange 回调返回新颜色。
在 ColorPickerPanel 组件里处理下 onChange:
function onPaletteColorChange(color: Color) {
setColorValue(color);
onChange?.(color);
}
修改当前颜色,并且调用它的 onChange 回调函数。
测试下:
没啥问题。
只是现在初始的颜色不对:
最开始也要计算一次滑块位置:
我们给 useColorDrag 添加 color 和 calculate 两个参数。
最开始和 color 改变的时候,调用 calculate 计算位置,重新设置 offsetValue。
import { useEffect, useRef, useState } from 'react';
import { TransformOffset } from './Transform';
import { Color } from './color';
type EventType =
| MouseEvent
| React.MouseEvent<Element, MouseEvent>
type EventHandle = (e: EventType) => void;
interface useColorDragProps {
offset?: TransformOffset;
color: Color;
containerRef: React.RefObject<HTMLDivElement>;
targetRef: React.RefObject<HTMLDivElement>;
direction?: 'x' | 'y';
onDragChange?: (offset: TransformOffset) => void;
calculate?: () => TransformOffset;
}
function useColorDrag(
props: useColorDragProps,
): [TransformOffset, EventHandle] {
const {
offset,
color,
targetRef,
containerRef,
direction,
onDragChange,
calculate,
} = props;
const [offsetValue, setOffsetValue] = useState(offset || { x: 0, y: 0 });
const dragRef = useRef({
flag: false
});
useEffect(() => {
if (dragRef.current.flag === false) {
const calcOffset = calculate?.();
if (calcOffset) {
setOffsetValue(calcOffset);
}
}
}, [color]);
useEffect(() => {
document.removeEventListener('mousemove', onDragMove);
document.removeEventListener('mouseup', onDragStop);
}, []);
const updateOffset: EventHandle = e => {
const scrollXOffset = document.documentElement.scrollLeft || document.body.scrollLeft;
const scrollYOffset = document.documentElement.scrollTop || document.body.scrollTop;
const pageX = e.pageX - scrollXOffset;
const pageY = e.pageY - scrollYOffset;
const {
x: rectX,
y: rectY,
width,
height
} = containerRef.current!.getBoundingClientRect();
const {
width: targetWidth,
height: targetHeight
} = targetRef.current!.getBoundingClientRect();
const centerOffsetX = targetWidth / 2;
const centerOffsetY = targetHeight / 2;
const offsetX = Math.max(0, Math.min(pageX - rectX, width)) - centerOffsetX;
const offsetY = Math.max(0, Math.min(pageY - rectY, height)) - centerOffsetY;
const calcOffset = {
x: offsetX,
y: direction === 'x' ? offsetValue.y : offsetY,
};
setOffsetValue(calcOffset);
onDragChange?.(calcOffset);
};
const onDragStop: EventHandle = e => {
document.removeEventListener('mousemove', onDragMove);
document.removeEventListener('mouseup', onDragStop);
dragRef.current.flag = false;
};
const onDragMove: EventHandle = e => {
e.preventDefault();
updateOffset(e);
};
const onDragStart: EventHandle = e => {
document.addEventListener('mousemove', onDragMove);
document.addEventListener('mouseup', onDragStop);
dragRef.current.flag = true;
};
return [offsetValue, onDragStart];
}
export default useColorDrag;
然后在调用的时候传入这两个参数:
const [offset, dragStartHandle] = useColorDrag({
containerRef,
targetRef: transformRef,
color,
onDragChange: offsetValue => {
const newColor = calculateColor({
offset: offsetValue,
containerRef,
targetRef: transformRef,
color
});
onChange?.(newColor);
},
calculate: () => {
return calculateOffset(containerRef, transformRef, color)
}
});
这里的 calculateOffset 在 utils.ts 里定义:
export const calculateOffset = (
containerRef: React.RefObject<HTMLDivElement>,
targetRef: React.RefObject<HTMLDivElement>,
color: Color
): TransformOffset => {
const { width, height } = containerRef.current!.getBoundingClientRect();
const {
width: targetWidth,
height: targetHeight
} = targetRef.current!.getBoundingClientRect();
const centerOffsetX = targetWidth / 2;
const centerOffsetY = targetHeight / 2;
const hsv = color.toHsv();
return {
x: hsv.s * width - centerOffsetX,
y: (1 - hsv.v) * height - centerOffsetY,
};
};
就是根据 hsv 里的 s 和 v 的百分比乘以 width、height,计算出 x、y,然后减去滑块的宽高的一半。
可以看到,现在初始位置就对了:
我在 App.tsx 里设置个不同的颜色:
<ColorPickerPanel value="rgb(166 57 57)"></ColorPickerPanel>
初始位置也是对的:
我们在下面加一个颜色块:
<div style={{width: 20, height: 20, background: colorValue.toRgbString()}}></div>
可以看到,随着滑块的移动,返回的颜色是对的。
但有时候会变为选择,而不是拖拽,我们优化下体验:
user-select: none;
cursor: pointer;
好多了。
还有一点,我们前面的 value 参数其实是 defaultValue:
也就是用来作为内部 state 的初始值。
这里我们同时支持受控和非受控,用 ahooks 的 useControllableValue 做。
安装 ahooks:
npm install --save ahooks
把 useState 换成 ahooks 的 useControllableValue:
export interface ColorPickerProps {
className?: string;
style?: CSSProperties;
value?: ColorType;
defaultValue?: ColorType;
onChange?: (color: Color) => void;
}
const [colorValue, setColorValue] = useControllableValue<Color>(props);
这样就同时支持了 value 和 defaultValue,也就是受控和非受控模式。
然后我们加上调节色相和亮度的滑块:
因为我们计算颜色用的是 hsv,这里两个滑块分别改变的就是 h(色相)、v(明度)。
我们简化下,直接用 input range 来做吧:
import React, { ChangeEventHandler, useState } from 'react';
import logo from './logo.svg';
import './App.css';
import ColorPickerPanel from './ColorPicker/ColorPickerPanel';
import { Color } from './ColorPicker/color';
function App() {
const [color, setColor] = useState<Color>(new Color('rgb(166,57,255)'));
const handleHueChange: ChangeEventHandler<HTMLInputElement> = (e) => {
const hsv = color.toHsv();
let val = +e.target.value;
setColor(new Color({
h: val,
s: hsv.s,
v: hsv.v,
}))
}
const handleVChange: ChangeEventHandler<HTMLInputElement> = (e) => {
const hsv = color.toHsv();
let val = +e.target.value;
setColor(new Color({
h: hsv.h,
s: hsv.s,
v: val,
}))
}
return (
<div style={{width: '300px'}}>
<ColorPickerPanel value={color} onChange={newColor => setColor(newColor)}></ColorPickerPanel>
<div>
色相:<input type='range' min={0} max={360} step={0.1} value={color.toHsv().h} onChange={handleHueChange}/>
</div>
<div>
明度:<input type='range' min={0} max={1} step={0.01} value={color.toHsv().v} onChange={handleVChange}/>
</div>
</div>
);
}
export default App;
h 的取值范围是 0 到 360
而 v 的取值范围是 0 到 100%
试一下:
这样,ColorPicker 就完成了。
案例代码上传了小册仓库。
总结
这节我们实现了 ColorPicker 的调色板。
它的布局不复杂,就是一个渐变的背景,加上一个绝对定位的滑块。
就是根据位置计算颜色、根据颜色计算位置,这两个方向的计算比较复杂。
根据位置计算颜色,以 x 方向为例:
需要用 mousemove 时的 e.pageX(距离文档左边的距离) 减去 scrollLeft 计算出滑块距离视口的距离,然后减去容器距离视口的距离,再减去滑块半径就是滑块距离容器的距离 x。
然后用这个 x 除以 width 计算出 hsv 中的 s 的值。
这样就根据拖拽位置计算出了颜色。
根据颜色计算位置比较简单,直接拿到 hsv 的 s 和 v 的值,根据百分比乘以 width、height 就行。
此外,颜色我们用的 @ctrl/tinycolor 这个包的颜色类,antd 也是用的这个。但是参数不用直接传 Color 类的实例,可以传 rgb、string 等我们内部转成 Color 类。
我们还用 ahooks 的 useControllableValue 同时支持了 value 和 defaultValue 也就是受控和非受控模式。
最后,支持了色相和明度的调整。
至此,ColorPicker 组件就完成了。