canvas 水印及图片处理(Vue3)

229 阅读8分钟

引言

水印是前端领域中常见的功能,可以用来保护产权,其实现也有多种方案,本文使用 canvas 来实现

技术选型

技术方案实现方式优点缺点
css背景通常结合 background-sizebackground-positionbackground-repeat 等属性进行布局调整实现简单,兼容性好,不需要额外的 JavaScript 代码容易被修改或删除,无法动态更新水印内容
canvas使用 HTML5 Canvas API 动态绘制水印,将绘制结果转换为 Base64 数据 URL 并设置为背景图像可以绘制复杂的水印内容,支持动态更新水印内容,可以实现更精细的控制兼容性稍差,需要支持 HTML5 Canvas,性能消耗相对较高
svg使用 SVG 元素作为水印,通常结合 clip-path 和 mask 进行布局调整支持矢量图形,可无限缩放,可以动态更新水印内容,兼容性较好实现复杂度较高,可能存在兼容性问题
css伪元素使用 CSS 伪元素 ::before 或 ::after 插入水印内容,结合 content 属性动态生成水印文本实现简单,兼容性好,可以动态更新水印内容水印容易被修改或删除,可能会影响页面布局
js监听背景变化使用 MutationObserver 监听背景图像的变化,一旦发现背景图像被修改,立即恢复原样提高了安全性,防止外部修改背景图像,可以动态更新水印内容实现复杂度较高,性能消耗相对较高,适合水印固定的场景
Web Worker在 Web Worker 中生成水印图像,将生成的图像数据传递回主线程并设置为背景图像避免阻塞主线程,提高性能,可以实现更复杂的水印效果实现复杂度较高,兼容性较差,需要支持 Web Worker

总结如下

  • 简单场景:可以考虑使用 CSS 背景图像水印或伪元素水印。
  • 复杂场景:可以考虑使用 Canvas 水印或 SVG 水印。
  • 高安全性要求:可以考虑使用 JavaScript 监听背景图像变化或 Web Worker 方案。

代码实现

水印完整代码

图片处理完整代码

从使用者的角度来讲,只要传入 dom 元素,即可完成水印的添加,设计如下

<script setup>
const p = ref(null);
draw({
  text: "版权所有!",
  container: p,
});
</script>

<template>
  <p class="text" ref="p">
    这是一段文字。这是一段文字。这是一段文字。这是一段文字。这是一段文字。这是一段文字。这是一段文字。这是一段文字。这是一段文字。这是一段文字。这是一段文字。这是一段文字。这是一段文字。这是一段文字。这是一段文字。这是一段文字。这是一段文字。
  </p>
</template>

那问题就变成如何封装这个 hooks,思路如下

  1. 使用 canvas 绘制水印文本,进行排列
  2. 监听 dom 元素的挂载,调用第一步的函数,设置水印
  3. 防篡改,自适应

先来看第一步,使用 canvas 绘制水印文本,进行排列,也是水印的核心代码,首先设置下 canvas 的宽高,然后拿到绘制上下文,设置字体信息后,使用 fillText 进行绘制

let element = document.createElement("canvas");
element.width = canvasWidth.value;
element.height = canvasHeight.value;
if (!element.getContext) return;
const ctx = element.getContext("2d");
ctx.fillStyle = color;
ctx.font = `${fontSize}px ${fontType}`;
ctx.fillText(text, parseInt(i), parseInt(j));

但是水印往往是铺满容器的,增加一些代码,让文字铺满容器,核心逻辑是使用 restore 和 save 来配合 rotate 循环绘制文本

let element = document.createElement("canvas");
element.width = canvasWidth.value;
element.height = canvasHeight.value;
if (!element.getContext) return;
const ctx = element.getContext("2d");
ctx.fillStyle = color;
ctx.font = `${fontSize}px ${fontType}`;

