纯js实现超好用的拾色器(颜色吸管工具)

8,308 阅读3分钟

网页拾色器(颜色吸管工具)可以吸取网页上每个像素的颜色值。

一、介绍和使用

图例

屏幕录制2021-11-03 下午3.gif

使用

import ColorPipette from './color-pipette';
// 初始化
const pipette = new ColorPipette({
  container: document.body,
  scale: 2,
  listener: {
    onOk({ color, colors }) => {
      console.log(color, colors);
    },
  }
});
// 开始取色
pipette.start();

参数说明

参数名含义默认值备注
container想截图的页面dom元素document.body-
scale显示像素比例1绘制截图时的比例,范围为1-4,数字越大越清晰但是速度越慢
listener事件监听-onOk: ({color: string; colors: string[][]}) => void
listener.onOk监听完成-onOk: ({color: string; colors: string[][]}) => void
listener.onChange监听变化-onChange: ({color: string; colors: string[][]}) => void

二、实现方法

实现拾色器主要分为一下几个步骤

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

1. 网页截图

想要实现获取到页面的像素点颜色,首先要对整个网页进行截图,为了兼容性考虑我们使用dom-to-image来实现截图功能。源码在此:dom-to-image

使用过程中发现dom-to-image截图很模糊,因此复制了源码(只有几百行),支持设置分辨率。

  • 使用截图
import domtoimage from './dom-to-image';

async getPagePng() {
    const base64 = await domtoimage.toPng(this.targetDom, { scale: 2 });
    return base64;
}
  • 只修改了绘制函数这里,增加了scale的设置
function draw(domNode, options) {
    const { scale = 1 } = options;
    return toSvg(domNode, options)
        .then(util.makeImage)
        .then(util.delay(100))
        .then(function (image) {
            const { canvas, ctx } = newCanvas(domNode, options);
            console.log(canvas, ctx, canvas.width, canvas.height, scale );
            ctx.drawImage(image, 0, 0, canvas.width / scale, canvas.height / scale);
            return canvas;
        });

    function newCanvas(domNode, options) {
        const canvas = document.createElement('canvas');
        const { scale = 1 } = options;
        canvas.width = (options.width || util.width(domNode)) * scale;
        canvas.height = (options.height || util.height(domNode)) * scale;
        const ctx = canvas.getContext('2d');
        ctx.scale(scale, scale);
        if (options.bgcolor) {
            ctx.fillStyle = options.bgcolor;
            ctx.fillRect(0, 0, canvas.width, canvas.height);
        }

        return { canvas, ctx };
    }
}
  • dom-to-image.js完整代码如下(将draw函数替换成上面即可):

dom-to-image.js

2. 绘制&获取像素点颜色

  • 截图获取base64,然后绘制到canvas上
/**
 * 加载base64图片
 * @param base64
 * @returns
 */
export const loadImage = (base64: string) => {
  return new Promise((resolve) => {
    const img = new Image();
    img.src = base64;
    img.onload = () => resolve(img);
    img.onerror = () => resolve(null);
  });
};


/**
 * 将dom节点画到canvas里
 */
async drawCanvas() {
  const base64 = await domtoimage.toPng(this.container, { scale: this.scale }).catch(() => '');
  if (!base64) {
    return;
  }
  const img = await loadImage(base64);
  if (!img) {
    return;
  }
  this.ctx.drawImage(img, 0, 0, this.rect.width, this.rect.height);
}

  • 获取画布上一点的颜色值

/**
 * rbga对象转化为16进制颜色字符串
 * @param rgba
 * @returns
 */
export const rbgaObjToHex = (rgba: IRgba) => {
  let { r, g, b } = rgba;
  const { a } = rgba;
  r = Math.floor(r * a);
  g = Math.floor(g * a);
  b = Math.floor(b * a);
  return `#${hex(r)}${hex(g)}${hex(b)}`;
};

/**
 * 获取鼠标点的颜色
 */
getPointColor(x: number, y: number) {
  const { scale } = this;
  const { data } = this.ctx.getImageData(x * scale, y * scale, 1, 1);
  const r = data[0];
  const g = data[1];
  const b = data[2];
  const a = data[3] / 255;
  const rgba = { r, g, b, a };
  return rbgaObjToHex(rgba);
}
  • 获取画布上某一矩形区域的颜色值
