Figma插件API:深入探讨高级算法和数据结构 - 火星编年史,邪恶火星人团队博客
2024 年 5 月 22 日

在开发 Figma 插件时,可能会遇到官方文档未涵盖的问题。这种情况发生在我们开发 Polychrom 时,这是一个(即将成为著名的)UI 文本可读性插件。在我们的情况下,我们不得不考虑应用算法和树遍历技术等解决方案来完成工作。请继续阅读,以了解我是如何“超越文档”的!

Polychrom (opens new window) 是我们的免费 Figma 插件,可更精确地控制 UI 文本的可读性。Polychrom 的一个重要部分涉及使用 APCA 方法 (opens new window) 自动检测所选项目与其背景层之间的对比水平。

考虑到这是一个具有简单界面和(表面上)直观功能的插件,有些人可能会认为背后并没有太多事情发生。

但实际上是有的。现在,我们不仅仅是 我们拥有一款我们引以为豪的产品,我写了一篇充满了我在这个过程中学到的教训的文章。

事实是,虽然一些问题得以轻松解决,多亏了Figma API文档,但就像所有文档一样,它们不可能考虑到开发人员可能遇到的所有情况。那么,你会怎么做呢?多年前,当我开始涉足前端世界时,我会转向阅读像您现在即将阅读的这篇文章一样的文章!

现在,下面这个误解可能已经快被遗忘了,但作为前端开发人员,我们可以做的远不止关注网站美学,或者特定警告通知的颜色。具体来说,我们也可以在工作中融入更多传统的计算机科学元素,比如算法、数据结构和树遍历技术。

在打造Polychrom成为最佳产品的过程中,这正是我们所做的。

希望您能阅读这篇文章,并从中获益。 观察最终结果

从用户的角度来看,Polychrom基本上是一个无痛的解决方案:他们只需在Figma文档中选择一个元素,插件就会显示所选元素与其背景之间的对比度。

Polychrom在操作中的基本示例

Polychrom的自动背景搜索是这个故事有趣的重要原因之一。

这个功能对用户来说是必不可少的,因为我们不想强迫任何人手动指定所选元素的背景是哪个;正如您在下面的视频中所看到的那样,它非常流畅:

自动背景搜索在这里真的很出色

但是从 从开发的角度来看,这个算法特别包含了插件源代码的重要部分,创建它是一次充满了许多有趣曲折的冒险。

所以,让我们谈谈我们面临的问题,我们做出的决定,我将分享这个功能的工作原理(以及它与树数据结构的联系)。

用户场景解释

因此,上面的例子看起来既令人满意又简单。然而,一旦开始列出可能的用户场景,可能会出现一些比最初假设的要复杂得多的情况。

从用户的角度看,可能存在以下几种场景:

  • 选择了一个或多个元素
  • 如果元素在框架中还是不在框架中
  • 如果元素在组内还是在组件内
  • 如果元素只是放置在页面上
  • 如果有几个其他元素在该元素下面的级别

还有一些关于元素填充本身的情景:

  • 简单的实心 * 多种实心填充
  • 背景覆盖描边
  • 渐变填充
  • 元素或填充是否有透明度

在发布之前,我们需要解决上述所有情况。但是,首先,我们将探索我们可以的最简单情况:这是当我们选择了一个具有实心填充且没有透明度的元素。

算法的基础 #

所以,假设我们选择了一个具有实心填充且没有透明度的元素。我们需要为这个元素找到合适的背景。但是我们该如何做呢?

首先,我们想要检测所有完全位于选定元素下方的元素,这些元素还具有实心填充且没有透明度: 接下来,我们希望能够检测到实际背景元素,即直接位于所选元素下方的元素。

Figma插件API的复杂性 #

Figma文档API中的每个元素都有描述其块模型的属性:坐标、高度、宽度等。因此,我们可以从确定完整几何交集的条件开始。为此,我们需要设定几个标准。

我们需要注意的一点是:所选元素的坐标原点是否在假定背景元素的坐标原点的下方且位于右侧(在Figma中,所有文档和页面的坐标原点都是左上角)。

其次,我们必须知道所选元素是否 接口的尺寸在假定背景本身的尺寸范围内。

interface Rect {
  readonly x: number
  readonly y: number
  readonly width: number
  readonly height: number
}

export const isContainedIn = (outer: Rect, inner: Rect): boolean =>
  inner.x >= outer.x &&
  inner.y >= outer.y &&
  inner.x + inner.width <= outer.x + outer.width &&
  inner.y + inner.height <= outer.y + outer.height;

这就是我们用来筛选页面元素的标准。现在,让我们看看如何执行这种类型的过滤或绕过。

元素过滤:幕后操作 #

可能首先想到的方法是遍历整个文档树,从所选元素开始,然后检查每个元素是否符合我们前面提到的标准。

