Canvas-系列
Canvas 2D 基础
Canvas 2D 进阶
Canvas 2D 贝塞尔曲线
Canvas 2D 事件
开发了一个 Canvas 2D 渲染引擎

前言

Canvas 并没有提供原生的事件机制,所有的事件交互都需要基于 dom 事件来实现,为了操作画布上的图形,往往需要自己实现一个事件系统。

很多 Canvas 库都带有自己的事件系统:Konvafabric.jspixi

事件

常用鼠标事件:

  1. click 点击
  2. dblclick 双击
  3. mouseover 鼠标移入及在元素内移动,冒泡,移入和移出其子元素时也会触发
  4. mouseout 鼠标移出,冒泡,移入和移出其子元素时也会触发
  5. mouseenter 鼠标首次移入,不会冒泡
  6. mouseleave 鼠标移出,不会冒泡
  7. mousedown 鼠标按下
  8. mouseup 鼠标抬起
  9. mousemove 鼠标移动
  10. mousewheel 鼠标滚轮

键盘事件

三个键盘事件:

  1. keydown 按下按键触发,返回键盘码
  2. keyup 松开按键触发,返回键盘码
  3. keypress 按下按键,并产生一个字符时触发,返回其ASCII码。一些功能键不会产生字符,也就不会触发该事件。

keydown 和 keypress 在按住不松开时会重复触发。

注意:键盘事件只发生在当前拥有焦点的HTML元素上,如果没有元素拥有焦点,那么事件将会上移至windows和document对象。所以 canvas 不能直接监听键盘事件。

两种解决办法:

1、添加 tabindex 属性,使 canvas 具有焦点:
tabindex 属性表示元素可聚焦,值表示元素是否/在何处参与顺序键盘导航(通常使用Tab键,因此得名)。

  1. 负值:不能通过键盘导航来访问到该元素。
  2. 0:默认值,可以通过键盘导航来访问到该元素。导航相对顺序由 DOM 结构决定。
  3. 正值:可以通过键盘导航来访问到该元素。导航相对顺序由 tabindex 的值决定。
1
2
3
4
5
canvas.tabIndex = -1;
canvas.focus();
canvas.addEventListener("keydown", (e)=>{
console.log(e.key);
}, false);

当其失去焦点时,则也会失去键盘监听,通常适合做游戏等当失去焦点游戏自动暂停等场景。

2、监听 window 键盘事件:
容易与其它元素冲突,需要更复杂的逻辑来判断是否在 canvas 上按下按键。

1
2
3
window.addEventListener("keydown", (e) => {
console.log(e.key);
}, false);

图形绘制demo

用一个 demo 初试事件逻辑。在线演示

获取鼠标位置

事件对象具有 clientXclientY 属性,表示鼠标在视口中的坐标。
dom 元素在视口中的坐标,可以使用 getBoundingClientRect 方法获取。

于是,可以通过以下代码获取鼠标在 canvas 上的坐标:

1
2
3
4
5
6
canvas.addEventListener("mousemove", (e) => {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.x;
const y = e.clientY - rect.y;
console.log(`X: ${x}, Y: ${y}`);
});

架构

所有图形都应该是具有 draw 方法的对象,于是可以抽象出一个父类 Shape,所有图形都继承自它,并且实现 draw 方法。

还需要实现 isInside 方法,用于判断一个点是否在图形上。

外部使用一个数组保存所有图形对象,每次绘制时,遍历数组,调用每个图形的 draw 方法。

js 没有原生的抽象类,一般使用 ts 方便约束。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 图形父类
class Shape {
draw() {}
isInside(x, y) {}
}

// 保存所有图形
const shapes = [];

function main() {
// 清空画布
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 画所有图形
shapes.forEach((shape) => {
shape.draw();
});
requestAnimationFrame(main);
}
main();

在 main 主循环中,重复清空画布和绘制图形,性能问题暂不考虑,可以使用标志位来控制是否需要重绘等,这并不是本文需要关心的。

画矩形

每种图形类的实现都为 drawisInside 方法服务。

实现矩形绘制后其它图形也差不多,无非就是画的方式和检测位置的算法不同。

