前言
-
本文在 canvas实现画板功能的基础上进行了一些完善。
-
可以通过Canvas_API了解canvas。
-
代码直接使用了ES6的语法,在谷歌浏览器(版本 76.0.3809.100)上能实现预期的效果。
实现过程
添加元素
在HTML中添加上操作按钮以及canvas元素。
<div id="operations">
<input type="button" id="pencil" value="铅笔"/>
<input type="button" id="straightLine" value="直线"/>
<input type="button" id="rectangle" value="矩形"/>
<input type="button" id="solidRectangle" value="实心矩形"/>
<input type="button" id="circle" value="圆形"/>
<input type="button" id="solidCircle" value="实心圆形"/>
<input type="button" id="eraser" value="橡皮擦"/>
<input type="button" id="image" value="导入图片"/>
<input type="button" id="save" value="保存"/>
<input type="button" id="redo" value="重做"/>
<input type="button" id="undo" value="撤销"/>
<input type="button" id="clear" value="清除"/>
<label>颜色:<input type="color" id="color" /></label>
<label>线条粗细:1<input type="range" id="lineWidth" min="1" max="100" value="1"/>100</label>
<input type="file" id="imageFile" name="image"/>
<a id="downloadLink"></a>
</div>
<div class="canvas-container">
<canvas id="canvas" width="800" height="800"></canvas>
</div>
添加处理事件
创建了一个名为Draw的类,其中handleOperations属性用于放置按钮的处理函数。handleMousemove属性用于处理选中不同类型(例如直线和铅笔)的情况下,在mousemove的过程中需要做的处理。
class Draw {
constructor (elements) {
const { canvas, color, lineWidth, operations, imageFile, downloadLink } = elements; // 控制画布的元素
this.type = 'pencil'; // 类型初始化为铅笔
this.canvas = canvas; // canvas元素
this.context = canvas.getContext('2d'); // 获取canvas的2d上下文对象
...
},
...
handleOperations () { ... }
handleMousemove () { ... }
绑定元素的点击事件
前几个按钮的点击事件设置Draw实例的type属性。
clear事件把画布用背景色填充,达到“清除”的效果。image事件模拟type为file的input框的点击,通过FileReader对象获取到input框选中的文件的base64地址,并使用drawImage将其绘制到canvas上。save事件通过toDataURL方法得到当前画布的base64地址,并设置a标签的download属性,调用元素的click()方法模拟点击,从而下载文件。redo事件和undo事件的思路差不多,都是通过拿到保存在historyUrls中的base64地址,调用canvas的drawImage方法将图片绘制到canvas中。只不过一个是往前拿(undo),一个是往后拿(redo)。
handleOperations () {
return {
pencil: () => { this.type = 'pencil'; }, // 铅笔按钮绑定的事件
straightLine: () => { this.type = 'straightLine'; }, // 直线按钮绑定的事件
rectangle: () => { this.type = 'rectangle'; }, // 矩形按钮绑定的事件
solidRectangle: () => { this.type = 'solidRectangle'; }, // 实心矩形按钮绑定的事件
eraser: () => { this.type = 'eraser'; }, // 橡皮擦绑定的事件
circle: () => { this.type = 'circle'; }, // 圆形按钮绑定的事件
solidCircle: () => { this.type = 'solidCircle'; }, // 实心圆形按钮绑定的事件
clear: () => { this.clear(); }, // 清除按钮绑定的事件
image: () => { // 导入图片按钮绑定的事件
this.imageFile.click();
this.imageFile.onchange = (event) => {
let reader = new FileReader();
reader.readAsDataURL(event.target.files[0]);
reader.onload = (evt) => {
let img = new Image();
img.src = evt.target.result;
img.onload = () => {
this.context.drawImage(img, 0, 0); // 将图片画在画布上
this.addHistory();
};
}
}
},
save: () => { // 保存按钮绑定的事件
this.downloadLink.href = this.canvas.toDataURL('image/png');
this.downloadLink.download = 'drawing.png';
this.downloadLink.click();
},
redo: () => { // 重做按钮绑定的事件
let length = this.historyUrls.length;
let currentIndex = this.currentHistoryIndex + 1;
if (currentIndex > length - 1 ) {
this.currentHistoryIndex = length - 1;
return;
};
this.currentHistoryIndex = currentIndex;
this.historyImage.src = this.historyUrls[currentIndex];
this.historyImage.onload = () => {
this.context.drawImage(this.historyImage, 0, 0);
}
},
undo: () => { // 撤回按钮绑定的事件
let currentIndex = this.currentHistoryIndex - 1;
if (currentIndex < 0) {
currentIndex === -1 && this.clear();
this.currentHistoryIndex = -1;
return;
}
this.currentHistoryIndex = currentIndex;
this.historyImage.src = this.historyUrls[currentIndex];
this.historyImage.onload = () => {
this.context.drawImage(this.historyImage, 0, 0);
}
}
}
}
鼠标移动的过程中触发的事件
绑定canvas的mousemove事件处理函数。
this.canvas.addEventListener('mousemove', (event) => {
if (this.isDrawing) {
const { clientX, clientY } = event;
const x = clientX - offsetLeft;
const y = clientY - offsetTop;
let newOriginX = originX, newOriginY = originY;
let distanceX = Math.abs(x-originX);
let distanceY = Math.abs(y-originY);
// 让形状左上角的坐标永远大于右下角坐标,保证图形能正常绘制
if (x < originX) newOriginX = x;
if (y < originY) newOriginY = y;
// (x, y)为鼠标移动的过程中在画布上的坐标,(originX, originY)为鼠标点击时在画布上的坐标,
//(newOriginX, newOriginY)为绘制形状(比如矩形)时形状左上角的坐标
const mousePosition = { x, y, originX, originY, newOriginX, newOriginY, distanceX, distanceY };
let handleMousemove = this.handleMousemove();
let currentHandleMousemove = handleMousemove[this.type]; // 根据当前类型的不同采取不同的操作
currentHandleMousemove && currentHandleMousemove(mousePosition);
}
}, false);
在mousemove的过程中会根据type的值做相应的处理。
pencil:x,y是鼠标移动的过程中的坐标,直接使用lineTo将线条连接到当前的(x, y)坐标,就能实现铅笔的效果了。eraser:和铅笔的实现方式相同,不过橡皮擦需要设置线条的颜色为画布的背景色,这样看起来就像被擦掉了一样。在擦除之后需要把线条颜色重新置为当前color元素选中的颜色(这部分的处理放在mouseup中,而不是mousemove中比较好)。straightLine:将绘画的起点移动到鼠标点击的那个点(originX, originY),再将起点和鼠标移动时的(x, y)连接,就能达到直线的效果了。这里的this.reDraw();是为了防止在mousemove的过程中把“轨迹”也绘制出来。- 矩形的绘制、圆形的绘制和直线类似,只是调用的方法不同,而且在调用绘图方法时,必须保证绘制的形状的左上角要高于右上角,否则无法正常地绘制。
handleMousemove () {
return {
pencil: (mousePosition) => {
const { x, y } = mousePosition;
this.context.lineTo(x, y);
this.context.stroke();
},
eraser: (mousePosition) => {
const { x, y } = mousePosition;
this.context.strokeStyle = this.canvasBackground;;
this.context.lineTo(x, y);
this.context.stroke();
this.context.strokeStyle = this.color.value;
this.context.fillStyle = this.color.value;
},
straightLine: (mousePosition) => {
let { x, y, originX, originY } = mousePosition;
this.reDraw();
this.context.moveTo(originX, originY);
this.context.lineTo(x, y);
this.context.stroke();
this.context.closePath();
},
rectangle: (mousePosition) => {
let {newOriginX, newOriginY, distanceX, distanceY } = mousePosition;
this.reDraw();
this.context.rect(newOriginX, newOriginY, distanceX, distanceY);
this.context.stroke();
this.context.closePath();
},
solidRectangle: (mousePosition) => {
let { newOriginX, newOriginY, distanceX, distanceY } = mousePosition;
this.reDraw();
this.context.fillRect(newOriginX, newOriginY, distanceX, distanceY);
this.context.closePath();
},
circle: (mousePosition) => {
let { newOriginX, newOriginY, distanceX, distanceY } = mousePosition;
this.reDraw();
let r = Math.sqrt(distanceX * distanceX + distanceY * distanceY);
this.context.arc(distanceX + newOriginX, distanceY + newOriginY , r, 0, 2 * Math.PI);
this.context.stroke();
this.context.closePath();
},
solidCircle: (mousePosition) => {
let { newOriginX, newOriginY, distanceX, distanceY } = mousePosition;
this.reDraw();
let r = Math.sqrt(distanceX * distanceX + distanceY * distanceY);
this.context.arc(distanceX + newOriginX, distanceX + newOriginY , r, 0, 2 * Math.PI);
this.context.fillStyle = this.color.value;
this.context.fill();
this.context.closePath();
},
clear: () => {
this.clear();
}
}
}
解决的问题
- 画布上的像素值和页面的像素值不一致。
我在页面上画了个50px * 50px的正方形,又在canvas画布上用this.context.strokeRect( 0 ,0 ,50, 50);画了个正方形,发现画布上与页面上的像素大小不一致。
原因是我一开始没有在canvas元素的属性中定义宽高,只在css样式中定义了canvas元素的宽高。
.canvas {
height: 800px;
width: 800px;
background-color: #ccc;
}
当没有设置宽度和高度的时候,canvas会初始化宽度为300像素和高度为150像素。
在canvas元素的属性中定义好宽高后就没问题了。
<canvas id="canvas" class="canvas" width="800" height="800"></canvas>
- 成功获取图片的base64URL了,但是在画布上什么也画不出来。
这是因为图片还没有加载完成就开始绘制了,等图片加载完再绘就可以了。
img.onload = () => {
this.context.drawImage(img, 0, 0); // 将图片画在画布上
};
- 下载的图片在电脑上打开是正常的,但是在手机上打开就是一片漆黑。 猜想是因为没有添加背景颜色的原因,手机默认使用黑色作为背景色,把线条“隐藏”了,使用不同颜色的画笔画了幅画保存之后,果然能看见彩色的画笔画的部分。所以只要图片添加上背景色就可以了。
this.context.fillStyle = '#ffffff';
this.context.fillRect(0, 0, 800, 800);
一开始橡皮擦使用了clearRect方法来擦除画布上的内容,添加背景色后就不能使用这种方法了,因为会把背景色也擦掉。将橡皮擦设置为白色的画笔来模拟擦除的效果。
之前橡皮擦的实现方式:
let eraserWidth = parseInt(this.lineWidth.value);
if (eraserWidth < 10) {
eraserWidth = 10; // 因为橡皮擦像素太小的时候清除的效果不明显,所以设置橡皮擦的最小宽度为5px
}
let halfEraserWidth = eraserWidth / 2;
this.context.clearRect(x - halfEraserWidth, y - halfEraserWidth, eraserWidth, eraserWidth);
处理完问题后橡皮擦的实现方式:
this.context.strokeStyle = '#ffffff';
this.context.lineTo(x, y);
this.context.stroke();
this.context.strokeStyle = this.color.value;
其他
- 源码地址。
- 实现效果: