用canvas实现一个简易流程图

lxf2023-04-23 16:38:01

本文已参与「新人创作礼」活动,一起开启AdminJS创作之路。 点击查看活动详情

写在前面

前面我们实现了一个简单的canvas库 100行代码写个canvas库 ,现在我们试一下这个库实际用起来怎么样,能不能满足常规的开发要求。流程图大家应该都见过用过,我们实是能不能整个简易版出来。

像这种

用canvas实现一个简易流程图

文字节点

由于前面我们实现的库已经实现了拖拽、点击这些交互功能,我们直接实现具体的业务类。 一个文字节点由一个文本,一个背景,一个删除按钮,四周边线中间的吸附点(连线的时候要用到)

class TextElement {
    constructor(options) {
        // 先初始化一个组合容器
        this.container = new Container({
            x: options.x,
            y: options.y,
            w: options.w,
            h: options.h
        });
        
        // 内置一个粉色背景(这里当然可以用传参来自定义颜色)
        this.bg = new Rect({
            x: 0,
            y: 0,
            w: options.w,
            h: options.h,
            offsetX: 0,
            offsetY: 0,
            color: "pink"
        })
        
        // 内置一个删除按钮
        this.icon = new Icon({
            offsetX: options.w - 20,
            offsetY: 0,
            w: 20,
            h: 20,
            src: "./close"
        }); // icon固定在容器右上角

        this.icon.addEvent("click", (t) => {
            this.container.destory();
        });
        
        // 文本
        this.word = new Word({
            text: options.text,
            offsetX: 0,
            offsetY: options.h / 2,
            color: "blue"
        }); // 文本垂直居中
        
        // 把这几个元素都加到容器里
        this.container.add(this.bg)
        this.container.add(this.icon);
        this.container.add(this.word);
        
        return this.container
    }
}

这样一个文字节点就准备好了,我们初始化几个看看效果

// 初始化一个800 * 700的舞台
let s2 = new Stage(document.getElementById("stage"));

// 在 (50, 50)的位置初始化一个200 * 50的文字板
let t1 = new TextElement({
    text: "hello 解决1",
    x: 350,
    y: 250,
    w: 200,
    h: 50,
    color: "blue",
    parent: s2
});
s2.add(t1);

连线节点

连线的功能一度搞得我好头痛,因为连线的操作跟其他的节点操作完全不一样。 首先连线有两个端点都可以拖拽,连线还能拖拽到文本节点上,拖上去就要吸附住,接下来拖拽文本节点,线段也要跟着变,连线还能从节点上拖走,绘制连线的箭头,两个文本之间怎么绘制出一个最合适的连线(这个我暂时只解决了几个简单的相对位置)等等,所以最终连线节点我没有用组合容器Container实现,而是单独实现了一个类,具体功能设计在代码里都有注释。

class Connect {
    // 连线的起止点,可以是文本节点
    constructor(startPoint, endPoint) {
        // 起点,Point其实就是一个实心圆,只不过封装了一下,响应了Stage的功能,有了拖拽和事件的能力
        this.startPoint = new Point({
            x: startPoint.x,
            y: startPoint.y,
            r: 5,
            color: "green"
        })
        // 终点
        this.endPoint = new Point({
            x: endPoint.x,
            y: endPoint.y,
            r: 5,
            color: "green"
        })
        // 把拖拽点放入children用来判断绘制
        
        this.children.push(this.startPoint)
        this.children.push(this.endPoint)
    }
    
    draw(ctx) {
        // 重头戏
        // 1 要判断起止点是否有吸附对象,有的话要更新位置
        if(this.startBindTarget) {
            this.startPoint.x = this.startBindTarget.x
            this.startPoint.y = this.startBindTarget.y
        }
        if(this.endBindTarget) {
            this.endPoint.x = this.endBindTarget.x
            this.endPoint.y = this.endBindTarget.y
        }
        
        // 2 绘制线段,和最后的指向箭头,这里我自己根据起始点生成了一条路径(可以自行实现)
        ctx.beginPath();
        ctx.lineWidth = 3
        ctx.strokeStyle = this.color;
        // 绘制连线,箭头用倒数第二个点和最后一个点来确定
        let path = getElementPathLinePoints(
            Object.assign(this.startPoint, {w: 0, h: 0}), 
            Object.assign(this.endPoint, {w: 0, h: 0}));
        let sp = path[0];
        let ep = path[path.length - 1];
        let secToLast = path.length > 2 ? path[path.length - 2] : sp
        let pathPoints = path.slice(1, path.length - 1);

        var arrowPoints = getArrowControlPoint(secToLast, ep)
        ctx.moveTo(sp.x, sp.y);
        pathPoints.forEach((item) => {
            ctx.lineTo(item.x, item.y);
        });
        ctx.lineTo(ep.x, ep.y);

        ctx.moveTo(arrowPoints.m.x, arrowPoints.m.y);
        ctx.lineTo(ep.x, ep.y);

        ctx.moveTo(arrowPoints.n.x, arrowPoints.n.y);
        ctx.lineTo(ep.x, ep.y);
        ctx.stroke();
        ctx.closePath();
        
        // 增加了一个逻辑,只有选段选中之后才出现拖拽点
        if(this.active) {
            this.children.forEach(item => {
                item.draw(ctx)
            })
        }
    }
}

这样就有一个只要传入起始点就能绘制一条带箭头的线段了,接下来就要完成吸附的操作了。

元素吸附

所谓元素吸附,就是拖拽线段的端点时,放下的时候落在哪个元素上,那么线段的端点就始终为该元素,由于文本节点是个矩形,我这里加了一个判断,落点除了在矩形内,更靠近哪条边就吸附在那条边的中点上,通过Stage提供的自定义事件,我们添加以下逻辑

class Connect {
    constructor() {
        // 省略
        // 起点的点击事件,只要点了就开始把绑定元素清空
        this.startPoint.addEvent("click", (t) => {
            this.startBindTarget = null
        })
        this.startPoint.addEvent("move", (t) => {
            // 在拖拽点的时候,把父元素线的active设为true,这样点才会一直显示
            this.active = true
        });
        this.startPoint.addEvent("mouseup", (t) => {
            // 拖拽结束后,判断落点是否有元素,如果有,以元素的xy为准,
            // 同时记录元素,元素在更新位置的时候,也会更新线段
            // 根据xy寻找到画布上的元素
            let target = this.getMouseUpTarget(t.x, t.y)
            // 更新 container已经虚化了,落点必定是container里的元素,所以绑定点为落点元素的parent
            if(target.parent) {
                // 箭头落点坐标计算方式要优化,不能直接用元素的顶点,要判断边,更接近哪条边就用哪条边
                this.startBindTarget = target.parent[pointNearEdge(t, target)]
            }
        })
        // endPoint同样处理
    }
   
    // draw()
}

这样在线段端点的事件操作时,我们可以完成连线的元素绑定,元素更新后,连线也能拿到新的xy从而绘制新的连线。

节点信息绑定

有了文本节点和连线节点,我们就可以完成一个简易的流程了,接下来只要在每个文本节点上添加一个点击事件,就能在上面绑定自定义的信息了。