【Canvas学习】05-canvas写字板和刮刮卡效果
发表于:2024-09-24 |

前言

相信大家在平时生活中,应该遇到过电子签名和电子刮刮卡的情况吧,其实这俩种效果都可以利用 canvas 来实现,本篇文章就带着大家一起实现一下这俩个小案例。

写字板

这里我用原生 HTML 写,这样大家可以更好根据这个进行 UI 的扩展

HTML 代码

先写这样的一段 HTML 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<div id="setting-line">
<div>
字体大小:
<select id="font-size"></select>
</div>
<div>
字体颜色:
<input type="color" id="font-color" />
</div>
<button id="undo">撤销</button>
<button id="clear">清空画布</button>
<button id="download">生成图片</button>
</div>
<canvas width="800" height="300" id="canvas"></canvas>

CSS 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#setting-line {
display: flex;
margin: auto;
margin-top: 100px;
line-height: 27px;
justify-content: center;
gap: 20px;
}

#canvas {
display: block;
margin: auto;
margin-top: 10px;
background-color: #eee;
}

JS 代码

初始化 canvas

先初始化 canvas 和其 2d 上下文 ctx
设置线宽为 5,线的末端和连接处均为圆角,这里我就简单处理了,如果大家有兴趣,也可以把线宽和线的末端和连接处进行配置修改

1
2
3
4
5
const tablet = document.getElementById("canvas");
const ctx = tablet.getContext("2d");
ctx.lineWidth = 5;
ctx.lineCap = "round";
ctx.lineJoin = "round";

字体大小

然后我们生成字体大小的选项,允许 1-16px,默认为 5px

1
2
3
4
5
6
7
8
9
10
11
12
13
const fontSize = document.getElementById("font-size");
for (let i = 1; i <= 16; i++) {
const option = document.createElement("option");
option.value = i;
option.innerText = i + "px";
fontSize.appendChild(option);
if (i == 5) {
option.selected = true;
}
}
fontSize.addEventListener("change", (e) => {
ctx.lineWidth = e.target.value;
});

字体颜色

读取颜色 input 的内容设置 canvas 画笔的笔触颜色

1
2
3
4
const fontColor = document.getElementById("font-color");
fontColor.addEventListener("change", (e) => {
ctx.strokeStyle = e.target.value;
});

写字

写字分为三部分

鼠标落下:开始写字,对应 mousedown 事件
鼠标移动:正在写字,对应 mousemove 事件
鼠标抬起:结束写字,对应 mouseup 事件

写字无非就是许多点连成的线,在写字过程中,记录鼠标上一点的位置,将其与当前点连起来

代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
let prex, prey; // 记录上一点的位置
const mousedown = (e) => {
if (e.button != 0) return; // 不是左键直接返回
prex = e.offsetX;
prey = e.offsetY;
ctx.beginPath(); // 开始路径
ctx.moveTo(prex, prey);
// 注册事件
tablet.addEventListener("mousemove", mousemove);
document.addEventListener("mouseup", mouseup);
tablet.addEventListener("mouseenter", mouseenter);
};
const mousemove = (e) => {
ctx.lineTo(e.offsetX, e.offsetY);
ctx.stroke();
};
const mouseup = () => {
// 关闭路径
ctx.closePath();
// 清除事件
tablet.removeEventListener("mousemove", mousemove);
document.removeEventListener("mouseup", mouseup);
tablet.removeEventListener("mouseenter", mouseenter);
};
const mouseenter = (e) => {
// 从外部回到画布,改变上一点的位置,继续书写
prex = e.offsetX;
prey = e.offsetY;
ctx.moveTo(prex, prey);
};
tablet.addEventListener("mousedown", mousedown);

撤销

这里撤销我只做一步,多做也可以的,就是存数组,但是因为内容比较大,这个比较吃性能,建议如果真要存多步的话存少一点。

1
2
3
4
5
6
7
8
9
10
let imageData // 图片数据
const mousedown = (e) => {
imageData = ctx.getImageData(0, 0, tablet.width, tablet.height)
……
}

const undo = document.getElementById('undo')
undo.addEventListener('click', () => {
ctx.putImageData(imageData, 0, 0)
})

清空画布

清空画布很简单,clearRect 就好了

1
2
3
4
const clear = document.getElementById("clear");
clear.addEventListener("click", () => {
ctx.clearRect(0, 0, tablet.width, tablet.height);
});