矩形根据四个点就能确定图形的位置和大小,所以实现起来也很简单。因为拖动的方向不确定,endX 也可能比 startX 小,所以创建 getter 来获取矩形的四个边界点和矩形大小,方便后续计算。
draw 在最小边界点开始,绘制一个矩形即可。
isInside 也很简单,判断点是否在矩形的四个边界内即可。

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
class Rect extends Shape {
// 传入四个点的坐标和颜色
constructor(startX, startY, endX, endY, color) {
super();
this.startX = startX;
this.startY = startY;
this.endX = endX;
this.endY = endY;
this.color = color;
}

draw() {
ctx.save();
ctx.fillStyle = this.color;
ctx.fillRect(this.minX, this.minY, this.width, this.height);
ctx.restore();
}

isInside(x, y) {
return x >= this.minX && x <= this.maxX && y >= this.minY && y <= this.maxY;
}

get minX() {
return Math.min(this.startX, this.endX);
}

get minY() {
return Math.min(this.startY, this.endY);
}

get maxX() {
return Math.max(this.startX, this.endX);
}

get maxY() {
return Math.max(this.startY, this.endY);
}

get width() {
return this.maxX - this.minX;
}

get height() {
return this.maxY - this.minY;
}

get size() {
return this.width * this.height;
}
}

拖动绘制

监听 mousedown 事件,在鼠标按下后,在该位置创建一个大小为 0 的矩形。
然后监听 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
// 鼠标按下时开始拖动逻辑
canvas.addEventListener("mousedown", (e) => {
const rect = canvas.getBoundingClientRect();
// 鼠标按下时相对于画布的坐标
const mX = e.clientX - rect.x;
const mY = e.clientY - rect.y;
// 绘制一个矩形,刚开始时起点和终点相同
const shape = new Rect(clickX, clickY, clickX, clickY, color.value);
// 记录是已经加入到图形数组中
let isPush = false;
// 鼠标移动时修改终点坐标
canvas.onmousemove = (e) => {
// 拖动后图形具有大小,才加入到图形数组中
if (!isPush) {
isPush = true;
// 加入到图形数组中
shapes.push(shape);
}
// 鼠标移动时相对于画布的坐标
const x = e.clientX - rect.x;
const y = e.clientY - rect.y;
// 修改终点坐标
shape.endX = x;
shape.endY = y;
};
// 鼠标抬起时清除事件
canvas.onmouseup = () => {
canvas.onmousemove = null;
canvas.onmouseup = null;
};
});

拖动移动

mousedown 事件中,判断鼠标按下的位置是否在图形上,如果在,则开始拖动图形的逻辑。

倒序遍历图形数组,因为后画的图形在数组的后面,调用 isInside 方法判断是否在图形上,如果在,则根据鼠标移动的距离,在图形原来坐标的基础上,修改图形坐标。

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
// 鼠标按下时开始拖动逻辑
canvas.addEventListener("mousedown", (e) => {
const rect = canvas.getBoundingClientRect();
// 鼠标按下时相对于画布的坐标
const clickX = e.clientX - rect.x;
const clickY = e.clientY - rect.y;
// 记录被选中的图形
let select;
// 倒序遍历所有图形,判断是否有图形被选中
for (let i = shapes.length - 1; i >= 0; i--) {
if (shapes[i].isInside(clickX, clickY)) {
select = {
shape: shapes[i],
index: i,
};
break;
}
}
// 如果有图形被选中, 则移动图形
if (select) {
// 记录矩形原来位置
const { startX, startY, endX, endY } = select.shape;
canvas.onmousemove = (e) => {
// 鼠标移动时相对于画布的坐标
const x = e.clientX - rect.x;
const y = e.clientY - rect.y;
// 鼠标移动距离
const dx = x - clickX;
const dy = y - clickY;
// 更新起点和终点坐标
select.shape.startX = startX + dx;
select.shape.startY = startY + dy;
select.shape.endX = endX + dx;
select.shape.endY = endY + dy;
};
} else {
// 绘制一个矩形,刚开始时起点和终点相同
const shape = new Rect(clickX, clickY, clickX, clickY, color.value);
// 记录是已经加入到图形数组中
let isPush = false;
// 鼠标移动时修改终点坐标
canvas.onmousemove = (e) => {
// 鼠标移动时相对于画布的坐标
const x = e.clientX - rect.x;
const y = e.clientY - rect.y;
// 修改终点坐标
shape.endX = x;
shape.endY = y;
// 拖动后图形具有大小,才加入到图形数组中
if (!isPush && shape.size > 0) {
isPush = true;
// 加入到图形数组中
shapes.push(shape);
}
};
}

// 鼠标抬起时清除事件
canvas.onmouseup = () => {
canvas.onmousemove = null;
canvas.onmouseup = null;
};
});

清空与撤销

撤销图形的绘制,只需要将数组中最后一个图形删除即可。

