之前发两篇关于canvas学习笔记,发的这几篇文档主要为了记录面试需求如何实现,下面是面试官提出的实现效果,上传一张图片,然后可以分区域选择头发,衣服等,通过画笔橡皮擦进行修改,最终将选择区域导出成为单独图片。
在博客上,基于上面需求做简化demo,主要记录核心功能,选区、画笔、橡皮擦、导出
项目素材
最后一张是用户上传图片source.png
,前两张是上传后端返回两张图层mask1.png
、mask2.png
,后续操作是基于蒙版进行选区、修改导出操作。
前置条件
在实现相关功能得先学习一些基础canvas学习笔记,实现刮刮卡、在线签名、图片涂抹裁切 - 掘金 (juejin.cn)或者其他学习资料,了解画笔,画布相关概念,和canvas与图片像素点之间关系即可。
先创建一个index.html
,实际上img图片和canvas绘画区域是分开的两部分内容,取消相关style可以从下图中看相关内容。
js
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
#prec{
position: relative;
border: 1px solid #000;
width: 302px;
height:403px;
}
#prec #lodCanvas{
position: absolute;
top: 0;
left: 0;
border: 1px solid #000;
}
#newCanvas{
position: absolute;
top: 0;
left: 350px;
border: 1px solid #000;
width: 302px;
height:403px;
}
</style>
</head>
<body>
<button id="addIscoordte">添加选区</button>
<button id="cancelIscoordte">取消选区</button>
<button id="brush">画笔</button>
<button id="eraser">橡皮擦</button>
<button id="export">导出</button>
<div id="prec">
<canvas id="newCanvas"></canvas>
</div>
<script src="script.js"></script>
</body>
</html>
初始化创建
js
const width = 302;
const height = 403;
const brushSize = 10;
//是否添加选区
var iscoordte = false;
//是否删除选区
var canceliscoordte = false;
//是否开启签名状态
var isSign = false;
//是否开启橡皮擦
var isEraser = false;
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
canvas.style.border = "1px solid #ccc";
const ctx = canvas.getContext("2d");
canvas.id = "lodCanvas";
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.lineWidth = brushSize;
ctx.strokeStyle = "rgb(255 0 0/ 39%)"; //设置透明渡的颜色选取内容
var temp = document.getElementById("prec");
temp.appendChild(canvas);
//渲染背景图
ccreateimg();
//添加背景
function ccreateimg(){
const img = document.createElement("img");
img.src = "./test/source.png";
img.classList.add("bg");
img.width =width;
img.height = height;
document.getElementById("prec").appendChild(img);
}
因为要实现点击canvas对应区域展示该图层信息,所以需要将任务拆分
- 将所有图片都封装promise,并等待所有图片加载完成;
- 监听点击事件canvas点击位置的坐标event;
- 根据点击canvas对应位置的坐标event,查询那个图层有内容;
- 在canvas画布上绘制该图层有内容的区域;
简单绘制整个流程
根据img数组地址imgArray
,封装img为promise,通过Promise.all
静态方法等待所有img拿到数据imagesarr
,并在获取所有图片后,通过for循环将所有图片对其进行转换canvas数组图层。
js
/**
* 根据点击位置判断是否绘制图层
* 1、将多个图层设置为数组;
* 2、循环每个图层
* 3、创建一个临时画布
* 4、根据 img图层/ 判断当前点位rgb是否透明
* 5、如果不透明,则清空原画布所有内容,在canvas根据img图层绘制
* 5.1画笔设置为半透明
*/
// 假设img数组包含了需要加载的图片URL
const imgArray = [ "./test/mask1.png","./test/mask2.png"];
// 创建一个Promise数组,每个Promise完成时都会resolve对应的图片元素
const imgpromises = imgArray.map((imgUrl) => {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = (error) => reject(error);
img.src = imgUrl;
});
});
// 使用Promise.all()等待所有图片加载完成
const imagesarr = Promise.all(imgpromises)
.then((images) => {
// 所有图片加载完成后执行的操作
// console.log(images); // 可以在这里处理加载完成的图片数组
let arrData = [];
//将图片转换为canvas
for(let i = 0; i < images.length; i++){
const img = images[i];
const imgcanvas = document.createElement("canvas");
imgcanvas.width = img.width;
imgcanvas.height = img.height;
const imgctx = imgcanvas.getContext("2d");
imgctx.drawImage(img, 0, 0,width,height);
const imgData = imgctx.getImageData(0, 0, width, height);
arrData.push(imgData);
}
return arrData;
})
添加选区
执行点击事件获取,坐标和canvas图层数组信息,根据条件执行方法replaceRgb(下标)
js
//选区事件
canvas.onmousedown = function(e){
selectRegion(e);
}
//默认开启选区事件,当用户点击画笔、橡皮擦、导出则不调用该方法
async function selectRegion(e){
// console.log("点击事件",e.offsetX,e.offsetY);
var tempi = await iscoordinate(e.offsetX,e.offsetY,imagesarr);
console.log(tempi);
if(tempi ==-1){
//选中区域没有图层
}else if(canceliscoordte){
//如果取消选取为false,并且ctx已经绘画图层则取消盖图层内容,不然添加该图层内容
//判断坐标ctx是否有颜色,如果有颜色则按照图片坐标将图层改为透明
console.log("是否进入这里");
cancel(tempi)
}else{
//添加选取,可以选择多个图层
replaceRgb(tempi)
}
}
接下来就是进行页面绘制工作, 首先判断是选多个图层还是单个图层,如果是多个图层,则是叠加而不需要清空画布,否则调用closeCanvas
函数先清空画布,然后进行绘制。
然后获取imageData图层所有坐标,通过循环每个像素点(每个像素点有rbga四个值,其中a表示是否透明),如果透明则不做操作,如果不是0,则在页面上把对应像素的绘制上(在rbg不同通道上设置固定值)。 然后将处理后的imageData通过putImageData
更新到页面上。
js
/**
* 传入图层下标
* 将该图层颜色替换红色
* 绘制在canvas上
*/
function replaceRgb(index){
//根据状态是否添加选取,iscoordte 判断是否选择清空画布
if(!iscoordte ){
closeCanvas();
}
let preimg = new Image();
preimg.src = imgArray[index];
preimg.onload = function(){
ctx.drawImage(preimg,0,0,width,height);
// console.log("绘制成功",canvas.toDataURL());
// 获取canvas上的像素数据
var imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
var pixels = imageData.data;
var targetColor = [255, 0, 0]; // 自定义颜色,这里使用红色
for (var i = 0; i < pixels.length; i += 4) {
var r = pixels[i];
var g = pixels[i + 1];
var b = pixels[i + 2];
var a = pixels[i + 3];
// 判断当前像素是否在主题区域内
if (r === 255 && g === 255 && b === 255 && a >0) {
// 替换为自定义颜色
pixels[i] = targetColor[0];
pixels[i + 1] = targetColor[1];
pixels[i + 2] = targetColor[2];
pixels[i + 3] = 100;
}
}
// 将修改后的像素数据重新绘制到canvas上
ctx.putImageData(imageData, 0, 0);
}
}
//清空画布
function closeCanvas(){
//清空画布先结束之前操作
ctx.closePath()
ctx.clearRect(0,0,width,height);
ctx.beginPath();
//设置橡皮擦
// isErase = false;
// daubEvent();
}
添加点击事件
js
/**
* 点击事件
*/
document.getElementById("addIscoordte").onclick = function(){
iscoordte = !iscoordte;
canceliscoordte = false
isEraser = false;
isSign = false;
canvas.onmousedown = null;
canvas.onmousemove = null;
canvas.onmouseup = null;
console.log("添加选取");
canvas.onmousedown = function (e){
selectRegion(e);
};
}
这样实现以下效果,然后我可以控制添加选区iscoordte
控制是否加载清空,页面是否叠加
取消选区
取消选区的点击事件
js
//去除选取
document.getElementById("cancelIscoordte").onclick = function(){
iscoordte =false;
canceliscoordte = true;
//重写点击事件
isEraser = false;
isSign = false;
canvas.onmousedown = null;
canvas.onmousemove = null;
canvas.onmouseup = null;
canvas.onmousedown = function (e){
selectRegion(e);
};
}
取消选区通过调用selectRegion
方法,确定用户点击是那个模块,它进入第二个if调用cancel
方法实现取消权限功能。
在cancel
方法中主要进行下面几个步骤
- 创建一个临时画布,图片绘制到临时画布上,根据传入下标获取图层图片,获取imageData.data(像素点位置)
- 获取canvas目前
let ctxData = ctx.getImageData(0, 0, canvas.width, canvas.height);
像素点位置 - 循环临时画布的每个点位,判断临时画布不为空点位,对应修改ctx画布点位为透明
- 将修改后的像素数据重新绘制到canvas上
ctx.putImageData(ctxData, 0, 0);
js
/**
* 取消选取
* 判断坐标ctx是否有颜色,如果有颜色则按照图片坐标将图层改为透明
* 1、创建一个临时画布
* 2、将图片绘制到临时画布上
* 3、循环临时画布的每个点位
* 4、判断临时画布不为空点位,对应修改ctx画布点位为透明
*/
function cancel(item){
let tempCanvas = document.createElement("canvas");
tempCanvas.width = width;
tempCanvas.height = height;
let tempctx = tempCanvas.getContext("2d");
// 声明一张图片
let tempImg = new Image();
tempImg.src = imgArray[item];
tempImg.onload = function(){
//图片添加到canvas上
tempctx.drawImage(tempImg, 0, 0,width,height);
//获取画布数据
let imageData = tempctx.getImageData(0, 0, canvas.width, canvas.height);
let pixels = imageData.data;
// 获取canvas上的像素数据
let ctxData = ctx.getImageData(0, 0, canvas.width, canvas.height);
let ctxpixels = ctxData.data;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const index = (y * width + x) * 4;
const text4a = pixels[index + 3];
// 判断像素是否为主体区域(透明度大于0)
if (text4a > 0 ) {
ctxpixels[index] = 0; // 红色通道
ctxpixels[index + 1] = 0; // 绿色通道
ctxpixels[index + 2] = 0; // 蓝色通道
ctxpixels[index + 3] = 0; // 透明度
}
}
}
// 将修改后的像素数据重新绘制到canvas上
ctx.putImageData(ctxData, 0, 0);
}
}
画笔橡皮擦
画笔和橡皮擦点击事件,先清空已有的点击事件canvas.onmousedown = null;
,调用daubEvent
方法,重新赋值点击事件
js
//画笔事件
document.getElementById("brush").onclick = function(){
iscoordte =false;
canceliscoordte = false;
isSign = true;
isEraser = false;
//重写点击事件
console.log("点击画笔");
canvas.onmousedown = null;
daubEvent();
ctx.beginPath();
}
//橡皮擦事件
document.getElementById("eraser").onclick = function(){
iscoordte =false;
canceliscoordte = false;
isSign = true;
isEraser = true;
//重写点击事件
console.log("点击橡皮擦");
canvas.onmousedown = null;
daubEvent();
ctx.beginPath();
}
通过ctx.globalCompositeOperation
判断是橡皮擦还是画笔事件,destination-out 将两个图层重叠部分设置为null,source-over属性是在原图层基础上添加内容,通过重写了鼠标按下事件,实现选区和画笔两个功能的分割独立,当用户点击对应按钮,对应的鼠标按下事件将会不同。
js
/**
* 撰写画笔事件
*/
function daubEvent(){
console.log("开始绘制");
// 鼠标按下事件
canvas.onmousedown = function (event) {
if (!isSign) return;
if (isEraser) {
ctx.globalCompositeOperation = 'destination-out';
}else{
ctx.globalCompositeOperation = 'source-over';
}
ctx.beginPath();
ctx.moveTo(event.offsetX,event.offsetY);
canvas.onmousemove = function(event) {
if (isEraser) {
// 设置橡皮擦粗细
ctx.lineWidth = brushSize;
ctx.strokeStyle = "rgba(0, 0, 0, 1)";
ctx.globalAlpha = 1;
}else{
ctx.strokeStyle = "rgb(255 0 0/ 10%)";
ctx.globalAlpha = 0.05;
}
ctx.lineTo(event.offsetX,event.offsetY);
ctx.stroke();
}
canvas.onmouseup = function(event) {
canvas.onmousemove = null;
canvas.onmouseup = null;
}
}
}
实现效果
此时项目整体流程如下:
导出功能
导出功能点击事件,调用contrast
方法将图片绘制新的canvas上面。
js
//导出事件
document.getElementById("export").onclick = function(){
console.log("点击导出");
contrast()
}
导出功能:
- 先获取旧的canvas已经绘制图层(canvas绘制是红色)
- text4data 旧图层的内容 (用户涂抹所有内容,即rgb不透明)获取所有像素的
- 创建新的图层newCanvas, 新图层画笔newcontext
- 先将图片添加新图层中
newcontext.drawImage(newimg, 0, 0,width,height);
- 循环旧的图层canvas每个像素点,计算是否透明
const text4a = text4data[index + 3];
- 若是旧图层canvas像素点是透明,则将新图层newcanvas对应区域也设置为透明
- 将新图层最终结果渲染到页面上
js
/**
* 根据图层点位,将图片内容绘制到新的canvas上
* text4data 旧图层的内容 (用户涂抹所有内容,即rgb不透明)获取所有像素的
* newCanvas 创建新的图层
* newcontext 新图层画笔
* 1、新图层newcontext引入需要裁剪的图片
* 2、图片加载完成,循环旧图层每个像素点,
* 3、判断text4data旧图层像素的是否透明,如果透明则将新图层对应内容区域也修改透明
*/
function contrast() {
const text4data = ctx.getImageData(0, 0, width, height).data;
// 创建一个新的canvas图层
const newCanvas = document.getElementById("newCanvas");
const newcontext = newCanvas.getContext('2d');
// 设置Canvas的大小与图片一致
newCanvas.width = width;
newCanvas.height = height;
const newimg = new Image();
newimg.src = "./test/source.png";
// 将图片绘制到Canvas上
newcontext.drawImage(newimg, 0, 0,width,height);
const imageData = newcontext.getImageData(0, 0, width, height);
const newdata = imageData.data;
newimg.onload = function () {
//循环每个像素点,如果是白色则 将其设为透明
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const index = (y * width + x) * 4;
const text4a = text4data[index + 3];
// 判断像素是否为主体区域(透明度大于0)
if (text4a > 0 ) {
// const r = newdata[index];
// const g = newdata[index + 1];
// const b = newdata[index + 2];
// const a = newdata[index + 3];
}else{
newdata[index] = 0; // 红色通道
newdata[index + 1] = 0; // 绿色通道
newdata[index + 2] = 0; // 蓝色通道
newdata[index + 3] = 0; // 透明度
}
}
}
newcontext.putImageData(imageData, 0, 0);
}
}
最终流程(草图)
最终效果