canvas 中如何实现物体的框选
作者:尤水就下
前言
虽然这两个月基金涨的还行,但是离回本还有一大大大段距离😂。
今天呢,我们要实现的是 canvas 中物体的框选功能,大概就像下面这个样子:
然后话不多说,直接开撸 ✍🏻
框选的实现
先来说下拖蓝选区(鼠标拖拽区域)的实现方式吧,仔细观察你会发现选区其实就是个普通矩形,这个区域由鼠标按下的点和拖动的终点组成,通过这两点我们就能够确认一个规规矩矩的矩形(边和 xy 轴平行),那在哪里绘制呢?还记得我们之前说过的么,所有的交互都是在上层画布进行的,所以它理所当然的应该绘制在上层画布,并且这样一来还可以避免重绘所有的物体。
- 然后抬起鼠标的时候又要做些什么呢?
首先要做的就是把上层画布的拖蓝选区清除掉,再来就是不可避免的要遍历所有物体,找出和这个拖蓝选区有交集的所有物体。显然这又是一个数学问题,等价于判断两个矩形是否相交,相比之前判断点是否在矩形内部好像又麻烦了一丢丢,因为我们并没有直观的思路,并且还希望最好还可以推广到两个多边形,em...这里可以先思考几秒钟🤔。。。
- 仔细想想两个矩形相交会有什么效果呢?
它们的边必相交,所以问题又可以转化为判断两个矩形的边是否相交。那如何判断两个矩形的边是否相交呢,稍微一想,最根本的就是判断两条边是否相交,这么一来,是不是稍微明朗了一点😄。
具体一点就是:假设现在有物体 A 和物体 B,我们可以用 A 的第一条边去遍历 B 的每条边,如果能找到一个交点就说明两个物体相交;
否则继续用 A 的第二条边去遍历 B 的每条边,以此类推,如果遍历完了所有的还是没有交点,则说明物体 A、B 不相交。
当然这种方法还不够完全,少了一种特例,就是物体 A、B 还可能是包含与被包含的关系,比如物体被拖蓝选区完全包围,它们的边是没有交点的,所以我们也应该囊括这种情况,这种包含关系判断起来就比较简单了,就是比较下两个物体的最大最小 xy 值即可。
经过上面简单的推论不难得出,最基本的判断就是看两条线段是否相交,常规的解法就是:
- 因为每条线段的端点是已知的,所以能求出两条线段所在的直线方程(注意直线和线段的措词,后面内容也是)
- 如果两条直线斜率相同,那两条线段肯定不相交
- 如果斜率不同,就需要联立方程组求解
- 不过这个求解结果是直线的交点,最后还要简单校验下这个解是不是在两个线段的坐标范围内才行 这个就是最朴实无华的解法啦,我们先这么理解就行。其实在图形学中,类似这种运算都是用向量来计算的,比如用向量叉乘来判断线段是否相交,fabric.js 中也是用这样的思想,不过这个系列我并没有强调向量的概念,因为容易劝退,所以这些内容我会在这个系列的最后几个章节中单独写一篇来讲解,这里就简单贴下代码,可跳过👇🏻:
/** * 判断两条线段是否想交 * @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)的概念。
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 的位置,而不是相对于画布的位置,这点要尤其注意,类似这种嵌套关系,子物体的位置一般都是相对于其父元素来说的,而不是画布的位置😁。
回过头来再说说框选,当鼠标抬起的时候,我们会找出与拖蓝选区相交的所有物体:
- 如果只有一个物体与之相交的话,其实就变成了普通点选的情况,我们直接将该物体的置为选中态即可
- 如果有多个物体相交,那就需要临时创建一个 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 类的实现,最重要的有以下几点:
- 判断两个多边形相交的方法:判断各个边是否相交 && 整体包含关系
- 框选的时候我们会临时生成一个组,之后销毁即可
- 组的变换会影响到其子元素的变换
- 渲染的时候需要将所有激活的物体放在后面绘制,有需要的话可以加上 zIndex 属性进行精确控制
- 另外补充一点:Group 也让我们整个物体链变成了树形结构
然后这里是简版 fabric.js 的代码链接,有兴趣的可以看看。下个章节我们会讲解怎么对一个物体进行各种变换操作(拖拽、缩放、旋转),也是本系列最重要的章节之一😎。
实现一个轻量 fabric.js 系列一(摸透 canvas)💥
以上就是canvas 中如何实现物体的框选的详细内容,更多关于canvas物体框选的资料请关注脚本之家其它相关文章!