文章详情

短信预约-IT技能 免费直播动态提醒

请输入下面的图形验证码

提交验证

短信预约提醒成功

canvas 中如何实现物体的框选

2022-11-13 14:05

关注

前言

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

今天呢,我们要实现的是 canvas 中物体的框选功能,大概就像下面这个样子:

然后话不多说,直接开撸 ✍?

框选的实现

先来说下拖蓝选区(鼠标拖拽区域)的实现方式吧,仔细观察你会发现选区其实就是个普通矩形,这个区域由鼠标按下的点和拖动的终点组成,通过这两点我们就能够确认一个规规矩矩的矩形(边和 xy 轴平行),那在哪里绘制呢?还记得我们之前说过的么,所有的交互都是在上层画布进行的,所以它理所当然的应该绘制在上层画布,并且这样一来还可以避免重绘所有的物体。

首先要做的就是把上层画布的拖蓝选区清除掉,再来就是不可避免的要遍历所有物体,找出和这个拖蓝选区有交集的所有物体。显然这又是一个数学问题,等价于判断两个矩形是否相交,相比之前判断点是否在矩形内部好像又麻烦了一丢丢,因为我们并没有直观的思路,并且还希望最好还可以推广到两个多边形,em...这里可以先思考几秒钟?。。。

它们的边必相交,所以问题又可以转化为判断两个矩形的边是否相交。那如何判断两个矩形的边是否相交呢,稍微一想,最根本的就是判断两条边是否相交,这么一来,是不是稍微明朗了一点?。

具体一点就是:假设现在有物体 A 和物体 B,我们可以用 A 的第一条边去遍历 B 的每条边,如果能找到一个交点就说明两个物体相交;

否则继续用 A 的第二条边去遍历 B 的每条边,以此类推,如果遍历完了所有的还是没有交点,则说明物体 A、B 不相交。

当然这种方法还不够完全,少了一种特例,就是物体 A、B 还可能是包含与被包含的关系,比如物体被拖蓝选区完全包围,它们的边是没有交点的,所以我们也应该囊括这种情况,这种包含关系判断起来就比较简单了,就是比较下两个物体的最大最小 xy 值即可。

经过上面简单的推论不难得出,最基本的判断就是看两条线段是否相交,常规的解法就是:


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 值即可,这里我们直接看代码应该会更好理解(具体代码可以随便瞟一瞟,但是注释一定要看哦)??:


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;
        });
    }
    
    add(object: FabricObject) {
        this.objects.push(object);
        return this;
    }
    
    remove(object: FabricObject) {
        Util.removeFromArray(this.objects, object);
        return this;
    }
    
    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 的位置,而不是相对于画布的位置,这点要尤其注意,类似这种嵌套关系,子物体的位置一般都是相对于其父元素来说的,而不是画布的位置?。

回过头来再说说框选,当鼠标抬起的时候,我们会找出与拖蓝选区相交的所有物体:

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 类的实现,最重要的有以下几点:

然后这里是简版 fabric.js 的代码链接,有兴趣的可以看看。下个章节我们会讲解怎么对一个物体进行各种变换操作(拖拽、缩放、旋转),也是本系列最重要的章节之一?。

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

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

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

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

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

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

阅读原文内容投诉

免责声明:

① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。

② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341

软考中级精品资料免费领

  • 历年真题答案解析
  • 备考技巧名师总结
  • 高频考点精准押题
  • 2024年上半年信息系统项目管理师第二批次真题及答案解析(完整版)

    难度     813人已做
    查看
  • 【考后总结】2024年5月26日信息系统项目管理师第2批次考情分析

    难度     354人已做
    查看
  • 【考后总结】2024年5月25日信息系统项目管理师第1批次考情分析

    难度     318人已做
    查看
  • 2024年上半年软考高项第一、二批次真题考点汇总(完整版)

    难度     435人已做
    查看
  • 2024年上半年系统架构设计师考试综合知识真题

    难度     224人已做
    查看

相关文章

发现更多好内容

猜你喜欢

AI推送时光机
位置:首页-资讯-前端开发
咦!没有更多了?去看看其它编程学习网 内容吧
首页课程
资料下载
问答资讯