大家好,我是前端西瓜哥。
之前给我的开源图形编辑器 suika 新增了编组的功能,支持类似 Figma 的组嵌套能力。
为此对图形编辑器的整个底层做了大量的改造工作。
改造过程细节很多,且相当琐碎,我们在这篇文章说一说。
suika 图形编辑器 github 地址:
线上体验:
数据结构改造
首先要做一下数据结构的改造。
原来图形树只有一层,所以用一维数组表达。
现在要改成图形树,这种有父子关系的结构,我们需要想办法加上图形父子关系的表达。
确定方向
我先研究了 excalidraw 的数据结构。excalidraw 是一款流行的开源白板工具,但不得不说它的源码写的很烂。
excalidraw 的编组,在数据上并没有一个真实的 group 对象,而是是给具体的图形,比如矩形打了个组的标签,如果两个图形的组标签一致,那它们就来自同一个组。
对应的属性名叫做 groupIds,是一个字符串数组,数组的话就能做多层嵌套了,数组靠前的 id 为更远的祖先节点。
我尝试用这种方式来改造,但发现了很多问题,其中有几个难解决的问题:
-
group 无法旋转,这样做只是给图形对象做了关联而已,group 对象本身不存在,对 group 旋转后旋转信息会丢失,因为并没有 group,所谓的对 group 旋转其实也只是给 group 下的图形旋转。excalidraw 的 group,本质只是选中图形时,把和它同类的图形也选中;
-
最主要的问题还是,图形和 group 好像两个国家的人一样,风俗习惯都不一样,同一个交互,数据结构不同,要对应两套写法,写得比较割裂。
尝试性改造了部分代码后,最终选择放弃了,方向是有问题的,至少不符合我的要求。
我希望实现类似 Figma 的效果,group 要保持旋转,甚至斜切的效果,所以 group 本身应该是需要是一个和普通图形类似的对象。
后来我 尝试拿到 Figma 的数据结构,当时和同事在下班坐地铁时不时讨论这个问题,然后找到了一个 figma-to-json 的工具。
最新版 fig 文件无法解析,因为换了新的压缩方式
最后拿到了 Figma 的图形树的真正的数据结构,通过研究分析它们,我找到了编组功能的大致方向。
每个图形会有一个 parentIndex 的属性,指定它在哪个父节点(guid)下,且在父节点下的索引位置(position),这样就维护好多个图形的父子关系以及兄弟节点的顺序。
"parentIndex": {
"guid": {
"sessionID": 0,
"localID": 0
},
"position": "~"
},
然后图形的位移、旋转、缩放使用 transform 矩阵表达的。
"transform": {
"m00": 1, "m01": 3, "m02": 5,
"m10": 2, "m11": 4, "m12": 6
}
可以是对象,也可以是长度为 6 的数组,但对应的算法需要配套。
transform 这种表达,在组这种嵌套的场景是适配的且使用非常频繁。
比如缩放旋转,修改图形的父节点等,基本上绝大部分用户的操作,都涉及到矩阵的各种运算。
图形在画布上的最终矩阵 worldTransform,为所有父节点的 transform 相乘再乘上自己的 transform 即可。
fig 格式的属性说明可以看这个文档:
改造
理解这些后,下面我们对图形类进行改造。
图形类改为 transform 表达。
先将 图形都换成 transform 来进行表达,丢弃了原来用的 x、y、rotation 的属性,这些属性还是可以基于 transform 计算得到。
实例化图形类理论上应该传 transform,但为了方便使用,还是可以传入 x、y、rotation 这些参数,构造函数中会转换为 transform。
改成 transform 属性后,缩放旋转逻辑也进行了配套的交互和算法调整,这个后面详细说。
新增 Group 类,实现组相关功能。
新增新的图形类型 Group 类,和其他图形一样继承同一个 Graphics 基类,也是一种图形对象。
这样 Group 就可以维护旋转等属性,但一些属性不能设置,比如填充色、描边色等。
不过在我的 suika 开源项目中的类叫做 Frame,因为我还要做画板分区域的功能,这个不在本文讨论范围。
Graphics 基类加入一些组的能力,比如 children 数组维护其下的子节点,insertChild 插入节点方法等。
普通对象也会有 children 属性,但永远为空,这是为了统一普通图形和 group 的接口,编写代码时逻辑可以保持一致,在绝大多数场景不用专门特殊处理 group,只有少量场景需要。
几个基本原则
group 有几个比较重要的点:
-
group 需要紧密包围它的子节点(resizeToFit)。这意味着一个简单操作可能会导致大量的更新,后面会说明;
-
父子节点不能同时被选中。如果你选中父节点,子节点就需要被取消选中,反之亦然。
-
group 需要计算 width 和 height 的值,虽然可以基于子节点计算得到,但为了性能考虑(比如频繁的 hitTest),还是要及时计算缓存起来。
-
图形有 localTransform(相对直接父节点)和 worldTransform(相对画布根节点)两种,注意使用场景。比如从父节点 A 移动到父节点 B,需要将节点的 worldTransform 左乘父节点 B 的逆矩阵,作为新的 localTransform。
选中交互
因为组嵌套的原因,我们可能需要进入某个组下,去选中一个子节点。
因此选中交互需要做大量改动,出现了相当多的场景。
我们需要可以选中到父节点下的子节点,甚至孙子节点。
连击选中子节点
首先我们要实现 连击选中子节点。
注意不是双击,是连击,因为嵌套可能很深,需要不断地点击直到选中目标图形。
首先在原生鼠标事件的基础上 实现了连击事件。
然后 Group 类需要实现 getHitGraphics 方法,传入光标位置,返回被选中的子节点,且因为嵌套的原因,这里需要用递归实现。
算法核心是:将光标位置转为 group 下的本地位置,然后判断是否在包围盒中,如果在,按从上到下的顺序遍历子节点判断是否为在包围盒内,如果在则进行更精细的 hitTest。如果是 group,递归。如果都没有命中,表示点到空白位置上了,返回 null。
连选
**按 shift 进行连选时哪些图形可以选中?**或者说 hover 时哪些图形是可以被高亮的。
这和当前有什么图形被选中有关。
原则是:选中图形的兄弟节点,以及选中图形的父节点的兄弟节点可以被选中。如果没有任何图形被选中,那只能选中第一层级的图形。
下面示例图中,红色为选中节点,橙色为选中节点的父节点,蓝色的就是此时可以选中的节点了。
演示一个 group 节点被选中后,其他哪些图形可以被 hover。
可以看到,这个 group 下的图形是不能被hover 的,Group3 下的图形也不能 hover。
全选
然后是全选的逻辑。
如果是有图形被选中的情况,如果都在同一个 group 下才有效,全选的结果是父节点下的所有图形被选中。
如果图形在不同的 group 下,全选无效。
如果没有图形被选中,全选会选中第一层级的图形。
其他
此外还可以支持透选功能,就是按住 Command 或 Ctrl 功能,然后去选中,此时会直接透过所有父节点,直接选中子节点。
另外注意有图形锁定的情况,此时图形不能被选中,父节点的 hitTest 区域也会收窄。
figma 完整的选中逻辑整理可以看我的这篇文章:
更新子节点对父 group 的影响
子节点的 transform 是相对 group 的,且 group 需要紧密包裹子节点。
所以我们在 对子节点更新时,父节点也会受到影响,也需要进行更新。
更具体的说明可以看我的 这篇文章。
形变操作
对子节点进行变形操作(移动、旋转、缩放等),可能会导致父 group 的区域的改变,需要对父 group 进行重新计算 transform,因为子图形可能跑出父 group 的包围盒之外了。
父 group 的 transform 的修改,尤其是 x、y 的修改,又会导致其下的其他兄弟子节点 transform 不对,又要更新。
group 的 transform 改变,group 的父节点的 transform 可能也不对了,于是出现了牵一发而动全身的现象。
最后,如果对节点进行的是 缩放行为,如果希望线宽不变,也要重新计算子节点的 transform,确保 width 和 height 进行 transform 后保持原来的值,这样线宽才能保持。
因为父子联动,导致一个简单的操作会同时更新多个图形的属性,所以我 不再使用原来的有语义的历史记录命令(如删除图形命令、移动图形命令),改为一个更泛用的更新图形属性命令:UpdateGraphicsAttrsCmd。
UpdateGraphicsAttrsCmd 会记录
-
需要删除的图形 id;
-
需要新增的图形 id;
-
被修改的图形的 id 和对应的修改前的属性值列表;
-
被修改的图形的 id 和对应的修改后的属性值列表;
然后在其上封装了一个名为 Transaction 的类,收集图形的属性改变,并内置了对父节点的 transform 重新计算方法。
执行完需要执行的一切后,调用 commit 提交这些修改,这时会创建 UpdateGraphicsAttrsCmd 命令实例,然后添加到历史记录中。
删除和隐藏子节点
然后是子节点被删除或被隐藏的场景。
因为要确保父节点能紧密包围子节点,所以 图形被删除但其存在兄弟节点时,和前面形变一样,是要更新父节点的属性,从而引起大量节点的更新。
拖拽图形,更新图形的父节点,效果同删除操作。
另外有特殊一个场景,子节点删除完,父节点下可能已经为空,此时父节点需要被删掉,因为一个没有子节点的 group 是没有意义的。注意这里也要做递归处理。
图形被隐藏也是类型。
父节点的包围盒是基于可见图形来计算得到的,不可见子节点不参与计算。
特殊的,如果父节点的所有子节点都不可见,则父节点视作不存在,无法被选中,但图层面板中还存在,可以通过图层面板选中。
group 的代理行为
group 本身的作用是将多个子节点视作一个整体,所以一些对 group 的操作需要映射为对子节点的批量操作。
常见的就是设置颜色,group 虽然因为和普通图形继承了同一个 Group,也有颜色属性,可以设置,但一般是不需要的。
我们需要额外定义一个和直接更新属性不同的 setProps 方法,通过它去递归给所有子节点设置颜色。
粘贴到哪里?
粘贴的位置应该在哪里,则有点复杂,和被复制的图形以及当前选中的图形都有关。
我观察了一下 Figma 的行为,有点复杂,下面给出几种场景:
-
如果多个不在同一个父节点的图形被选中,粘贴会找到被选中图形的最近公共父节点,然后找到这个公共父节点下的最靠上的被选中图形的父节点,放到它的上方。
-
如果没有任何图形被选中,逻辑类似,会找到被复制图形的最外层的公共父节点,然后找到这个公共父节点下的最靠上的被复制图形的父节点,放到它的上方。被复制图形可能不存在(跨图纸复制或已被删除),则忽略掉。
-
如果选中的图形都是 group ,粘贴时这些 group 下的最上方都会新增被复制图形,且 transform 不会进行转换。(这个应该是 frame 的功能,导致 group 也带上了这个特性,figma 中 frame 是特殊的 group,不需要紧密包围子节点)。
-
如果选中的图形都在同一个父节点下,且不全是 group,则粘贴在最靠上的选中图形的上方。
......
具体要怎么处理,可以根据需求自行调整,figma 考虑的场景太多了,建议初版怎么简单怎么来。
UI
编辑器内核改完了,我们来改 UI 层。
图层面板
因为树结构,图层面板也要改造成树列表。
图层面板 变成支持缩进的列表形式来表达图形树。
锁定的 icon 要表达出子节点因为父节点被锁定了,所以自身也锁定了。可见不可见同理。
嵌套深的图形会跑到非常右边的位置,所以要支持水平滚动。
还有就是要实现在图层面板中将图形拖拽到其他组下的逻辑。
属性面板
属性面板 用来显示选中的图形的属性。
前面说了,group 不需要设置颜色属性,但属性面板还是要显示一下的颜色。
如果子节点的颜色都相同,我们就显示这个颜色。
figma 的表现:
但如果子节点的颜色不同,我们就不显示了。
但这样不好改颜色,需要加一个被选中颜色面板,实现将子节点所有匹配颜色 A 的都改成颜色 B。
结尾
编组是图形编辑器的一套底层的系统,开发任何模块都要充分考虑编组带来的影响。
另外,编组涉及到各种矩阵的运算,要求开发者对矩阵运算有较深的理解。
因为组嵌套的原因,在写法上会更多地使用递归写法。当你发现你没用上递归,只是遍历了一层,请认真思考一下这样写是否真的没问题,是否真正考虑到组的存在。
本文相对全面地系统阐述了如何对图形编辑器改造,使其支持编组功能。可以看到,工作量还是相当大的,交互上发生了变化,实现上也需要调整以适应这些变化,然后还要理解一些底层的概念,像是矩阵。
我是前端西瓜哥,关注我,学习更多图形编辑器知识。
相关阅读,