Figma API提供了完全遍历文档的方法,即遍历所有元素。这些方法是node.findOne()node.findAll();带有搜索条件的回调。 在调用每个方法时都必须传递此参数。

// 查找具有填充颜色为'#FF5733'的第一个矩形节点
const node = figma.root.findOne((node) => {
  return node.type === 'RECTANGLE' && node.fills[0]?.color === '#FF5733';
});

// 查找所有不带描边的椭圆节点
const nodes = figma.root.findAll((node) => {
  return node.type === 'ELLIPSE' && node.strokes.length === 0;
});

但是,考虑到这些方法检查文档中的每个元素,如果我们从页面的根部开始搜索,我们将认为它们是未优化的。

Figma API还提供了用于优化搜索的方法:例如,仅通过后代执行搜索,或按元素类型(文本、矩形、框架等)执行搜索。

interface ChildrenMixin {
  findAll: (callback?: (node: SceneNode) => boolean) => SceneNode[];
  findAllWithCriteria: <T extends NodeType[]>(
    criteria: FindAllCriteria<T>
  ) => Array<
    {
      type: T[number];
    } & Scene

``` Node
  >;
  findChild: (callback: (node: SceneNode) => boolean) => null | SceneNode;
  findChildren: (callback?: (node: SceneNode) => boolean) => SceneNode[];
  findOne: (callback: (node: SceneNode) => boolean) => null | SceneNode;
  findWidgetNodesByWidgetId: (widgetId: string) => WidgetNode[];

因此,理论上,我们可以使用这些方法来找到我们需要的内容。但是,有一个问题...

## 大冻结

这就是一个非常大的问题:**在执行长脚本期间,接口会冻结**。在巨大的文档中,这些冻结可能会非常长,以至于用户可能会认为插件完全出现故障。Figma 本身也会出现冻结。

[Figma 警告](https://www.figma.com/plugin-docs/frozen-plugins/) 不要使用完整的、未经优化的、全文搜索。具有许多页面和元素的大型文档可能会导致遍历函数执行缓慢,导致 UI 冻结。

也就是说,Figma 的文档确实建议一些方法来预防。 UI冻结。

例如,我们可以将工作分解为较小的部分(如遍历固定数量的元素,或在运行几毫秒后),并在将控制权传递给主线程之前执行有限的计算。

这使得浏览器可以继续渲染页面或处理用户事件,如鼠标点击。例如,我们可以使用`Promise API`和`setTimeout`函数的组合在每个后续帧渲染之前安排一些计算。

抽象地表达,这看起来像这样:

const pauseForMainThread = async () => { return await new Promise((resolve) => { setTimeout(resolve, 0); }); }

const storeUserPreferences = async () => { // 要执行的程序列表: const operations = [ checkInputForm, displayLoadingIcon, writeToDatabase, refreshUserInterface, logActivityMetrics, ];

// 遍历每个操作:
while (operations.length > 0) {
    // 检索 t 现在,我们已经阐明了问题,让我们回到Figma,继续我们在Polychrom上的工作。在我们的项目中,未经优化的方法表现得很糟糕,正如预料的那样。例如,在我们使用它在一个设计了我们网站的文档中时,为选定文档识别背景会使页面冻结了_几秒钟_。

不用说,这对我们来说是行不通的,我们必须找到另一种方法。更具体地说,这意味着围绕Figma文档的树结构中的元素构建我们自己的方法。

以下是来自Figma API文档的一个示例,演示如何在不使用内置Figma API方法的情况下从根部遍历树:

// 这 程实用程序会计算设计文件中实例内部子层的总层数。

let layerCounter = 0;

const analyzeLayers = (layerNode: SceneNode) => {
  if ('children' in layerNode) {
    layerCounter++;

    if (layerNode.type !== 'INSTANCE') {
      for (const subLayer of layerNode.children) {
        analyzeLayers(subLayer);
      }
    }
  }
}

analyzeLayers(figma.root); // 从文档根开始初始化图层分析

alert(`总层数:${layerCounter}`);

所以,展望未来,这是我最终选择的方法:编写一个通用算法,以便可以在几毫秒内浏览非常庞大的 Figma 文档。但即使这是我的最终选择,也不是我解决问题的第一次尝试。 在选择的项目到根之间,收集仅位于下方的兄弟节点(在数组中的“左侧”)和父节点。注意:我们只需要“左侧兄弟节点”,因为在 Figma 图层的视觉组成中,只有这些节点会位于所选项目的下方。

export const getSiblingsBefore = (
  targetNode: SceneNode,
  allNodes: readonly SceneNode[]
): SceneNode[] => {
  const targetIndex = allNodes.indexOf(targetNode);
  return targetIndex === -1 ? [] : allNodes.slice(0, targetIndex);
};

export const traverseToRoot = (node: SceneNode): SceneNode[] => {
  const parents: SceneNode[] = [];

  while (notEmpty(node)) {
    parents.unshift(node);
    node = node.parent as SceneNode;
  }

  return parents;
};

第一个绕过方法的示例

每个列表,包括兄弟和父节点,均根据我们的几何交集进行筛选。 根据选定元素的指定标准,对每个项目进行评估,将它们理论化为 Figma 中选定元素的潜在背景。

export const isNodeIntersecting = (
  node: SceneNode,
  selectedNode: SceneNode
): boolean => {
  if (!hasBoundingBox(selectedNode)) return false;

  return (
    isDifferentNodeWithBoundingBox(node, selectedNode.id) &&
    isContainedIn(node.absoluteBoundingBox, selectedNode.absoluteBoundingBox) &&
    nodeHasFills(node)
  );
};

后来,我想到一件事:如果父元素可以用作背景,那么它的其中一个子元素也可以用作背景吗?🤔 如果可以,那我们会遇到一个问题:要么我们必须检查所有子元素和所有父元素,要么我们可能会错过真正的背景。

对于更好或更糟糕的情况,这是一种绕过方法,以树结构进行说明。 经过仔细思考,我们意识到该算法仍然存在一些问题,这意味着我们必须改进它或尝试另一种方法。

顿悟时刻 #

经过一天半的思考,我让大脑自行搜索解决方案,而这时恰好在喝咖啡的时候灵光乍现。

我意识到树中的每个节点都有一组属性,这些属性使我们能够确定几何匹配,因此我们可以首先改变搜索方向(不从深度开始,而是从根开始)...

...其次,通过相同的标准切断整个树的分支(在Figma页面上也称为帧)。 几何匹配:

因此,如果我们检查到一个父节点,例如子节点数量巨大,且该父节点与所选元素不存在几何重叠,这意味着我们无需进一步检查其子节点。在脚本执行时间/操作方面,这显著提高了效率。

import { areNodesIntersecting } from './are-nodes-intersecting.ts';

export const traverseAndCheckIntersections = (
  nodes: SceneNode[],
  selectedNode: SceneNode,
  accumulator: SceneNode[] = []
): SceneNode[] => {
  nodes.forEach((node) => {
    if (areNodesIntersecting(node, selectedNode)) {
      accumulator.push(node);

      if ('children' in node && node.children.length > 0) {
        traverseAndCheckIntersections(
          Array.from(node.children),
          selectedNode,
          accumulator
        );
      }
``` });

  return accumulator;
};

