製作 Figma Plugin


Posted by ArvinH on 2020-12-08

前言

Figma 已經是現在設計師間的必備武器,不少人都已經從 Sketch 轉移到 Figma 上,其免費方案幾乎包含所有核心功能,讓像我一樣沒有設計專業的工程師也能毫無壓力的使用。

Figma 主要建構於 Web 技術,透過 Webassembly 來使用 C++ 等模組,而其推出的 Plugin 平台理所當然也能由 Javascript 撰寫。

基於這個事實,讓我覺得或許學習一下如何撰寫 Figma plugin 是個不錯的投資,如果能提供公司內部設計師所需要的外掛,不僅能提升整體工作的效率,也能提高自己對團隊的影響力,雙贏策略!所以今天就來學習一下如何製作 Figma plugin!

如果有讀者不太知道 Figma plugin 會長什麼樣子,這邊給大家看個範例:

demo

也可以去官方 community 網站看看其他實際發佈上線的外掛,並載下來試用。

上圖的範例是為設計中的元件加入一些 status,像是 In reviewWork in progress 等等。(實際上早已有人實作ㄧ樣點子的外掛,也比較完善,所以我也不打算發佈出去,就當作一個練習,並拿來說明實作過程,原始碼在此。)

在開始之前,想跟大家分享一個很讚的東西,這是一齣由 Figma 員工在其內部一年兩次的 Maker Weeks 中所製作的音樂劇,完整體現 2020 年因為武漢肺炎而造成的許多工作軼事,其中還包含他們處理 incident 的過程,好聽之外也非常有趣,他們應該都工作得很愉快吧:)

剛好最近又是他們的 Maker Weeks,可以到 Figma 的 Twitter 看看員工分享他們做了什麼!

一些簡單的前置作業

如果你跟我一樣,是個蠻愛跟風的前端工程師,需要做的前置作業相信你都早已準備好了。

基本就是需要你有能夠運行 Typescript、Nodejs 與 NPM 的環境、適合 TS 的編輯器(VScode, etc)以及桌面版本的 Figma。

對,目前若是要開發 Figma Plugin,你必須使用桌面版本,他們才能讀取你本地端的程式來執行。

Figma plugin context 介紹

在真的進入開發階段前,先了解一下 Figma plugin 是怎麼運作,其基本架構為何,對開發會有不小的幫助,就像是你開發 Chrome extension 時,也得先搞懂何謂 background scriptscontent scripts ㄧ樣。

如同前面所說,Figma plugin 全由 Web 技術打造,也就是 Javascript、CSS 與 HTML,然而為了維持 Figma 本身的穩定與安全性,你的 plugin 需要拆分成兩個部分,執行在不同的環境:

  • Sandbox

一部分是運行在 Figma main thread 中 Sandbox 內的程式,可以使用完整的 ES6 Javascript,存取操作 Figma 檔案內容,但無法存取 DOM 物件,也無法發出網路請求。

  • iframe

另一部分則是運行在 iframe 內,可以存取 DOM 元素、發起網路請求等一般的網頁功能,因此也是你提供 plugin 使用者外掛操作介面的地方。

官方文件的一張圖中,可以非常簡單明瞭的看出架構:

figma-plugin-structure

兩個部分的程式的溝通如同 worker 與 main thread 溝通一般,透過 postMessageonmessage 來收發訊息。

目前支援的 Figma Plugin API

目前 Figma plugin API 支援:

  • Reading layers and layer properties in the local file

    理論上整個 figma file 內的 content 你都能夠讀取,包含 layer 的各種屬性。在現有架構下,即便有缺少的,也能夠提 request 請官方補上,不會太困難。

  • Create a modal with custom UI

  • Access browser APIs

    由於有一部分的 code 是會運行在上述提到的 iframe 中,而在 iframe 中基本上你可以創建任何 Web UI,運行 JS、發送 network request 等等。

雖然看起來只有三個大類別,但其實也已經包含了上百個 method 與可操作的屬性。

未來官方還想繼續拓展延伸的部分有更多,包含像是 Access Team libraryAccess Team info,以及我最想要的 Trigger plugin code on events 等等。不過這些並沒有排入他們目前的 Road map 中,因為要在不影響主要產品的穩定度下進行,需要考慮的東西很多,所以沒有明確的 Timeline。

至於詳細的項目以及個別項目實作上會有的困難,官方都有在文件中說明,我覺得很簡單易讀,建議大家有興趣的話去看一看,可以看出他們對於整體設計上的一些思路。

存取 Figma Document

基本上 Figma 的 plugin 都是想要存取 Figma 檔案內的物件,或是應該說是 Layer:

figma-layers-example

不過以程式的角度來看,Layer 其實比較適合用 node 來代表。

