canvas 中如何实现物体的框选


Posted in Javascript onAugust 05, 2022

前言

虽然这两个月基金涨的还行,但是离回本还有一大大大段距离?。

今天呢,我们要实现的是 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 的代码链接,有兴趣的可以看看。下个章节我们会讲解怎么对一个物体进行各种变换操作(拖拽、缩放、旋转),也是本系列最重要的章节之一?。

canvas 中如何实现物体的点选(五)?

canvas 中物体边框和控制点的实现(四)?

实现一个轻量 fabric.js 系列三(物体基类)?

实现一个轻量 fabric.js 系列二(画布初始化)?

实现一个轻量 fabric.js 系列一(摸透 canvas)?

以上就是canvas 中如何实现物体的框选的详细内容,更多关于canvas物体框选的资料请关注三水点靠木其它相关文章!

Javascript 相关文章推荐
Javascript操纵Cookie实现购物车程序
Feb 15 Javascript
关于jQuery中.attr()和.prop()的问题探讨
Sep 06 Javascript
javascript比较两个日期的先后示例代码
Dec 31 Javascript
jquery原理以及学习技巧介绍
Nov 11 Javascript
三种AngularJS中获取数据源的方式
Feb 02 Javascript
AngularJS 自定义过滤器详解及实例代码
Sep 14 Javascript
JS中split()用法(将字符串按指定符号分割成数组)
Oct 24 Javascript
JQuery.validationEngine表单验证插件(推荐)
Dec 10 Javascript
javascript实现下雨效果
Mar 27 Javascript
Js利用Canvas实现图片压缩功能
Sep 13 Javascript
详解ng-alain动态表单SF表单项设置必填和正则校验
Jun 11 Javascript
微信小程序的开发范式BeautyWe.js入门详解
Jul 10 Javascript
JS前端使用canvas实现物体的点选示例
Aug 05 #Javascript
前端canvas中物体边框和控制点的实现示例
Aug 05 #Javascript
JS前端轻量fabric.js系列物体基类
Aug 05 #Javascript
JS前端轻量fabric.js系列之画布初始化
Aug 05 #Javascript
vue3 自定义图片放大器效果的示例代码
Jul 23 #Vue.js
JavaScript parseInt0.0000005打印5原理解析
Jul 23 #Javascript
JavaScript实现一键复制内容剪贴板
Jul 23 #Javascript
You might like
为PHP5.4开启Zend OPCode缓存
2014/12/26 PHP
php解决DOM乱码的方法示例代码
2016/11/20 PHP
php求数组全排列,元素所有组合的方法总结
2017/03/14 PHP
chrome原生方法之数组
2011/11/30 Javascript
关于全局变量和局部变量的那些事
2013/01/11 Javascript
jquery获取元素值的方法(常见的表单元素)
2013/11/15 Javascript
javascript如何判断输入的url是否正确
2014/04/11 Javascript
js获取当前日期时间及其它操作汇总
2015/04/17 Javascript
javascript连续赋值问题
2015/07/08 Javascript
jQuery实现的选择商品飞入文本框动画效果完整实例
2016/08/10 Javascript
前端 Vue.js 和 MVVM 详细介绍
2016/12/29 Javascript
Vue2递归组件实现树形菜单
2017/04/10 Javascript
ES6 系列之 WeakMap的使用示例
2018/08/06 Javascript
nodejs 使用nodejs-websocket模块实现点对点实时通讯
2018/11/28 NodeJs
深入浅析vue中cross-env的使用
2019/09/12 Javascript
javascript简单实现深浅拷贝过程详解
2019/10/08 Javascript
jquery ajax 请求小技巧实例分析
2019/11/11 jQuery
python笔记(1) 关于我们应不应该继续学习python
2012/10/24 Python
python连接远程ftp服务器并列出目录下文件的方法
2015/04/01 Python
python检查URL是否正常访问的小技巧
2017/02/25 Python
Python2实现的LED大数字显示效果示例
2017/09/04 Python
Django框架验证码用法实例分析
2019/05/10 Python
Python实现EXCEL表格的排序功能示例
2019/06/25 Python
Python基于Twilio及腾讯云实现国际国内短信接口
2020/06/18 Python
Python第三方库安装缓慢的解决方法
2021/02/06 Python
详解Sticky Footer 绝对底部的两种套路
2017/11/03 HTML / CSS
canvas进阶之如何画出平滑的曲线
2018/10/15 HTML / CSS
微软中国官方商城:Microsoft Store中国
2018/10/12 全球购物
物业工作计划书
2014/01/10 职场文书
垃圾桶标语
2014/06/24 职场文书
入党积极分子个人总结
2015/03/02 职场文书
画展观后感
2015/06/17 职场文书
python实现简单倒计时功能
2021/04/21 Python
python 如何获取页面所有a标签下href的值
2021/05/06 Python
Vue vee-validate插件的简单使用
2021/06/22 Vue.js
golang生成vcf通讯录格式文件详情
2022/03/25 Golang