const textWidth = ctx.measureText(text).width;
const textHeight = fontSize;
const tiltAngle = (degree * Math.PI) / 180;
// 使用 body 的宽高来定制范围,防止无法铺满,其实这个范围可以通过几何计算得出
const pageWidth = document.body.clientWidth;
const pageHeight = document.body.clientWidth;

for (let i = -pageWidth; i < pageWidth * 2; i += textWidth + columnGap) {
  for (let j = -pageHeight; j < pageHeight * 2; j += textHeight + rowGap) {
    // 使用 restore 和 save,将状态进行恢复,将 rotate 操作复原,否则绘制角度会一直自增,可以尝试注释掉来看效果
    if (!(i === j && i === 0)) {
      ctx.restore();
    }
    ctx.save();
    // 设置倾斜变换
    ctx.rotate(tiltAngle);
    ctx.textAlign = textAlign;
    ctx.fillText(text, parseInt(i), parseInt(j));
  }
}

完成水印的绘制后,可以调用 toDataURL 来获得 canvas 的 base64 版本,供后续使用

const getDataURL = () => {
    let element = document.createElement("canvas");
    element.width = canvasWidth.value;
    element.height = canvasHeight.value;
    if (!element.getContext) return;
    const ctx = element.getContext("2d");
    ctx.fillStyle = color;
    ctx.font = `${fontSize}px ${fontType}`;

    const textWidth = ctx.measureText(text).width;
    const textHeight = fontSize;
    const tiltAngle = (degree * Math.PI) / 180;
    const pageWidth = document.body.clientWidth;
    const pageHeight = document.body.clientWidth;

    for (let i = -pageWidth; i < pageWidth * 2; i += textWidth + columnGap) {
      for (let j = -pageHeight; j < pageHeight * 2; j += textHeight + rowGap) {
        if (!(i === j && i === 0)) {
          ctx.restore();
        }
        ctx.save();
        ctx.rotate(tiltAngle);
        ctx.textAlign = textAlign;
        ctx.fillText(text, parseInt(i), parseInt(j));
      }
    }
    return element.toDataURL();
};

配合之前所提到的 hooks 设计,向外封装一些参数,可以得到以下代码,可以看到,hooks 通过监听 dom 元素的挂载,实现水印的绘制,并且设置为背景元素,也就实现了需求

import { onBeforeUnmount, ref, watch } from "vue";

export const draw = (options = {}) => {
  const defaultSize = 500;
  const {
    container = {},
    text = "",
    textAlign = "start",
    color = "rgba(255, 255, 255, 0.2)",
    fontSize = 25,
    fontType = "Arial",
    degree = 45,
    rowGap = 100,
    columnGap = 100,
  } = options;

  const canvasWidth = ref(defaultSize);
  const canvasHeight = ref(defaultSize);
  const dom = ref(null);

  watch(
    () => container.value,
    (val) => {
      if (val && text) {
        handleStyle(val);
      }
    }
  );

  const handleStyle = (dom) => {
      dom.style.backgroundImage = `url(${getDataURL()})`;
      dom.style.backgroundRepeat = "no-repeat";
  };

  const getDataURL = () => {
    let element = document.createElement("canvas");
    element.width = canvasWidth.value;
    element.height = canvasHeight.value;
    if (!element.getContext) return;
    const ctx = element.getContext("2d");
    ctx.fillStyle = color;
    ctx.font = `${fontSize}px ${fontType}`;

    const textWidth = ctx.measureText(text).width;
    const textHeight = fontSize;
    const tiltAngle = (degree * Math.PI) / 180;
    const pageWidth = document.body.clientWidth;
    const pageHeight = document.body.clientWidth;

    for (let i = -pageWidth; i < pageWidth * 2; i += textWidth + columnGap) {
      for (let j = -pageHeight; j < pageHeight * 2; j += textHeight + rowGap) {
        // 设置倾斜变换
        if (!(i === j && i === 0)) {
          ctx.restore();
        }
        ctx.save();
        ctx.rotate(tiltAngle);
        ctx.textAlign = textAlign;
        ctx.fillText(text, parseInt(i), parseInt(j));
      }
    }
    return element.toDataURL();
  };
};

