前言
-
本文在 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;
其他
- 源码地址。
- 实现效果: