前言
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 的值决定。
canvas.tabIndex = -1;canvas.focus();canvas.addEventListener("keydown", (e)=>{ console.log(e.key);}, false);当其失去焦点时,则也会失去键盘监听,通常适合做游戏等当失去焦点游戏自动暂停等场景。
2、监听 window 键盘事件: 容易与其它元素冲突,需要更复杂的逻辑来判断是否在 canvas 上按下按键。
window.addEventListener("keydown", (e) => { console.log(e.key);}, false);图形绘制demo
用一个 demo 初试事件逻辑。在线演示。
获取鼠标位置
事件对象具有 clientX 和 clientY 属性,表示鼠标在视口中的坐标。 dom 元素在视口中的坐标,可以使用 getBoundingClientRect 方法获取。
于是,可以通过以下代码获取鼠标在 canvas 上的坐标:
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 方便约束。
// 图形父类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 也很简单,判断点是否在矩形的四个边界内即可。
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 事件,在鼠标抬起后,清除相关事件。
// 鼠标按下时开始拖动逻辑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 方法判断是否在图形上,如果在,则根据鼠标移动的距离,在图形原来坐标的基础上,修改图形坐标。
// 鼠标按下时开始拖动逻辑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();};完整代码
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 渲染引擎。
评论