效果如图所示,但是工作依然没有完成,还有三个需要实现的点

  1. 需要支持水印对齐的选项,目前的水印并没有对齐
  2. 若容器元素的大小发生变化,水印也需要重新绘制
  3. 需要防止篡改,因为无论是通过 js 还是浏览器自带的元素调试工具,都可以更改元素的样式 image.png

第一点比较简单,本文方案采用 background-repeat 来实现,代码如下

const getAlignDataURL = () => {
    let element = document.createElement("canvas");
    if (!element.getContext) return;
    const ctx = element.getContext("2d");
    ctx.font = `${fontSize}px ${fontType}`;
    const textWidth = ctx.measureText(text).width;
    const textHeight = fontSize;
    const tiltAngle = (degree * Math.PI) / 180;

    element.width = textWidth + columnGap;
    element.height = textWidth + rowGap;

    ctx.fillStyle = color;
    ctx.font = `${fontSize}px ${fontType}`;
    if (tiltAngle > 0) {
      ctx.translate(textHeight, 0);
    } else {
      ctx.translate(0, textWidth);
    }
    ctx.rotate(tiltAngle);
    ctx.textAlign = textAlign;
    ctx.fillText(text, textHeight, textHeight);
    return element.toDataURL();
};

可以在 hooks 参数中增加一个 isAlign 的选项来控制,代码如下

import { onBeforeUnmount, ref, watch } from "vue";

export const draw = (options = {}) => {
  const defaultSize = 500;
  const {
    isAlign = true,
    container = {},
    text = "",
    textAlign = "start",
    color = "rgba(255, 255, 255, 0.2)",
    fontSize = 25,
    fontType = "Arial",
    degree = 45,
    rowGap = 100,
    columnGap = 100,
  } = options;

  const canvasWidth = ref(defaultSize);
  const canvasHeight = ref(defaultSize);
  const resizeOb = ref(null);
  const mutationOb = ref(null);
  const dom = ref(null);

  watch(
    () => container.value,
    (val) => {
      if (val && text) {
        handleStyle(val);
      }
    }
  );

  const handleStyle = (dom) => {
    if (isAlign) {
      dom.style.backgroundImage = `url(${getAlignDataURL()})`;
      dom.style.backgroundRepeat = "repeat";
    } else {
      dom.style.backgroundImage = `url(${getDataURL()})`;
      dom.style.backgroundRepeat = "no-repeat";
    }
  };

  const getAlignDataURL = () => {
    let element = document.createElement("canvas");
    if (!element.getContext) return;
    const ctx = element.getContext("2d");
    ctx.font = `${fontSize}px ${fontType}`;
    const textWidth = ctx.measureText(text).width;
    const textHeight = fontSize;
    const tiltAngle = (degree * Math.PI) / 180;

    element.width = textWidth + columnGap;
    element.height = textWidth + rowGap;

    ctx.fillStyle = color;
    ctx.font = `${fontSize}px ${fontType}`;
    if (tiltAngle > 0) {
      ctx.translate(textHeight, 0);
    } else {
      ctx.translate(0, textWidth);
    }
    ctx.rotate(tiltAngle);
    ctx.textAlign = textAlign;
    ctx.fillText(text, textHeight, textHeight);
    return element.toDataURL();
  };

  const getDataURL = () => {
    let element = document.createElement("canvas");
    element.width = canvasWidth.value;
    element.height = canvasHeight.value;
    if (!element.getContext) return;
    const ctx = element.getContext("2d");
    ctx.fillStyle = color;
    ctx.font = `${fontSize}px ${fontType}`;

    const textWidth = ctx.measureText(text).width;
    const textHeight = fontSize;
    const tiltAngle = (degree * Math.PI) / 180;
    const pageWidth = document.body.clientWidth;
    const pageHeight = document.body.clientWidth;

    for (let i = -pageWidth; i < pageWidth * 2; i += textWidth + columnGap) {
      for (let j = -pageHeight; j < pageHeight * 2; j += textHeight + rowGap) {
        // 设置倾斜变换
        if (!(i === j && i === 0)) {
          ctx.restore();
        }
        ctx.save();
        ctx.rotate(tiltAngle);
        ctx.textAlign = textAlign;
        ctx.fillText(text, parseInt(i), parseInt(j));
      }
    }
    return element.toDataURL();
  };
};
image.png 在 handleStyle 里面进行判断,生成不同的水印,至此,第一点完成,第二第三点可以通过浏览器提供的 ResizeObserver 和 MutationObserver 来实现,增加参数注释,完整代码如下
import { onBeforeUnmount, ref, watch } from "vue";