/**
 * 获取将canvas输出的数据转化为二位数组
 * @param data
 * @param rect
 * @param scale
 * @returns
 */
 const getImageColor = (data: any[], rect: IRect, scale: number = 1) => {
  const colors: any[][] = [];
  const { width, height } = rect;
  for (let row = 0; row < height; row += 1) {
    if (!colors[row]) {
      colors[row] = [];
    }
    const startIndex = row * width * 4 * scale * scale;
    for (let column = 0; column < width; column += 1) {
      const i = startIndex + column * 4 * scale;
      const r = data[i];
      const g = data[i + 1];
      const b = data[i + 2];
      const a = data[i + 3] / 255;
      const color = rbgaObjToHex({ r, g, b, a });
      colors[row][column] = color;
    }
  }
  return colors;
};

/**
 * 获取canvas某一区域的颜色值二位数组
 * @param ctx
 * @param rect
 * @param scale
 * @returns
 */
export const getCanvasRectColor = (ctx: any, rect: IRect, scale: number = 1) => {
  const { x, y, width, height } = rect;
  const image = ctx.getImageData(x * scale, y * scale, width * scale, height * scale);
  const { data } = image;
  const colors = getImageColor(data, rect, scale);
  return colors;
}

3. 绘制放大镜和颜色值

  • 监听鼠标移动和点击事件,进行交互操作
  • 绘制放大镜
  • 绘制颜色值和选中颜色

/**
 * 绘制放大镜canvas
 * @param colors
 * @param size
 * @returns
 */
 export function drawPipetteCanvas(colors: IColors, size: number) {
  const count = colors.length;
  const diameter = size * count;
  const radius = diameter / 2;
  const { canvas, ctx } = getCanvas({
    width: diameter,
    height: diameter,
    scale: 2,
    attrs: {
      style: `border-radius: 50%;`,
    },
  });
  if (!ctx) {
    return;
  }
  // 画像素点
  colors.forEach((row, i) => row.forEach((color, j) => {
    ctx.fillStyle = color;
    ctx.fillRect(j * size, i * size, size, size);
  }));
  // 画水平线
  for (let i = 0; i < count; i += 1) {
    ctx.beginPath();
    ctx.strokeStyle = '#eee';
    ctx.lineWidth = 0.6;
    ctx.moveTo(0, i * size);
    ctx.lineTo(diameter, i * size);
    ctx.stroke();
  }
  // 画垂直线
  for (let j = 0; j < count; j += 1) {
    ctx.beginPath();
    ctx.strokeStyle = '#eee';
    ctx.lineWidth = 0.6;
    ctx.moveTo(j * size, 0);
    ctx.lineTo(j * size, diameter);
    ctx.stroke();
  }
  // 画圆形边框
  ctx.beginPath();
  ctx.strokeStyle = '#ddd';
  ctx.arc(radius, radius, radius, 0, 2 * Math.PI);
  ctx.stroke();
  // 画中心像素点
  ctx.strokeStyle = '#000';
  ctx.lineWidth = 1;
  ctx.strokeRect(radius - size / 2, radius - size / 2, size, size);
  return canvas;
}


/**
 * 绘制放大镜dom
 * @param colors 颜色二位数组
 * @param size 单个像素点显示大小
 * @returns
 */
export function drawPipette(colors: IColors, size = 8) {
  const scale = 2;
  const canvasContainer: any = document.createElement('div');
  const canvasContent: any = document.createElement('div');
  const pipetteCanvas: any = drawPipetteCanvas(colors, size);
  canvasContainer.style = `position: relative;`;
  canvasContent.style = `
    position: absolute;
    top: 0;
    left: 0;
    width: ${pipetteCanvas.width / scale}px;
    height: ${pipetteCanvas.height / scale}px;
    border-radius: 50%;
    box-shadow: 0 0 10px 10px rgba(150,150,150,0.2) inset;
  `;
  canvasContainer.appendChild(pipetteCanvas);
  canvasContainer.appendChild(canvasContent);
  return canvasContainer;
}


/**
 * 颜色方块和颜色值显示
 * @param color
 * @returns
 */
export function drawColorBlock(color: string) {
  const colorBlock: any = document.createElement('div');
  colorBlock.style = `
    display: flex;
    align-items: center;
    background-color: rgba(0,0,0,0.4);
    padding: 2px 4px;
    border-radius: 3px;
  `;
  colorBlock.innerHTML = `
    <div style="
      width: 20px;
      height: 20px;
      background-color: ${color};
      border-radius: 3px;
      border: 1px solid #eee;
    "></div>
    <div style="
      width: 65px;
      border-radius: 3px;
      color: #fff;
      margin-left: 4px;
    ">${color}</div>
  `;
  return colorBlock;
}

三、完整源码

取色器类

/**
 * 网页颜色吸管工具【拾色器】
 * date: 2021.10.31
 * author: alanyf
 */
