WebExtensionsのコンテキストメニューの新常識 on Firefox 64

 * この投稿は[個人サイトとのクロスポストです](https://piro.sakura.ne.jp/latest/blosxom/mozilla/xul/2018-11-06_override-context-on-fx64.htm)。
 * この記事はFirefox 64での改善についての話で、Firefox ESR60は対象外です。

朗報があります。2016年6月にWebExtensionsへ一本化の方針が示された時に出した要望である[Bug 1280347 - Add ability to provide custom HTML elements working as alias of existing Firefox UI items, especially tabs](https://bugzilla.mozilla.org/show_bug.cgi?id=1280347)が、最近ようやく解決されました。

何故これが朗報なのでしょうか？　アドオンのXULからWebExtensionsへの移行の話をおさらいしてみましょう。


# XULアドオン同士は暗黙的に連携できた

いにしえの時代、XUL製アドオンは同じ名前空間の上で動作し、Firefox自体のUI要素や挙動を自由に変更する事ができました。

![（XULアドオンがFirefox自体を改変する様子を表した図）](https://qiita-image-store.s3.amazonaws.com/0/61387/d2abd42c-311a-a950-f83a-1c355401ffeb.png)


旧ツリー型タブの「ツリー表示されたタブ」はFirefox自体のタブバーそのもので、他のアドオンによって提供されるタブ関係の機能もそのまま利用できました。

![（XULベースのツリー型タブがFirefoxにUI要素を追加するのではなくFirefoxのUIの見た目を変えているだけだった事を表した図）](https://qiita-image-store.s3.amazonaws.com/0/61387/fbee2bb9-42bb-dd09-86f4-6a5d2c8c438b.png)


[ツリー型タブの移行の話に書いた通り](https://qiita.com/piroor/items/21ce5c99d5311973661b)、このようにXULアドオン同士は暗黙的に連携する事ができました。これは他のアプリの拡張機能の仕組みに対する大きな利点でしたが、このような単一の名前空間で多数のアドオンが動作するというモデルは混乱の元でもあり、そこにはセキュリティ上のリスクもありました。

# WebExtensionsのアドオンは分離され、他の物と連携するための特別な努力が必要となった

他方で、WebExtensions APIは個々のアドオンを別々の名前空間に分離するように設計されています。

![（WebExtensionsベースのアドオン同士が別々の名前空間に分かれている様子を表す図）](https://qiita-image-store.s3.amazonaws.com/0/61387/f1b4b158-5856-1821-2ae2-5c159a8061c3.png)


他のアドオンが追加したコンテキストメニュー項目はツリー型タブのコンテキストメニューでは利用できません。何故かというと、ツリー型タブのコンテキストメニューはサイドバー内でHTMLとCSSを使って構築された、偽物のメニューに過ぎないからです。そのため、偽コンテキストメニューはサイドバーのフレームからはみ出す事もできず、表示幅が狭く使いにくい物となってしまっていました。

他のアドオンとの互換性の問題に対する回避策として、僕は他のアドオンから`browser.runtime.sendMessage()`経由で呼び出す事のできる[公開のAPI群](https://github.com/piroor/treestyletab/wiki/API-for-other-addons)をツリー型タブに実装しました。

![（独自のAPIを使ってアドオン同士が連携する様子を表した図）](https://qiita-image-store.s3.amazonaws.com/0/61387/91530d16-25a1-946d-492f-8f8aa90229e0.png)


ツリー型タブの独自のAPIはアドオン同士の連携を部分的に可能にしましたが、ツリー型タブのAPIを明示的に呼び出すためのコードを追加する必要がありました。いくつかのアドオンの開発者の方は実際にこれを使ってくれて、また、僕自身もいくつかのアドオンにプルリクエストを送りました。しかし、アドオンの総数はあまりに多いため、将来的な事も考えると、このやり方には限界がありました。

# このような辛い状況を打破する、Firefox 64での改良点とは？

Firefox 64では、ポップアップまたはサイドバーのパネルにおいて、他のタブまたはブックマーク関係のアドオンが追加した項目を含むコンテキストメニューを表示できるようになりました。これはつまり、追加のコンテキストメニューを提供するWebExtensionsベースのアドオンが、特別な対策無しにツリー型タブと協調動作できるようになったという事を意味します。

![（新しいコンテキストメニューが、Firefox自体のコンテキストメニューと同様に動作している様子を表した図）](https://qiita-image-store.s3.amazonaws.com/0/61387/74bbb949-6185-4b76-beae-56c7357575de.png)


どのようにすればそういったアドオン同士の連携が可能になるのでしょうか？　WebExtensionsのアドオンにおける独自のコンテキストメニュー実装の基本をおさらいしてみましょう。

![（contextmenuイベントと既定のコンテキストメニューの関係を表した図）](https://qiita-image-store.s3.amazonaws.com/0/61387/17f99e9f-ade0-78f2-2344-a67fe43449ee.png)


ユーザーが右クリック（またはそれ以外の、コンテキストメニューを開くための何らかの操作）をした時には、対象のDOM要素から`contextmenu`というDOMイベントが発火し、既定のコンテキストメニューが表示されます。

![（既定のコンテキストメニューがキャンセル可能である事を表す図）](https://qiita-image-store.s3.amazonaws.com/0/61387/e9343742-d3d0-27b7-c22e-4a0822b3c34a.png)


このイベントは`preventDefault()`メソッドを呼ぶ事でキャンセルでき、そうすると既定のコンテキストメニューもまたキャンセルされるようになっています。その後はアドオンの側で好きな事ができるため、ツリー型タブはここで、Firefoxのタブのコンテキストメニューを模したメニュー風のUIを勝手に描画するという事をしていました。長らく、これがWebExtensionsのアドオンが独自のコンテキストメニューを提供するための一般的な方法でした。（他のアプローチとして、[HTML5の&lt;menu&gt;要素およびその関連要素を使う方法](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/menu)もありましたが、僕は、アドオンの中で使うのには色々と問題が多いという印象を受けました。）

Firefox 64以降では、新たに`browser.menus.overrideContext()`というAPIが導入されます。

![（Firefox 64以降で何をすればよいかという事を表した図）](https://qiita-image-store.s3.amazonaws.com/0/61387/b5c1a919-a924-49bb-a612-0141b24c336c.png)


このメソッドは`contextmenu`イベントのハンドラから呼ぶ事ができ、その際に、開かれようとしているコンテキストメニューに対する適切な「コンテキスト（文脈）」を指定できるようになっています:

![（コンテキストメニューが指定されたコンテキストに合わせた物に切り替わる様子の図）](https://qiita-image-store.s3.amazonaws.com/0/61387/481ce19d-8fb4-7116-2a05-4e1e5e3e91b0.png)


この時、既定のコンテキストメニューは指定されたコンテキストに合わせた内容に切り替わります。この時表示されるメニューは偽物ではなくFirefox自身により提供される本物のメニューなので、他のアドオンによって追加されたコンテキストメニュー項目も利用できますし、サイドバーやポップアップのパネルの領域からはみ出す事もできます。

# どうすればこの機能を使えるのか？

この機能を使うにあたっては、以下の3つのステップが必要となります。

 * 新しい権限として`menus.overrideContext`を追加する
 * `browser.menus.overrideContext()`でコンテキストを指定する
 * `browser.menus.create()`で、パラメータに`viewTypes`を指定してメニュー項目を追加する

[様々な場合についてコンテキストメニューの追加の仕方を解説した別の記事](https://piro.sakura.ne.jp/latest/blosxom/mozilla/xul/2018-10-26_override-context-for-each-case.htm)を別途用意しましたので、この記事では一般的な話のみに限定して説明します。


## 新しい権限`menus.overrideContext`の追加

新設のAPIである`browser.menus.overrideContext()`は、そのアドオンが`menus.overrideContext`というこれまた新設の権限を持っている時にだけ呼び出し可能になります。

そのためには、単純に`manifest.json`の`permissions`のリストに`menus.overrideContext`を加えればOKです。アドオンをFirefox 63またはそれ以前のバージョンにも対応させなくてはいけない場合でも問題ありません。というのは、古いFirefoxは未知の権限を単純に無視するからです（実際に、Firefox ESR60画素のような動作になる事を確認済みです）。なので、この権限を`optional_permissions`配下に置いておき、インストール後に権限を取得するために`browser.permissions.request({ permissions: ['menus.overrideContext'] })`を実行する、というようなめんどくさい事をする必要はありません。

## `browser.menus.overrideContext()`にコンテキストを指定する

以下は、独自のUI上でタブのコンテキストメニューを開く時のコードの例です:

```javascript
document.addEventListener('contextmenu', event => {
  const tab = event.target.closest('.tab');
  if (tab && typeof browser.menus.overrideContext == 'function') {
    // タブに対応する要素の上でコンテキストメニューが開かれた場合、
    // 「指定のタブの上でタブのコンテキストメニューが開かれた」もの
    // であると指定する。
    browser.menus.overrideContext({
      context: 'tab',
      tabId:   parseInt(tab.dataset.id)
    });
  }
  else {
    // そうでないなら、メニューを開かない。
    event.preventDefault();
  }
}, { capture: true });
```

同様に、以下は独自のUI上でブックマークのコンテキストメニューを開く時のコードの例です:

```javascript
document.addEventListener('contextmenu', event => {
  const item = event.target.closest('.bookmark');
  if (item && typeof browser.menus.overrideContext == 'function') {
    // ブックマークに対応する要素の上でコンテキストメニューが開かれた場合、
    // 「指定のブックマークの上でブックマークにのコンテキストメニューが
    // 開かれた」ものであると指定する。
    browser.menus.overrideContext({
      context:    'bookmark',
      bookmarkId: parseInt(item.dataset.id)
    });
  }
  else {
    // そうでないなら、メニューを開かない。
    event.preventDefault();
  }
}, { capture: true });
```

しかしながら、これだけだと他のアドオンによって追加された項目だけが表示されるという、ずいぶん貧相なメニューを目にする事になります。

![（タブのコンテキストを指定して開かれた新しいコンテキストメニューのスクリーンショット。既定のコンテキストメニューの項目は全く無く、アドオンによって追加された項目だけが見えている。）](https://qiita-image-store.s3.amazonaws.com/0/61387/3a0bc019-9d36-1422-796f-47c2eb20a191.png)


既定のコンテキストメニューの項目が無いのはバグだ！と思うかもしれませんが、[これは仕様です](https://bugzilla.mozilla.org/show_bug.cgi?id=1280347#c17)。パッチを書いた人はこのように説明しています:

> Firefoxの既定のメニュー項目を取り込む事は目的外です、何故なら既定のメニュー項目のラベルは常に適切な意味をなすとは限らないからです。例えば、「右側のタブを閉じる」は縦型のタブバーを提供する拡張機能においてはおかしな意味になるでしょう。
> 
> 拡張機能用のAPIで再実装できないメニュー項目がある場合は、それらがどのように対応されるべきであるかケースバイケースで判断する事になります。

## メニュー項目を`viewTypes`パラメータを伴って追加する

以上の事から、もしFirefoxの既定のコンテキストメニューと互換性のあるメニューを提供したいのであれば、それらのメニュー項目は自分自身で実装し直さなくてはなりません。
（それだけでなく、最上位のメニュー項目として他に任意の項目を追加する事もできます。）

ご存じの通り、[`browser.menus.create()`](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/menus/create)で追加された複数のメニュー項目は自動的にサブメニュー配下にまとまるようになっています。しかし、これには例外があります。`browser.menus.overrideContext()`を呼び出したアドオン自身が追加したメニュー項目だけは、サブメニューにまとまらずに最上位に列挙されるようになっているのです。そのため、Firefoxの既定のメニュー項目を真似た項目やそれ以外の任意の項目を、何の心配もなく最上位のメニュー項目として表示させる事ができます。

また、`browser.menus.create()`の新しいパラメータである`viewTypes`が、ポップアップパネルやサイドバーパネルの上でだけ表示されて欲しい項目を追加する時に役立ちます。

```javascript
browser.menus.create({
  id:        'context_reloadTab',
  title:     browser.i18n.getMessage('context_reloadTab_title'),
  type:      'normal',
  contexts:  ['tab'],
  viewTypes: ['sidebar'], // これが重要！！
  documentUrlPatterns: [`moz-extension://${location.host}/*`] // これも重要！！
});
```

`viewTypes`は以下のうちいずれか1つ以上の値を含む配列です:

 * `popup`: ポップアップパネル上で開かれたコンテキストメニュー用
 * `sidebar`: サイドバー領域上で開かれたコンテキストメニュー用
 * `tab`: <del>Firefox本体のタブバー上</del><ins>コンテンツ領域上</ins>で開かれたコンテキストメニュー用

「タブのコンテキストメニュー」を意味する`viewTypes`の値は存在しない、という事には注意が必要です。「ポップアップパネルでだけ表示されず、サイドバーとタブバーでは表示される」という結果を得たい場合に`viewTypes:["tab","sidebar"]`と指定するのは間違いで、これだと「ポップアップパネルとタブバーでは表示されず、サイドバーとコンテンツ領域では表示される」という結果になってしまいます。意図通りの結果を得るためには、`viewTypes`を指定せずに、[`browser.menus.onShown`](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/menus/onShown)のリスナーに渡される`info`の`viewType`の値に基づいて、項目の表示・非表示を`browser.menus.update()`の`visible`オプションで動的に切り替える必要があります。

`contexts`と`viewTypes`の両方を同時に指定した場合、その項目は両方の条件が満たされる時だけ表示されるようになります。

`documentUrlPatterns`の指定は、あなたのアドオンが最上位のメニュー項目として追加した項目が、他のアドオンによって提供されたポップアップパネルやサイドバーパネル上では表示されないようにするために必要です。[Bug 1498896](https://bugzilla.mozilla.org/show_bug.cgi?id=1498896 "1498896 - Always use documentUrlPatterns to match the document URL if viewTypes is present.")によって、`documentUrlPatterns`はポップアップパネルおよびサイドバーパネル自体のURLに基づいて項目の表示・非表示を制御するために使えるようになっています。

あなたのアドオンが提供するポップアップパネルやサイドバーパネル*以外*でだけ表示したいメニュー項目がある場合はどうすればよいでしょうか？　例えば[マルチプルタブハンドラ](https://addons.mozilla.org/firefox/addon/multiple-tab-handler/)がそういう事をしています。マルチプルタブハンドラのポップアップパネル上では独自の最上位のメニュー項目が表示されますが、それ以外の場面では「選択されたタブ」というサブメニューの下に項目が置かれるようになっています。サブメニューのラベルがアドオン自体の名前と同じになっていて構わないのであれば、難しく考える必要はありません。単に`viewTypes`を指定せずにメニュー項目を追加するだけでOKです。しかし、アドオンの名前と違うラベル文字列をグループ化用のサブメニューに使いたい場合には、最上位のメニュー項目用の項目とは別にそのような項目を定義し、表示・非表示を個別に制御する必要があります。

あなたのアドオンのポップアップパネルまたはサイドバーパネル上でのみ表示されて欲しいメニュー項目は、`viewTypes`と`documentUrlPatterns`の併用で簡単に定義できます。その一方で、あなたのアドオンのポップアップパネルまたはサイドバーパネル上でのみ*非表示*になって欲しい項目を定義するのはちょっと面倒です。それをするには、項目の表示・非表示を`browser.menus.update()`の`visible`オプションで動的に切り替える必要があります。どのようにやればいいのか、[マルチプルタブハンドラの当該コミット](https://github.com/piroor/multipletab/commit/494adf948e6f9e42a5cbbfa7c1edf2f50ea45ed0)から重要箇所を抜き出してみます:

 1. ポップアップパネルまたはサイドバーパネルの開閉状態をトラッキングするためのコードを追加します。バックグラウンドスクリプト側:
    
    ```javascript
    let gIsPanelOpen = false;
    browser.runtime.onConnect.addListener(port => {
      if (!/^connection-from-my-panel:/.test(port.name))
        return;
      gIsPanelOpen = true;
      port.onDisconnect.addListener(() => {
        gIsPanelOpen = false;
      });
    });
    ```
    
    ポップアップパネルまたはサイドバーパネル側:
    
    ```javascript
    browser.runtime.connect({
      // 接続名は一意である必要がある事に注意！
      name: `connection-from-my-panel:${Date.now()}`
    });
    ````
 2. メニュー項目の表示状態を更新するコードをバックグラウンドスクリプトに追加します:
    
    ```javascript
    if (typeof browser.menus.overrideContext == 'function') {
      let gLastVisible = true;
      browser.menus.onShown.addListener(async () => {
        const visible = !gIsPanelOpen;
        if (gLastVisible == visible)
          return;
        await browser.menus.update('id-of-the-item-you-want-to-hide-in-your-panel', { visible });
        gLastVisible = visible;
        browser.menus.refresh();
      });
    }
    ```

`viewTypes`はFirefox 64以降、`visible`はFirefox 63以降で追加された機能である事に注意が必要です。あなたのアドオンをFirefox 63以前のバージョンでも使えるようにしたい場合、これらのオプションを伴ってメニュー項目を追加・更新するコードはFirefox 64以降でのみ実行されるようにしておく必要があります。これは、例のように`typeof browser.menus.overrideContext == 'function'`の結果が`true`である場合にのみ実行するようにすればOKです。

## 既定のコンテキストメニュー項目の機能の再現

タブのコンテキストメニューの既定の機能のうちのほとんどは、WebExtensions APIで再現する事ができます。すべての機能の再現は大変ですが、[ツリー型タブでは実際にそれをやっています。](https://github.com/piroor/treestyletab/blob/d0491c587d8d251e665195189bf8cdd696a7c5c9/webextensions/background/tab-context-menu.js)

![（サイドバー上での新しいコンテキストメニューのスクリーンショット。ツリー型タブによって再現された既定のメニュー項目の機能が含まれている。）](https://qiita-image-store.s3.amazonaws.com/0/61387/24353dde-1a37-53bb-efb3-acc2d8eb6d3c.png)


[コンテナータブ用に色分けされたSVGアイコンを自分で用意して](https://github.com/piroor/treestyletab/blob/master/webextensions/update-contextual-identities-icons.sh)、それをXPIパッケージの中に含める必要があるという事に注意して下さい。残念な事に、Firefox自体に組み込まれたSVGアイコンを直接参照してカラー表示することは、セキュリティ上の制限により不可能となっています。

また、今の所[WebExtensionsのAPIが不足している](https://bugzilla.mozilla.org/show_bug.cgi?id=1469764)ために、「タブを端末へ送信」は再現できないというのも残念なところです。

# まとめ

Firefox 64でできるようになった事:

 * タブやブックマークについて、他のアドオンによって追加された追加のコンテキストメニューを含む、独自のコンテキストメニューを開けるようになりました。
   アドオン同士の暗黙的な連携が、部分的にですが再び可能になりました。
 * `browser.menus.overrideContext()`を呼んだアドオン自身は、複数のコンテキストメニュー項目を最上位のメニュー項目として表示させる事ができます。
   （それらは自動的にはサブメニュー配下にグループ化されません。）

依然としてできない事:

 * コンテキストメニューを`mouseover`などの任意のタイミングで開く事はできません。この機能はあくまで`contextmenu`イベントが発行される場面でのみ使えます。
 * 他のアドオンによって追加されたコンテキストメニュー項目を制御したり挙動を変えたりする事はできません。そのような事をしたい場合には依然として、[メッセージベースの独自のAPIを使って連携する](https://qiita.com/piroor/items/213b1550e7904ae4077f)必要があります。
 * Firefox自体が提供する既定のコンテキストメニュー項目を引用する事はできません。それらを使いたければ、自分で再実装する必要があります。

Firefoxのアドオンの仕組みがXULからWebExtensionsに移行するにあたって、多くの利点が失われ、FirefoxがChromeと同じになってしまったと言う人もいました。しかしながら、Firefoxはそのカスタマイズ性を少しずつ取り戻しつつあります。Firefoxはこの記事で述べたような独自の改良を試みている場合があるため、FirefoxのWebExtensions APIの発展の様子を引き続き注視していく必要がある、というのが僕の見解です。