/**
 * 绘制带有文本的背景图案
 * @param {Object} options - 绘制选项
 * @param {boolean} [options.isAlign=true] - 是否绘制对齐的背景图案
 * @param {HTMLElement} [options.container={}] - 容器元素
 * @param {string} [options.text=""] - 文本内容
 * @param {string} [options.textAlign="start"] - 文本对齐方式(start, center, end)
 * @param {string} [options.color="rgba(255, 255, 255, 0.2)"] - 文本颜色
 * @param {number} [options.fontSize=25] - 字体大小(像素)
 * @param {string} [options.fontType="Arial"] - 字体类型
 * @param {number} [options.degree=45] - 文本旋转角度(度数)
 * @param {number} [options.rowGap=100] - 行间距(像素)
 * @param {number} [options.columnGap=100] - 列间距(像素)
 */
export const draw = (options = {}) => {
  const defaultSize = 500;
  const {
    isAlign = true,
    container = {},
    text = "",
    textAlign = "start",
    color = "rgba(255, 255, 255, 0.2)",
    fontSize = 25,
    fontType = "Arial",
    degree = 45,
    rowGap = 100,
    columnGap = 100,
  } = options;

  const canvasWidth = ref(defaultSize);
  const canvasHeight = ref(defaultSize);
  const resizeOb = ref(null);
  const mutationOb = ref(null);
  const dom = ref(null);

  watch(
    () => container.value,
    (val) => {
      if (val && text) {
        setListener(val);
      }
    }
  );

  onBeforeUnmount(() => {
    resizeOb.value && resizeOb.value.unobserve(dom.value);
    mutationOb.value && mutationOb.value.disconnect();
  });

  const setListener = (element) => {
    if (!ResizeObserver) return;
    const resizeObserver = new ResizeObserver((entries) => {
      entries.forEach((entry) => {
        const { width, height } = entry.contentRect;
        canvasWidth.value = width;
        canvasHeight.value = height;
        handleStyle(element);
      });
    });
    dom.value = element;
    
    resizeObserver.observe(element);
    resizeOb.value = resizeObserver;

    const observer = new MutationObserver((mutations) => {
      mutations.forEach((mutation) => {
        if (mutation.attributeName === "style") {
          // 恢复原来的背景图像
          handleStyle(mutation.target);
        }
      });
    });
    // 配置观察选项
    const config = { attributes: true, attributeFilter: ["style"] };
    // 开始观察目标元素
    observer.observe(element, config);
    mutationOb.value = observer;
  };

  const handleStyle = (dom) => {
    if (isAlign) {
      dom.style.backgroundImage = `url(${getAlignDataURL()})`;
      dom.style.backgroundRepeat = "repeat";
    } else {
      dom.style.backgroundImage = `url(${getDataURL()})`;
      dom.style.backgroundRepeat = "no-repeat";
    }
  };

  const getAlignDataURL = () => {
    let element = document.createElement("canvas");
    if (!element.getContext) return;
    const ctx = element.getContext("2d");
    ctx.font = `${fontSize}px ${fontType}`;
    const textWidth = ctx.measureText(text).width;
    const textHeight = fontSize;
    const tiltAngle = (degree * Math.PI) / 180;

    element.width = textWidth + columnGap;
    element.height = textWidth + rowGap;

    ctx.fillStyle = color;
    ctx.font = `${fontSize}px ${fontType}`;
    if (tiltAngle > 0) {
      ctx.translate(textHeight, 0);
    } else {
      ctx.translate(0, textWidth);
    }
    ctx.rotate(tiltAngle);
    ctx.textAlign = textAlign;
    ctx.fillText(text, textHeight, textHeight);
    return element.toDataURL();
  };

  const getDataURL = () => {
    let element = document.createElement("canvas");
    element.width = canvasWidth.value;
    element.height = canvasHeight.value;
    if (!element.getContext) return;
    const ctx = element.getContext("2d");
    ctx.fillStyle = color;
    ctx.font = `${fontSize}px ${fontType}`;

    const textWidth = ctx.measureText(text).width;
    const textHeight = fontSize;
    const tiltAngle = (degree * Math.PI) / 180;
    const pageWidth = document.body.clientWidth;
    const pageHeight = document.body.clientWidth;

    for (let i = -pageWidth; i < pageWidth * 2; i += textWidth + columnGap) {
      for (let j = -pageHeight; j < pageHeight * 2; j += textHeight + rowGap) {
        // 设置倾斜变换
        if (!(i === j && i === 0)) {
          ctx.restore();
        }
        ctx.save();
        ctx.rotate(tiltAngle);
        ctx.textAlign = textAlign;
        ctx.fillText(text, parseInt(i), parseInt(j));
      }
    }
    return element.toDataURL();
  };
};

