可拖曳的图形
撰写时间:2024-04-14
修订时间:2024-04-14
使用Path2D技术,实现了可在Canvas上拖曳的几何图形。
基类DraggableShape
基类DraggableShape代码:
import {grids} from './canvas-grids.js';
grids.setupCanvas('myCanvas');
let {canvas, ctx} = grids;
let shapeHover = null;
let shapeDragging = null;
let isDragging = false;
class DraggableShape {
pathDataWrapper;
color;
hiColor;
path2DObj;
constructor(pathDataWrapper, color = 'CADETBLUE', hiColor = 'AQUAMARINE') {
this.pathDataWrapper= pathDataWrapper;
this.color = color;
this.hiColor = hiColor;
this.path2DObj = new Path2D();
this.doOnDefineShape(this.path2DObj, this.pathDataWrapper);
}
draw() {
ctx.fillStyle = (shapeHover === this) ? this.hiColor : this.color;
ctx.fill(this.path2DObj);
}
drag(x, y) {
delete this.path2DObj;
this.path2DObj = new Path2D();
this.doOnDragging(x, y, this.pathDataWrapper);
this.doOnDefineShape(this.path2DObj, this.pathDataWrapper);
}
isPointInPath(x, y) {
return ctx.isPointInPath(this.path2DObj, x * grids.scale, y * grids.scale);
}
// virtual methods
doOnDragging(x, y, pathDataWrapper) {
}
// virtual methods
doOnDefineShape(path2DObj, pathDataWrapper) {
}
}
使用了CanvasGrids提供背景网络,并提供了高分辨率设备的自适应。
作为基类,只提供了最核心的功能。包括类型为Path2D的path2DObj,以及常规颜色color及高亮颜色hiColor。
由于Path2D并未提供操控内部路径的方法,因此在响应用户拖曳行为时,先删除原来的path2DObj,再重新生成新的实例。
drag(x, y) {
delete this.path2DObj;
this.path2DObj = new Path2D();
this.doOnDragging(x, y, this.pathDataWrapper);
this.doOnDefineShape(this.path2DObj, this.pathDataWrapper);
}
由于经常删除并重新创建,因此上面代码delete this.path2DObj
可让JavaScript引擎在必要时及时释放垃圾内存。
因各种图形有不尽相同的参数,为增加灵活性,doOnDefineShape及doOnDragging这两个方法被设计为纯虚类方法(pure virtual method, C++术语),由所继承的子类自己实现。
子类DraggableCircle
现在,可定义子类了。先定义一个DraggableCircle类:
class DraggableCircle extends DraggableShape {
constructor(pathDataWrapper) {
super(pathDataWrapper);
}
doOnDragging(x, y, pathDataWrapper) {
pathDataWrapper.x = x;
pathDataWrapper.y = y;
}
doOnDefineShape(path2DObj, pathDataWrapper) {
const {x, y, radius} = pathDataWrapper;
path2DObj.arc(x, y, radius, 0, Math.PI * 2);
}
}
有此定义,客户端可这样调用:
let circle1 = new DraggableCircle({x: 100, y: 100, radius: 15});
对于在构造参数中所传入的{x: 100, y: 100, radius: 15}
,由doOnDefineShape方法制定如何利用它来绘制出一个图形的规则,在这里是绘制出一个圆形:
doOnDefineShape(path2DObj, pathDataWrapper) {
path2DObj.arc(pathDataWrapper.x, pathDataWrapper.y, pathDataWrapper.radius, 0, Math.PI * 2);
}
而当用户在Canvas上拖动该类图形时,由doOnDragging来定义如何根据鼠标坐标来更新其内部状态:
doOnDragging(x, y, pathDataWrapper) {
pathDataWrapper.x = x;
pathDataWrapper.y = y;
}
这个子类的设置就完成了。
子类DraggableRect
下面再看如何实现可拖曳的矩形:
class DraggableRect extends DraggableShape {
constructor(pathDataWrapper) {
super(pathDataWrapper);
}
doOnDragging(x, y, pathDataWrapper) {
pathDataWrapper.x = x - pathDataWrapper.width / 2;
pathDataWrapper.y = y - pathDataWrapper.height / 2;
}
doOnDefineShape(path2DObj, pathDataWrapper) {
const {x, y, width, height} = pathDataWrapper;
path2DObj.rect(x, y, width, height);
}
}
let rect1 = new DraggableRect({x: 100, y: 300, width: 100, height: 100});
同样的三部曲,传入矩形所特有的数据,利用该数据绘制矩形,定义在拖动时如何更新内部状态。
代码:
doOnDragging(x, y, pathDataWrapper) {
pathDataWrapper.x = x - pathDataWrapper.width / 2;
pathDataWrapper.y = y - pathDataWrapper.height / 2;
}
可根据鼠标位置来自动居中该矩形。
因为如何绘制具体的图形完全由子类自己定义,因此,在需要时我们很容易实现诸如三角形、直线等不同图形的子类。
其他代码
function clear() {
ctx.clearRect(-grids.PADDING, -grids.PADDING, canvas.width, canvas.height);
}
canvas.addEventListener('mousemove', (e) => {
if (isDragging) {
shapeDragging.drag(e.offsetX - grids.PADDING, e.offsetY - grids.PADDING);
clear();
renderCanvas();
return;
}
for (let i = 0; i < sceneObjs.length; i++) {
if (sceneObjs[i].isPointInPath(e.offsetX, e.offsetY)) {
if (sceneObjs[i] === shapeHover) { // hover while moving
break;
} else { // enter hover
shapeHover = sceneObjs[i];
sceneObjs[i].draw();
canvas.style.cursor = 'pointer';
break;
}
} else {
if (sceneObjs[i] === shapeHover) { // moving out of shape
shapeHover = null;
sceneObjs[i].draw();
canvas.style.cursor = 'auto';
break;
}
}
}
});
canvas.addEventListener('mousedown', (e) => {
if (!shapeHover) {
return;
}
if (!isDragging) {
shapeDragging = shapeHover;
isDragging = true;
canvas.style.cursor = 'move';
}
});
canvas.addEventListener('mouseup', (e) => {
if (isDragging) {
shapeDragging = null;
isDragging = false;
canvas.style.cursor = 'pointer';
}
});
function renderCanvas() {
sceneObjs.grids.draw();
sceneObjs.forEach(obj => {
obj.draw();
});
}
let sceneObjs = [];
sceneObjs.grids = grids;
let circle1 = new DraggableCircle({x: 100, y: 100, radius: 15});
sceneObjs.push(circle1);
let circle2 = new DraggableCircle({x: 200, y: 200, radius: 15});
sceneObjs.push(circle2);
let rect1 = new DraggableRect({x: 100, y: 300, width: 100, height: 100});
sceneObjs.push(rect1);
renderCanvas();
运行应用。
因为CanvasGrids使用了缩放及平移变换,因此上面这些类也统一进行了处置。
Path2D来实现可拖曳的图形,优势是其isPointInPath可方便地判断鼠标是否悬停在哪一个独立的图形上,不便利的地方在于不方便操作其内部路径数据,且需额外考虑图形变换的问题。
而实现一个普通的可拖曳的应用,难点在于需同时跟踪较多的内部状态,尤其是当场景中的对象数量较多时。因此上面代码的条件语句看起来较多,但均是有必要的。