下载图片

canvas 生成 DateURL,然后利用 a 标签下载

1
2
3
4
5
6
7
8
9
10
let a;
const download = document.getElementById("download");
download.addEventListener("click", () => {
if (!a) {
a = document.createElement("a");
}
a.href = tablet.toDataURL("image/png");
a.download = "sign.png";
a.click();
});

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>写字板</title>
</head>
<body>
<div id="setting-line">
<div>
字体大小:
<select id="font-size"></select>
</div>
<div>
字体颜色:
<input type="color" id="font-color" />
</div>
<button id="undo">撤销</button>
<button id="clear">清空画布</button>
<button id="download">生成图片</button>
</div>
<canvas width="800" height="300" id="canvas"></canvas>
</body>
<script>
const tablet = document.getElementById("canvas");
const ctx = tablet.getContext("2d");
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.lineWidth = 5;
ctx.strokeStyle = "#000";

// 生成字体大小选项
const fontSize = document.getElementById("font-size");
for (let i = 1; i <= 16; i++) {
const option = document.createElement("option");
option.value = i;
option.innerText = i + "px";
if (i == 5) {
option.selected = true;
}
fontSize.appendChild(option);
}
fontSize.addEventListener("change", (e) => {
ctx.lineWidth = e.target.value;
});

const fontColor = document.getElementById("font-color");
fontColor.addEventListener("change", (e) => {
ctx.strokeStyle = e.target.value;
});
let imageData;
let prex, prey;
const mousedown = (e) => {
if (e.button != 0) return; // 不是左键直接返回
imageData = ctx.getImageData(0, 0, tablet.width, tablet.height);
prex = e.offsetX;
prey = e.offsetY;
ctx.beginPath();
ctx.moveTo(prex, prey);
tablet.addEventListener("mousemove", mousemove);
document.addEventListener("mouseup", mouseup);
tablet.addEventListener("mouseenter", mouseenter);
};
const mousemove = (e) => {
ctx.lineTo(e.offsetX, e.offsetY);
ctx.stroke();
};
const mouseup = () => {
tablet.removeEventListener("mousemove", mousemove);
document.removeEventListener("mouseup", mouseup);
tablet.removeEventListener("mouseenter", mouseenter);
};
const mouseenter = (e) => {
prex = e.offsetX;
prey = e.offsetY;
ctx.moveTo(prex, prey);
};
tablet.addEventListener("mousedown", mousedown);

const undo = document.getElementById("undo");
undo.addEventListener("click", () => {
ctx.putImageData(imageData, 0, 0);
});

const clear = document.getElementById("clear");
clear.addEventListener("click", () => {
ctx.clearRect(0, 0, tablet.width, tablet.height);
});

let a;
const download = document.getElementById("download");
download.addEventListener("click", () => {
if (!a) {
a = document.createElement("a");
}
a.href = tablet.toDataURL();
a.download = "sign.png";
a.click();
});
</script>
<style>
#setting-line {
display: flex;
margin: auto;
margin-top: 100px;
line-height: 27px;
justify-content: center;
gap: 20px;
}

#canvas {
display: block;
margin: auto;
margin-top: 10px;
background-color: #eee;
}
</style>
</html>

效果

注意

PC 端的鼠标事件在移动端是不存在的,需要做一定的转化

1
2
3
4
5
//pc,移动事件兼容写法
const hastouch = Boolean("ontouchstart" in window),
tapstart = hastouch ? "touchstart" : "mousedown",
tapmove = hastouch ? "touchmove" : "mousemove",
tapend = hastouch ? "touchend" : "mouseup";

刮刮卡

实现原理

我们来讲解一下实现原理,本质上就是利用 canvas 的后绘制的内容在原先内容之上的逻辑,我们可以设置一张底图或者文字,然后,铺一层灰黑色的内容。在我们鼠标划过的时候,把这层灰黑色的内容给清楚掉即可。

HTML

1
2
3
4
5
<body onload="init()">
<div id="wrapper">
<div id="reward"></div>
</div>
</body>

CSS

1
2
3
4
5
6
7
8
9
10
11
#wrapper {
display: flex;
margin: auto;
margin-top: 100px;
justify-content: center;
}
#reward {
background: url("./assets/img/winning.png") no-repeat;
width: 697px;
height: 376px;
}

JS

我们没有在 Dom 中直接加上 canvas,这是因为,我们需要我们的 canvas 等于底图的大小,因此需要在 dom 加载完成之后进行操作,也就是我加了 onload 方法。

