Figmaのマスク仕様が納得いかないので、プラグインを作った

この記事は [ここのえ Advent Calendar 2023](https://qiita.com/advent-calendar/2023/99no_exit) Day 22 の記事です。

# Figmaのココだけ納得いかない……

昨年のAdobeによるFigma買収発表後[^1]、年明け早々Adobeの製品一覧からAdobe Xdが姿を消しました。[^2]。10月頃には終了をにおわせるような内容が記載されていた[^3]ことで、Twitterで話題になったのも記憶に新しいです。

そんなこんなでXdを諦めて、今年の3月頃から **Figma** に本格的に移行を始めました。

少人数のチーム開発で何度か使っていますが、かなりパワフルな印象があります。最近は`Dev Mode`なんかもあるのでエンジニア視点でも期待しています。

<br/>

そんな **Figma** にはどうしても1点だけ腑に落ちない点があります。

**マスクの仕様**です。

![figma_mask.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3617021/12c4eda3-f05d-0a87-1d60-99a91a13049c.png)

**イラレ、Xdと逆！！**

どうやら色々調査をしていると、Sketchを源流としてUIデザインツールではこの仕様が当たり前みたいな風潮があるようです。
調べた限りではXd以外の大半のツールがレイヤーを下に置く形みたいです。

ただ理解はできても納得はできず、同じように考えてる人は相当数いるようで……　フォーラムにもこんな記事が出てます。

https://forum.figma.com/t/masking-the-masking-layer-should-be-over-not-under/1732/8

なんだかんだでしばらく我慢していたのですが、どうやっても毎秒操作を間違えておかしなマスクを作ってしまいます。

一年の後、汝水のほとりに宿った時、遂に発狂して虎になってしまいました。

# 作った

上からマスクする方法は、設定でどうにかできるものでもありません。プラグインも軽く探しましたが、発見できませんでした。

無いならどうするか？ 作りましょう。……**作りました。**

https://www.figma.com/community/plugin/1309075086721748903/upper-mask

![hero.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3617021/14a19e88-6a31-1ee6-a598-035e2a00300d.png)

マスクしたいオブジェクトとシェイプを複数選択した状態で右クリックして、 コンテキストメニューの `Plugins` -> `upper-mask` でマスクを作成できます。

# Figmaプラグイン実装まわりのTips

ここから本題です。
せっかくプラグインを作ったので、Figmaのプラグイン周りのTipsを幾つか紹介します。

基本的な概念は **Japanese Dammy Text** の作者さんの記事が分かりやすいのでお勧めです。私も制作時にお世話になりました。この場を借りて感謝申し上げます :bow:

https://zenn.dev/ixkaito/articles/how-to-make-a-figma-plugin

## `@types/node` と競合する

プラグイン作成時、自動で以下のような `tsconfig.json` になっています。
```tsconfig.json
{
  "compilerOptions": {
    "target": "es6",
    "lib": ["es6"],
    "strict": true,
    "typeRoots": [
      "./node_modules/@types",
      "./node_modules/@figma"
    ]
  }
}
```

デフォルトでこんな設定になっていますが、`Custom UI`の開発などの理由で`@types/node`を導入した場合、 `fetch()` の定義が干渉します。

```bash
node_modules/@types/node/globals.d.ts:350:18 - error TS2451: Cannot redeclare block-scoped variable 'fetch'.

350 declare function fetch(
                     ~~~~~

  node_modules/@figma/plugin-typings/index.d.ts:27:9
    27   const fetch: (url: string, init?: FetchOptions) => Promise<FetchResponse>
               ~~~~~
    'fetch' was also declared here.
```

Typescriptを入れると反射的に入れがちですが、不要なら`@types/node` は導入しないほうが良いです。
`typeRoots` を書き換えて解決するのも一応できます。

```tsconfig.json
"typeRoots": [
  "./node_modules/@figma"
]
```

## `group()` によるグループ化を行う際のparent要素

グループ化に使う`group`メソッドですが、第二引数でグループが属する親要素を指定する必要があります。

```ts
group(nodes, parent, index?): GroupNode
```

:::note warn
Figmaは長方形や円、画像といったオブジェクトのことをNodeと呼びます。
`node.js`のnodeではないので注意。
:::

SceneNodeは基本的に親要素を所持しているため、例えば選択したノードをグループ化する時はこれが最適です。
```ts
// 選択しているノード
const selection = figma.currentPage.selection;
// 最後に選択したノードの親要素をグループの親要素とする
const parent = selection[selection.length - 1].parent

group(selection, parent)
```

ただし例外があり、**ページ直下にあるノードは `.parent` が `null` になります**。
従って、parent要素がない場合はページの参照を取ってくる必要があります。

```ts
let parent: (BaseNode & ChildrenMixin) | null = selection[selection.length - 1].parent;
if (parent === null) parent = figma.currentPage;
```

## ドキュメントと並行して`plugin-api.d.ts`も確認すべし

例えばオブジェクトをマスクにする際は、`isMask` を `true` に必要があります。ドキュメントを見ると、`BooleanOperationNode`, `ComponentNode`, `ComponentSetNode`, etc...とあり16種のノードが対応しています。[^4]

当然ですが、型ガードの為に全部`|`で繋いでなんて書いていられません。Plugin APIのDocsには何も書いていないのですが、`@figma/plugin-typings/plugin-api.d.ts`を見るとひっそりとインターフェースが定義してあります。

```plugin-api.d.ts
interface BlendMixin extends MinimalBlendMixin {
  isMask: boolean
  maskType: MaskType
  effects: ReadonlyArray<Effect>
  effectStyleId: string
}
```

ちなみに注意なのですが、**`BlendMixin`をドキュメント側で検索しても、何も出てきません**。

:::note info
### Tips: WebStormを開発に使っている場合
パッケージ内にある型定義ファイルに存在しているので、**Include non-project items** をオンにしておかないとSearch Everywhereの検索にひっかりません。
自分はこれにやられました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3617021/d80f211a-f16f-2cbf-55ae-40f2483eaafb.png)
:::