import domtoimage from './dom-to-image.js';
import { drawTooltip, getCanvas, getCanvasRectColor, loadImage, rbgaObjToHex, renderColorInfo } from './helper';
import type { IProps, IRect } from './interface';

export * from './interface';

/**
 * 网页拾色器【吸管工具】
 */
class ColorPipette {
  container: any = {};
  listener: Record<string, (e: any) => void> = {};
  rect: IRect = { x: 0, y: 0, width: 0, height: 0 };
  canvas: any = {};
  ctx: any;
  scale = 1;
  magnifier: any = null;
  colorContainer: any = null;
  colors: string[][] = [];
  tooltipVisible = true;
  useMagnifier = false;
  constructor(props: IProps) {
    try {
      const { container, listener, scale = 1, useMagnifier = false } = props;
      this.container = container || document.body;
      this.listener = listener || {};
      this.rect = this.container.getBoundingClientRect();
      this.scale = scale > 4 ? 4 : scale;
      this.useMagnifier = useMagnifier;
      // 去除noscript标签,可能会导致
      const noscript = document.body.querySelector('noscript');
      noscript?.parentNode?.removeChild(noscript);
      this.initCanvas();
    } catch (err) {
      console.error(err);
      this.destroy();
    }
  }
  /**
   * 初始化canvas
   */
  initCanvas() {
    const { rect, scale } = this;
    const { x, y, width, height } = rect;
    const { canvas, ctx } = getCanvas({
      width: rect.width,
      height: rect.height,
      scale,
      attrs: {
        class: 'color-pipette-canvas-container',
        style: `
          position: fixed;
          left: ${x}px;
          top: ${y}px;
          z-index: 10000;
          cursor: pointer;
          width: ${width}px;
          height: ${height}px;
        `,
      },
    });
    this.canvas = canvas;
    this.ctx = ctx;
  }
  /**
   * 开始
   */
  async start() {
    try {
      await this.drawCanvas();
      document.body.appendChild(this.canvas);
      const tooltip = drawTooltip('按Esc可退出');
      document.body.appendChild(tooltip);
      setTimeout(() => tooltip?.parentNode?.removeChild(tooltip), 2000);
      // 添加监听
      this.canvas.addEventListener('mousemove', this.handleMove);
      this.canvas.addEventListener('mousedown', this.handleDown);
      document.addEventListener('keydown', this.handleKeyDown);
    } catch (err) {
      console.error(err);
      this.destroy();
    }
  }
  /**
   * 结束销毁dom,清除事件监听
   */
  destroy() {
    this.canvas.removeEventListener('mousemove', this.handleMove);
    this.canvas.removeEventListener('mousedown', this.handleDown);
    document.removeEventListener('keydown', this.handleKeyDown);
    this.canvas?.parentNode?.removeChild(this.canvas);
    this.colorContainer?.parentNode?.removeChild(this.colorContainer);
  }

  /**
   * 将dom节点画到canvas里
   */
  async drawCanvas() {
    const base64 = await domtoimage.toPng(this.container, { scale: this.scale }).catch(() => '');
    if (!base64) {
      return;
    }
    const img = await loadImage(base64);
    if (!img) {
      return;
    }
    this.ctx.drawImage(img, 0, 0, this.rect.width, this.rect.height);
  }

  /**
   * 处理鼠标移动
   */
  handleMove = (e: any) => {
    const { color, colors } = this.getPointColors(e);
    const { onChange = () => '' } = this.listener;
    const point = { x: e.pageX + 15, y: e.pageY + 15 };
    const colorContainer = renderColorInfo({
      containerDom: this.colorContainer,
      color,
      colors,
      point,
    });
    if (!this.colorContainer) {
      this.colorContainer = colorContainer;
      document.body.appendChild(colorContainer);
    }
    onChange({ color, colors });
  }

  /**
   * 处理鼠标按下
   */
  handleDown = (e: any) => {
    const { onOk = () => '' } = this.listener;
    const res = this.getPointColors(e);
    console.log(JSON.stringify(res.colors, null, 4));
    onOk(res);
    this.destroy();
  }

  /**
   * 处理键盘按下Esc退出拾色
   */
  handleKeyDown = (e: KeyboardEvent) => {
    if (e.code === 'Escape') {
      this.destroy();
    }
  };

  /**
   * 获取鼠标点周围的颜色整列
   */
  getPointColors(e: any) {
    const { ctx, rect, scale } = this;
    let { pageX: x, pageY: y } = e;
    x -= rect.x;
    y -= rect.y;
    const color = this.getPointColor(x, y);
    const size = 19;
    const half = Math.floor(size / 2);
    const info = { x: x - half, y: y - half, width: size, height: size };
    const colors = getCanvasRectColor(ctx, info, scale);
    return { color, colors };
  }