如果还需要撤销对图形的操作,那可以在图形类中实现一个栈结构,保存每次操作完毕的位置等状态,总之,办法很多,类似的还能实现重做等功能。

1
2
3
4
5
6
7
8
9
// 清空画布
clear.onclick = () => {
shapes.length = 0;
};

// 撤销图形绘制
revoke.onclick = () => {
shapes.pop();
};

完整代码

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
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
const color = document.querySelector("#color");
const clear = document.querySelector("#clear");
const revoke = document.querySelector("#revoke");
const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");

// 图形父类
class Shape {
draw() {}
isInside(x, y) {}
}

class Rect extends Shape {
// 传入四个点的坐标和颜色
constructor(startX, startY, endX, endY, color) {
super();
this.startX = startX;
this.startY = startY;
this.endX = endX;
this.endY = endY;
this.color = color;
}

draw() {
ctx.save();
ctx.fillStyle = this.color;
ctx.fillRect(this.minX, this.minY, this.width, this.height);
ctx.restore();
}

isInside(x, y) {
return x >= this.minX && x <= this.maxX && y >= this.minY && y <= this.maxY;
}

get minX() {
return Math.min(this.startX, this.endX);
}

get minY() {
return Math.min(this.startY, this.endY);
}

get maxX() {
return Math.max(this.startX, this.endX);
}

get maxY() {
return Math.max(this.startY, this.endY);
}

get width() {
return this.maxX - this.minX;
}

get height() {
return this.maxY - this.minY;
}

get size() {
return this.width * this.height;
}
}

// 保存所有图形
const shapes = [];

function main() {
// 清空画布
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 画所有图形
shapes.forEach((shape) => {
shape.draw();
});
requestAnimationFrame(main);
}
main();

// 鼠标按下时开始拖动逻辑
canvas.addEventListener("mousedown", (e) => {
const rect = canvas.getBoundingClientRect();
// 鼠标按下时相对于画布的坐标
const clickX = e.clientX - rect.x;
const clickY = e.clientY - rect.y;
// 记录被选中的图形
let select;
// 倒序遍历所有图形,判断是否有图形被选中
for (let i = shapes.length - 1; i >= 0; i--) {
if (shapes[i].isInside(clickX, clickY)) {
select = {
shape: shapes[i],
index: i,
};
break;
}
}
// 如果有图形被选中, 则移动图形
if (select) {
// 记录矩形原来位置
const { startX, startY, endX, endY } = select.shape;
canvas.onmousemove = (e) => {
// 鼠标移动时相对于画布的坐标
const x = e.clientX - rect.x;
const y = e.clientY - rect.y;
// 鼠标移动距离
const dx = x - clickX;
const dy = y - clickY;
// 更新起点和终点坐标
select.shape.startX = startX + dx;
select.shape.startY = startY + dy;
select.shape.endX = endX + dx;
select.shape.endY = endY + dy;
};
} else {
// 绘制一个矩形,刚开始时起点和终点相同
const shape = new Rect(clickX, clickY, clickX, clickY, color.value);
// 记录是已经加入到图形数组中
let isPush = false;
// 鼠标移动时修改终点坐标
canvas.onmousemove = (e) => {
// 鼠标移动时相对于画布的坐标
const x = e.clientX - rect.x;
const y = e.clientY - rect.y;
// 修改终点坐标
shape.endX = x;
shape.endY = y;
// 拖动后图形具有大小,才加入到图形数组中
if (!isPush && shape.size > 0) {
isPush = true;
// 加入到图形数组中
shapes.push(shape);
}
};
}

// 鼠标抬起时清除事件
canvas.onmouseup = () => {
canvas.onmousemove = null;
canvas.onmouseup = null;
};
});

// 清空画布
clear.onclick = () => {
shapes.length = 0;
};

// 撤销图形绘制
revoke.onclick = () => {
shapes.pop();
};

事件系统

在上面的 demo 中,我们让每个图形各自管理自己的 draw 和 isInside 方法,不同图形具有不同实现。
isInside 方法用于辅助事件处理方法,判断鼠标点击的位置是否在该图形上。

可以完全独立出来一个框架系统,用于处理所有图形的点击、拖拽等事件,这就是 Canvas 事件系统
事件系统的前提是碰撞检测,而判断一个点是否在一个封闭的直边多边形内部相对是比较容易的,这就需要实现一个渲染引擎。

在后续文章中,将会使用 Rollup + TS 实现一个 Canvas2D 渲染引擎。