从零打造现代化绘图框架 Plait
我司大概从今年(2022年)年初决定思维导图,经过半年多的研究与实践,我们基于自研的绘图框架初步完成了一个脑图组件并成功集成到我们 PingCode Wiki 中,这篇文章主要探讨下这个绘图框架( Plait)的一些设计点和思维导图落地的一些关键技术。
概论
对于思维导图、流程图前期我们做了很多调研工作,流程图方向我们研究了 excalidraw 和 react-flow,它们都是基于 react 框架实现的库,在社区中有很高的知名度,思维导图方向我们研究了 mind-elixir、 mindmap-layouts (自动布局算法),在开源领域中思维导图发展不是很好,没有成熟、知名的作品。
mind-exlixir 介绍:
mind-elixir 功能示意图
优点:
- 麻雀虽小但五脏俱全
- 纯 JS 库、轻量
缺点:
- 不依赖前端框架、开发方式和主流的方式不同
- 架构设计没有太多可取之处,节点布局方式不易扩展
虽然我们前期的目标是研发 「思维导图」 ,但是最终我们的产品目标应该是做一个 「一体化的交互式绘图画板」 ,包含思维导图、流程图、自由画笔等。
最终调研发现目前开源社区恰恰缺少这样一个一体化的绘图框架,用于实现一体化的交互式绘图编辑器,集思维导图、流程图、自由画笔于一体,所以我们结合做富文本编辑器的经验,重新架构了一套绘图框架(Plait)、拥有插件机制,并在它的基础上实现思维导图插件、落地到 PingCode Wiki 产品中,所以今天分享的主角就是 Plait 框架。
下面正式进入今天的主题,分为四部分:
- 绘图框架设计
- 思维导图整体方案
- 思维导图自动布局算法
- 框架历程/未来
一、绘图框架设计
这部分首先会先简单介绍下绘图方案的选型(Canvas vs SVG)考量,然后介绍下 Plait 绘图框架中核心部分的设计:插件机制、数据管理,最后介绍下框架优势。
绘图方案:Canvas vs SVG
Canvas 还是 SVG 其实社区中也没有一个明确的答案,我们参考了一些知名产品的方案选型,SVG 和 Canvas 都有并且实现的效果都不差,比如语雀的白板使用的是 SVG,ProcessOn 使用的是 Canvas,Excalidraw 使用的是 Canvas,drawio 使用的是 SVG 等等。因为我们没有 Canvas 的使用经验,加上我们的思维导图节点希望支持富文本内容,所以暂时选定对 DOM 更友好的 SVG,觉得先按照这个方案试试水。
经过这么长时间的验证,发现基于 SVG 的方案并没有什么明显的不足,性能问题我们也经过验证,支持 1000+ 的思维导图节点渲染完全没有问题、操作依然很流畅。
对于 SVG 绘制我们没有直接使用 SVG 的底层API ,而是使用了一个第三方的绘图库 roughjs。
下面我们看看 Plait 框架「插件机制」的部分,这部分的灵感来源于富文本编辑器框架 Slate。
插件机制
Web 前端的画图领域有很多可以深度研发的业务方向,如何基于同一个框架实现不同业务方向功能开发,就需要用到插件机制了。
插件机制是 Plait 框架一个重要特性,框架底层并不提供具体的业务功能实现,业务功能都需要基于插件机制实现,如下图所示:
插件机制通俗讲就是框架层构建的一座基础桥梁,为实现具体的业务功能提供必要的支持,Plait 插件机制有三个核心要素:
- 抽象数据范式(插件数据)
- 可复写行为(识别交互)
- 可复写渲染(控制渲染)
具体到流程图、思维导图这类绘图场景,它的核心是基于用户交互行为(鼠标、键盘操作)实现符合交互预期的元素绘制、渲染,如果做成可扩展的那就插件开发者可以自定义交互行为、自定义节点元素渲染,基于自定义交互生成插件数据,基于插件数据控制插件元素渲染,构成插件闭环,如下图所示(插件机制闭环示意图):
这部分的核心就是设计可重写方法,目前 Plait 中主要有两类:
第一类用于实现自定义交互:mousedown、mouseup、mousemove、keydow、keyup。
第二类用于实现自定义渲染:drawElement、redrawElement、destroyElement 然后就是框架层与插件衔接部分的设计了,这一部分在 plait/core 中目前被设计的是比较松散的,drawElement 可以返回一个 SVGGElement 类型的 DOM 元素也可以返回一个框架组件,既可以直接衔接框架也可以基于 DOM 的方式对接。
目前 Plait 框架整个是基于 Angular 框架实现的,后续可能会考虑脱离框架的设计模式,这不是本文的重点。
举个例子: 画圆插件三步走
步骤一:定义数据结构
export interface CircleElement {
type: 'cirle';
radius: number;
dot: [x: number, y: number];
}
步骤二:处理画圆交互
board.mousedown = (event: MouseEvent) => {
if (board.cursor === 'circle') {
start = toPoint(event.x, event.y, board.host);
return;
}
mousedown(event);
};
board.mousemove = (event: MouseEvent) => {
if (start) {
end = toPoint(event.x, event.y, board.host);
if (board.cursor === 'circle') {
// fake draw circle
}
return;
}
mousemove(event);
};
board.globalMouseup = (event: MouseEvent) => {
globalMouseup(event);
if (start) {
end = toPoint(event.x, event.y, board.host);
const radius = Math.hypot(end[0] - start[0], end[1] - start[1]);
const circleElement = { type: 'circle', dot: start, radius };
Transforms.insertNode(board, circleElement, [0]);
}
};
步骤三:实现画圆方法
board.drawElement = (context) => {
if (context.elementInstance.element.type === 'circle') {
const roughSVG = HOST_TO_ROUGH_SVG.get(board.host) as RoughSVG;
const circle = context.elementInstance.element as unknown as CircleElement;
roughSVG.circle(circle.dot[0], circle.dot[1], circle.radius);
}
return drawElement(context);
}
这是一个最简单的插件示例,通过框架提供的桥梁实现一个画圆插件:拖放画一个圆 -> 生成对应圆数据 -> 根据数据渲圆。
插件机制大概就是这些内容,下面看看数据管理部分。
数据管理
数据管理是 Plait 框架中非常重要的部分,它是框架的灵魂,前面的插件机制是外在表现,主要包含以下特性:
- 提供基础数据模型
- 提供基于原子的数据变化方法(Transfroms)
- 基于不可变数据模型(基于 Immer)
- 提供 Change 机制,与框架配合完成数据驱动渲染
- 与插件机制融合,数据修改的过程可以被拦截处理
这些都是非常优秀的特性,既可以完成数据的约束,又可以灵活实现很多复杂高级的需求,感觉这块的设计和实现其实可以算是一种特定场景的状态管理。
框架状态流转图:
上面说到的插件机制的闭环要依赖数据模型状态作为的标准,最终的插件闭环如下图所示:
这里可以列举两个具体的业务场景,都是我们开发中经常落入的陷阱,体现数据管理在的约束作用及灵活性(这部分可能不好理解,谨慎阅读,其实也是框架作用的具体说明):
场景 1: 自动选中根节点
下面这张图是一个需求示意:新建脑图时自动选中根节点并弹出工具栏
新建思维导图自动弹出工具栏示需求意图
这是一个合理的需求,但它不是常规的交互路径(常规路径是用户点击节点,触发节点选中,进而触发工具栏弹出),我们的新同学在最开始的时候就选择了一种不标准的数据修改方式(手动修改数据)去完成这个需求。
常规点选:
框架数据会存储一个选区状态(点击位置或者框选位置),点击操作会触发选区数据变化,选区变化会触发 Change 事件,基于这个机制处理节点选中和工具栏弹出。
自动选中(手动修改数据):
不推荐的操作路径示意图
这里的思路是首先模拟位置(根据自动选择的节点计算),手动修改数据,然后自己手动调用工具栏的弹出、强制刷新界面让节点选中,这就是典型的没有按照框架约束实现需求的例子,前面说到的数据流没有正确运转,需要做很多特殊处理。 不通过 Transfrom 的方式手动修改数据是不被框架允许的,不会触发框架 Change 行为,理论上应该直接抛出异常(很可惜当时没有做到这步)。
自动选中(标准路径):
标准路径就是基于模拟位置通过 Transfrom 的方式修改数据(相当于模拟点击了要自动选中的节点),后面的流程就可以依赖框架机制去控制执行,无须再做很多手动的处理。
场景 2: 思维导图节点删除后节点的选中状态自动切换到临近节点
这是一个很基础的需求,目前我们的实现是拦截节点删除行为(按 Delete/Backspace 键)处理,这样做有两个弊端:
- 假如将来要做右键菜单删除需要把这部分的处理代码再写一遍,即使封装成工具方法,也要额外增加一个调用入口。
- 一个不太好解决的问题,新建一个节点后按 Ctrl + Z 撤回,无法把选中状态转移到临近节点上,虽然这里撤回执行的也是节点删除操作。
推荐路径:
拦截节点删除操作,前面提到框架统一了数据修改的方法,所以插件开发者可以对数据修改过程进行拦截,这个拦截过程可以在数据修改前,也可以在在数据修改后(Change),在这个地方做出就不会有任何漏掉的场景。
框架作用
- 插件机制实现分层
- 数据管理实现约束
- 配合框架规范数据流
框架最大的意义就是分层解构,降低业务的复杂度,每个领域或者每个模块只处理自己的事情。
比如前端框架,组件化开发,就是能够把一定的逻辑归拢到一个逻辑域中(组件),而不是所有东西杂糅在一起,这是架构演进的趋势。
架构图:
二、思维导图整体方案
这里简单介绍下思维导图插件的整体技术方案,但是不会介绍特别细,因为它是基于 Plait 框架实现,大的方案肯定是与 Plait 框架一致。
思维导图整体技术方案导图
1、整体方案
我们整体是 SVG + Richtext 的方案。
绘图使用 SVG ,目前我们脑图节点、节点连线、展开收起图标等等都是基于 SVG 绘制的。
节点内容使用 foreignObject 包括嵌入到 SVG 中,这种方案使节点内容 支持富文本。
2、功能方案
脑图核心交互处理及渲染都是可重写方法完成,与 Plait 集成。
脑图插件仅仅负责脑图部分的渲染,至于整个画布的渲染以及画布的移动放大缩小等等是框架底层功能。
因为 Plait 是支持扩展任意元素的,支持渲染多个元素,它只由数据决定,所以它支持同时渲染多个脑图。
底层脑图插件并不包含工具栏实现,它只处理核心交互、渲染、布局。
3、组件化
脑图组件渲染、节点渲染的整体机制就是我们前端经常提到的:组件化、数据驱动,虽然组件内部节点渲染还是创建 DOM、销毁 DOM,但是大的功能还是通过组件来进行划分的。
基于脑图业务里面有两个非常重要的组件:MindmapComponent 、MindmapNodeComponent,MindmapComponent 处理脑图整体的逻辑,比如执行节点布局算法,MindmapNodeComponent 处理某一个节点的逻辑,比如节点绘制、连线绘制、节点主题绘制等。
之所以把这个部分提出来说一下,是因为我觉得这块的思想其实是主流前端框架思想的延续,包括和 Plait 框架整体的机制是统一的。
4、绘图编辑器
这里可以理解为业务层的封装,业务层级决定集成那些扩展插件,以及进一步扩展插件上层功(比如脑图节点工具栏实现),Mindmap 插件层不依赖于我们的组件库和业务组件,所以工具栏这类需要组件库组件的场景统一放到业务层实现,这样 Mindmap 插件层可以减少依赖、保持聚焦。
思维导图具体落地到 PingCode Wiki 业务中,其实有一个更虽复杂、但清晰的分层结构:
三、自动布局算法
节点自动布局是思维导图的一个核心技术,它是思维导图美观以及内容表现力的决定性因素,它关注节点如何分布,这部分说复杂不复杂,说简单也不简单,包含以下几个部分:
- 布局分类
- 节点抽象
- 算法过程
- 方向变换
- 布局嵌套
布局分类
介绍说明下常规思维导图的布局分类:
示意图
标准布局:
逻辑布局:
缩进布局:
时间线:
鱼骨图:
美学标准
前面说过思维导图对可视化树的展现有很高的要求,需要它是美观(这个就很直观,每个人的审美可能不一样,但是它也应当有一些基础标准)的,所以需要基础的美学标准:
- 节点不重叠
- 子节点按照指定的顺序排列
- 父节点在子节点中心(逻辑布局)
- 主轴方向上不同层级节点不重叠
节点抽象
为了简化可视化树的绘制,[Reingold-Tilford] 提出可以把节点之间的间距和绘制连线抽象出来。通过在节点的宽度和高度上添加间隙来添加节点之间的间距。如下图中的实线框显示了原始宽度和高度,虚线框显示了添加间隙后的宽度和高度。
[Reingold-Tilford] 可视化树节点抽象示意图
我们的思维导图自动布局遵循这个抽象:
- 节点布局本身不关注节点连线,只关注节点的宽高和间距
- 节点从间距中抽象出来(节点宽高和节点间隙作抽象为一个虚拟节点 LayoutNode)
- 布局算法基于 LayoutNode 进行布局
节点在布局时它的宽和高已经融合了实际宽高和上下左右的间隙了,这样可以降低自动布局的复杂度,上图其实是我们布局后的结果,节点从间距中抽象出来之后,节点的垂直顶部位置是其父节点的底部坐标,而父节点的底部坐标又是其顶部坐标加上其高度,真实节点与虚拟节点的逻辑关系如下图所示:
LayoutNode 示意图
算法执行过程
算法流程图:
自动布局算法执行流程图
用一个包含三个节点的例子介绍它自动布局过程,理想的结果应当如下图所示:
步骤一、前置操作: 构造 LayoutNode
这个就是前面提到的节点抽象,基于节点宽高和节点之间的间隙构建布局使用的抽象节点,此时三个处于初始状态,x、y 坐标均为零且相互重叠,如下图所示:
初始状态示意图
左边是真实状态,右侧虚线框部分没有特别的意义,只是一个不重叠的示意
步骤二、布局准备: 垂直分离
布局准备:垂直分离示意图
基于节点的父子级关系进行分层,保证垂直方向是父子级节点不重叠(节点 0与节点 1、2不重叠)。
步骤三:分离兄弟节点
分离兄弟节点过程示意图
就是分离「节点 1」和「节点 2」,保证他们水平不重叠。
步骤四:定位父节点
父级节点定位示意图
基于「节点 1」和「节点 2」重新地位父节点「节点 0」的水平位置,保证父节点水平方向上居中与「节点 1」 和「节点 2」。
布局结果:
以上就是一个的完整布局过程(逻辑下布局),逻辑并不复杂,即使多一些层级和节点也只需要递归执行「步骤三」和「步骤四」。
可以看出「逻辑下布局」只用了「算法流程图」中的前四步就完成了,最后一步「方向变换」就是在「逻辑下布局」的基础上通过数学变换的方式实现「逻辑上」「逻辑右」等布局,下面对方向变换进行专门的解释。
方向变换
1、逻辑下 -> 逻辑上
可以看出这是垂直方向上的变换关系,它们应该是基于一个水平线对称,具体的变换关系如下图所示:
逻辑上变换图
可以看最右侧最下方的节点的「y 点」应该就对应的最右侧最上方的节点「y点」,它们的位置关系应该就是:y= y - (y-yBase) * 2 - node.height。
注意上下变换应该只涉及位移,不涉及上下翻转,也就是节点内部的方向不变,y 对应 y`这两个对应的都是节点的下边上的点位。
2、逻辑下 -> 逻辑右
逻辑右示意图
从上图可以看出,这个逻辑变换也不复杂:就是一个垂直到水平的变换过程,反应到布局算法层中 x、y 方向以及节点宽高的变换,比如:
- 垂直分层:需要将垂直分层变换为水平分层
- 增加 buildTee 过程:基于分层的节点需要将节点宽度变换高度、x 坐标变为 y 坐标
处理水平布局:增加 buildTree 过程示意图
最后在「方向变换」中将宽高和 x、y 再变换回来:
得到布局结果:
3、逻辑右 -> 逻辑左
逻辑右到逻辑左的位置对应关系应该和最上面说逻辑下到逻辑上的类似,这里不再赘述。
方向变换大概就这三种,下面介绍下下布局嵌套的思路。
布局嵌套
先看一个布局嵌套的示意:
上图第二个子节点使用了另外一种布局(缩进布局),这就属于布局嵌套,布局嵌套仍然需要保证前面说到的「美学标准」比如节点不重叠、父节点居中对齐等。
简单思考: 布局嵌套中的那个有独立布局的子树,它对于整体布局的影响在于它的延伸方向的不受控制,但是如果把有独立布局的子树看做一个整体,提前计算出子树的布局,然后把子树作为整体代入布局算法就可以屏蔽子树延伸方式对整体布局的影响。
整体的处理思路如下图所示:
布局嵌套处理思路示意图
这里可以有一个抽象:把有独立布局的子节点抽象成一个黑盒子(我把它叫做 BlackNode),那么子树布局的影响就会被带入到主布局中,而子树的布局可以保持独立性。
关键点:需要先计算出有独立布局的子树的布局,然后才可以计算父节点布局
四、框架历程/未来
从技术调研到架构设想再到架构落地到产品中,历时大概1年左右的时间,核心工作集中在 2022 年的 1-9 月份,大概的时间线如下:
Plait 框架未来的一些设想
结束语
本文主要介绍从零开始做画图应用、自研画图框架、落地思维导图场景的一些技术方案,作为一个 Web 前端开发者有机会做这样的东西个人感觉很幸运,对于 Plait 框架未来还有很多事情要做,希望它可以发展成为一个成熟的开源社区作品,也期待对画图框架有兴趣的同学可以加入到 Plait 的开源建设中。