jsplumb + React 实现可拖拽流程图

5,218 阅读6分钟

1.jpg

大致介绍下文章内容

这周接到了新需求,大致就是需要实现一个可拖拽,可连线的类似流程图的一个东西(技术不精,大佬们轻喷)。下面先上最终的实现效果:

20210902_101133.gif 左边是antd的Tree组件,需要做的就是拖动树节点到右边的流程图里面,记录最后的位置,并新增一个节点,该节点可以连线。 上图用到的插件是jsplumb(有朋友推荐我用g6,但因为代码已经肝好了,所以就先用这个写篇文章),我在网上找了一些参考文档,下面是链接:

shawchen08.github.io/2019/03/21/…

github.com/wangduandua…

 const flowchart: jsPlumbInstance = jsPlumb.getInstance({
        grid: [10, 10],  //节点移动时的最小距离  
        Anchors: ["TopCenter", "RightMiddle"],     // 动态锚点、位置自适应  
        Container: "flowChart",  //画布容器id
        // 连线的样式 StateMachine、Flowchart,有四种默认类型:Bezier(贝塞尔曲线),Straight(直线),Flowchart(流程图),State machine(状态机)  
        Connector: ["Flowchart", { cornerRadius: 5, alwaysRespectStubs: true, stub: 5 }],
        // ConnectionOverlays: [["Label", { label: '111' }]],
        // ConnectionsDetachable: false,  // 鼠标不能拖动删除线  
        // DeleteEndpointsOnDetach: false,  // 删除线的时候节点不删除  
        EndpointStyle: { fill: 'transparent', stroke: '#1565C0', strokeWidth: 1, radius: 4 }, //端点的默认样式
        LogEnabled: false, //是否打开jsPlumb的内部日志记录  
        PaintStyle: { stroke: "#2AAF8F", strokeWidth: 2 },  // 绘制线
        HoverPaintStyle: { stroke: "#2AAF8F", strokeWidth: 2 },  // 绘制箭头
        Overlays: [["Arrow", { width: 8, length: 8, location: 1 }]],
        RenderMode: "svg"
    })

上面是获取jsplumb实例时一些简单的配置,这里就不多介绍了,上面的参考文档里有详细介绍(其实是我也不太清楚)。

具体的实现过程

首先要实现的就是antd TreeNode的可拖拽,这个很简单,只需要在渲染TreeNode时给树节点加上draggable={true}这个属性,就可以让元素可拖拽了。但如果仅仅是这样,岂不是太简单了,我想的是封装一个DragElement组件,只要是用这个组件包裹的元素,都可以拖拽到流程图里生成节点。

2.png

DragElement组件的render很简单,就是用可拖拽的div包裹一下传入的children。下图是jsplumb插入节点时的dom结构,可以看到节点其实就是div(我本来以为会是canvas绘制的),所以我们可以通过css来修改节点的样式;每个节点都有一个唯一id,这个id后面连线的时候会用到。

return <div draggable={true} className={styles.dragElement} onDragEnd={handleDragEnd} onDragStart={handleDragStart} data-key={onlyId} data-title={title}>
    {children}
</div>

attr里的data-key是为了生成流程节点时的唯一id,data-title是生成流程节点时的节点名称,这两个属性是必须的;关键的点在于onDragEnd、onDragStart这两个事件。因为jsplumb是基于jquery的,所以我在react中使用的时候用了很多的原生js(我也不知道这种写法好不好,希望大佬们多多指教)。

onDragStart-拖拽开始事件

拖拽开始事件只是为了给右侧的流程框添加样式,更直观的表达可以把元素拖拽进来。#flowChart这个元素就是右侧的流程框。

 const handleDragStart = (e: React.DragEvent) => {
    const flowChart = document.querySelector('#flowChart');
    if (!flowChart) return;
    flowChart.classList.add('flowChart_focus');
}

onDragEnd-拖拽结束事件

拖拽结束时需要做的操作就很多了,首先就是需要往#flowChart里添加一个div,并且需要获取到DragElement里的data-key、data-title,其次就是要记录节点拖拽的最终位置,好在对应位置生成节点。因为DragElement组件和右侧的FlowChart组件是兄弟关系,如果通过state,redux等来控制FlowChart渲染的话会很麻烦(个人感觉),所以我没有用state来驱动FlowChart渲染,而是直接进行了dom操作。

/**
 * 获取元素在页面中的坐标(x, y) 
 * @param {Object} e
 */
