canvas中物体的框选功能怎样实现,代码是什么
Admin 2022-09-07 群英技术资讯 314 次浏览
今天呢,我们要实现的是 canvas 中物体的框选功能,大概就像下面这个样子:
然后话不多说,直接开撸
先来说下拖蓝选区(鼠标拖拽区域)的实现方式吧,仔细观察你会发现选区其实就是个普通矩形,这个区域由鼠标按下的点和拖动的终点组成,通过这两点我们就能够确认一个规规矩矩的矩形(边和 xy 轴平行),那在哪里绘制呢?还记得我们之前说过的么,所有的交互都是在上层画布进行的,所以它理所当然的应该绘制在上层画布,并且这样一来还可以避免重绘所有的物体。
首先要做的就是把上层画布的拖蓝选区清除掉,再来就是不可避免的要遍历所有物体,找出和这个拖蓝选区有交集的所有物体。显然这又是一个数学问题,等价于判断两个矩形是否相交,相比之前判断点是否在矩形内部好像又麻烦了一丢丢,因为我们并没有直观的思路,并且还希望最好还可以推广到两个多边形,em...这里可以先思考几秒钟????。。。
它们的边必相交,所以问题又可以转化为判断两个矩形的边是否相交。那如何判断两个矩形的边是否相交呢,稍微一想,最根本的就是判断两条边是否相交,这么一来,是不是稍微明朗了一点。
具体一点就是:假设现在有物体 A 和物体 B,我们可以用 A 的第一条边去遍历 B 的每条边,如果能找到一个交点就说明两个物体相交;
否则继续用 A 的第二条边去遍历 B 的每条边,以此类推,如果遍历完了所有的还是没有交点,则说明物体 A、B 不相交。
当然这种方法还不够完全,少了一种特例,就是物体 A、B 还可能是包含与被包含的关系,比如物体被拖蓝选区完全包围,它们的边是没有交点的,所以我们也应该囊括这种情况,这种包含关系判断起来就比较简单了,就是比较下两个物体的最大最小 xy 值即可。
经过上面简单的推论不难得出,最基本的判断就是看两条线段是否相交,常规的解法就是:
/**
* 判断两条线段是否想交
* @param a1 线段1 起点
* @param a2 线段1 终点
* @param b1 线段2 起点
* @param b2 线段3 终点
*/
static intersectLineLine(a1: Point, a2: Point, b1: Point, b2: Point): Intersection {
// 向量叉乘公式 `a️b = (x1, y1)️(x2, y2) = x1y2 - x2y1`
let result,
// b1->b2向量 与 a1->b1向量的向量叉乘
ua_t = (b2.x - b1.x) * (a1.y - b1.y) - (b2.y - b1.y) * (a1.x - b1.x),
// a1->a2向量 与 a1->b1向量的向量叉乘
ub_t = (a2.x - a1.x) * (a1.y - b1.y) - (a2.y - a1.y) * (a1.x - b1.x),
// a1->a2向量 与 b1->b2向量的向量叉乘
u_b = (b2.y - b1.y) * (a2.x - a1.x) - (b2.x - b1.x) * (a2.y - a1.y);
if (u_b !== 0) {
let ua = ua_t / u_b,
ub = ub_t / u_b;
if (0 <= ua && ua <= 1 && 0 <= ub && ub <= 1) {
result = new Intersection('Intersection');
result.points.push(new Point(a1.x + ua * (a2.x - a1.x), a1.y + ua * (a2.y - a1.y)));
} else {
result = new Intersection('No Intersection');
}
} else {
// u_b == 0时,角度为0或者180 平行或者共线不属于相交
if (ua_t === 0 || ub_t === 0) {
result = new Intersection('Coincident');
} else {
result = new Intersection('Parallel');
}
}
return result;
}
可以看到框选的最终效果就是用一个更大的包围盒把所有物体都框起来,最终生成的也只有外面的包围盒和控制点,被包裹的物体则只进行边框绘制,而没有控制点。
里面的物体好绘制,就是把物体设置成选中态即可,只是不绘制控制点(多加一个变量的事)。那外面的包围盒呢,怎么将这个大的包围盒和多个物体进行关联呢,这里又可以停下来想个几秒钟啦????。。。
一个大的包围盒和多个物体,能想到什么呢?
其实我们所有的物体是不是都在画布中,画布就可以看做是一个很大的包围盒,框住所有物体,所有物体也都依附于这个画布,这很形象,也顺便引出了接下来要介绍的组(Group)的概念。
Group 本身也继承于 FabricObject 类,它也是个物体,只不过这个物体下面还会有很多个小物体;
至于组的包围盒,和一个普通物体类似,找出所有子物体的最大最小 xy 值即可,这里我们直接看代码应该会更好理解(具体代码可以随便瞟一瞟,但是注释一定要看哦):
/**
* Group 类,可用于自己手动组合几个物体,也可以用于拖蓝选区包围的物体
* Group 虽然继承至 FabricObject,但是要注意获取某些属性有时是没有的,因为子物体的属性各不相同
*/
class Group extends FabricObject {
public type: string = 'group';
public objects: FabricObject[]; // 组中所有的物体
constructor(objects: FabricObject[], options: any = {}) {
super(options);
this.objects = objects || [];
this._calcBounds(); // 计算组的包围盒
this._updateObjectsCoords(); // 更新组中的物体信息
}
/** 计算组的包围盒 */
_calcBounds() {
// 就是求子物体中所有 objects 的最大最小 xy 值
}
/** 更新所有子物体的坐标值,像这种情况子物体都是以父元素的坐标系为参考,而不是画布的坐标系为参考 */
_updateObjectsCoords() {
let groupDeltaX = this.left,
groupDeltaY = this.top;
this.objects.forEach((object) => {
let objectLeft = object.get('left'),
objectTop = object.get('top');
object.set('originalLeft', objectLeft);
object.set('originalTop', objectTop);
object.set('left', objectLeft - groupDeltaX);
object.set('top', objectTop - groupDeltaY);
object.setCoords();
// 当有选中组的时候,不显示子物体的控制点
object.orignHasControls = object.hasControls;
object.hasControls = false;
});
}
/** 将物体添加到 group 中 */
add(object: FabricObject) {
this.objects.push(object);
return this;
}
/** 将物体从 group 中移除 */
remove(object: FabricObject) {
Util.removeFromArray(this.objects, object);
return this;
}
/** 将物体添加到 group 中,并重新计算位置尺寸等 */
addWithUpdate(object: FabricObject): Group {
this._restoreObjectsState();
this.objects.push(object);
this._calcBounds();
this._updateObjectsCoords();
return this;
}
/** 将物体从组中移除,并重新计算组的大小位置 */
removeWithUpdate(object: FabricObject) {
this._restoreObjectsState();
Util.removeFromArray(this.objects, object);
object.setActive(false);
this._calcBounds();
this._updateObjectsCoords();
return this;
}
/** 组的渲染会特殊一点,它主要是子物体的渲染,但是组的变换会影响所有子物体的变换 */
render(ctx: CanvasRenderingContext2D) {
ctx.save();
this.transform(ctx); // 组有自身的变换,会影响所有子物体
for (let i = 0, len = this.objects.length; i < len; i++) { // 遍历绘制组中所有物体
let object = this.objects[i],
object.render(ctx); // 回顾一下:每个物体的 render = 每个物体的 transform + 每个物体的 _render
}
if (this.active) { // 组是否被选中
this.drawBorders(ctx);
this.drawControls(ctx);
}
ctx.restore();
this.setCoords();
}
}
所以我们把 Group 当做一个普通的大物体就行,里面的子物体该怎么绘制还是怎么绘制,当 hover 和 click 的时候只要判断 Group 的包围盒即可,里面的子物体是不用去遍历的,因为它们是一个整体。
但是要注意的是上面代码中的 _updateObjectsCoords
方法,当我们把某些物体放进一个 Group 的时候,需要修改其 top 和 left 值,使其位置变为相对 Group 的位置,而不是相对于画布的位置,这点要尤其注意,类似这种嵌套关系,子物体的位置一般都是相对于其父元素来说的,而不是画布的位置。
回过头来再说说框选,当鼠标抬起的时候,我们会找出与拖蓝选区相交的所有物体:
_activeGroup
,将这些物体都添加进来,然后对这个临时组完成一些操作之后再销毁这个组即可 来看下核心代码,也是很通俗易懂的:class Canvas {
/**
* 获取拖蓝选区包围的元素
* 如果只有一个物体,那就是普通的点选;如果有多个物体,那就生成一个组
*/
_findSelectedObjects(e: MouseEvent) {
let objects: FabricObject[] = [], // 存储最终框选的元素
x1 = this._groupSelector.ex,
y1 = this._groupSelector.ey,
x2 = x1 + this._groupSelector.left,
y2 = y1 + this._groupSelector.top,
selectionX1Y1 = new Point(Math.min(x1, x2), Math.min(y1, y2)),
selectionX2Y2 = new Point(Math.max(x1, x2), Math.max(y1, y2));
for (let i = 0, len = this._objects.length; i < len; ++i) {
let currentObject = this._objects[i];
// 物体是否与拖蓝选区相交或者被选区包含,用到的就是前面说过的多边形相交算法,具体的算法会在文末附上
if (currentObject.intersectsWithRect(selectionX1Y1, selectionX2Y2) || currentObject.isContainedWithinRect(selectionX1Y1, selectionX2Y2)) {
currentObject.setActive(true);
objects.push(currentObject);
}
}
if (objects.length === 1) { // 如果只有一个物体被选中
this.setActiveObject(objects[0], e);
} else if (objects.length > 1) { // 如果有多个物体被选中
const newGroup = new Group(objects);
this.setActiveGroup(newGroup);
}
this.renderAll();
}
setActiveGroup(group: Group): Canvas {
this._activeGroup = group;
if (group) {
group.canvas = this;
group.setActive(true);
}
return this;
}
}
上面代码中要注意的就是我们还需要对 renderAll 这个绘制方法做一些修改,就是把所有激活的物体都放到最后绘制,就像下面这样:
class Canvas {
renderAll(): Canvas {
...
// 先将物体排个序,这样才能体现出层级关系,简单来说就是先绘制未激活物体,再绘制激活物体
const sortedObjects = this._chooseObjectsToRender();
for (let i = 0, len = sortedObjects.length; i < len; ++i) {
this._draw(canvasToDrawOn, sortedObjects[i]);
}
return this;
}
/** 将所有物体分成两个组,一组是未激活态,一组是激活态,然后将激活组放在最后,这样就能够绘制到最上层 */
_chooseObjectsToRender() {
// 当前有没有激活的物体
let activeObject = this.getActiveObject();
// 当前有没有激活的组(也就是多个物体)
let activeGroup = this.getActiveGroup();
// 最终要渲染的物体顺序,也就是把激活的物体放在后面绘制
let objsToRender = [];
if (activeGroup) { // 如果选中多个物体
const activeGroupObjects = [];
for (let i = 0, length = this._objects.length; i < length; i++) {
let object = this._objects[i];
if (activeGroup.contains(object)) {
activeGroupObjects.push(object);
} else {
objsToRender.push(object);
}
}
objsToRender.push(activeGroup);
} else if (activeObject) { // 如果只选中一个物体
let index = this._objects.indexOf(activeObject);
objsToRender = this._objects.slice();
if (index > -1) {
objsToRender.splice(index, 1);
objsToRender.push(activeObject);
}
} else { // 所有物体都没被选中
objsToRender = this._objects;
}
return objsToRender;
}
当然如果是框选或点击到空白处,只要把所有物体的 active 属性都设置 false 就行了。但有同学肯定又会有疑问了,上面这样的排序绘制好像并不能精确控制每个物体的层级关系,如果我们需要做个上移一层、下移一层的功能该怎么搞呢?
这个也很简单,在 html 中也已经给了我们答案,就是用 z-index
,我们给每个物体多加一个 zIndex 属性就行了,之后直接用 zIndex 排序就行。
其实在 canvas 上绘制东西和浏览器展示页面内容这个过程很像很像,很多思想都是共通的,比如盒模型、元素的继承、transform、zIndex、top、left 等常见的 css 属性,以及后续会提到的事件监听,只不过我们习惯了用 html 和 css 去描绘这个页面,而 canvas 需要我们用 js 去描述,canvas 库则是提供了这个桥梁,极大方便了我们开发。
这个章节我们主要讲的是 canvas 中框选和 Group 类的实现,最重要的有以下几点:
免责声明:本站发布的内容(图片、视频和文字)以原创、转载和分享为主,文章观点不代表本网站立场,如果涉及侵权请联系站长邮箱:mmqy2019@163.com进行举报,并提供相关证据,查实之后,将立刻删除涉嫌侵权内容。
猜你喜欢
判断方法:1、用“输入控件对象.value”语句获取用户输入的数据;2、用“var re = /^[0-9]+.?[0-9]*/;”语句定义正则表达式对象;3、用“re.test(输入数据)”语句通过正则表达式判断输入数据是否为数值类型。
主线程从"任务队列"中读取事件,这个过程是循环不断的,所以整个的这种运行机制又称为Event Loop(事件循环)。下面本篇文章就来带大家掌握Node.js中的eventloop,希望对大家有所帮助!
这篇文章主要介绍js实现上传图片到服务器的功能,下文有实现的代码以及注释,对大家学习怎样实现上传图片到服务器有一定的帮助,感兴趣的朋友可以借鉴参考,接下来小编带着大家一起了解看看。
这篇文章主要介绍了JS中的Map对象用法及说明,具有很好的参考价值,希望对大家有所帮助。如有错误或未考虑完全的地方,望不吝赐教
Javascript生成器是什么?对于新手来说,可能对Javascript生成器比较陌生,因此这篇文章就给大家介绍一下Javascript生成器的概念以及用法。感兴趣的朋友就往下看吧。
成为群英会员,开启智能安全云计算之旅
立即注册Copyright © QY Network Company Ltd. All Rights Reserved. 2003-2020 群英 版权所有
增值电信经营许可证 : B1.B2-20140078 粤ICP备09006778号 域名注册商资质 粤 D3.1-20240008