## Mixinの型ガード

FigmaというよりTypescriptの話題になりますが、プラグイン開発の上で使用頻度が高そうなので紹介しておきます。

例えば先述の`isMask`を使うために`BlendMixin`かどうかの判定が必要になりますが、`@figma/plugin-typings`で提供されているの型定義だけですし、実行時に判定するため`instanceOf`, `typeof`は使えません。

ただ `SceneNode`には`isMask`プロパティは存在しないので、当然以下のようなコードはコンパイルエラーになります。

```ts
if(maskNode.isMask !== undefined) {
// TS2339: Property  isMask  does not exist on type  SceneNode 
// Property  isMask  does not exist on type  SliceNode
```

これについては2パターンの対応ができます。

### 1. `as` を普通に使う

`as`を使って判定を行う方法です。最もシンプルです。

```ts
if((maskNode as BlendMixin).isMask !== undefined) {
```

### 2. ユーザ定義型ガードを使う

Typescriptの構文に、 [**ユーザ定義型ガード**](https://typescriptbook.jp/reference/functions/type-guard-functions) があります。
`foo is bar` の構文を使うことでユーザ定義の型ガード関数を作成することができます。

```ts
const isMaskable = (node: SceneNode | (SceneNode & BlendMixin)): node is SceneNode & BlendMixin => {
	return (node as BlendMixin).isMask !== undefined;
};

...

if(isMaskable(maskNode)){
  // code
}
```

## その他

- `GroupNode.children`はレイヤー構造を保証します。`[0]`が最背面、`[length-1]`が最前面です。
    - 対して`figma.currentPage.selection`はレイヤー順を保証しません。恐らく選んだ順になっています。
- `group.insertChild(index, target)`の`target`をグループ内の要素にした場合、コピーはされず順序が入れ替わる挙動になります。

# おわりに

プラグイン製作なので少し癖はありますが、比較的素直にAPIを叩けたのでなんとかなりました。Webベースで動いている事もあり、デバッグで普通にJavascriptのコンソールが使えるのも有難いです。

ちなみにこの記事を書き終えた時点では、まだFigma側の審査が終わっていないためPublishされていません。気長に待ちます。

実は「何故Figmaだけレイヤーを下に？」みたいな記事を書こうと思ってたのですが、調べてたらむしろXdが特殊だという事に気づきボツにした経緯があります。マスク上が普通である世界線に行きたい……

# なおその後

`upper-mask`が承認されたタイミングでリリースページを見ていたら、「これもオススメ！」に [こんなプラグインが出てきました](https://www.figma.com/community/plugin/781317794301477536/easymask)。

車輪の再発明で泣きました。プラグイン検索でもGoogle検索でも全然出てこなかったのに……

[^1]:https://blog.adobe.com/jp/publish/2022/09/16/cc-intent-to-acquire-figma
[^2]:https://forest.watch.impress.co.jp/docs/serial/yajiuma/1472928.html
[^3]:https://helpx.adobe.com/jp/support/xd.html
[^4]:https://www.figma.com/plugin-docs/api/properties/nodes-ismask/