初始化 canvas

没有 canvas 的 dom 我们就创建一个,根据父级的宽高设置 canvas 的宽高,将 canvas 作为子级添加进去

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function init() {
let width, height;
if (!document.getElementById("canvas")) {
const canvas = document.createElement("canvas");
// 获取dom
const fatherDom = document.getElementById("reward");
// 获取dom的宽高
width = fatherDom.offsetWidth;
height = fatherDom.offsetHeight;
// 设置宽高
canvas.setAttribute("width", width + "px");
canvas.setAttribute("height", height + "px");
// 设置样式
canvas.setAttribute("style", "border:1px solid green");
canvas.id = "canvas";
// canvas添加到dom
fatherDom.appendChild(canvas);
}

const myCanvas = document.getElementById("canvas");
const ctx = myCanvas.getContext("2d");
}

添加黑灰色的刮刮层

这里我们绘制一个矩形和写上刮一刮三个字

1
2
3
4
5
6
7
8
9
10
11
//绘制黑色矩形
ctx.beginPath();
ctx.fillStyle = "#939393";
ctx.rect(0, 0, width, height);
ctx.closePath();
ctx.fill();
// 写上刮一刮三个字
ctx.font = "bold 80px Arial";
ctx.fillStyle = "#000";
ctx.textAlign = "center";
ctx.fillText("刮一刮", width / 2, height / 2);

定义常量/变量

接下来,我们定义一些常量/变量

1
2
3
4
5
6
7
8
9
10
let isDown = false; //鼠标是否按下标志
const pointerArr = []; //鼠标移动坐标数组
let xPointer = 0; //鼠标当前x坐标
let yPointer = 0; //鼠标当前y坐标

//pc,移动事件兼容写法
const hastouch = Boolean("ontouchstart" in window),
tapstart = hastouch ? "touchstart" : "mousedown",
tapmove = hastouch ? "touchmove" : "mousemove",
tapend = hastouch ? "touchend" : "mouseup";

定义事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
//鼠标按下
myCanvas.addEventListener(tapstart, function (e) {
this.style.cursor = "move";
isDown = true;
xPointer = hastouch ? e.targetTouches[0].pageX : e.clientX - this.offsetLeft;
yPointer = hastouch ? e.targetTouches[0].pageY : e.clientY - this.offsetTop;
pointerArr.push([xPointer, yPointer]);
circleReset(ctx);
});

//鼠标按下后拖动
myCanvas.addEventListener(tapmove, function (e) {
if (isDown) {
xPointer = hastouch
? e.targetTouches[0].pageX
: e.clientX - this.offsetLeft;
yPointer = hastouch ? e.targetTouches[0].pageY : e.clientY - this.offsetTop;
pointerArr.push([xPointer, yPointer]);
circleReset(ctx);
}
});

//鼠标抬起取消事件
myCanvas.addEventListener(tapend, function (e) {
isDown = false;
pointerArr = [];
});

//刮刮卡效果
function circleReset(ctx) {
ctx.save();
ctx.beginPath();
ctx.moveTo(pointerArr[0][0], pointerArr[0][1]);
ctx.lineCap = "round"; //设置线条两端为圆弧
ctx.lineJoin = "round"; //设置线条转折为圆弧
ctx.lineWidth = 60;
ctx.globalCompositeOperation = "destination-out";
if (pointerArr.length == 1) {
ctx.lineTo(pointerArr[0][0] + 1, pointerArr[0][1] + 1);
} else {
for (var i = 1; i < pointerArr.length; i++) {
ctx.lineTo(pointerArr[i][0], pointerArr[i][1]);
ctx.moveTo(pointerArr[i][0], pointerArr[i][1]);
}
}
ctx.closePath();
ctx.stroke();
ctx.restore();
}

这里给大家说一下为什么要用这么麻烦的方式清除内容,也许你会认为只需要

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
let isDrawing = false;

canvas.addEventListener("mousedown", (e) => {
isDrawing = true;
draw(e);
});

canvas.addEventListener("mousemove", draw);

canvas.addEventListener("mouseup", () => {
isDrawing = false;
});

canvas.addEventListener("mouseout", () => {
isDrawing = false;
});

function draw(e) {
if (!isDrawing) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
ctx.globalCompositeOperation = "destination-out";
ctx.beginPath();
ctx.arc(x, y, 10, 0, 2 * Math.PI);
ctx.fill();
}

