用canvas写一个颜色选择器

1,602 阅读6分钟

颜色选择器用得多了,是时候也要学学怎么自己写一个了!通过canvas的ImageData像素矩阵可以轻易获取RGBA值,就是这么快趣!

1. 颜色选择器功能

image.png

  • 色相条:修改hue色相角度值
  • 颜色深浅板:在该hue色相角度下,修改饱和度和亮度调整颜色
  • 透明度条:修改rgba的alpha透明度
  • 色系渐变列表:颜色从浅到深渐变
  • 取色器:通过EyeDropper滴管获取页面的颜色值
  • 输入框:颜色值的文本同步在输入框,并且改变输入框的值对应解析同步到色相条,颜色深浅板,透明度条,色系渐变列表

用canvas画颜色选择器的好处就是不同格式颜色都会存在ImageData里面,转化rgba的像素矩阵,只要计算出对应的坐标就能取到对应的rgba颜色值,非常方便。

2. 用canvas画色相条

360度的色相角度,每60度取一个颜色值,饱和度100%,亮度50%,得到六个颜色,头尾都用色相0deg的红色,通过渐变即可得到一个标准色渐变条

image.png

  const huelist = [
    'hsl(60deg, 100%, 50%)',
    'hsl(120deg, 100%, 50%)',
    'hsl(180deg, 100%, 50%)',
    'hsl(240deg, 100%, 50%)',
    'hsl(300deg, 100%, 50%)'
  ];
  const createBar = () => {
    const canvas = barRef.value;
    if (canvas) {
      canvas.width = canvas.offsetWidth;
      canvas.height = canvas.offsetHeight;

      const ctx = canvas.getContext('2d');
      if (ctx) {
        const grd = ctx.createLinearGradient(0, 0, 0, canvas.height);//创建线性渐变
        const len = huelist.length + 1;
        grd.addColorStop(0.01, 'hsl(0deg, 100%, 50%)');//红色
        huelist.forEach((a, i) => {
          grd.addColorStop((i + 1) / len, a);
        });
        grd.addColorStop(0.99, 'hsl(360deg, 100%, 50%)');
        ctx.fillStyle = grd;
        ctx.fillRect(0, 0, canvas.width, canvas.height);
        //截取宽度为1的像素矩阵
        barImgData = ctx.getImageData(0, 0, 1, canvas.height).data;
      }
    }
  };

image.png

注意: 开始的红色是0.01的位置,结束的红色是0.99的位置,避免取不到红色。

3. 用canvas画颜色深浅板

颜色深浅板是由色相条选中的标准色作为底色,然后添加一层从左向右的白色到透明的渐变,和一层从下向上的黑色到透明的渐变,这样就形成了不同亮度和饱和度的板面。

const createPanel = () => {
    const canvas = panelRef.value;
    if (canvas) {
      canvas.width = canvas.offsetWidth;
      canvas.height = canvas.offsetHeight;

      const ctx = canvas.getContext('2d');
      if (ctx) {
        ctx.fillStyle = state.bgColor;
        ctx.fillRect(0, 0, canvas.width, canvas.height);
        {//从左到右,白色到透明
          const grd = ctx.createLinearGradient(0, 0, canvas.width, 0);
          grd.addColorStop(0.01, 'white');
          grd.addColorStop(0.99, 'rgba(255, 255, 255, 0)');
          ctx.fillStyle = grd;
          ctx.fillRect(0, 0, canvas.width, canvas.height);
        }
        {//从下到上,黑色到透明
          const grd = ctx.createLinearGradient(0, canvas.height, 0, 0);
          grd.addColorStop(0.01, 'black');
          grd.addColorStop(0.99, 'rgba(0, 0, 0, 0)');
          ctx.fillStyle = grd;
          ctx.fillRect(0, 0, canvas.width, canvas.height);
        }
        //面板像素矩阵
        panelImgData = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
        //宽度具有的像素矩阵索引数量
        panelWidthPx = canvas.width * 4;
      }
    }
  };

image.png

注意: 跟上面的色相条一样,两层黑白渐变色头尾分别都是0.010.99,避免取不到边缘颜色。

4. 给色相条和颜色深浅板加上动作

在面板上点击,拖拽移动获取对应的坐标