function getElementPosition(e: any) {
    var x = 0, y = 0;
    const width = e.offsetWidth,
        height = e.offsetHeight;
    while (e != null) {
        x += e.offsetLeft;
        y += e.offsetTop;
        e= e.offsetParent;
    }
    return { x: x, y: y, width, height };
}

/**
 * 判断元素是否在另一元素内
 * @param {Object} child
 * @param {Object} father
 */
interface dom {
    x: number, 
    y: number,
    width: number,
    height: number
}
function comparePos(child: dom, father: dom) {
    let result = null;
    if (child.x >= father.x && child.x <= (father.x + father.width) && child.y >= father.y && child.y <= (father.y + father.height)) {
        const _x = child.x - father.x,
            _y = child.y - father.y;
        result = {
            x: _x + 100 > father.width ? father.width - 100 : _x,
            y: _y + 100 > father.height ? father.height - 100 : _y,
        };
    }
    return result;
}
const handleDragEnd = (e: any) => {
        const flowChart: flowChartProps | null = document.querySelector('#flowChart');
        if (!flowChart) return;
        const items = flowChart.querySelectorAll('.item');
        flowChart.classList.remove('flowChart_focus');
        if (Array.from(items).findIndex((item: any) => item.getAttribute('id') === onlyId) !== -1) {
            console.error('模块已存在');
            return;
        }
        const pos = getElementPosition(flowChart);// 获取元素的坐标
        const result = comparePos({
            x: e.clientX,
            y: e.clientY,
            width: e.target.offsetWidth,
            height: e.target.offsetHeight
        }, pos); // 判断元素是否在另一元素内
        if (!!result) {
            /* 创建控件 */
            const _div: any = document.createElement('div');
            _div.setAttribute('id', onlyId);
            _div.setAttribute('title', title);
            _div.flowParams = {
                onlyId,
                title,
                ...params
            }
            _div.classList.add('item');
            _div.style.left = result.x + 'px';
            _div.style.top = result.y + 'px';
            /* 创建控件 */
            /* 创建空间中间的文字块 */
            const _text = document.createElement('span');
            _text.classList.add('text');
            _text.innerText = title.length > 6 ? title.slice(0, 5) + '...' : title;
            /* 创建空间中间的文字块 */
            /* 创建删除按钮 */
            const _del = document.createElement('span');
            _del.classList.add('delIcon');
            /* 创建删除按钮 */
            _div.appendChild(_text);
            _div.appendChild(_del);
            flowChart.appendChild(_div);
            var common = {
                maxConnections: -1 // 不限制链接数
            }
            flowChart.instance.addEndpoint(onlyId, {
                anchor: 'Left',
                maxConnections: -1
            }, { ...common, isTarget: true }) // 添加了节点之后,需要给节点加上可连线的,isTarget标记为终点
            flowChart.instance.addEndpoint(onlyId, {
                anchor: 'Right',
                maxConnections: -1
            }, { ...common, isSource: true, }) // isSource标记为起点
            flowChart.instance.draggable(_div, { containment: "#flowChart" });// 给新加入的节点赋予可拖拽属性
        }
    }

FlowChart组件-右侧流程图

FlowChart组件主要就是实例化jsplumb,生成默认的开始、结束节点,并绑定传入的各种事件。

onClick,onDelete

onClick节点的click事件,onDelete节点的删除事件,这两个事件触发时都会抛出该节点上的{id,title}属性,你可以根据id进行其他操作。

onConnect

flowchart.bind("connection", function (info) { const { sourceId, targetId } = info; onConnect && typeof onConnect === 'function' && onConnect({ sourceId, targetId }) });

其他事件

onConnect是jsplumb自带的连线事件,当连线完成时,会抛出起点与终点的节点属性。 flowchart.bind("click", function (connection) { flowchart.deleteConnection(connection); });这个事件是连线的点击事件,这里是删除连线。

beforeDrop-连接建立前事件,这里后续待完善,主要是为了在连接建立时,生成一个label,可供用户填写该连接的名称。

flowchart.bind("beforeDrop", function (info) {
    // const connector = info.connection.canvas;
    // const _left = parseInt(parseFloat(connector.style.left) + parseFloat(connector.getAttribute('width') / 2));
    // const _top = parseInt(connector.style.top);
    // const _div = document.createElement();
    return true;
});

FlowChart代码