不过这个方案依然存在缺点,比如外界要设置 background 时,会产生冲突,在生产环境中使用时,还需要做一些调整

图片处理

在 canvas 里面可以渲染图片,从而获取图片的像素点信息,操作空间很大,下文实现一个改变图片颜色的功能,先看案例

image.png

思路如下

  1. canvas 渲染图片,使用 getImageData 获取图片像素点信息
  2. 按照 input 中所选颜色来修改获取到的 imageData,然后应用

先完成第一步,将图片绘制到 canvas 中,并获取像素点信息,代码如下

<template>
  <div class="container">
    <input type="file" accept="image/*" @change="loadImage" />
    <h2>修改颜色</h2>
    <div>
      <label for="color">颜色:</label>
      <input type="color" id="color" value="#ff0000" @change="change" />
    </div>
    <canvas id="canvas"></canvas>
  </div>
</template>

<script setup>
let ctx = null;
let canvas = null;
let color = [];
let img;
onMounted(() => {
  canvas = document.getElementById("canvas");
  canvas.addEventListener("click", updateColor);
  ctx = canvas.getContext("2d", {
    // 反复读取 canvas 中像素点信息,需要设置,否则会报出 warning
    willReadFrequently: true,
  });
  color = hexToRGBA(document.getElementById("color").value);
});

const updateColor = () => {
  // 获取像素点信息,很关键,后续会修改这里,进而修改颜色
  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
};

const change = () => {
  color = hexToRGBA(document.getElementById("color").value);
};

// 将十六进制转换成 rgba 的格式,方便后续使用
function hexToRGBA(hexColor, alpha = 255) {
  // 确保输入是有效的 16 进制颜色字符串
  if (
    !hexColor ||
    !/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.test(hexColor)
  ) {
    throw new Error("Invalid hex color");
  }
  // 去掉 '#' 符号
  hexColor = hexColor.replace("#", "");
  // 解析 RGB 值
  const r = parseInt(hexColor.substr(0, 2), 16);
  const g = parseInt(hexColor.substr(2, 2), 16);
  const b = parseInt(hexColor.substr(4, 2), 16);
  return [r, g, b, alpha];
}

const loadImage = (e) => {
  const reader = new FileReader();
  reader.onload = function (event) {
    img = new Image();
    img.onload = function () {
      canvas.width = img.width;
      canvas.height = img.height;
      ctx.drawImage(img, 0, 0);
    };
    img.src = event.target.result;
  };
  reader.readAsDataURL(e.target.files[0]);
};
</script>

获取到的像素点信息如图,里面的 data 就是图片的颜色信息,其实就是每个像素点的 rgba 的色值,每4个数字为一个颜色,上面的 hexToRGBA 就是用来格式化 input 选择的颜色

image.png

下面来修改 imageData 的内容,并且再次绘制,就可以达到变色的效果,关注 updateColor 函数,代码如下

<script setup>
let ctx = null;
let canvas = null;
let color = [];
let img;
onMounted(() => {
  canvas = document.getElementById("canvas");
  canvas.addEventListener("click", updateColor);
  ctx = canvas.getContext("2d", {
    willReadFrequently: true,
  });
  color = hexToRGBA(document.getElementById("color").value);
});

const updateColor = (e) => {
  const { offsetX: x, offsetY: y } = e;
  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
  // 获取鼠标点击位置的颜色
  const clickColor = getColor(x, y, imageData);
  // 获取 input 选择的颜色(格式化后的)
  const targetColor = color;
  const changeColor = (x, y) => {
    // 判断边界情况,超出边界什么都不做
    if (x < 0 || y < 0 || x > canvas.width || y > canvas.height) {
      return;
    }
    // 根据鼠标位置,换取对应的 imageData.data 中的下标
    const index = offsetToIndex(x, y, imageData);
    // 应用颜色的修改
    imageData.data.set(color, index);
  };
  changeColor(x, y);
  // 将刚才的修改应用到 canvas 上面
  ctx.putImageData(imageData, 0, 0);
};

const getColor = (x, y, imgData) => {
  // 由于存储了 rgba 四个值,所以会反悔 4 个值
  const index = offsetToIndex(x, y, imgData);
  return [
    imgData.data[index],
    imgData.data[index + 1],
    imgData.data[index + 2],
    imgData.data[index + 3],
  ];
};

const offsetToIndex = (x, y, imageData) => {
  // 如果 imagaData 里面存储的每一个值都是一个像素点的颜色,那么 x + y * imageData.width 即可,由于 rgba,需要乘以 4,得到 imageData 中对应的下标
  return (x + y * imageData.width) * 4;
};
</script>

目前为止,已经可以完成对于点击区域的颜色变更,但是并不明显,这是因为目前只修改了一个像素点的颜色,所以下一步需要改变和点击区域相邻且相近区域的颜色,其中两点需要实现

  1. 相邻如何实现
  2. 相近区域如何判定

对于第一点,可以通过递归来实现,代码如下

const updateColor = (e) => {
  const { offsetX: x, offsetY: y } = e;
  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
  const clickColor = getColor(x, y, imageData);
  const targetColor = color;
  const changeColor = (x, y) => {
    if (x < 0 || y < 0 || x > canvas.width || y > canvas.height) {
      return;
    }
    const color = getColor(x, y, imageData);
    const index = offsetToIndex(x, y, imageData);
    imageData.data.set(color, index);
    changeColor(x - 1, y);
    changeColor(x + 1, y);
    changeColor(x, y - 1);
    changeColor(x, y + 1);
  };
  changeColor(x, y);
  ctx.putImageData(imageData, 0, 0);
};

通过递归来改变点击区域周围的颜色,但是递归需要终止条件,可以写一个辅助函数来判断,思路就是对两个颜色的 rgba 相减的绝对值再相加,若颜色相等,结果一定为 0,代码如下

const diff = (color1, color2) => {
  return (
    Math.abs(color1[0] - color2[0]) +
    Math.abs(color1[1] - color2[1]) +
    Math.abs(color1[2] - color2[2]) +
    Math.abs(color1[3] - color2[3])
  );
};

完整代码如下,修改颜色之前,判断差异,差异过大,说明递归到了颜色改变的边界,终止变更,颜色相等时,终止变更

let ctx = null;
let canvas = null;
let color = [];
let img;
onMounted(() => {
  canvas = document.getElementById("canvas");
  canvas.addEventListener("click", updateColor);
  ctx = canvas.getContext("2d", {
    willReadFrequently: true,
  });
  color = hexToRGBA(document.getElementById("color").value);
});

const change = () => {
  color = hexToRGBA(document.getElementById("color").value);
};

function hexToRGBA(hexColor, alpha = 255) {
  // 确保输入是有效的 16 进制颜色字符串
  if (
    !hexColor ||
    !/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.test(hexColor)
  ) {
    throw new Error("Invalid hex color");
  }
  // 去掉 '#' 符号
  hexColor = hexColor.replace("#", "");
  // 解析 RGB 值
  const r = parseInt(hexColor.substr(0, 2), 16);
  const g = parseInt(hexColor.substr(2, 2), 16);
  const b = parseInt(hexColor.substr(4, 2), 16);
  return [r, g, b, alpha];
}

const loadImage = (e) => {
  const reader = new FileReader();
  reader.onload = function (event) {
    img = new Image();
    img.onload = function () {
      canvas.width = img.width;
      canvas.height = img.height;
      ctx.drawImage(img, 0, 0);
    };
    img.src = event.target.result;
  };
  reader.readAsDataURL(e.target.files[0]);
};

const updateColor = (e) => {
  const { offsetX: x, offsetY: y } = e;
  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
  const clickColor = getColor(x, y, imageData);
  const targetColor = color;
  const changeColor = (x, y) => {
    if (x < 0 || y < 0 || x > canvas.width || y > canvas.height) {
      return;
    }
    const color = getColor(x, y, imageData);
    if (diff(color, clickColor) > 100) {
      return;
    }
    if (diff(color, targetColor) === 0) {
      return;
    }
    const index = offsetToIndex(x, y, imageData);
    imageData.data.set(color, index);
    changeColor(x - 1, y);
    changeColor(x + 1, y);
    changeColor(x, y - 1);
    changeColor(x, y + 1);
  };
  changeColor(x, y);
  ctx.putImageData(imageData, 0, 0);
};

const getColor = (x, y, imgData) => {
  const index = offsetToIndex(x, y, imgData);
  return [
    imgData.data[index],
    imgData.data[index + 1],
    imgData.data[index + 2],
    imgData.data[index + 3],
  ];
};

const diff = (color1, color2) => {
  return (
    Math.abs(color1[0] - color2[0]) +
    Math.abs(color1[1] - color2[1]) +
    Math.abs(color1[2] - color2[2]) +
    Math.abs(color1[3] - color2[3])
  );
};

const offsetToIndex = (x, y, imageData) => {
  return (x + y * imageData.width) * 4;
};

细心的小伙伴会发现,这段代码无法正常运行,原因是递归次数过多引起的栈溢出,所以需要改进方案,可以采用广度优先遍历的方式来实现,改进代码如下