有稍微用過 Figma 這類設計工具就知道,物件都可以被 Grouping 在一起成為一個物件,但你還是能繼續編輯 Group 底下的每一個物件,也就是 Layer 底下還可能會有其他 Layer,而這個概念就跟樹狀結構一樣?,所以在撰寫程式的時候,用 node 來代表會直覺一點。

例如,如果我們想要將當前選擇的物件中的文字統一調整成 font size 16,我們可以這樣寫:

for (const node of figma.currentPage.selection) {
  if (node.type === "TEXT") {
    text.fontSize = 16;
  }
}
// 告知 figma 你的 plugin 已經結束,可以關閉。
figma.closePlugin()

而當你需要遍歷尋找特定節點時,就可以依靠我們學習樹狀資料結構時最常用的 traverse 函式,自己去找出需要的 Node:

function traverse(node) {
  if ("children" in node) {
    if (node.type !== "INSTANCE") {
      for (const child of node.children) {
        traverse(child)
      }
    }
  }
  // leaf, do something...etc
}
traverse(figma.currentPage) // 從 root 開始跑, 用 figma.root 也可

Node Type

Figma 中有許多類別的 Node,像是 [RectangleNode](https://www.figma.com/plugin-docs/api/RectangleNode)[TextNode](https://www.figma.com/plugin-docs/api/TextNode)[FrameNode](FrameNode) 等等,完整列表可以參考官方文件 - Node Type

零零總總有 16 種不同類別的 Node,每一種也都擁有不同的屬性。要記起來不太可能,這時就得感謝一下 Typescript 的幫忙,Figma 有提供 @figma/plugin-typings 供你使用,搭配 VSCode 的支援,在操作不同 Node 時,可以輕鬆知道其所擁有的屬性,若是不清楚詳細用法,再對照官方文件即可。

ts-autocomplete

此外,因為大部分的 Plugin 都是作用在特定的 Node type,為了不讓系統 crash,製作 plugin 時,各種 edge case 的處理就很重要,你要顧慮到當使用者在不適合的 Node type 上使用你的 plugin 時該怎麼處理,無論是 ignore 或是給予警告都可以,重點是要盡量讓系統維持穩定。

編輯屬性

看到這邊你可能會覺得奇怪,編輯屬性為什麼需要特別提出來說呢?

一般來說,更改 Object 內部的 properties,我們自然會這樣處理:

text.fontSize = 12

在 Figma plugin sandbox 中操作普通的 Node object 時,這樣的寫法大部分也會是有效的。

然而,若是要操作一些複雜一點的屬性,像是陣列(Array)與陣列內的物件(Object)時,就不能這樣處理,除了修改不會生效外,Figma 也會報 Error:

// error: object is not extensible
figma.currentPage.selection.push(otherNode)
// error: Cannot assign to read only property 'r' of object
node.fills[0].color.r = 10

若要成功更改內容,你必須複製整個內容然後取代,例如:

const selection = figma.currentPage.selection.slice()
selection.push(someNode)
figma.currentPage.selection = selection
const fills = JSON.parse(JSON.stringify(rect.fills))
fills[0].color.r = 0.5
rect.fills = fills

之所以要這樣做,主要原因是,某些 Javascript Object 在 Figma 的 Sandbox 中其實並不是一般的 Javascript Object,Figma 在其內部有特殊的實作,expose 出的介面可能背後有複雜的抽象細節,像是屬性更動時,需要處理 re-renderupdate instance 等等;加上 Figma 背後使用 WebAssembly memory,基於穩定性與實作複雜度的考量,才只能出此下策。

這點在實作 Plugin 的過程中,算是比較需要注意以及比較麻煩的點。

官方在文件內有說明他們的難處與考量,有興趣的可以前去詳讀,附上連結在此

開始實作吧

基本的概念介紹得差不多了,可以開始實作了。

回顧一下我們的範例:

demo

功能很簡單,就是幫使用者選取的物件加上 Label,會創建出一個 FrameNode,然後與使用者選取的物件群組在一起。

前面說過,要開發 Figma plugin 需要使用 Figma 桌面版(有 Windows 與 Mac 版本),在 Figma 桌面版中,點選右鍵 -> Plugins -> Development -> New Plugin...

figma-new-plugin-step1

figma-new-plugin-step2

Figma 已經為你準備好了一些基本的 template,有三種選項可以選擇,RunOnceWith UI & browser APIs 的差別就在於有沒有 iframe 的環境可以提供 Plugin 使用者一個 UI 介面操作。

figma-new-plugin-step3

選定好 template 後,選擇 Save as... 就可以將一個 Figma plugin template 載下。(這次的範例選擇 With UI & browser APIs

載下來的 Plugin template 結構如下:

figma-new-plugin-template-structure

是一個簡單的 typescript project,code.ts 是主要的 Figma Plugin Sandbox 程式,code.js 可想而知是編譯後的檔案。

ui.html 則是運行在 iframe 裡面,你可以用來繪製 UI 與使用 browser APIs、Network reqeust 的部分。

mainifest.json 則是用來描述你的 Plugin,告知 Figma 你的 Sanbox code 與 iframe code 位置在哪等等:

{
  "name": "StatusLabel",
  "id": "917361515292167655",
  "api": "1.0.0",
  "main": "code.js",
  "ui": "ui.html"
}

這邊的 mainui 就是主要程式進入點,所以你其實也可以像開發一般的 Web SPA 一樣,用 React、Vue 來製作 UI,用 Webpack 來 bundle 你的程式,只要指定對路徑即可。範例可以參考此處

ui.html

我們先從 ui.html 開始看起:

<h2>Select status label</h2>
<label for="status">Choose a status</label>

<select name="status" id="status">
  <option value="LGTM">LGTM</option>
  <option value="Work in progress">Work in progress</option>
  <option value="In Review">In Review</option>
  <option value="Please Review">Please Review</option>
</select>
<button id="create">Create</button>
<button id="cancel">Cancel</button>
<script>
  document.getElementById('create').onclick = () => {
    const select = document.getElementById('status');
    const text = select.options[select.selectedIndex].value;
    parent.postMessage({ pluginMessage: { type: 'create-label', text } }, '*')
  }

  document.getElementById('cancel').onclick = () => {
    parent.postMessage({ pluginMessage: { type: 'cancel' } }, '*')
  }
</script>

完全就是一個普通的 HTML 檔案,唯一要注意的是,因為他是運行在 iframe 裡面,所以當我們要將資訊傳遞給 Sandbox 內的 code.ts 時,得用 parent.postMessage()

傳遞的參數要用 pluginMessage,可以傳遞幾乎任何 object。這邊我們為了要讓 Sandbox 內的 code.ts 知道使用者點選了哪種 Button,以及選擇了哪個 status label,我們定義以下的 Message 來傳遞:

type Msg {
  type: 'create-label' | 'cancel';
  text?: string; // 使用者 select 的字串
}

上面的程式碼呈現在 Figma 當中就如下圖(畫面中的 Developer VM 待會會說明):

figma-plugin-ui

code.ts

使用者介面有了,接著來看 code.ts

主要重點有兩個函式:figma.showUIfigma.ui.onmessage

// This shows the HTML page in "ui.html".
figma.showUI(__html__);

figma.ui.onmessage = msg => {
  // ...Implementation details
  // ...ignore for now
  // Make sure to close the plugin when you're done. Otherwise the plugin will
  // keep running, which shows the cancel button at the bottom of the screen.
  figma.closePlugin();
};

figma.showUI 就是單純告知 Figma 你有一個 UI 要呈現給使用者,參數 __html__ 會指向 ui.html 的內容,Figma 會開啟一個 Modal 去呈現這個 iframe UI。

figma.ui.onmessage 用來接收從 iframe 傳來的 postMessage。(相反的,我們也可以用 figma.ui.postMessage 傳遞資訊給 iframe,iframe 以 window.onmessage 接收。)

以我們的範例來說,我們想要接收的訊息可以分為兩種:

  • 插入使用者選擇的 Status label(Create button)
  • 關閉外掛(Cancel button)

figma.ui.onmessage 中,我們根據接收到的 msg.type 來判斷是要處理哪一種類型的動作:

figma.ui.onmessage = async (msg) => {
  if (msg.type === 'create-label') {
    // 替選取的物件加上 label
  }
  // 若 msg.type !== 'create-label',就直接關掉 plugin,也就是 cancel button
  figma.closePlugin();
};

msg.typecreate-label 時,代表我們要創建 status label 並與使用者當前選取的 Layers(Nodes)群組在一起:

if (msg.type === 'create-label') {
  const nodes: SceneNode[] = [];
  await figma.loadFontAsync({ family: "Roboto", style: "Bold" });
  let group;
  for (const node of figma.currentPage.selection) {
    const frame = createLabel(msg);
    // 調整位置
    frame.x = node.x;
    frame.y = node.y - 50;
    group = figma.group([node, frame], figma.currentPage);
    group.layoutGrow = 1;
  }
  nodes.push(group);
  figma.currentPage.selection = nodes;
  figma.viewport.scrollAndZoomIntoView(nodes);
}

因為我們的 Label 需要用到文字,也就是 TextNode,在 Figma 內他會要求你先載入字體檔,所以才需要加入這行:await figma.loadFontAsync({ family: "Roboto", style: "Bold" });

接著我們遍歷 figma.currentPage.selection 這個陣列,該陣列包含所有被使用者選取的 Layers(Nodes),我們針對每一個 Node 都創建一個 Label,這邊我們使用的是 FrameNode,類似於 HTML 中的 Div,我們用他與 TextNode 一起組合排版出一個 Label,像這樣:

figma-plugin-ui-label

createLabel 函式是我自己抽出去實作的,主要就是針對 FrameNodeTextNode 做排版、顏色、內容的處理,這邊簡略給大家看一下,實際上 Node 的使用方式,例如有什麼屬性可以調整,可以搭配官方文件閱讀:

const createLabel = (msg) => {
  const frame = figma.createFrame();
  frame.name = 'Status Label';
  // ... 略
  frame.fills = [{type: 'SOLID', color: colorMap[msg.text]}];
  frame.cornerRadius = 4;
  frame.resize(120, 30);
  const text = figma.createText();
  text.fontName = { family: "Roboto", style: "Bold" };
  text.fontSize = 12;
  // ... 略
  // 這邊將 ui.html 傳入的 msg.text assign 給 TextNode
  text.characters = msg.text;
  frame.constrainProportions = true;
  frame.appendChild(text);
  return frame;
}

再來呢,我們想要讓群組好的 GroupNode,自動被選取,並移動到使用者視野正中間。

要做到這件事,我們必需把他放入一個 Node 陣列中,因為如同我們在 編輯屬性 提到的,要跟改陣列物件,我們必須整個陣列改掉才能生效:

//...略
const nodes: SceneNode[] = [];
//...略
// 將我們創造的 Label frameNode 與 目前選到的 Node 群組起來。
group = figma.group([node, frame], figma.currentPage);
//...略
nodes.push(group);
figma.currentPage.selection = nodes;
// 呼叫此 API 來移動 view port
figma.viewport.scrollAndZoomIntoView(nodes);

到這邊為止,整個 Plugin 的實作就完成了,接著只要執行 npm run build,將檔案編譯好,你就能在 Figma Desktop 中找到你的 Plugin 來使用。(可以在 Development 下面找到)

figma-plugin-in-app

當然,每次都要 build 會很麻煩,所以你一樣可以設定 Webpack watch mode,這樣會比較方便一些。官方有範例可以參考

如何除錯

開發的過程中,免不了需要除錯,Figma 既然是以 Web 技術為底,當然有 Dev Tool 可以使用,可以從 右鍵 -> plugins -> Development -> Open console 或是用跟 Chrome 開啟 Devtool 一樣的 shortcut 來開啟:

figma-plugin-debug

不過這只能讓你看到錯誤訊息,若是你想加入 Debugger 來下中斷點,並在 Console 內看到你的 source code,你需要啟用 Developer VM

figma-plugin-debug-developer-vm

figma-plugin-debugger

Caveat

運行在 Developer VM 環境的 Plugin,其效能與 runtime 與實際跑在 Figma 上的會有不同,所以正式發佈前記得要取消 Developer VM 的選項,在一般環境下運行看看。

發佈你的 Plugin

最後,完成你的 Plugin 後,當然會想要上架啦。

目前要上架 Plugin 需要經過 Figma 團隊的審核,官方有文章詳細說明每一個步驟,我就不贅述,可以到這邊查看:

Publish a plugin to the Community

Caveat

雖然我沒有實際上架 Plugin,但相信照著文章步驟做不會有太大問題,只是有一個點要注意一下。

你在 Figma 的帳號需要啟動 two-factor authentication 才能夠申請發佈 Plugin,如果你是用 Google account SSO 申請的帳號,是不能夠啟用 two-factor authentication 的,必須重新申請一個以 Email 註冊的帳號。

結論

Figma Plugin 的製作概念不複雜,除了要手動去設定每個 Node 的屬性來改變物件型態一開始不太習慣外,整體實作起來的感覺是蠻迅速方便的,程式編譯完以後,在 Figma 桌面程式內可以直接使用,不需要有額外載入的動作。

比較可惜的是目前還無法根據 Event 來觸發 Plugin,但依照 Figma 工程師的能力與創造力,相信在未來是有可能的。看看他們今年 maker week 就有人做了一個 Gameboy 的 plugin LOL:

感謝閱讀到這邊的讀者,不知道是否有激起你一點慾望想去試試看製作 Figma plugin?有或沒有都很好,至少希望你對此有個大概的了解。而我呢,準備要去跟設計師討論看看,有什麼內部需求可以來玩玩了!


#Figma #plugin #Web









Related Posts

【Day03】 Node Red使用教學

【Day03】 Node Red使用教學

[ 學習筆記系列 ] 很基礎的 JavaScript 入門 (二) - ES6 語法與 Module

[ 學習筆記系列 ] 很基礎的 JavaScript 入門 (二) - ES6 語法與 Module

講 Webpack 之前,先講 module 概念

講 Webpack 之前,先講 module 概念




Newsletter




Comments