interface FlowChartInterface {
    flow?: {
        getData: () => void
        setFlow: (params: object) => void
    },
    onClick?: (params: object) => void,
    onDelete?: (params: object) => void,
    onConnect?: (params: object) => void,
}

const FlowChart: FC<FlowChartInterface> = ({ flow, onClick, onDelete, onConnect }) => {
    const [items] = useState([
        {
            key: 'startPoint',
            title: '开始',
            class: 'startPoint'
        },
        {
            key: 'endPoint',
            title: '结束',
            class: 'endPoint'
        }
    ]);
    useEffect(() => {
        const flowchart: jsPlumbInstance = jsPlumb.getInstance({
            grid: [10, 10],  //节点移动时的最小距离  
            Anchors: ["TopCenter", "RightMiddle"],     // 动态锚点、位置自适应  
            Container: "flowChart",  //画布容器id
            // 连线的样式 StateMachine、Flowchart,有四种默认类型:Bezier(贝塞尔曲线),Straight(直线),Flowchart(流程图),State machine(状态机)  
            Connector: ["Flowchart", { cornerRadius: 5, alwaysRespectStubs: true, stub: 5 }],
            // ConnectionOverlays: [["Label", { label: '111' }]],
            // ConnectionsDetachable: false,  // 鼠标不能拖动删除线  
            // DeleteEndpointsOnDetach: false,  // 删除线的时候节点不删除  
            EndpointStyle: { fill: 'transparent', stroke: '#1565C0', strokeWidth: 1, radius: 4 }, //端点的默认样式
            LogEnabled: false, //是否打开jsPlumb的内部日志记录  
            PaintStyle: { stroke: "#2AAF8F", strokeWidth: 2 },  // 绘制线
            HoverPaintStyle: { stroke: "#2AAF8F", strokeWidth: 2 },  // 绘制箭头
            Overlays: [["Arrow", { width: 8, length: 8, location: 1 }]],
            RenderMode: "svg"
        })
        if(flow) {
            flow.setFlow({
                getData: () => {
                    const items = document.querySelectorAll('#flowChart .item');
                    const controls = Array.from(items).map(item => {
                        const _style = getComputedStyle(item, null);
                        return {
                            id: item.getAttribute('id'),
                            title: item.getAttribute('title'),
                            top: _style.getPropertyValue('top'),
                            left: _style.getPropertyValue('left'),
                        }
                    })
                    const connects = Array.from(flowchart.getAllConnections()).map((item) => ({
                        sourceId: item.sourceId,
                        targetId: item.targetId
                    }))
                    return {
                        controls,
                        connects
                    }
                }
            });
        }
        flowchart.addEndpoint('startPoint', {
            anchor: 'Right',
            maxConnections: -1
        }, { maxConnections: -1, isSource: true })
        flowchart.addEndpoint('endPoint', {
            anchor: 'Left',
            maxConnections: -1
        }, { maxConnections: -1, isTarget: true })
        flowchart.bind("click", function (connection) {
            flowchart.deleteConnection(connection);
        });
        flowchart.bind("connection", function (info) {
            const { sourceId, targetId } = info;
            onConnect && typeof onConnect === 'function' && onConnect({ sourceId, targetId })
        });
        flowchart.bind("beforeDrop", function (info) {
            // const connector = info.connection.canvas;
            // const _left = parseInt(parseFloat(connector.style.left) + parseFloat(connector.getAttribute('width') / 2));
            // const _top = parseInt(connector.style.top);
            // const _div = document.createElement();
            return true;
        });
        flowchart.draggable(document.querySelectorAll('#flowChart .item'), { containment: "#flowChart" });
        const _dom: flowChartProps | null = document.querySelector('#flowChart');
        const flowChartClick = (e: any) => {
            if(!e.target) return;
            e.stopPropagation();
            if (e.target.classList.contains('delIcon')) {
                flowchart.remove(e.target.parentNode);
                onDelete && typeof onDelete === 'function' && onDelete(e.target.parentNode.flowParams);
            }
            if (e.target.classList.contains('item')) {
                onClick && typeof onClick === 'function' && onClick(e.target.flowParams);
            }
            if (e.target.classList.contains('text')) {
                onClick && typeof onClick === 'function' && onClick(e.target.parentNode.flowParams);
            }
        }
        if (_dom) { 
            _dom['instance'] = flowchart;
            _dom.addEventListener('click', flowChartClick);
        }
        return () => {
            flowchart.unbind("click");
            if (_dom) {
                _dom.removeEventListener('click', flowChartClick);
            }
        }
    }, []);