export interface DragMoveConfig {
  start?: (e: MouseEvent) => void;
  move?: (e: MouseEvent) => void;
  end?: (e: MouseEvent) => void;
}
export function onDragMove(config: DragMoveConfig) {
  let el: HTMLElement;
  const onMouseDown = (ev: MouseEvent) => {
    ev.stopImmediatePropagation();
    config.start && config.start(ev);
    //禁用选择
    document.onselectstart = () => false;
    
    document.addEventListener('mousemove', onMouseMove);
    document.addEventListener('mouseup', onMouseUp);
  };
  const onMouseMove = (ev: MouseEvent) => {
  //只返回el范围内的
    ev.target === el && config.move && config.move(ev);
  };
  const onMouseUp = (ev: MouseEvent) => {
  //只返回el范围内的
    ev.target === el && config.end && config.end(ev);
    //取消禁用选择
    document.onselectstart = null;
    document.removeEventListener('mousemove', onMouseMove);
    document.removeEventListener('mouseup', onMouseUp);
  };
  return {
    init: (dom: HTMLElement) => {
      el = dom;
      el.addEventListener('mousedown', onMouseDown);
    },

    destroyed: () => {
      el.removeEventListener('mousedown', onMouseDown);
    }
  };
}

色相条获取颜色

给色相条canvas添加动作,获取y坐标计算颜色值

let isLock=false;
const onBarMove = (ev: MouseEvent) => {
    const canvas = barRef.value;
    if (canvas) {
      let y = ev.offsetY;
      //避免超出范围
      if (y < 0) {
        y = 0;
      } else if (y > canvas.height) {
        y = canvas.height;
      }
      state.barY = y;//垂直方向坐标,用于计算色相值hue
      const i = y * 4;
      //barImgData宽度为1的像素矩阵,即标准色
      const res = getImageDataColor(barImgData, i);
      if (res) {
        const { color } = res;
        console.log(`%c color`, 'background:' + color, color);
        //标准色
        state.bgColor = color;
        //节流
        if (!isLock) {
          isLock = true;
          nextTick(() => {
          //同步更新颜色深浅板
            createPanel();
            getPanelColor();
            isLock = false;
          });
        }
      }
    }
  };
  //拖拽移动动作
  const dragmoveBar = onDragMove({
    start: onBarMove,
    move: onBarMove,
    end: onBarMove
  });
  
  onMounted(() => {
  //初始化传入DOM
    dragmoveBar.init(barRef.value as HTMLElement); 
  });
  onBeforeUnmount(() => {
  //销毁动作
    dragmoveBar.destroyed(); 
  });
  • 色相条先绘制好,并截取到barImgData宽度为1的像素矩阵,因为canvas已经将hsl转化为rgba,所以(y*4)及对应像素索引,即对应的标准色作为深浅板的底色。
  • 色相条选中的底色改变后,深浅板也要重新绘制更新,做到操作同步

获取对应索引的像素矩阵: 像素矩阵以rgba4个元素为单位,避免错误索引,要进行规整化计算i = Math.floor(i / 4) * 4

interface DataColor {
    color: string;
    r: number;
    g: number;
    b: number;
  }
  const getImageDataColor = (imgData: Uint8ClampedArray, i: number): DataColor | undefined => {
    i = Math.floor(i / 4) * 4;
    if (i >= 0 && i <= imgData.length - 5) {
      const r = imgData[i];
      const g = imgData[i + 1];
      const b = imgData[i + 2];
      const color = `rgb(${r},${g},${b})`;

      return {
        color,
        r,
        g,
        b
      };
    }
  };

20240529_220144.gif

颜色深浅板获取颜色

给颜色深浅板canvas添加动作,获取xy坐标计算颜色值

const onMovePanel = (ev: MouseEvent) => {
    const canvas = panelRef.value;
    if (canvas) {
      let x = ev.offsetX;
      //避免x超出范围
      if (x < 0) {
        x = 0;
      } else if (x > canvas.width) {
        x = canvas.width;
      }
      let y = ev.offsetY;
      //避免y超出范围
      if (y < 0) {
        y = 0;
      } else if (y > canvas.height) {
        y = canvas.height;
      }
      state.panelX = x;
      state.panelY = y;
      //更新选中颜色
      getPanelColor();
    }
  };
   //拖拽移动动作
  const dragmovePanel = onDragMove({
    start: onMovePanel,
    move: onMovePanel,
    end: onMovePanel
  });
  
  onMounted(() => {  
  //初始化传入DOM
    dragmovePanel.init(panelRef.value as HTMLElement); 
  });
  onBeforeUnmount(() => { 
  //销毁动作
    dragmovePanel.destroyed(); 
  });
  • 更新选中颜色:画板宽度具有的像素矩阵索引数量panelWidthPx=panelWidth*4,前(y-1)行的数量是panelWidthPx*(y-1),第y行的像素索引数量(x-1)*4,最终的像素矩阵索引坐标是(y <= 1 ? 0 : (y - 1) * panelWidthPx) + (x <= 1 ? 0 : (x - 1) * 4)
