Canvas之画图板
js
const canvas1 = document.createElement('canvas');
const ctx1 = canvas1.getContext('2d');
const canvas2 = document.createElement('canvas');
const ctx2 = canvas2.getContext('2d');
document.body.append(canvas1, canvas2);
canvas1.width = canvas2.width = 800;
canvas1.height = canvas2.height = 400;
let shapeType = 'line';
let color = '#000000';
let lineWidth = 1;
先设计好基本的画布,以及需要的 css 和 html 。
html
<div id="toolbar">
<ul>
<li><button class="shapeType active" data="line" id="lineBtn">线条</button></li>
<li><button class="shapeType" data="rect" id="rectBtn">矩形</button></li>
<li><button class="shapeType" data="circle" id="circleBtn">圆形</button></li>
<li><button class="shapeType" data="fill" id="fillBtn">填充</button></li>
<li><button class="shapeType" data="rubber" id="rubberBtn">橡皮擦</button></li>
<li>
<select id="boldBtn">
<option>1</option>
<option>2</option>
<option>4</option>
<option>8</option>
</select>
</li>
<li>
<input id="colorBtn" type="color" />
</li>
<li><button id="clearBtn">清空画布</button></li>
<li><button id="saveBtn">保存图片</button></li>
</ul>
</div>
<div id="container"></div>
js
function preBtnEvent(){
document.querySelectorAll('.shapeType').forEach(btn=>{
btn.onclick=function(){
//如果当前按钮已经选中了,则返回
if(this.classList.contains('active')){
return;
}
//切换选中样式
document.querySelector('.active').classList.remove('active');
this.classList.add('active');
//切换绘制类型
shapeType = this.getAttribute('data');
}
})
document.querySelector('#boldBtn').onchange=function(){
lineWidth = this.value ;
}
document.querySelector('#colorBtn').onchange=function(){
color = this.value ;
}
document.querySelector('#clearBtn').onclick=function(){
ctx1.clearRect(0,0,canvas.width,canvas.height);
}
document.querySelector('#saveBtn').onclick=function(){
const url = canvas1.toDataURL();
console.log(url)
const a = document.createElement('a');
a.href = url;
a.download = '保存图片';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
}
preBtnEvent()
接着就是根据 html 来添加相应的点击事件。前面几个可以合并为一个点击事件,只需要在切换属性时切换相关的自定义属性。在保存图片时,先创建 base64 图片数据,然后将锚点的链接指向图片数据,这时当模拟点击时就会触发下载。记得在下载完成后移除锚点。
js
//定义基本的图形类
class Shape {
constructor(type, x, y, ctx) {
this.type = type;
this.x = x;
this.y = y;
this.ctx = ctx;
this.ex = x;
this.ey = y;
this.points = [];
}
draw() {
switch (this.type) {
case 'line': break;
case 'rect': break;
case 'circle': break;
case 'rubber': break;
case 'fill': break;
}
}
drawLine() { }
drawRect() { }
drawCircle() { }
drawRubber() { }
drawFill() { }
}
//绘制过程
//避免在移出画布时无法绘制
const area = canvas2.getBoundingClientRect();
canvas2.onmousedown = function (e) {
let x = e.clientX - area.left;
let y = e.clientY - area.top;
let shape = new Shape(shapeType, x, y, ctx2);
//如果是填充不需要移动,直接在画布1绘制
if (shapeType == 'fill') {
shape.ctx = ctx1;
shape.draw();
} else {
window.onmousemove = function (e) {
let ex = e.clientX - area.left;
let ey = e.clientY - area.top;
switch (shapeType) {
case 'line': break;
case 'rect': break;
case 'circle': break;
case 'rubber': break;
}
};
window.onmouseup = function () {
window.onmousemove = null;
window.onmouseup = null;
};
}
}
在设置基本的类的时候,需要设定类型,位置,画布,过程点,以及连续点的数组。在绘制时不同的点有不同的情况。
在绘制时,为了避免鼠标在移动时移出画布无法绘制的情况不能使用 e.offsetX ,e.offsetY ,只能使用视口相对于画布的坐标。如果类型为填充类型,则不需要移动,直接绘制在画布一上,剩下的情况需要分情况讨论。
如果鼠标抬起时结束相关事件。
js
drawLine() {
this.ctx.save();
this.ctx.beginPath();
setStrokeStyle(this.ctx);
this.ctx.moveTo(this.x,this.y)
this.points.forEach((point,i)=>{
this.ctx.lineTo(point.x,point.y);
});
this.ctx.stroke()
this.ctx.moveTo(this.x,this.y);
}
//如果是填充不需要移动,直接在画布1绘制
if (shapeType == 'fill') {
shape.draw();
} else {
ctx2.save()
setStrokeStyle(ctx2)
ctx2.beginPath();
ctx2.moveTo(x, y);
window.onmousemove = function (e) {
let ex = e.clientX - area.left;
let ey = e.clientY - area.top;
switch (shapeType) {
case 'line':
ctx2.lineTo(ex, ey);
shape.points.push({ x: ex, y: ey });
ctx2.stroke();
break;
case 'rect': break;
case 'circle': break;
case 'rubber': break;
}
};
window.onmouseup = function () {
window.onmousemove = null;
window.onmouseup = null;
ctx2.restore();
shape.draw()
ctx2.clearRect(0, 0, canvas2.width, canvas2.height);
};
}
然后可以在画布二上进行绘制。当鼠标移动时,不停绘制线条,并将所有线条的点保存起来,然后在 drawLine 里根据这些点进行画布一的绘制。在鼠标抬起时,进行 画布一 shape 的绘制,然后清空画布二。
这样做主要是为了在画布二绘制时不影响画布一的原来的图形,否则需要不停地重绘所有的图形。
js
drawRect() {
this.ctx.beginPath();
this.ctx.save();
setStrokeStyle(this.ctx);
//x,y取最小的值
const x = Math.min(this.x, this.ex);
const y = Math.min(this.y, this.ey);
//w,h取绝对值
const w = Math.abs(this.x - this.ex);
const h = Math.abs(this.y - this.ey);
this.ctx.strokeRect(x, y, w, h)
this.ctx.restore();
}
case 'rect':
shape.ex = ex;
shape.ey = ey;
ctx2.clearRect(0, 0, canvas2.width, canvas2.height);
shape.draw();
break;
//绘制在画布1上
shape.ctx = ctx1;
shape.draw()
绘制矩形需要在两个画布上绘制,绘制前清空前面的图形。绘制时,x,y 取最小值,w,h 取绝对值,直接生成一个相应的矩形。
js
drawCircle(){
this.ctx.beginPath();
this.ctx.save();
setStrokeStyle(this.ctx) ;
//两个点横纵坐标的一般即为x轴半径 和 y轴半径。
const r1 = Math.abs(this.x - this.ex) / 2 ;
const r2 = Math.abs(this.y - this.ey) / 2 ;
//找到最小的点, 分别加上各自轴的半径,即为圆心点。
const x = Math.min(this.x , this.ex) + r1 ;
const y = Math.min(this.y, this.ey) + r2 ;
if(r1 == r2){
//正圆
this.ctx.arc(x,y,r1,0,Math.PI*2);
}else{
//椭圆
this.ctx.ellipse(x,y,r1,r2,0,0,Math.PI*2) ;
}
this.ctx.stroke();
this.ctx.restore();
}
绘制圆和前面类似,只是在绘制时需要加上两个半径,然后根据情况绘制正圆和椭圆。
在后面的 switch 部分需要进行 break 穿透,即两个 case 写在一起,当前面执行之后没有 break 时就会继续往后执行。这些图形除了 line 之外都是先在画布二上画完之后展示在画布一,而 line 不需要清空之前绘制的点。
js
drawRubber() {
this.ctx.beginPath();
this.ctx.save();
this.ctx.globalCompositeOperation = "destination-out";
setStrokeStyle(this.ctx);
this.ctx.moveTo(this.x, this.y)
this.points.forEach((point, i) => {
this.ctx.lineTo(point.x, point.y);
});
this.ctx.stroke()
this.ctx.moveTo(this.x, this.y);
}
if (shapeType == 'rubber') {
ctx1.save()
setStrokeStyle(ctx1)
ctx1.beginPath();
ctx1.moveTo(x, y);
} else {
ctx2.save()
setStrokeStyle(ctx2)
ctx2.beginPath();
ctx2.moveTo(x, y);
}
case 'rubber':
ctx1.globalCompositeOperation = "destination-out";
ctx1.lineTo(ex, ey);
shape.points.push({ x: ex, y: ey });
ctx1.stroke();
break;
使用橡皮擦和绘制线条类似,只是要直接绘制到画布一中,这样当擦除的时候可以直接显示出来。注意,使用 destination-out 合成效果,会使得保留前面和后面不重叠的部分,也就是实现了擦除的功能。
前面的部分也要分出适合 rubber 的画布。
js
function change(x,y) {
const stack = [[x, y]];
while (stack.length > 0) {
const [x, y] = stack.shift();
if (x < 0 || y < 0 || x > canvas1.width || y > canvas1.height) {
continue;
}
const i = point2Index(imageData, x, y)
if (baseImageData.data[0] == imageData.data[i] && baseImageData.data[1] == imageData.data[i + 1] && baseImageData.data[2] == imageData.data[i + 2] && baseImageData.data[3] == imageData.data[i + 3]) {
imageData.data[i] = 255;
imageData.data[i + 1] = 0;
imageData.data[i + 2] = 0;
imageData.data[i + 3] = 255;
} else {
continue;
}
stack.push([x - 1, y]);
stack.push([x + 1, y]);
stack.push([x, y - 1]);
stack.push([x, y + 1]);
}
}
填充颜色时需要设置一个栈来保证在向四周扩撒的时候不会超出栈内存。因为四周的坐标始终在 stack 内存里添加。如果超出,就无法添加。注意判断边界条件以及判断四个通道值。
如果四个通道值与点击的点坐标通道值相同,则继续扩散,否则跳过这次循环。
js
function hex2rgb(hex) {
hex = hex.replace('#', '');
return [
parseInt(hex.substr(0, 2), 16),
parseInt(hex.substr(2, 2), 16),
parseInt(hex.substr(4, 2), 16)
];
}
将16进制的颜色转换为rgb形式的数字。