const updateColor = (e) => {
  const { offsetX: x, offsetY: y } = e;
  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
  const clickColor = getColor(x, y, imageData);
  const targetColor = color;
  const changeColor = (x, y) => {
    const queue = [[x, y]]; // 初始化队列
    while (queue.length > 0) {
      const [currentX, currentY] = queue.shift();

      // 检查边界条件
      if (
        currentX < 0 ||
        currentY < 0 ||
        currentX >= canvas.width ||
        currentY >= canvas.height
      ) {
        continue;
      }

      const color = getColor(currentX, currentY, imageData);
      if (diff(color, clickColor) > 100) {
        continue;
      }

      if (diff(color, targetColor) === 0) {
        continue;
      }

      const index = offsetToIndex(currentX, currentY, imageData);
      imageData.data.set(targetColor, index);
      // 将相邻的四个像素点加入队列
      queue.push([currentX - 1, currentY]);
      queue.push([currentX + 1, currentY]);
      queue.push([currentX, currentY - 1]);
      queue.push([currentX, currentY + 1]);
    }
  };
  changeColor(x, y);
  ctx.putImageData(imageData, 0, 0);
};

完整图片处理代码如下,可以封装为 hooks 方便使用,本文不再赘述

let ctx = null;
let canvas = null;
let color = [];
let img;
onMounted(() => {
  canvas = document.getElementById("canvas");
  canvas.addEventListener("click", updateColor);
  ctx = canvas.getContext("2d", {
    willReadFrequently: true,
  });
  color = hexToRGBA(document.getElementById("color").value);
});

const change = () => {
  color = hexToRGBA(document.getElementById("color").value);
};

function hexToRGBA(hexColor, alpha = 255) {
  // 确保输入是有效的 16 进制颜色字符串
  if (
    !hexColor ||
    !/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.test(hexColor)
  ) {
    throw new Error("Invalid hex color");
  }
  // 去掉 '#' 符号
  hexColor = hexColor.replace("#", "");
  // 解析 RGB 值
  const r = parseInt(hexColor.substr(0, 2), 16);
  const g = parseInt(hexColor.substr(2, 2), 16);
  const b = parseInt(hexColor.substr(4, 2), 16);
  return [r, g, b, alpha];
}

const loadImage = (e) => {
  const reader = new FileReader();
  reader.onload = function (event) {
    img = new Image();
    img.onload = function () {
      canvas.width = img.width;
      canvas.height = img.height;
      ctx.drawImage(img, 0, 0);
    };
    img.src = event.target.result;
  };
  reader.readAsDataURL(e.target.files[0]);
};

const updateColor = (e) => {
  const { offsetX: x, offsetY: y } = e;
  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
  const clickColor = getColor(x, y, imageData);
  const targetColor = color;
  const changeColor = (x, y) => {
    const queue = [[x, y]]; // 初始化队列

    while (queue.length > 0) {
      const [currentX, currentY] = queue.shift();

      // 检查边界条件
      if (
        currentX < 0 ||
        currentY < 0 ||
        currentX >= canvas.width ||
        currentY >= canvas.height
      ) {
        continue;
      }

      const color = getColor(currentX, currentY, imageData);
      if (diff(color, clickColor) > 100) {
        continue;
      }

      if (diff(color, targetColor) === 0) {
        continue;
      }

      const index = offsetToIndex(currentX, currentY, imageData);
      imageData.data.set(targetColor, index);
      // 将相邻的四个像素点加入队列
      queue.push([currentX - 1, currentY]);
      queue.push([currentX + 1, currentY]);
      queue.push([currentX, currentY - 1]);
      queue.push([currentX, currentY + 1]);
    }
  };
  changeColor(x, y);
  ctx.putImageData(imageData, 0, 0);
};

const getColor = (x, y, imgData) => {
  const index = offsetToIndex(x, y, imgData);
  return [
    imgData.data[index],
    imgData.data[index + 1],
    imgData.data[index + 2],
    imgData.data[index + 3],
  ];
};

const diff = (color1, color2) => {
  return (
    Math.abs(color1[0] - color2[0]) +
    Math.abs(color1[1] - color2[1]) +
    Math.abs(color1[2] - color2[2]) +
    Math.abs(color1[3] - color2[3])
  );
};

const offsetToIndex = (x, y, imageData) => {
  return (x + y * imageData.width) * 4;
};