const getPanelColor = () => {
    const x = state.panelX,
      y = state.panelY;
    const i = (y <= 1 ? 0 : (y - 1) * panelWidthPx) + (x <= 1 ? 0 : (x - 1) * 4);
    const res = getImageDataColor(panelImgData, i);
    if (res) {
      const { color, r, g, b } = res;
      console.log(`%c color`, 'background:' + color, color);
      state.colorSet.r = r;
      state.colorSet.g = g;
      state.colorSet.b = b;
      //深浅板选择的颜色
       state.color = formatColor(r, g, b, state.colorSet.a, props.format);
      //更新色系渐变色
      getGradients(r, g, b);
    }
  };

20240529_225507.gif

5. 色系渐变列表

 <div class="color-gradient">
      <span
        :class="[state.activeGrd === i ? 'active' : '']"
        v-for="(item, i) in state.colorGradients"
        :key="i"
        :style="{ backgroundColor: item.rgb }"
        @click="onGrdColor(i)"
      ></span>
    </div>

同色系即色相和饱和度相同,亮度不同形成不同的渐变色

const getGradients = (r: number, g: number, b: number) => {
    let { h, s } = rgb2hsv(r, g, b);
    h = Math.floor(h);
    s = Math.floor(s);

    const grdList = [];
    const len = 11;
    let minDist = 255 * 3;
    let idx = 0;
    for (let i = 0; i <= len; i++) {
      const c = hsl2rgb(h, s, Math.floor((i / len) * 100));
      grdList.push({ rgb: `rgb(${c.r},${c.g},${c.b})`, ...c });
      const d = Math.abs(c.r - r) + Math.abs(c.g - g) + Math.abs(c.b - b);
      if (d < minDist) {
        minDist = d;
        idx = i;
      }
    }
    state.colorGradients = grdList;
    state.activeGrd = idx;
  };
  
  • 改变颜色深浅板会同步更新,获取色系渐变列表
  • 将100%的亮度分成12份,对应不同明暗程度的颜色。可能不能完全跟选中的颜色一致,但色系相同,可以计算rgb的距离最短距离,来确认最近的颜色作为激活状态 20240529_231356.gif

通过色系渐变条选中颜色

  const onGrdColor = (idx: number) => {
    const { r, g, b } = state.colorGradients[idx];
    const { s1, v1 } = rgb2hsv(r, g, b);
    //同步更新颜色深浅板
    const canvas = panelRef.value;
    if (canvas) {
      state.panelX = Math.floor(s1 * canvas.width);
      state.panelY = Math.floor((1 - v1) * canvas.height);
    }
    //选中渐变列表的索引
    state.activeGrd = idx;
    state.colorSet.r = r;
    state.colorSet.g = g;
    state.colorSet.b = b;
    state.color = formatColor(r, g, b, state.colorSet.a, props.format);
  };

20240529_234112.gif

6. 透明度条

<div class="color-alpha">
      <div
        class="color-alpha-bar"
        ref="alphaRef"
        :style="{
          background: `linear-gradient(to right,  ${resultColor.rgb}  0.1%, rgba(${resultColor.num}, 0) 99.9%)`
        }"
      ></div>
      <span
        class="color-alpha-thumb"
        :style="{ left: state.alphaX + 'px', background: resultColor.rgba }"
      ></span>
    </div>

给透明度条添加拖拽移动动作,获取x坐标计算出[0-1]范围的透明度。

//规范显示,避免小数位太多
const getAlpha = (a: number) => {
    return a === 1 ? 1 : a === 0 ? 0 : Number(a.toFixed(2));
  };
  const onAlphaMove = (ev: MouseEvent) => {
    if (alphaRef.value) {
      const w = alphaRef.value.offsetWidth;
      let x = ev.offsetX;
      //透明度范围
      if (x < 0) {
        x = 0;
      } else if (x > w) {
        x = w;
      }
      state.alphaX = x;
      state.colorSet.a = getAlpha(1 - x / w);

      const { r, g, b, a } = state.colorSet;
      state.color = formatColor(r, g, b, a, props.format);
    }
  };
  const dragmoveAlpha = onDragMove({
    start: onAlphaMove,
    move: onAlphaMove,
    end: onAlphaMove
  });
   onMounted(() => {     
    dragmoveAlpha.init(alphaRef.value as HTMLElement);
  });
  onBeforeUnmount(() => { 
    dragmoveAlpha.destroyed();
  });

20240529_230044.gif

7. 解析输入框修改的颜色

