前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Figma 的编组功能,比你想象的要复杂得多

Figma 的编组功能,比你想象的要复杂得多

作者头像
前端西瓜哥
发布2024-06-17 13:38:51
700
发布2024-06-17 13:38:51
举报

大家好,我是前端西瓜哥。

最近做个人的开源编辑器项目,实现了和 Figma 一样的编组功能,期间踩了不少坑,和大家分享一下。

阅读本文需要一些前置知识,所以你会看到很多文章引用。一时半会可能看不明白,建议先收藏。

图形树

我们了解一下 Figma 数据结构的特点。

Figma 的图形表达,使用的 width、height、transform 组合的一套表达。transform 里面保存了图形的位置信息(x、y)和旋转角度(rotation)甚至切斜的信息。

Figma 使用一个拍平的一维图形对象数组,来表达图形树。

注意它本身没有做嵌套,但图形对象上有 parentIndex 的属性,记录着它的父节点 id,以及在父节点中的位置。

基于这些信息,Figma 会构造出一棵树,然后渲染。

初始化时,先创建好所有的对象,并做 id 到图形对象的映射。

然后再遍历这些对象,通过 parentIndex 找对对应的父节点,添加父节点的 children 数组下,最后 children 再基于子节点的 postion 做排序,这样图形树就构造好了。

之后如果进行图形的更新操作,需要手动维护 children 数组。

Figma 的这套设计是为了方便做协同编辑,能更好更简单地解决冲突问题。

group 对象

Figma 支持编组,为此它有一种类型为 frame 的图形类。

当它的属性 resizeToFit 为 true,它表现为组(group)。为方便讲解,后面我都会把这种特殊的 frame 叫做 group 对象。

它的效果是一个 刚好包裹住其子图形的矩形区域,不留空隙,也不让子图像超出这个矩形,表现上类似一个 OBB(有向包围盒)。

group 本身不做渲染,但会把其下的子节点做渲染,并给它们 应用上自己的 transform

矩阵的嵌套

Figma 的图形表达使用了矩阵。

首先图形有本地矩阵 localTransform,用于表达图形的形变,比如平移、旋转、斜切。

图形的 localTransform 是相对它的父节点的

Figma 支持组对象,和其他图形一样,有矩阵,有宽高,但它本身不渲染,渲染的是它的子图形。

Figma 支持多个 group 嵌套,然后一个图形实际在世界坐标系的矩阵 worldTransform,等价于从根节点到直接父节点 group 的矩阵顺序相乘,最后再乘上自己的矩阵

这两个概念很基础也很重要,所有的变形运算基本都围绕这它们进行。

  1. localTransform 是图形相对父节点的本地矩阵;
  2. worldTransform 是图形相对世界坐标系的,为根节点到目标节点所有矩阵相乘。

移动图形

支持 Figma 风格编组的图形编辑器,相比不支持编组的,逻辑上有很大的区别。

我们先来看相对比较简单的场景:移动组下的一个图形。

如果没有组的嵌套,移动图形其实比较简单,只要更新它的 localTransform,给它左乘一个相对的位移矩阵就好了。

代码语言:javascript
复制
newLocalTf = translateTf * originLocalTf

但要是有组的嵌套,做法就不一样了,我们要改为更新图形的 worldTransform

但 worldTransfrom 是计算出来的计算属性,所以 计算完新的 worldTransform 后,还需要通过逆矩阵的方式更新回 localTransform

我们快速过一下这个要用到矩阵知识。

假设 A 左乘 B 等于 C。已知 B 和 C,求 A。

代码语言:javascript
复制
B * A = C

我们可以利用逆矩阵的特性去求 A。

矩阵和逆矩阵相乘为单位矩阵,所以我们可以求出 B 的逆矩阵,往等式两边做左乘:

代码语言:javascript
复制
B' = INVERT(B)
B' * B * A = B‘ * C
则
A = B' * C

所以矩阵 A 的值为矩阵 B 的逆矩阵,乘以矩阵 C。

所以有:

代码语言:javascript
复制
// 位移前的世界矩阵
parentsTf = parentTf1 * parentTf2 * parentTf3
originWorldTf = parentsTf * originLocalTf

// 计算新的世界矩阵
newWorldTf = translateTf * originWorldTf

// 转换为本地矩阵
newLocalTf = invert(parentsTf) * newWorldTf

到这里,被移动的图形的矩阵就更新成功了。

更新父节点

但是西瓜哥我眉头一皱,发现事情没这么简单,事情到这里还没有完。

你看,父节点有问题。

我们还要额外做一件事,就是移动子节点后,再更新 父节点的几何信息,确保它能刚好包裹住子图形

做法是,计算被移动节点的它的兄弟节点本地(基于 localTransform 计算)的 AABB 包围盒,然后得到这些包围盒 merge 后的大包围矩形 boundingRect。

代码语言:javascript
复制
const boundingRect = boxToRect(
  mergeBoxes(
    this.children
      .map((item) => item.getLocalBbox()),
  ),
);

首先我们看看这个 boundingRect 是不是和父节点的宽高相同,如果相同,说明图形没有移出界,就不用更新父节点了,直接结束。