  /**
   * 获取鼠标点的颜色
   */
  getPointColor(x: number, y: number) {
    const { scale } = this;
    const { data } = this.ctx.getImageData(x * scale, y * scale, 1, 1);
    const r = data[0];
    const g = data[1];
    const b = data[2];
    const a = data[3] / 255;
    const rgba = { r, g, b, a };
    return rbgaObjToHex(rgba);
  }
}

export default ColorPipette;

工具方法

import type { IColors, IRect, IRgba } from './interface';

/**
 * 加载base64图片
 * @param base64
 * @returns
 */
export const loadImage = (base64: string) => {
  return new Promise((resolve) => {
    const img = new Image();
    img.src = base64;
    img.onload = () => resolve(img);
    img.onerror = () => resolve(null);
  });
};

// 十进制转化为16进制
export function hex(n: number){
  return `0${n.toString(16)}`.slice(-2);
}

/**
 * rbga对象转化为16进制颜色字符串
 * @param rgba
 * @returns
 */
export const rbgaObjToHex = (rgba: IRgba) => {
  let { r, g, b } = rgba;
  const { a } = rgba;
  r = Math.floor(r * a);
  g = Math.floor(g * a);
  b = Math.floor(b * a);
  return `#${hex(r)}${hex(g)}${hex(b)}`;
};

/**
 * rbga对象转化为rgba css颜色字符串
 * @param rgba
 * @returns
 */
export const rbgaObjToRgba = (rgba: IRgba) => {
  const { r, g, b, a } = rgba;
  return `rgba(${r},${g},${b},${a})`;
};

/**
 * 显示颜色信息,包括放大镜和颜色值
 * @param params
 * @returns
 */
export const renderColorInfo = (params: any) => {
  const { containerDom, color, colors, point } = params;
  let container = containerDom;
  const pos = point;
  const n = 7;
  const count = colors[0].length;
  const size = count * (n + 0) + 2;
  if (!container) {
    const magnifier: any = document.createElement('div');
    container = magnifier;
  }
  if (pos.x + size + 25 > window.innerWidth) {
    pos.x -= size + 25;
  }
  if (pos.y + size + 40 > window.innerHeight) {
    pos.y -= size + 40;
  }
  container.style = `
    position: fixed;
    left: ${pos.x + 5}px;
    top: ${pos.y}px;
    z-index: 10001;
    pointer-events: none;
  `;
  container.innerHTML = '';
  const pipette = drawPipette(colors, n);
  const colorBlock = drawColorBlock(color);
  const padding: any = document.createElement('div');
  padding.style = 'height: 3px;';
  container.appendChild(pipette);
  container.appendChild(padding);
  container.appendChild(colorBlock);
  return container;
}

/**
 * 绘制放大镜
 * @param colors 颜色二位数组
 * @param size 单个像素点显示大小
 * @returns
 */
export function drawPipette(colors: IColors, size = 8) {
  const scale = 2;
  const canvasContainer: any = document.createElement('div');
  const canvasContent: any = document.createElement('div');
  const pipetteCanvas: any = drawPipetteCanvas(colors, size);
  canvasContainer.style = `position: relative;`;
  canvasContent.style = `
    position: absolute;
    top: 0;
    left: 0;
    width: ${pipetteCanvas.width / scale}px;
    height: ${pipetteCanvas.height / scale}px;
    border-radius: 50%;
    box-shadow: 0 0 10px 10px rgba(150,150,150,0.2) inset;
  `;
  canvasContainer.appendChild(pipetteCanvas);
  canvasContainer.appendChild(canvasContent);
  return canvasContainer;
}

/**
 * 颜色方块和颜色值显示
 * @param color
 * @returns
 */
export function drawColorBlock(color: string) {
  const colorBlock: any = document.createElement('div');
  colorBlock.style = `
    display: flex;
    align-items: center;
    background-color: rgba(0,0,0,0.4);
    padding: 2px 4px;
    border-radius: 3px;
  `;
  colorBlock.innerHTML = `
    <div style="
      width: 20px;
      height: 20px;
      background-color: ${color};
      border-radius: 3px;
      border: 1px solid #eee;
    "></div>
    <div style="
      width: 65px;
      border-radius: 3px;
      color: #fff;
      margin-left: 4px;
    ">${color}</div>
  `;
  return colorBlock;
}

/**
 * 显示提示
 * @param content
 * @param tooltipVisible
 * @returns
 */