最后的一步:z-index排序

我需要做的最后一件事是按z-index对找到的元素进行排序。这是必要的,以确定所选元素的真实背景。

由于Figma不提供元素的z-index属性,我必须创建一个辅助函数来确定元素在树中的嵌套级别以及其在父元素的子元素数组中的位置:

因此,在一个辅助函数中,我必须确定元素在树中的嵌套级别以及其在父元素的子元素数组中的位置:

```typescript
export const createPolychromNode = (node: PageNode | SceneNode): PolychromNode => {
  const parents = collectNodeParents(node);

  return {
    nestingLevel: parents.length,
    parents,
    zIndex: node.parent?.children.findIndex((child) => {
      return child.id === node.id;
    }),
  };
};

export const collectNodeParents = (
  node: PageNode | SceneNode,
  parents: SceneNode[] = []
}; ```typescript
): SceneNode[] => {
  if (notEmpty(node.parent)) {
    if (node.parent.type === 'PAGE' || node.parent.type === 'DOCUMENT')
      return parents;

    parents.push(node.parent);

    collectNodeParents(node.parent, parents);
  }
  return parents;
};

然后,只需根据嵌套级别和z-index对找到的元素进行排序:

export const sortNodesByLayers = (nodes: PolychromNode[]): PolychromNode[] =>
  nodes.sort((a, b) => {
    const levelDifference = b.nestingLevel - a.nestingLevel;
    const zIndexDifference = Math.abs(b.zIndex ?? 0) - Math.abs(a.zIndex ?? 0);

    return levelDifference !== 0 ? levelDifference : zIndexDifference;
  });

这完全解决了问题。

总结思路和创新的推动 #

让我们快速总结一下。首先,考虑制作自己的Figma插件。这很简单而有趣!

其次,正如我在开头提到的,前端不仅仅是关于着色按钮。有时候您需要(或者)``` 在Evil Martians,我们将处于增长阶段的初创公司转变为独角兽,开发开发者工具,并创建开源产品。如果您准备启动超空间推进,请联系我们!并且当然,请随时在Figma上使用Polychrom (opens new window)!愿您有幸了解算法和数据结构。因此,即使您的主要关注点是界面开发,也不要忽视它们。