引言
水印是前端领域中常见的功能,可以用来保护产权,其实现也有多种方案,本文使用 canvas 来实现
技术选型
| 技术方案 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| css背景 | 通常结合 background-size, background-position, background-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,思路如下
- 使用 canvas 绘制水印文本,进行排列
- 监听 dom 元素的挂载,调用第一步的函数,设置水印
- 防篡改,自适应
先来看第一步,使用 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();
};
};
效果如图所示,但是工作依然没有完成,还有三个需要实现的点
- 需要支持水印对齐的选项,目前的水印并没有对齐
- 若容器元素的大小发生变化,水印也需要重新绘制
- 需要防止篡改,因为无论是通过 js 还是浏览器自带的元素调试工具,都可以更改元素的样式
第一点比较简单,本文方案采用 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();
};
};
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 里面可以渲染图片,从而获取图片的像素点信息,操作空间很大,下文实现一个改变图片颜色的功能,先看案例
思路如下
- canvas 渲染图片,使用 getImageData 获取图片像素点信息
- 按照 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 选择的颜色
下面来修改 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>
目前为止,已经可以完成对于点击区域的颜色变更,但是并不明显,这是因为目前只修改了一个像素点的颜色,所以下一步需要改变和点击区域相邻且相近区域的颜色,其中两点需要实现
- 相邻如何实现
- 相近区域如何判定
对于第一点,可以通过递归来实现,代码如下
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;
};