export function drawTooltip(content: string, tooltipVisible = true) {
  const tooltip: any = document.createElement('div');
  tooltip.id = 'color-pipette-tooltip-container';
  tooltip.innerHTML = content;
  tooltip.style = `
    position: fixed;
    left: 50%;
    top: 30%;
    z-index: 10002;
    display: ${tooltipVisible ? 'flex' : 'none'};
    align-items: center;
    background-color: rgba(0,0,0,0.4);
    padding: 4px 10px;
    border-radius: 3px;
    color: #fff;
    font-size: 20px;
    pointer-events: none;
  `;
  return tooltip;
}

/**
 * 绘制放大镜canvas
 * @param colors
 * @param size
 * @returns
 */
 export function drawPipetteCanvas(colors: IColors, size: number) {
  const count = colors.length;
  const diameter = size * count;
  const radius = diameter / 2;
  const { canvas, ctx } = getCanvas({
    width: diameter,
    height: diameter,
    scale: 2,
    attrs: {
      style: `border-radius: 50%;`,
    },
  });
  if (!ctx) {
    return;
  }
  // 画像素点
  colors.forEach((row, i) => row.forEach((color, j) => {
    ctx.fillStyle = color;
    ctx.fillRect(j * size, i * size, size, size);
  }));
  // 画水平线
  for (let i = 0; i < count; i += 1) {
    ctx.beginPath();
    ctx.strokeStyle = '#eee';
    ctx.lineWidth = 0.6;
    ctx.moveTo(0, i * size);
    ctx.lineTo(diameter, i * size);
    ctx.stroke();
  }
  // 画垂直线
  for (let j = 0; j < count; j += 1) {
    ctx.beginPath();
    ctx.strokeStyle = '#eee';
    ctx.lineWidth = 0.6;
    ctx.moveTo(j * size, 0);
    ctx.lineTo(j * size, diameter);
    ctx.stroke();
  }
  // 画圆形边框
  ctx.beginPath();
  ctx.strokeStyle = '#ddd';
  ctx.arc(radius, radius, radius, 0, 2 * Math.PI);
  ctx.stroke();
  // 画中心像素点
  ctx.strokeStyle = '#000';
  ctx.lineWidth = 1;
  ctx.strokeRect(radius - size / 2, radius - size / 2, size, size);
  return canvas;
}

/**
 * 生成canvas
 * @param param0
 * @returns
 */
 export function getCanvas({ width = 0, height = 0, scale = 1, attrs = {} as Record<string, any> }) {
  const canvas: any = document.createElement('canvas');
  Object.keys(attrs).forEach((key) => {
    const value = attrs[key];
    canvas.setAttribute(key, value);
  });
  canvas.setAttribute('width', `${width * scale}`);
  canvas.setAttribute('height', `${height * scale}`);
  canvas.style = `${attrs.style || ''};width: ${width}px;height: ${height}px;`;
  const ctx = canvas.getContext('2d');
  ctx?.scale(scale, scale);
  return { canvas, ctx };
}

/**
 * 获取将canvas输出的数据转化为二位数组
 * @param data
 * @param rect
 * @param scale
 * @returns
 */
 const getImageColor = (data: any[], rect: IRect, scale: number = 1) => {
  const colors: any[][] = [];
  const { width, height } = rect;
  for (let row = 0; row < height; row += 1) {
    if (!colors[row]) {
      colors[row] = [];
    }
    const startIndex = row * width * 4 * scale * scale;
    for (let column = 0; column < width; column += 1) {
      const i = startIndex + column * 4 * scale;
      const r = data[i];
      const g = data[i + 1];
      const b = data[i + 2];
      const a = data[i + 3] / 255;
      const color = rbgaObjToHex({ r, g, b, a });
      colors[row][column] = color;
    }
  }
  return colors;
};

/**
 * 获取canvas某一区域的颜色值二位数组
 * @param ctx
 * @param rect
 * @param scale
 * @returns
 */
 export const getCanvasRectColor = (ctx: any, rect: IRect, scale: number = 1) => {
  const { x, y, width, height } = rect;
  // console.log(x, y, width, height);
  const image = ctx.getImageData(x * scale, y * scale, width * scale, height * scale);
  const { data } = image;
  const colors = getImageColor(data, rect, scale);
  return colors;
}

interface接口


export interface Point {
  x: number;
  y: number;
}

export interface IRect {
  x: number;
  y: number;
  width: number;
  height: number;
}

export type IColors = string[][];

export interface IRgba {
  r: number;
  g: number;
  b: number;
  a: number;
}

export interface IProps {
  container: any;
  listener?: Record<string, (e: any) => void>;
  scale?: number;
  useMagnifier?: boolean;
}