//统一转换成rgba
 const parseColor = (newColor?: string) => {
    const c = newColor || props.modelValue;
    const res = getRgba(c);

    if (res) {
      const { r, g, b, a, color } = res;
      state.color = formatColor(r, g, b, a, props.format);
      
      const hsv = rgb2hsv(r, g, b);
      //计算色相条选中位置
      if (barRef.value) {
        state.barY = Math.floor(hsv.h1 * barRef.value.height);
        const barColor = getImageDataColor(barImgData, state.barY * 4);
        if (barColor) state.bgColor = barColor.color;
      }
      //绘制颜色深浅板
      createPanel();
      const canvas = panelRef.value;
      if (canvas) {
      //计算颜色深浅板选中的位置
        state.panelX = Math.floor(hsv.s1 * canvas.width);
        state.panelY = Math.floor((1 - hsv.v1) * canvas.height);
        if (alphaRef.value) state.alphaX = Math.floor((1 - a) * alphaRef.value.offsetWidth);
        state.colorSet.r = r;
        state.colorSet.g = g;
        state.colorSet.b = b;
        state.colorSet.a = a;
        //获取渐变列表
        getGradients(r, g, b);
      }
    }
  };
  //输入框修改后解析文本
  const onInputColor = (ev: Event) => {
    const target = ev.target as HTMLInputElement;
    if (target) parseColor(target.value);
  };

颜色格式转换

20240601_120504.gif

解析颜色值

function str2Num(str: string) {
  const c = str.match(/[0-9]+/);
  if (c) {
    return Number(c[0]);
  }
  return parseFloat(str);
}
export const getRgba = (str: string) => {
  if (!str) return;
  if (str.indexOf('hsl(') === 0) {
    const s = str
      .slice(4, str.length - 1)
      .replace(/\s/g, '')
      .split(',')
      .map((a: string) => str2Num(a));
    const c = hsl2rgb(s[0], s[1], s[2]);
    return { ...c, a: 1, color: `rgba(${c.r},${c.g},${c.b},1)` };
  } else if (str.indexOf('rgba(') === 0) {
    const s = str
      .slice(5, str.length - 1)
      .replace(/\s/g, '')
      .split(',')
      .map((a: string) => Number(a));
    return { r: s[0], g: s[1], b: s[2], a: s[3], color: `rgba(${s[0]},${s[1]},${s[2]},${s[3]})` };
  } else if (str.indexOf('rgb(') === 0) {
    const s = str
      .slice(4, str.length - 1)
      .replace(/\s/g, '')
      .split(',')
      .map((a: string) => Number(a));
    return { r: s[0], g: s[1], b: s[2], a: 1, color: `rgba(${s[0]},${s[1]},${s[2]},1)` };
  } else if (str.indexOf('#') === 0) {
    let res;
    if (str.length === 7) res = str;
    else if (str.length === 4) res = `#${str[1]}${str[1]}${str[2]}${str[2]}${str[3]}${str[3]}`;
    else if (str.length === 3) res = `#${str[1]}${str[1]}${str[1]}${str[2]}${str[2]}${str[2]}`;
    if (res) {
      const c = hex2rgb(res);
      return { ...c, a: 1, color: `rgba(${c.r},${c.g},${c.b},1)` };
    }
  }
};

格式化颜色值

export const formatColor = (
  r: number,
  g: number,
  b: number,
  a: number,
  type: string = 'rgba'
): string => {
  if (type === 'rgba') {
    return `rgba(${r},${g},${b},${a})`;
  } else if (type === 'rgb') {
    return `rgb(${r},${g},${b})`;
  } else if (type === 'hex') {
    return rgb2hex(r, g, b);
  } else if (type === 'hsl') {
    const { h, s, l } = rgb2hsl(r, g, b);
    return `hsl(${h}deg,${s}%,${l}%)`;
  }
  return `rgba(${r},${g},${b},${a})`;
};
  • 为了方便理解,所以采用了rgb作为中介。

8. 自定义颜色列表

20240601_115439.gif

将颜色值传入parseColor即可

9. 添加EyeDropper滴管取色

启用浏览器页面滴管取色工具,可以参考官网:developer.mozilla.org/zh-CN/docs/…

let eyeDropper: unknown;
  const onDropper = () => {
    if (!eyeDropper) eyeDropper = new EyeDropper();
    state.isDropper = true;
    eyeDropper
      .open()
      .then((result: any) => {
        state.isDropper = false;
        parseColor(result.sRGBHex);
      })
      .catch((e: Error) => {
        console.log(e);
        state.isDropper = false;
      });
  };

将滴管取到的颜色通过parseColor解析即可

20240601_121343.gif

10. 最终效果

canvas颜色选择器功能基本完成啦。虽然颜色之间的转换可能有点精度丢失,但无伤大雅。

20240601_163932.gif

11. GitHub地址

https://github.com/xiaolidan00/color-picker

参考