Canvas 2D 事件
发表于更新于
字数总计:3k阅读时长:11分钟阅读量:
前言
Canvas 并没有提供原生的事件机制,所有的事件交互都需要基于 dom 事件来实现,为了操作画布上的图形,往往需要自己实现一个事件系统。
很多 Canvas 库都带有自己的事件系统:Konva、fabric.js、pixi
事件
常用鼠标事件:
- click 点击
- dblclick 双击
- mouseover 鼠标移入及在元素内移动,冒泡,移入和移出其子元素时也会触发
- mouseout 鼠标移出,冒泡,移入和移出其子元素时也会触发
- mouseenter 鼠标首次移入,不会冒泡
- mouseleave 鼠标移出,不会冒泡
- mousedown 鼠标按下
- mouseup 鼠标抬起
- mousemove 鼠标移动
- mousewheel 鼠标滚轮
键盘事件
三个键盘事件:
- keydown 按下按键触发,返回键盘码
- keyup 松开按键触发,返回键盘码
- keypress 按下按键,并产生一个字符时触发,返回其ASCII码。一些功能键不会产生字符,也就不会触发该事件。
keydown 和 keypress 在按住不松开时会重复触发。
注意:键盘事件只发生在当前拥有焦点的HTML元素上,如果没有元素拥有焦点,那么事件将会上移至windows和document对象。所以 canvas 不能直接监听键盘事件。
两种解决办法:
1、添加 tabindex 属性,使 canvas 具有焦点:
tabindex
属性表示元素可聚焦,值表示元素是否/在何处参与顺序键盘导航(通常使用Tab键,因此得名)。
- 负值:不能通过键盘导航来访问到该元素。
- 0:默认值,可以通过键盘导航来访问到该元素。导航相对顺序由 DOM 结构决定。
- 正值:可以通过键盘导航来访问到该元素。导航相对顺序由 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 初试事件逻辑。在线演示。
获取鼠标位置
事件对象具有 clientX 和 clientY 属性,表示鼠标在视口中的坐标。
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 主循环中,重复清空画布和绘制图形,性能问题暂不考虑,可以使用标志位来控制是否需要重绘等,这并不是本文需要关心的。
画矩形
每种图形类的实现都为 draw
和 isInside
方法服务。
实现矩形绘制后其它图形也差不多,无非就是画的方式和检测位置的算法不同。
矩形根据四个点就能确定图形的位置和大小,所以实现起来也很简单。因为拖动的方向不确定,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 渲染引擎。