这样的确也可以清除内容,但是这样处理是比较卡顿的,而且绘制出来的可以很明显看到一个一个圆点。

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
<!DOCTYPE html>
<html>
<head>
<title>橡皮擦</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1.0, user-scalable=no"
/>
</head>

<body onload="init()">
<div id="wrapper">
<div id="reward"></div>
</div>
</body>
<script>
function init() {
let width, height;
if (!document.getElementById("canvas")) {
const canvas = document.createElement("canvas");
// 获取dom
const fatherDom = document.getElementById("reward");
// 获取dom的宽高
width = fatherDom.offsetWidth;
height = fatherDom.offsetHeight;
// 设置宽高
canvas.setAttribute("width", width + "px");
canvas.setAttribute("height", height + "px");
// 设置样式
canvas.setAttribute("style", "border:1px solid green");
canvas.id = "canvas";
// canvas添加到dom
fatherDom.appendChild(canvas);
}

const myCanvas = document.getElementById("canvas");
const ctx = myCanvas.getContext("2d");

//绘制黑色矩形
ctx.beginPath();
ctx.fillStyle = "#939393";
ctx.rect(0, 0, width, height);
ctx.closePath();
ctx.fill();
// 写上刮一刮三个字
ctx.font = "bold 80px Arial";
ctx.fillStyle = "#000";
ctx.textAlign = "center";
ctx.fillText("刮一刮", width / 2, height / 2);

let isDown = false; //鼠标是否按下标志
const pointerArr = []; //鼠标移动坐标数组
let xPointer = 0; //鼠标当前x坐标
let yPointer = 0; //鼠标当前y坐标

//pc,移动事件兼容写法
const hastouch = Boolean("ontouchstart" in window),
tapstart = hastouch ? "touchstart" : "mousedown",
tapmove = hastouch ? "touchmove" : "mousemove",
tapend = hastouch ? "touchend" : "mouseup";

//鼠标按下
myCanvas.addEventListener(tapstart, function (e) {
this.style.cursor = "move";
isDown = true;
xPointer = hastouch
? e.targetTouches[0].pageX
: e.clientX - this.offsetLeft;
yPointer = hastouch
? e.targetTouches[0].pageY
: e.clientY - this.offsetTop;
pointerArr.push([xPointer, yPointer]);
circleReset(ctx);
});

//鼠标按下后拖动
myCanvas.addEventListener(tapmove, function (e) {
if (isDown) {
xPointer = hastouch
? e.targetTouches[0].pageX
: e.clientX - this.offsetLeft;
yPointer = hastouch
? e.targetTouches[0].pageY
: e.clientY - this.offsetTop;
pointerArr.push([xPointer, yPointer]);
circleReset(ctx);
}
});

//鼠标抬起取消事件
myCanvas.addEventListener(tapend, function (e) {
isDown = false;
pointerArr = [];
});

//圆形橡皮檫/刮刮卡
function circleReset(ctx) {
ctx.save();
ctx.beginPath();
ctx.moveTo(pointerArr[0][0], pointerArr[0][1]);
ctx.lineCap = "round"; //设置线条两端为圆弧
ctx.lineJoin = "round"; //设置线条转折为圆弧
ctx.lineWidth = 60;
ctx.globalCompositeOperation = "destination-out";
if (pointerArr.length == 1) {
ctx.lineTo(pointerArr[0][0] + 1, pointerArr[0][1] + 1);
} else {
for (var i = 1; i < pointerArr.length; i++) {
ctx.lineTo(pointerArr[i][0], pointerArr[i][1]);
ctx.moveTo(pointerArr[i][0], pointerArr[i][1]);
}
}
ctx.closePath();
ctx.stroke();
ctx.restore();
}
}
</script>
<style>
#wrapper {
display: flex;
margin: auto;
margin-top: 100px;
justify-content: center;
}
#reward {
background: url("./assets/img/winning.png") no-repeat;
width: 697px;
height: 376px;
}
</style>
</html>

效果

结语

我们的那个刮刮卡也可以当作橡皮擦来使用,不只是局限于我说的那些个作用,我就只是抛砖引玉,本篇文章就到这里了,更多内容敬请期待,债见~

上一篇:
【可视化学习】89-从入门到放弃WebGL(十九)
下一篇:
【可视化学习】88-3D后期效果(二)