Figma 插件开发

avatar
前端工程师 @公众号:ELab团队

目的:介绍 Figma 插件;figma 插件开发从 0 到 1;分享自己开发的想法

一、Figma 是什么,figma 插件是什么

Figma 初印象

figma 是一个 基于浏览器 的协作式 UI 设计工具

figma 插件初印象

加强功能:让选中元素一起旋转的同时,让每个元素自己单独旋转 ...

组合功能:让某批同类型元素先改变颜色,然后建立层次结构...

导出功能:将一些想要的元素内容设置为一些数据结构导出供其他程序使用...

导入功能:与导出功能相反...

替换重复工作

二、展示 lego-quiz-figma 初步功能

  1. 插件将选中元素保存为图片
  2. 根据不同场景,转化元素的位置
  3. 图片上传到后端
  4. 调整元素顺序
  5. 将当前场景保存,之后直接使用
  6. 将当前位置复制到剪切板
  7. 刷新当前选中元素,重新执行 1-5
格式
{
  "data": [
    {
      "type": "container",
      "name": "container_01",
      "width": 222.78260803222656,
      "height": 172.21739196777344,
      "x": 16,
      "y": 204,
      "children": [
        {
          "type": "image",
          "name": "玩具车3",
          "width": 75.78260803222656,
          "height": 50.21739196777344,
          "x": 0,
          "y": 0,
          "index": [
            0,
            9,
            0
          ],
          "url": "https://sf6-ttcdn-tos.pstatp.com/img/edux-data/1627009282549ca8bcf0b17~0x0.png"
        },
        {
          "type": "container",
          "name": "container_02",
          "width": 170.78260803222656,
          "height": 124.21739196777344,
          "x": 52,
          "y": 48,
          "children": [
            {
              "type": "image",
              "name": "Frame_662",
              "width": 151.78260803222656,
              "height": 124.21739196777344,
              "x": 11,
              "y": 0,
              "index": [
                0,
                9,
                1,
                0
              ],
              "url": "https://sf3-ttcdn-tos.pstatp.com/img/edux-data/1627009282384e5da6a2689~0x0.png"
            },
            {
              "type": "image",
              "name": "玩具车2",
              "width": 75.78260803222656,
              "height": 50.21739196777344,
              "x": 95,
              "y": 9,
              "index": [
                0,
                9,
                1,
                1
              ],
              "url": "https://sf6-ttcdn-tos.pstatp.com/img/edux-data/1627009282222dfb3a4ffa4~0x0.png"
            },
            {
              "type": "image",
              "name": "玩具车1",
              "width": 75.78260803222656,
              "height": 50.21739196777344,
              "x": 0,
              "y": 45,
              "index": [
                0,
                9,
                1,
                2
              ],
              "url": "https://sf3-ttcdn-tos.pstatp.com/img/edux-data/16270092820316c732f80fe~0x0.png"
            }
          ],
          "index": [
            0,
            9,
            1
          ]
        }
      ],
      "index": [
        0,
        9
      ]
    }
  ],
  "scene": "ER_L1"
}

三、Figma 插件深入体会

组成元素

如上图所示,整个插件分为两个部分,左边成为沙箱线程,之后就叫做主线程,右边为 iframe 线程,之后就叫 UI 线程

整个插件的入口是主线程,主线程采用一种沙盒结构,其中存在类似 node 环境,可以运行 js 代码,访问 ES6 的 API,可以去操控 figma 里面的内容

由主线程创建创建 iframe ,并将我们写的 html 插入,由主线程控制整个 iframe 的大小,在 html 中可以使用浏览器的 API,比如说向后端发起请求,可以和用户进行交流

UI 线程与主线程通过 postMessage 相互通信,所以这里可以有个流程,主线程先运行,主线程设置接收UI线程信息的方法,创建 UI,UI 设置接收信息方法。注意:两者通信内容有限制,除了常见的JSON数据类型外,还有 blob,arraybuffer,像在 figma 中的一些对象都不能直接传输

这两个部分分别就是我们需要提供的两份文件

Figma 插件能做什么

  1. 读取本地文件中的图层和图层属性