代码语言:javascript
复制
if (
  boundingRect.x === 0 &&
  boundingRect.y === 0 &&
  boundingRect.width === this.attrs.width &&
  boundingRect.height === this.attrs.height
) {
  // size has no changed
  return;
}

然后给父节点设的宽高改为为 mergedBbox 对应的宽高。

代码语言:javascript
复制
parent.width = boundingRect.width;
parent.heigth = boundingRect.heigth;

接着让所有的子节点做一个复位,给它们的 localTransform 左乘一个位移矩阵 translate(-boundingRect.x, -boundingRect.y),回到父节点的矩形区域内。

代码语言:javascript
复制
for (const child of this.children) {
  const tf = new Matrix(...child.attrs.transform).translate(
    -boundingRect.x,
    -boundingRect.y,
  );
  child.attrs.
    transform: tf.getArray(),
  });
}

子节点移动,导致离开了原来的正确位置,那就让父节点再移动一下,移动到正确位置。

于是我们给父节点右乘一个位移矩阵 translate(boundingRect.x, boundingRect.y)

代码语言:javascript
复制
const translateTf = new Matrix().translate(boundingRect.x, boundingRect.y);
const tf = new Matrix(...this.attrs.transform).append(translateTf);
parent.attrs.
  transform: tf.getArray(),
});

这样被移动图形的父节点就更新完成了,达到了我们想要的父节点刚好包围所有子节点的效果。

然后组是嵌套的,父节点的物理信息改变了对不对,那它的父节点也要更新,你发现套娃出现了。

我们会继续递归调用,不断自底向上执行相同的逻辑,更新父节点属性,直到根节点。

这样,移动操作就算真正完成了。

我们来看看效果:

非常完美。

移动一个图形,极端情况下,当前节点,它的所有父节点,以及它们的兄弟节点都需要更新

更新子节点

前面考虑的是向上更新父节点的情况。

现在我们再想想稍微复杂一些的场景,**还要向下更新子节点 **的场景:对一个 group 做缩放(resize)。

核心思路是:计算需要应用的缩放矩阵,自上而下给所有子节点应用上

首先用之前的文章讲过如何通过拖拽不同控制点,计算图形缩放后的 transform,拿到需要应用的矩阵变换。 scaleTf。

可以看这两篇文章:

图形编辑器开发:基于 transfrom 的图形缩放

图形编辑器开发:基于 transfrom 对多个图形进行缩放

接着就是给节点应用上矩阵了。

类似移动操作,需要给所有子节点的 wolrdTransform 应用 scaleTf,然后计算出新的 localTf。

代码语言:javascript
复制
// 位移前的世界矩阵
const parentsTf = parentTf1 * parentTf2 * parentTf3
originWorldTf = parentsTf * originLocalTf

// 计算新的世界矩阵
newWorldTf = scaleTf * originWorldTf

// 转换为本地矩阵(注意要使用更新后的父节点矩阵)
newParentsTf = newParentTf1 * newParentTf2 * newParentTf3
newLocalTf = invert(newParentsTf) * newWorldTf

有一点需要注意:顺序需要自上而下,先给父节应用,再给子节点应用。

下面是示意图,展示缩放某个图形需要更新最多的节点的极端情况,此时需要更新的节点有哪些,以及为何被更新。

编组操作

简单说说怎么给选中的图形进行编组。

  1. 计算好被选中图形编组前的 worldTransform;
  2. 对选中图形排序;
  3. 创建一个 group 对象,将其放到最靠上的选中图形的位置上。基于选中图形相对于 group 父节点的形成的包围盒计算出 group 的 width、height、transform;
  4. 接着正式将选中图形放到这个 group 下,并基于它们原来的 worldTransform,以及 group 的矩阵重新计算 localTransform。
  5. 更新选中图形移动前的父节点,让父节点可以刚好包裹子节点。特殊的,如果某个父节点下一个子节点都没有了,需要把这个父节点删除。

解组

解组简单一些。

  1. 筛选出选中图形中的组对象;
  2. 遍历选中的组对象,对其进行拍平操作,即将其从父节点上删除,并取出它的所有子节点放到原来父节点的位置;
  3. 这些子节点在修改父节点前,先计算好被选中图形编组前的 worldTransform,然后再基于组对象的父节点重新计算 localTransform;
  4. 更新父节点。

在支持组嵌套下,更多其他操作的实现,比如对齐、排布,可以看我的开源图形编辑器 suika 的源码实现。

我正在开发的 suika 图形编辑器: https://github.com/F-star/suika 线上体验: https://blog.fstars.wang/app/suika/

结尾

Figma 对编组的支持看起来简单,实际上因为父子关联的原因,影响很广,复杂度很高。

看起来只是简单的移动一个图形,但和它有关联关系的大量父节点和子节点都要进行修正。这是编组的代价。

你是图形编辑器的上帝,当某个图形的修改时,你需要找出它带来的因果,然后去正确地更新这个世界(图形树)。

我是前端西瓜哥,欢迎关注我,学习更多图形编辑器知识。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2024-06-16,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 前端西瓜哥 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 图形树
  • group 对象
  • 矩阵的嵌套
  • 移动图形
  • 更新父节点
  • 更新子节点
  • 编组操作
  • 解组
  • 结尾
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档