大家好,我是南宫,今天来写一篇关于canvas绘制“图片选区”的博客。
最近我接到了一个新的任务,要求做一个新的弹窗,里面可以点击按钮,切换配置的能力。其中一个能力涉及到在图片上用鼠标拖动,框选出一个区域,这看起来比较复杂,各种百度无果,我只能自己从零开搞。历时两天终于弄完了,所以现在写一篇博客来记录一下这个思路和实现的过程。
一、成品效果
截图如下:
初始状态是这样的,左右是两张相同的图片,中间有一个钩的按钮。
左边的图可以用鼠标框出一个虚线框。具体来说是按下鼠标后,在图片区域移动鼠标,就能看到一个由按下鼠标的地方和鼠标当前位置作为对角线而确定的矩形。鼠标抬起或移出图片区域,则停止绘图,矩形形状变得稳定。
确定后点击绿色的钩,右边的图片区域在相同位置上也绘制出一个矩形。
二、实现思路
这个思路完全是我根据微信截图的时候在图上绘制矩形框的过程脑补出来的,具体如下:
1. 想要在图片区域上画出矩形框,那么就要在图片区域上放置一个透明画布,而且是盖在图片上面,大小与图片相同。(由于我不知道图片要用多大的,所以我在图片外面套了一层相对定位的div,canvas弄成绝对定位放在里面,就可以完美满足这个要求)
2.右边要显示左边的结果,所以左右的canvas和图片大小要一致。(我这边仅仅是外层div是左右浮动的不同,里面的图片和canvas样式都相同)
3. 绘制选区的时候,是先按下鼠标,然后才进入绘制的状态。之后移动鼠标,绘制的矩形也会移动到鼠标现在所在的点。然后抬起鼠标,就不再是绘制的状态。(所以要在左边的canvas上监听鼠标按下、移动和抬起的事件)
4. 鼠标按下的时候要做的事情是:让canvas进入绘图状态,获取当前点击的位置相对于canvas的坐标,并记录。
5. 鼠标移动的时候,要记录当前的位置,获取当前的位置相对于canvas的坐标,并且跟起点相比较。横坐标相减得到宽度,纵坐标相减得到高度。(需要判断当前是否为绘图状态,只有为绘图状态,才会这样)
如果宽高都大于0,那就把这两个点连成的线当成矩形的对角线,然后计算出剩余两个点的位置。把4个点的坐标记录到位置数组中。
6. 现在,得到了矩形4个点的坐标,把它们连接起来,用虚线绘制。考虑到每次移动都会有新的坐标,所以要用clearRect在绘制之前清空上一次的矩形。
7. 鼠标抬起后,让canvas解除绘图状态。
8. 点击中间的钩,让右边的canvas根据记录下来的位置数组,在右边的canvas上画出红色的矩形。
三、实现的过程中遇到的问题
1.canvas的高度怎么计算?
canvas跟图片一样,宽高涉及到了像素,那可不是简单用CSS设置宽高就可以的。canvas的宽高是有默认值的(好像是300*150),如果不设置canvas的像素宽高,单单设置CSS的宽高,那画出来是分分钟变形的。
但是图片多大,我也不知道,所以我用了el.getBoundingClientRect()来获取图片的宽高和位置,把宽高动态绑定到canvas上。作为初始化canvas的操作。(位置可以用来计算鼠标相对于canvas的位置)
2. 矩形的鼠标数组有非常非常多个数据。
这是因为在鼠标移动的过程中,我光顾着记录新的坐标,忘记删除旧的坐标了。鼠标移动的时候,要把后面3个坐标都删掉,只留下起点坐标,然后才记录新的位置的坐标。
3. 把矩形画成了三角形。
刚开始根据思路来得到坐标、连接成线后,看到效果我是懵逼的,因为我给画成了直角三角形
这是因为连点划线的时候少算了一个点,没有用到最后一个点的坐标。写循环的时候注意一下。
4. 鼠标绘制的过程中不小心移出了canvas区域,然后移动回去后又继续绘图,显得有些莫名其妙。
我参考了其他人的代码,给左边的canvas又绑了个鼠标移出的事件,逻辑跟鼠标抬起是一样的,这样体验会好一些。
5. 之前画的矩形并没有被清空。
效果是这样的,简直让人密恐发作。这是什么原因呢?我的第一反应的没有调用clearRect清空画布。然而我调试的时候对比了有clearRect和没有clearRect的,效果不太一样,显然clearRect是有生效的。
(下图是有clearRect的效果的,显得图案更淡一些,clearRect表示不背这个锅)
我怀疑是我用错了参数还是clearRect的效果不对,排查了半天,发现是我没有用beginPath导致的。所以,beginPath和clearRect是缺一不可的。
6. 对刚才的矩形选区不满意,想重新画一个,要怎么重新进入绘制状态?
我在点击左边的canvas的时候会进行判断,如果目前已经有记录过矩形坐标,就提示用户确认是否重新画一个。(确认是怕用户误点导致图案丢失)
确认后,清空左右两边的矩形框和矩形坐标数组,回到初始状态。再次点击鼠标,就可以进入绘制状态了。
四、完整代码
看完思路和遇到的问题后,我在这边贴出我目前的代码。以后目测还会再改一次,把中间区域分割出来作为一个可复用的组件。
这个是<template>部分:
<div class="form-box">
<div class="form">
<!-- 左右两个区域显示图片,中间显示一个钩 -->
<div class="img-area left">
<p class="label">实时预览图</p>
<div class="img-box">
<img class="img" src="./1.jpg" alt="预览图" />
<canvas
@mousedown="startDrawing"
@mousemove="drawCurrentRect"
@mouseup="stopDrawing"
@mouseout="stopDrawing"
:width="canvasWidth"
:height="canvasHeight"
class="canvas"
id="myCanvas"
></canvas>
</div>
</div>
<div class="tick">
<el-icon class="el-icon-success" @click.native="saveRect"></el-icon>
</div>
<div class="img-area right">
<p class="label">检测区域</p>
<div class="img-box">
<img class="img" src="./1.jpg" alt="检测区域" />
<canvas
:width="canvasWidth"
:height="canvasHeight"
class="canvas"
id="new-canvas"
></canvas>
</div>
</div>
</div>
<div class="form-btns">
<el-button size="small" type="primary">保存</el-button>
<el-button size="small">取消</el-button>
</div>
</div>
样式部分使用了scss:
.form {
height: 280px;
position: relative;
padding: 0 20px;
}
.form-box ::v-deep .form-btns {
padding: 70px;
text-align: center;
.el-button {
margin: 0 20px;
}
}
.img-area.left {
float: left;
}
.img-area.right {
float: right;
}
// 显示图片区域
.img-area {
position: relative;
padding-top: 20px;
width: 42%;
.label {
font-size: 14px;
line-height: 3;
}
.img-box {
position: relative;
}
.img {
width: 100%;
}
// canvas绝对定位,盖在图片上方,这里的z-indext如果没有设置,则canvas无法使用
.canvas {
position: absolute;
top: 0;
left: 0;
z-index: 1;
}
}
.tick {
position: absolute;
width: 16%;
left: 42%;
padding-top: 160px;
text-align: center;
font-size: 24px;
color: #70b948;
}
JavaScript部分的代码在这里,里面涉及到了我刚才讲的思路和问题,具体见注释:
export default {
name: "area-invade",
props: {},
data() {
return {
// canvas的像素大小
canvasWidth: 0,
canvasHeight: 0,
// 记录矩形的坐标
ractTarget: [],
// 记录图片的左上角坐标
imgX: 0,
imgY: 0,
// 是否正在绘制
isDrawing: false,
// 绘图上下文对象
canvasContext: null,
// 右边的canvas上下文
newCanvasContext: null,
};
},
mounted() {
// 初始化canvas
this.initCanvas();
// 如果窗口大小改变了,那么canvas宽高重新计算
window.onresize = () => {
this.initCanvas();
}
},
beforeDestroy() {
window.onresize = null
},
methods: {
// 初始化
initCanvas() {
// 读取图片的大小,赋值给canvas的宽高
const el = document.querySelector(".img-box");
this.canvasWidth = el.getBoundingClientRect().width;
this.canvasHeight = el.getBoundingClientRect().height;
// 记录图片左上角在页面的位置,方便计算坐标
this.imgX = el.getBoundingClientRect().left;
this.imgY = el.getBoundingClientRect().top;
// 清空点数组
this.ractTarget = []
},
// 鼠标按下,开始画图
startDrawing(e) {
// 如果还没有获取矩形,那就去获取起点坐标
if (this.ractTarget.length == 0) {
this.getStartPoint(e);
} else {
this.$confirm("确定要重新框选区域吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
})
.then(() => {
// 如果确认要重新画,就清空坐标数组和画布
this.ractTarget = [];
if (!this.canvasContext) {
this.canvasContext = document
.getElementById("myCanvas")
.getContext("2d");
}
this.canvasContext.clearRect(
0,
0,
this.canvasWidth,
this.canvasHeight
);
this.newCanvasContext.clearRect(
0,
0,
this.canvasWidth,
this.canvasHeight
);
})
.catch(() => {});
}
},
// 获取起点坐标
getStartPoint(e) {
this.isDrawing = true;
const point = {
x: Math.floor(e.pageX - this.imgX),
y: Math.floor(e.pageY - this.imgY),
};
// 记录到矩形坐标数组
this.ractTarget.push(point);
},
// 鼠标移动,绘制当前的矩形
drawCurrentRect(e) {
// 确认当前正在绘制
if (this.isDrawing) {
// 清空掉除了第一个以外的其他元素
while (this.ractTarget.length > 1) {
this.ractTarget.pop();
}
// 获取鼠标当前相对于canvas的坐标
const point = {
x: Math.floor(e.pageX - this.imgX),
y: Math.floor(e.pageY - this.imgY),
};
// 根据起点坐标和当前坐标,计算出矩形的宽高
const rectWidth = Math.abs(point.x - this.ractTarget[0].x);
const rectHeight = Math.abs(point.y - this.ractTarget[0].y);
// 如果组成的矩形宽高不为0,那就计算一下4个点的坐标
if (rectWidth > 0 && rectHeight > 0) {
// 第二个点的x跟起点一致,y跟当前点的一致
this.ractTarget.push({
x: this.ractTarget[0].x,
y: point.y,
});
// 第三个点坐标就是当前点的坐标
this.ractTarget.push(point);
// 第四个点的x跟当前点的一致,y跟起点的一致
this.ractTarget.push({
x: point.x,
y: this.ractTarget[0].y,
});
// 绘制矩形
this.drawDashedRect(this.ractTarget);
}
}
},
// 在左边canvas绘制虚线矩形
drawDashedRect() {
if (!this.canvasContext) {
this.canvasContext = document
.getElementById("myCanvas")
.getContext("2d");
}
// 绘制矩形
this.drawRect(this.canvasContext, "#36A9CE", 4, this.ractTarget, true);
},
// 停止绘图
stopDrawing() {
this.isDrawing = false;
},
// 点击钩,保存刚才选的矩形
saveRect() {
// 获取右边canvas的上下文对象
if (!this.newCanvasContext) {
this.newCanvasContext = document
.getElementById("new-canvas")
.getContext("2d");
}
// 绘制红色的矩形
this.drawRect(
this.newCanvasContext,
"#F32329",
4,
this.ractTarget,
false
);
},
// 抽象一个绘制矩形的函数,参数为context,颜色,粗细,坐标数组,是否虚线
drawRect(context, color, lineWidth, pointList, isDashed) {
// 设置线条的粗细和颜色
context.strokeStyle = color;
context.lineWidth = lineWidth;
// 设置虚线
if (isDashed) {
context.setLineDash([10, 5]);
}
// 清空之前的画布
context.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
context.beginPath();
// 根据矩形坐标数据,把矩形绘制出来
context.moveTo(pointList[0].x, pointList[0].y);
for (let i = 1; i <= pointList.length - 1; i++) {
context.lineTo(pointList[i].x, pointList[i].y);
}
context.closePath();
context.stroke();
},
},
};