插件能暴露文件的内容,这个内容指的是我们在 figma 插件中看到的,比如某个元素的尺寸,位置,层次结构,颜色,文本内容等,我们不仅能获取到,还能更改。

  1. 设计插件的 UI(iframe
  2. 可以访问一些浏览器 API (有些 API 例外,例如 indexedDB

有网络请求;打开文件;使用 canvaswebgl(pixi);使用 WebAssembly,使用音频 API;

四、从 0 到发布,走一遍流程

  1. 创建新插件

打开创建插件的窗口

创建 manifest.json 文件

manifest.json 内容

{
  "name": "lego-quiz-figma",
  "id": "996264569045667578",
  "api": "1.0.0",
  "main": "dist/code.js",
  "ui": "dist/ui.html"
}

注意:id,main,ui

id

有一些操作必须得用到这个 id,否则操作会被拒绝,目前我在让 figma 存储一些内容时,必须得在这里声明 id,否则报错。这个 id 生成的方式是,上图中选 "生成新的 manifest.json" ,在生成的 json 文件中就带有属于当前插件的 id

Main 和 ui

回顾 figma 插件两大组成,main 对应主线程内容,ui 对应 ui 线程内容。这里存放的只是路径,在开发模式下,意味着懒加载,即启动时才去相应路径取文件,所以后面我们开发时可以使用 webpack watch 特性,每次更新程序都会把更新程序打包到 dist 文件下,在之后打开插件取的就是更新后的插件,在发布模式下,文件会上传至公司内部,因此地址会发生变化,需要手动上传更新

总结

Figma 插件开发时,只需要向 figma 软件提供一个 manifest.json 文件即可,但是在 manifest.json 文件中必须带有 ui 线程和主线程需要的两份文件地址,当然相应文件得存在。在完成一些特别的操作,需要提供 id,id 声明在 manifest.json 文件中。在开发模式下,dist 文件夹中的内容可以实时更新,在 figma 中重启插件即可应用新的插件内容。

  1. 创建 Main 和 ui 对应的文件

Figma 获取到 manifest.json 文件后,会先执行 main 中的内容

回顾

插件需要两个文件,主线程用于和 figma 进行交互,ui 线程用于和用户进行交互。我们开发肯定不是直接在这两个文件中写内容,而是将写的内容打包到这两个文件中。

webpack 配置在项目中,建议后续开发在我这个配置上更改,因为里面有些内容是 figma 官方提供,官方提供插件案例代码地址:figma 插件案例

打包写入时有个细节,使用 HtmlWebpackInlineSourcePlugin 将代码嵌入到 ui.html 中,这里不能使用 link 或是 script 的 src 标签,因为 figma 只要 manifest.json 中声明的文件。

使用 ts 时安装 npm install --save-dev @figma/plugin-typings 获取 figma 中各种元素类型

编写主线程代码

/// <reference path="../../node_modules/@figma/plugin-typings/index.d.ts" />

import { receiveUIMessage, sendCurrentMode } from './ui-relation';

figma.showUI(__html__, { visible: true, width: 300, height: 180 });

function start() {
  // 1. 设置接受 ui 方法,第一步
  receiveUIMessage();

  // 2. 获取当前模式, 并发送 UI
  sendCurrentMode();
}

start();

说明

  • Reference 用作 figma 元素的类型提示
  • 第五行是主线程创建 ui 线程并赋予宽高

具体内容后面再看,主线程代码完成,并且创建了 ui ,接下来就是把我们写的 ui 嵌入

编写 UI 代码

return (
    <div className="upload-image">
      <div className="button-group">
        <Button
          loading={loading(imageInfo)}
          icon={<CopyOutlined />}
          id="copy-btn"
          data-clipboard-text={addImageInfo(imageInfo)}
        >
          复制
        </Button>

        <Button onClick={updateEvent} icon={<RetweetOutlined />}>
          刷新
        </Button>
      </div>

      <p className="label-model">业务场景</p>
      <Radio.Group onChange={onChange} value={model}>
        {/* 中点y轴向下 */}
        <Radio value={Models.COMMON_DEV}>通用</Radio>
        {/* 0,0 y轴向下 */}
        <Radio value={Models.ER_L1}>ER L1</Radio>
        {/* 中点y轴向上 */}
        <Radio value={Models.ER_GAME}>ER 课后练习</Radio>
      </Radio.Group>
    </div>
  );

这是使用 react 生成一个 div 标签,作为 ui 中的子节点,与平常开发网页类似,最后将有关内容全都集合在一个 ui.html 中

  1. 导入配置并执行

导入 manifest.json 过程之前已经介绍,导入后执行过程如下

点击执行-->执行主线程文件-->创建 iframe-->插入 UI 内容

  1. 发布插件

找到插件管理

发布插件

五、介绍 lego-quiz-figma 细节

主线程文件名为 code

Code 相关细节

  1. code 通过 postmessage 接收和传输消息
// 发送消息到 UI
export function transferUIData(transferData: CodeToUIData) {
  figma.ui.postMessage(transferData);
}

// 接受来自 UI 的消息
export function receiveUIMessage() {
  figma.ui.onmessage = ({ message, type }) => {
    if (type === UIToCodeType.UPDATE) {
      figmaStorage.setData('model', message);
      startDataTransform();
    }
  };
}
  1. Figma 存储和获取信息,用于存储当前选中的场景,下次打开时使用,这里的 api 就需要使用到 id,没有提供会报错(不能使用 LocalStorage 和 indexedDB
export const figmaStorage = {
  cache: {},
  async getData(key: string) {
    if (this.cache[key]) {
      return this.cache[key];
    }
    // 主要 api
    const value = await figma.clientStorage.getAsync(key);
    this.cache[key] = value;

    return value;
  },
  // value 可以是任意类型数据
  setData(key: string, value: any) {
    if (this.cache[key] && this.cache[key] === value) {
      return;
    }
    this.cache[key] = value;
    // 主要 api
    return figma.clientStorage.setAsync(key, value);
  }
};
  1. 获取选中元素,转化为结构化数据通过 postmessage 传输到 ui 线程,

获取选中元素的顺序是有问题的:

选中此页面上的节点。每个页面分别存储自己的选择。选择中的节点顺序是未指定的,您不应该依赖它

我的解决方案,记录下每个元素的层级信息 [0,8,4],[1,6,5],[0,8,5],[1,7],[2] 根据每位数值判定谁是上级谁是下级(先后顺序判断 )

获取选中节点 api: figma.currentPage.selection,得到选中元素的数组

遍历每一个元素,将每个元素转化成预期数据结构并存入一个数组中

不同场景下,每个元素的坐标略有差异,每个元素经过转化后的类型

// 1. 创建对象
  const nodeData = {
    type: exportNodeType,
    name: normalName,
    width: width,
    height: height,
    x: x + pos.x,
    y: y + pos.y,
    bytes,
    children: null
  };

type:分为四种类型,zone:热区,locateDot:锚点,container:容器,image:图片

热区:表示一块区域,这块区域用于一些判断操作,最后预览时不会显示,比如某些点击事件只能在这里面进行操作,比如某些元素只能在区域内部移动等

锚点:给某些元素提供一个参考原点,便于计算

容器:专门用作多级目录使用,存储自己以及孩子的信息,container 自己没有需要显示的内容

图片:基本上能看到的内容都属于图片类型

转化过程需要注意:在 figma 中,frame 和 group 坐标计算方式是不同的

  1. 转化过程

建立一个原点,让选中的所有元素的 x 和 y 都是基于这个原点,剔除 frame 对其孩子的影响

const pos = {
    x: 0,
    y: 0
  };
  let temp = findFrameNode(node.parent);
  let ratio = 1;
  while (temp) {
    ratio = temp.width / CONVENTION_SIZE.width;
    if (frameMayBeContainer(temp, ratio)) {
      break;
    }
    if (temp.parent.type !== 'PAGE') {
      // 当前 frame 在 page 中的地方
      pos.x += temp.x;
      pos.y += temp.y;
    }
    temp = findFrameNode(temp.parent);
  }

如果元素是图片,将图片导出成 Uint8Array 格式存入 bytes 属性中

// 遍历 node
  if (exportNodeType === 'image') {
    nodeData.bytes = await node.exportAsync({
      format: 'PNG',
      constraint: {
        type: 'SCALE',
        value: 1.5
      }
    });

    delete nodeData.children;
  } 

如果元素是 container 并且有孩子,那么递归调用自己

if ('children' in node) {
      for (const child of node.children) {
        nodeData.children.push(await getImageInfo(child, originNode));
      }
    }

前面工作完成后,就可以开始转换每个元素的坐标,影响坐标的元素有两大点,第一大点是当前场景,第二大点是选中节点是 container 类型

转化方法如下

// nodeInfo 为节点转化后的信息,position 相对原点
// originInfo 是离节点最近的参考点,主要用于 container,如果没有 container,最近参考点就是原点
function getModelPosition(nodeInfo: ExportNodeInfo, originInfo) {
  return {
    [Models.COMMON_DEV]: () => ({
      x: normalNum(
        nodeInfo.x - originInfo.originX - originInfo.w + nodeInfo.width / 2
      ),
      y: normalNum(
        nodeInfo.y - originInfo.originY - originInfo.h + nodeInfo.height / 2
      )
    }),
    [Models.ER_L1]: () => ({
      x: normalNum(nodeInfo.x - originInfo.originX),
      y: normalNum(nodeInfo.y - originInfo.originY)
    }),
    [Models.ER_GAME]: () => ({
      x: normalNum(
        nodeInfo.x - originInfo.originX - originInfo.w + nodeInfo.width / 2
      ),
      y: normalNum(
        -nodeInfo.y + originInfo.originY + originInfo.h - nodeInfo.height / 2
      )
    })
  };
}

这里还要考虑在 edit 中,三个场景的区别,方便后续将数据传入并解析

首先分析上述代码,以 ER_L1 为例,同时这也是最容易转换的情况

ER_L1 坐标计算方式与 figma 相同,因此不需要进行坐标变换,减去最近参考点坐标的原因如下

有同学肯定会问,之前把参考点都置为原点,现在又把参考点置为最近参考点,有必要吗?

有,置为原点原因在于:可能直接复制 frame 下的元素,此时该元素的 X 和 Y 的参考点就应该是原点,而不是 父节点frame。还原的原因是复制 container 时,由于要记录层级关系,因此要转化为相对于父节点的坐标,而非全局坐标

UI 相关细节

  1. UI 接收 code 消息
 window.addEventListener('message', (event) => {
      const { type, data } = event.data.pluginMessage;

      switch (type) {
        case CodeToUIType.UploadImage:
          uploadHandler(data);
          break;
        // 初始化 模式
        case CodeToUIType.ModelData:
          setModel(data);
          break;
        case CodeToUIType.COMMON_MESSAGE:
          message[data[0]]({
            content: data[1],
            className: 'message-style',
            duration: 2
          });
      }
    });
  1. UI 向 code 发送消息
const sendMessageToCore = (message: any, type: UIToCodeType) => {
// pluginMessage, * 是不可变的
    parent.postMessage({ pluginMessage: { message, type } }, '*');
  };
  1. 业务场景区别

三个场景 x 轴正方向都是 向右👉

通用:COMMON_DEV,锚点在 (0.5,0.5),y 轴正方向 向下👇

ER L1:ER_L1,锚点在(0,0),y 轴正方向 向下👇

ER 课后练习:ER_GAME,锚点在 (0.5,0.5),y 轴正方向 向上👆

六、体会与交流

我的体会

  • Figma 插件能做一些我们手动做的事,因此有些规律性工作可以使用 figma 插件
  • 我们不能总是向 UI 设计师索取内容,假如看到一些好的元素,好好利用 figma 插件导入一些资源,给 UI 一些惊喜也是好的
  • 我做的功能是导出自定义格式数据,可扩展性不高,后续可以调研如果导出一种通用的格式
  • Figma 有些比较有趣的内容,比如可以直接导出 Uint8Array 格式的图片,可以细腻控制每一个元素
  • 可以多多交流 制作插件的体会

Figma 对插件的展望

  1. 更多的访问文件、用户、团队信息、评论
  2. 完全访问团队库
  3. 在事件上触发插件代码,挑战在于:性能下降,内部稳定性,外部稳定性
  4. 长时间允许插件
  5. 属性面板/工具栏中设置插件
  6. 插件的键盘快捷键
  7. 文件浏览器中的插件
  8. 访问版本历史记录
  9. 辅助函数 API
  10. 插件的 Figma UI 组件,能够倒入 Figma 中的元素
  11. 插件分析错误报告

不会做

  1. 桌面特定的API
  2. 加载外部字体 

❤️ 谢谢支持

以上便是本次分享的全部内容,希望对你有所帮助^_^

喜欢的话别忘了 分享、点赞、收藏 三连哦~。

欢迎关注公众号 ELab团队 收货大厂一手好文章~

我们来自字节跳动,是旗下大力教育前端部门,负责字节跳动教育全线产品前端开发工作。

我们围绕产品品质提升、开发效率、创意与前沿技术等方向沉淀与传播专业知识及案例,为业界贡献经验价值。包括但不限于性能监控、组件库、多端技术、Serverless、可视化搭建、音视频、人工智能、产品设计与营销等内容。