• Babel 插件通关秘籍
  • Git 原理详解及实用指南
  • Nest 通关秘籍
  • React 通关秘籍
  • TypeScript 全面进阶指南
  • TypeScript 类型体操通关秘籍
  • 现代CSS
  • Babel 插件通关秘籍
  • Git 原理详解及实用指南
  • Nest 通关秘籍
  • React 通关秘籍
  • TypeScript 全面进阶指南
  • TypeScript 类型体操通关秘籍
  • 现代CSS
  • React 通关秘籍

    • 1.关于本小册
    • 2.一网打尽组件常用Hook
    • 3.Hook的闭包陷阱的成因和解决方案
    • 4.React组件如何写TypeScript类型
    • 5.React组件如何调试
    • 6.受控模式VS非受控模式
    • 7.组件实战:迷你Calendar
    • 8.组件实战:Calendar日历组件(上)
    • 9.组件实战:Calendar日历组件(下)
    • 10.快速掌握Storybook
    • 11.React组件如何写单测
    • 12.深入理解Suspense和ErrorBoundary
    • 13.组件实战:Icon图标组件
    • 14.组件实战:Space间距组件
    • 15.React.Children和它的两种替代方案
    • 16.三个简单组件的封装
    • 17.浏览器的5种Observer
    • 18.组件实战:Watermark防删除水印组件
    • 19.手写react-lazyload
    • 20.图解网页的各种距离
    • 21.自定义hook练习
    • 22.自定义hook练习(二)
    • 23.用react-spring做弹簧动画
    • 24.react-spring结合use-gesture手势库实现交互动画
    • 25.用react-transition-group和react-spring做过渡动画
    • 26.快速掌握Tailwind:最流行的原子化CSS框架
    • 27.用CSSModules避免样式冲突
    • 28.CSSInJS:快速掌握styled-components
    • 29.react-spring实现滑入滑出的转场动画
    • 30.组件实战:Message全局提示组件
    • 31.组件实战:Popover气泡卡片组件
    • 32.项目里如何快速定位组件源码
    • 33.一次超爽的React调试体验
    • 34.组件实战:ColorPicker颜色选择器(一)
    • 35.组件实战:ColorPicker颜色选择器(二)
    • 36.组件实战:onBoarding漫游式引导组件
    • 37.组件实战:Upload拖拽上传
    • 38.组件实战:Form表单组件
    • 39.React组件库都是怎么构建的
    • 40.组件库实战:构建esm和cjs产物,发布到npm
    • 41.组件库实战:构建umd产物,通过unpkg访问
    • 42.数据不可变:immutable和immer
    • 43.基于ReactRouter实现keepalive
    • 44.Historyapi和ReactRouter实现原理
    • 45.ReactContext的实现原理和在antd里的应用
    • 46.ReactContext的性能缺点和解决方案
    • 47.手写一个Zustand
    • 48.原子化状态管理库Jotai
    • 49.用react-intl实现国际化
    • 50.国际化资源包如何通过Excel和GoogleSheet分享给产品经理
    • 51.基于react-dnd实现拖拽排序
    • 52.react-dnd实战:拖拽版TodoList
    • 53.ReactPlayground项目实战:需求分析、实现原理
    • 54.ReactPlayground项目实战:布局、代码编辑器
    • 55.ReactPlayground项目实战:多文件切换
    • 56.ReactPlayground项目实战:babel编译、iframe预览
    • 57.ReactPlayground项目实战:文件增删改
    • 58.ReactPlayground项目实战:错误显示、主题切换
    • 59.ReactPlayground项目实战:链接分享、代码下载
    • 60.ReactPlayground项目实战:WebWorker性能优化
    • 61.ReactPlayground项目实战:总结
    • 62.手写MiniReact:思路分析
    • 63.手写MiniReact:代码实现
    • 64.手写MiniReact:和真实React源码的对比
    • 65.React18的并发机制是怎么实现的
    • 66.Ref的实现原理
    • 67.低代码编辑器:核心数据结构、全局store
    • 68.低代码编辑器:拖拽组件到画布、拖拽编辑json
    • 69.低代码编辑器:画布区hover展示高亮框
    • 70.低代码编辑器:画布区click展示编辑框
    • 71.低代码编辑器:组件属性、样式编辑
    • 72.低代码编辑器:预览、大纲
    • 73.低代码编辑器:事件绑定
    • 74.低代码编辑器:动作弹窗
    • 75.低代码编辑器:自定义JS
    • 76.低代码编辑器:组件联动
    • 77.低代码编辑器:拖拽优化、Table组件
    • 78.低代码编辑器:Form组件、store持久化
    • 79.低代码编辑器:项目总结
    • 80.快速掌握ReactFlow画流程图
    • 81.ReactFlow振荡器调音:项目介绍
    • 82.ReactFlow振荡器调音:流程图绘制
    • 83.ReactFlow振荡器调音:合成声音
    • 84.AudioContext实现在线钢琴
    • 85.React服务端渲染:从SSR到hydrate
    • 86.小册总结

这节我们开始写 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>

可以看到,随着滑块的移动,返回的颜色是对的。

但有时候会变为选择,而不是拖拽,我们优化下体验:

image.png

user-select: none;
cursor: pointer;

2024-08-31 17.55.28.gif

好多了。

还有一点,我们前面的 value 参数其实是 defaultValue:

image.png

也就是用来作为内部 state 的初始值。

这里我们同时支持受控和非受控,用 ahooks 的 useControllableValue 做。

安装 ahooks:

npm install --save ahooks

把 useState 换成 ahooks 的 useControllableValue:

image.png

export interface ColorPickerProps {
    className?: string;
    style?: CSSProperties;
    value?: ColorType;
    defaultValue?: ColorType;
    onChange?: (color: Color) => void;
}
const [colorValue, setColorValue] = useControllableValue < Color > props;

这样就同时支持了 value 和 defaultValue,也就是受控和非受控模式。

然后我们加上调节色相和亮度的滑块:

image.png

因为我们计算颜色用的是 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%

试一下:

2024-08-31 18.35.50.gif

这样,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 组件就完成了。

上次更新: 6/21/25, 9:42 AM
贡献者: YNight
Prev
34.组件实战:ColorPicker颜色选择器(一)
Next
36.组件实战:onBoarding漫游式引导组件