元素拖拽是一种常见的交互设计模式,在许多场景下都有应用:地图打标、画板图形拖拽、可视化组件拖拽、交互式表格等。实现元素拖拽的核心在于监听和响应鼠标事件,模拟canvas元素事件,并同步更新Canvas绘制。主要需实现:
- 创建元素
- 坐标转换(鼠标事件坐标和canvas坐标转换)
- 事件监听、元素重绘
- 元素状态管理(主要为元素层级、位置的处理)
- 自定义事件传递(可以省略)
以上五个要点是在Canvas上实现元素拖拽的基础逻辑框架。通过这样的方式,可以轻松赋予 Canvas 上的自定义图形或其他视觉元素可拖动的能力。在完成封装之后可以使用以下几行代码轻松创建可拖拽元素。
// 初始化舞台
const canvasDrag = new cRender.Stage({
template: '#myCanvas'
});
// 初始化元素参数(此时还不会渲染)
const rect = new cRender.DrawRect({
height: 50,
width: 50,
isDraggable: true
});
// 调用舞台的add方法,将图形渲染到舞台上
canvasDrag.add(rect);
下面就正式开始封装,封装后的目录结构如下,可以先简单了解一下,下面会一一说明:
👏👏👏 本文的代码有任何不理解或者不合理之处,欢迎在评论区提出或指正。知无不言。
创建Canvas元素
明确需要什么
在绘制元素之前首先要理一下需要什么:
- 创建舞台:首先,需要在页面上添加一个用于渲染元素和实现元素拖拽的舞台。这可以通过在页面上添加一个canvas元素,并为其设置一些基本的CSS样式来实现。可以设置canvas元素的宽度和高度,以及其他样式属性。
- Stage类:其次,需要声明一个Stage类,用于设置舞台属性和元素统一绘制。
- Base基类:需要一个元素基类,用于处理元素的共同方法,并作为其他元素类型的基础。在基类中可以定义各种共同的方法,例如设置id、处理拖拽事件等。其他具体的元素类型可以继承该基类,并根据自身特性进行扩展和定制。
- 各元素绘制方法:因为不同的元素有不同的绘制方法,所以这里要根据不同的元素,展开不同的绘制方法。
在HTML页面上新增一个舞台
直接在页面上添加一个canvas元素,并设置它的宽高等属性,不再过多描述。
index.html ==>
<style>
.myCanvasBox {
height: 400px;
width: 400px;
border: 1px solid #ccc;
position: absolute;
top: 160px;
left: 200px;
}
</style>
<canvas id="myCanvas" class="myCanvasBox"></canvas>
Stage类封装
Stage类主要封装了初始化舞台属性和添加图形到舞台的方法。通过使用Stage类,可以方便地管理舞台上的图形对象。
在初始化的时候传入舞台的id,并将舞台的dom以及相应的渲染上下文(ctx)保存留用。
初始化舞台属性。canvas是使用两种宽高定义的:样式宽高和属性宽高。样式宽高定义了canvas元素在页面上的大小,而属性宽高定义了canvas绘图区域的大小。如果样式宽高和属性宽高不相同,会导致canvas在页面中显示的大小与实际的绘图区域大小不一致。这可能会导致图像变形、位置错误或无法正确响应鼠标事件等问题。因此,为了确保canvas元素正确显示并与实际绘图区域一致,这里会把属性宽高与样式宽高保持一致。这样可以避免绘图的偏移和失真,并使得元素拖拽功能能够准确地计算鼠标位置和元素位置。
再在该类上新增一个add方法,这个方法的主要作用是传入元素实例,并在renderAll函数中调用元素实例中的绘制方法,将图形对象添加到舞台上。
Stage.js ==>
class CanvasDrag {
constructor(initParams) {
// 保存参数
this.initParams = initParams
// 保存舞台dom
this.stageDom = document.querySelector(this.initParams.template);
// 保存ctx
this.ctx = this.stageDom.getContext('2d');
// 初始化舞台属性
this.initStageParams();
// 保存元素
this.elements = [];
}
// 设置舞台属性
initStageParams() {
this.stageDom.width = this.stageDom.clientWidth;
this.stageDom.height = this.stageDom.clientHeight;
}
// 调用元素上的draw方法,将图形绘制出来
add(shape) {
this.elements.push(shape);
this.renderAll();
}
// 渲染全部元素
renderAll() {
// 这里是根据元素的层级做一个排序,下面会具体介绍层级
this.elements.sort((a, b) => {
return a.levelNum - b.levelNum
});
// 清空画布
this.ctx.clearRect(0, 0, this.stageDom.width, this.stageDom.height);
// 遍历仓库里的元素 重新绘制
this.elements.map((shape) => {
shape.draw(this.ctx);
})
}
}
Base基类封装
目前基类要做的事情还不多,主要使用crypto.randomUUID()方法生成一个唯一的id,用于标识每个元素的唯一性。然后定义一个全局的层级,每次加一之后为每个元素添加上一个状态参数: 元素层级 ,并添加一个更新全局层级的方法,方便在后续的操作中对元素进行标识和操作。
Base.js ==>
// 定义层级
let levelNum = 0;
export class Base {
constructor() {
this.id = crypto.randomUUID();
// 保存层级
levelNum++
this.levelNum = levelNum;
}
// 更新层级
updateLevel(num) {
levelNum = num;
}
}
元素绘制(本文演示一个矩形,其它元素可以以此类推)
目前用到的比较简单,主要就是继承基类,合并一下默认参数和传进来的参数,然后新增一个draw函数用来绘制一个矩形,不再过多描述。
另外这里额外在矩形类上新增 isPointInShape 函数用来判断坐标是否在元素内、setAttr 函数用来更新元素属性,并新增几个默认参数isDragging(是否拖拽中)、mouseOffsetLeft(鼠标左侧偏移量)、mouseOffsetTop(鼠标上侧偏移量)(暂时用不到这些属性,下面在事件监听里会具体用到并详细介绍)。
Rect.js ==>
import { Base } from './Base.js';
export class DrawRect extends Base {
constructor(options = {}) {
super();
const baseParams = { top: 0, left: 0, width: 100, height: 100, fillStyle: 'red', isDraggable: false };
this.elementAttr = {...baseParams, ...options};
this.type = 'rect';
this.isDragging = false;
this.mouseOffsetLeft = this.elementAttr.left;
this.mouseOffsetTop = this.elementAttr.top;
}
setAttr(changeAttr) {
this.elementAttr = {...this.elementAttr, ...changeAttr};
}
draw(ctx) {
if(ctx) {
ctx.save();
ctx.beginPath();
ctx.fillStyle = this.elementAttr.fillStyle;
ctx.fillRect(this.elementAttr.left, this.elementAttr.top, this.elementAttr.width, this.elementAttr.height);
ctx.closePath();
ctx.restore();
}
}
/**
* 传入鼠标点击的坐标 返回true或者false
* @param {number} left
* @param {number} top
* @returns {boolean} true表示点在元素内
*/
isPointInShape(left, top) {
const res = this.elementAttr.left <= left
&& this.elementAttr.left + this.elementAttr.width >= left
&& top >= this.elementAttr.top
&& top <= this.elementAttr.top + this.elementAttr.height;
return res;
}
}
汇总到 index.js
最后在外面包一层cRender,通过它导出上面封装的这些类,提供一种简单且统一的方式来使用这些功能。
index.js ==>
import { Stage } from "./Stage.js";
import { DrawRect } from "./Rect.js";
import { Handler } from "./Handler.js";
export class cRender {
static DrawRect = DrawRect
constructor(initParams) {
this.Stage = new Stage(initParams);
// 初始化事件系统 下面一节会详细介绍 可以先注释掉
// this.Handler = new Handler(initParams.template, this.Stage);
}
// 做一下中转
add(shape) {
this.Stage.add(shape);
}
}
初步成果(元素渲染成功)
到这里简单的创建元素就完成了。当然的,后面还会根据具体需要对一些方法进行拓展。接下来看一下创建元素的使用示例,可以看到页面上输出了一个黑色长方形加一个红色正方形。
test.js ==>
import { cRender } from "./src/index.js";
// 初始化
const canvasDrag = new cRender({
template: '#myCanvas'
});
// 初始化元素参数(此时还不会渲染)
const rect = new cRender.DrawRect({
height: 80,
width: 40,
fillStyle: '#000',
top: 200,
left: 200,
isDraggable: true
});
const rectOther = new cRender.DrawRect();
// 调用舞台的add方法,将元素实例传入,并将图形渲染到舞台上
canvasDrag.add(rect);
canvasDrag.add(rectOther);
目前尚未引入事件处理机制,拖拽功能自然是没有的。那么接下来将进一步完善系统,为矩形元素增添一套事件系统以支持拖拽操作。
事件系统添加
明确需要什么
首先要明确一点:canvas标签自身虽作为一个HTML元素存在于DOM结构中,但它所承载的图形元素并非是具有独立事件的实体元素。你可以把一整个canvas标签比喻成一张画,它是一个整体,你在画布内部绘制的任意形状、图像或者路径均 不可直接关联事件监听。所以如果想要在Canvas上实现图形交互,就必须在整体层面上通过捕捉并处理 作用于整个Canvas上的事件,然后通过计算事件产生的坐标来 间接判断 用户交互是否与图形元素产生了关联。
根据上面的说明来明确一下,做事件监听到底需要什么:
- 定位事件监听目标:由于元素不可直接添加对象,所以要为canvas标签绑定事件来计算元素身上的事件。为canvas标签绑定所需的DOM事件,常见的事件包括:鼠标点击(click)、鼠标移动(mousemove)、鼠标按下(mousedown)和鼠标释放(mouseup)等。
- 适配canvas坐标:由于浏览器的鼠标事件对象中提供的
clientX
和clientY
属性是相对于整个页面视口的坐标,而不是相对于canvas标签自身相对于左上角的位置的坐标,所以这里获取到鼠标事件之后,还需要进行计算转换。 - 检测坐标与元素的关系 并找出所有符合条件的元素:为了确定用户是否操作到了特定的图形元素,需要检测鼠标位置是否落在所绘制的图形(如矩形、圆形、多边形或自定义路径等)内。具体实现时,针对不同的形状类型,需要采用不同的方法来判断这个关系。
- 解析作用于元素的事件类型:基于canvas标签上的事件信息,计算出某一 事件是否作用于 canvas内 某个元素 上。
- 重新绘制:一旦识别了用户的交互意图并且确定了影响的图形元素,通常会触发重绘过程,即更新canvas内容以反映新的状态或变化(本小节主要描述位置变化)。
- 实现自定义事件传递机制:可以在具体元素的实例上传递一个自定义事件,并在检测到某种关系的时候,触发它(主要是拓展用户操作,这里可能不太好理解,下面会在具体的代码里详细说明)。
Handler类新增
将需要监听的dom名称传入,将对应的dom保存,并添加鼠标事件和处理事件的 eventHandler 方法,并将鼠标坐标转换成对应的canvas坐标系坐标。方法很简单,用鼠标事件产生的坐标减去canvas坐标距网页窗口的偏移距离即可得出。
Handler.js ==>
export class Handler {
constructor(rootDom) {
// 保存根节点
this.root = document.querySelector(rootDom);
// 添加监听事件
this.root.addEventListener('click', this.eventHandler.bind(this));
this.root.addEventListener('mousedown', this.eventHandler.bind(this));
this.root.addEventListener('mousemove', this.eventHandler.bind(this));
this.root.addEventListener('mouseup', this.eventHandler.bind(this));
}
// 统一处理事件
eventHandler(event) {
// 用鼠标事件产生的坐标减去canvas坐标距网页窗口的偏移距离得出canvas坐标系坐标
const left = event.clientX - this.root.offsetLeft,
top = event.clientY - this.root.offsetTop;
}
}
检测坐标与元素的关系 找出符合条件的元素
现在,已经在eventHandler函数中得到了鼠标事件对应的坐标,下面要做的是在Handler类中拿到所有元素的实例,用实例中元素的坐标来和鼠标事件坐标进行对比,来判断坐标与元素的关系(这里只演示一个矩形)。
为了确保能够获取和管理所有元素实例,那首先要先将元素们保存起来。具体措施是:在Stage类内部创建并维护一个名为elements的数组。由于所有需要渲染的元素都必须通过调用Stage类提供的add方法来添加到canvas画布中,因此决定在add方法内实现逻辑:每当有新元素被添加绘制时,都会自动将其实例保存到elements数组中。这样可以利用Stage类作为集中管控中心,跟踪和存储所有与绘图相关的元素实例(写起来很简单,见下方 Stage.js 中的 add 方法)。
Stage.js ==>
// 调用shape上的draw方法,将图形绘制出来
add(shape) {
this.elements.push(shape);
this.renderAll();
}
这样,就将元素实例保存在仓库中了,然后在index.js中传递给Handler类,并在Handler类的constructor
中保存起来即可(现在可以将index.js中初始化事件那行代码放出来了)。
index.js ==>
export class cRender {
...
constructor(initParams) {
this.Stage = new Stage(initParams);
// 初始化事件
this.Handler = new Handler(initParams.template, this.Stage);
}
...
}
由于不同类型的元素可能具有不同的几何属性和边界条件,因此,还需要在每个元素类的内部实现一个自己的方法来判断坐标与该元素之间的关系。这就要用到Reat.js中的isPointInShape函数了。
Rect.js ==>
export class DrawRect extends Base {
...
/**
* 传入鼠标点击的坐标 返回true或者false
* @param {number} left
* @param {number} top
* @returns {boolean} true表示点在元素内
*/
isPointInShape(left, top) {
const res = this.elementAttr.left <= left
&& this.elementAttr.left + this.elementAttr.width >= left
&& top >= this.elementAttr.top
&& top <= this.elementAttr.top + this.elementAttr.height;
return res;
}
...
}
现在我们有了可以判断的方法,最后且关键的一个步骤要回到Handler中使用它,继续拓展eventHandler
方法。见下方代码。
其中,findControlledEle 函数主要任务是,输入坐标,返回符合条件的元素。做法是遍历所有仓库里维护的元素实例。针对每个元素实例,调用其内部实现的isPointInShape
方法,并将当前的鼠标坐标作为参数传递进去。通过这种方法逐一检查各个元素与鼠标位置的关系,进而准确判断用户与哪个或哪些元素进行了有效交互。当拿到所有包含了鼠标坐标的元素之后,再对数组进行排序并取出其中层级最高的,这个就是符合条件的元素。我们将这个元素的层级设置为最高,并返回它。那么计算好的元素就回到了 eventHandler 函数中,如果它存在就重新绘制一下所有元素(主要是为了更新修改后的层级关系,确保元素间的覆盖关系正确)。
Handler.js ==>
export class Handler {
...
// 统一处理事件
eventHandler(event) {
// 用鼠标事件的坐标减去canvas坐标距网页窗口的偏移距离
const left = event.clientX - this.root.offsetLeft,
top = event.clientY - this.root.offsetTop,
controlledShape = this.findControlledEle(left, top);
// 可以在这里输出元素信息 进行事件测试
if(controlledShape) {
console.log(controlledShape.elementAttr.fillStyle, controlledShape.type, event.type);
// 更新层级了 重新绘制
this.stage.renderAll();
}
}
// 判断鼠标位置是否有物体存在
findControlledEle(left, top) {
let allElementData = this.stage.elements,
stagingData = [],
controlledShape = null,
levelData = [];
// 获取所有包含了点击位置的元素
for(let i = allElementData.length - 1; i >= 0; i--) {
// 获取所有元素的层级信息
levelData.push(allElementData[i].levelNum);
if(allElementData[i].isPointInShape(left, top)) {
stagingData.push(allElementData[i]);
}
}
// 将层级排序
levelData.sort((a, b) => { return b - a });
// 取层级最高的
if(stagingData.length) {
if(stagingData.length > 1) {
stagingData.sort((a, b) => {
return a.levelNum - b.levelNum
});
}
controlledShape = stagingData[stagingData.length - 1];
// 如果当前元素层级不是最高层 那么就将其设置为最高层
if(controlledShape.levelNum !== levelData[0]) {
// 将当前层级放到最高位
controlledShape.levelNum = levelData[0] + 1;
// 更新最高层级
controlledShape.updateLevel(levelData[0] + 1);
}
}
return controlledShape;
}
...
}
测试效果见下图,可以看见事件都已生效。
解析作用于元素的事件类型、重新绘制
现在已经将事件放到具体的元素上了,最后一步就要根据不同的事件类型,执行特定的操作来模拟元素身上的拖拽事件,具体为:
mousedown
事件,设置元素的拖拽状态为true,并记录鼠标点击时相对于元素上边和左边的偏移量。mousemove
事件,若元素处于拖拽状态,根据鼠标移动的距离更新元素的位置,重新渲染整个舞台来更新元素的新位置。mouseup
事件,取消元素的拖拽状态。
Handler.js ==>
// 统一处理事件
eventHandler(event) {
// 用鼠标事件的坐标减去canvas坐标距网页窗口的偏移距离
const allElementData = this.stage.elements,
left = event.clientX - this.root.offsetLeft,
top = event.clientY - this.root.offsetTop;
Object.values(allElementData).map((shape) => {
if(shape.isPointInShape(left, top)) {
switch (event.type) {
case 'mousedown':
shape.isDragging = true;
// 计算偏移量
shape.mouseOffsetLeft = left - shape.elementAttr.left;
shape.mouseOffsetTop = top - shape.elementAttr.top;
break;
case 'mousemove':
if(shape.isDragging) {
// 计算移动后元素的坐标
const moveToLeft = left - shape.mouseOffsetLeft,
moveToTop = top - shape.mouseOffsetTop;
// 更新属性
shape.setAttr({
top: moveToTop,
left: moveToLeft,
});
this.stage.renderAll();
}
break;
case 'mouseup':
shape.isDragging = false;
break;
default:
break;
}
}
})
}
上面代码中有几个比较陌生的参数和方法,下面一一解释:
-
mouseOffsetLeft、mouseOffsetTop:这俩变量不好解释,但是画个图立马就能明白,上图!
蓝色的图形是的鼠标(假装的),那么当鼠标点击下方的黑色矩形元素的时候,绿色距离就是mouseOffsetTop,黄色距离就是mouseOffsetLeft。也就是鼠标的位置,到元素上边和左边的距离。mousemove事件中,计算元素坐标的时候会用到。
-
moveToLeft、moveToTop:在移动的过程中,用鼠标在canvas中的横纵坐标分别 减去 上图中小黄和小绿的距离,可以得到元素真正的坐标,也就是这里的两个参数的值。
-
shape.setAttr函数:上面提到的Rect类内部的setAttr函数,是个用于更新元素属性的函数。
-
renderAll函数:这个函数是仓库Stage类中的方法,用来遍历所有元素并重新绘制。具体如下:
Stage.js ==>
// 重新渲染全部元素 renderAll() { // 按照层级排序 this.elements.sort((a, b) => { return a.levelNum - b.levelNum }); // 清空画布 this.ctx.clearRect(0, 0, this.stageDom.width, this.stageDom.height); // 遍历仓库里的元素 重新绘制 this.elements.map((shape) => { shape.draw(this.ctx); }) })
自定义事件(拓展)
经过上面的事件监听和封装,自定义事件就比较简单了,只需要做到三件事即可,传递事件、绑定到元素上、触发即可。
传递事件
按照事件监听的使用惯例,在声明元素之后,用.on的方式将事件名称和函数传递到元素上。
test.js ==>
// 初始化元素
const rectOther = new cRender.DrawRect();
// 传入click方法
rectOther.on('click', (e) => {
console.log(e);
})
绑定事件到元素上
接着在base类上,新增一个on方法,来对事件进行绑定。
Base.js ==>
export class Base {
...
on(type, callback) {
this['on' + type] = callback;
}
...
}
触发事件
上面已经完成了元素自身事件的触发了,只需要在Handler类的 eventHandler 函数中,根据不同的鼠标事件去触发相应的事件即可。在相应位置增加下面几行代码。
Handler.js ==>
export class Handler {
...
// 统一处理事件
eventHandler(event) {
// 用鼠标事件的坐标减去canvas坐标距网页窗口的偏移距离
const left = event.clientX - this.root.offsetLeft,
top = event.clientY - this.root.offsetTop,
controlledShape = this.findControlledEle(left, top);
if(controlledShape) {
this.stage.renderAll();
// 新增加的代码 用于触发传入的自定义事件 =================================
if(typeof shape['on' + event.type] === 'function') {
shape['on' + event.type].call(this, event);
}
// 结束 ===================================
if(controlledShape.elementAttr.isDraggable) {
this.dragHandle(controlledShape, event.type, left, top);
}
}
}
...
}
总结
最后附上代码链接:https://github.com/boqiwen/visualization/tree/practice/drag-canvas ,为了便于大家透彻理解,对整体流程做出如下归纳概括:
封装类说明
初始化流程说明