Help us understand the problem. What is going on with this article?

WebExtensionsでのタブの複数選択APIのつかいかた

More than 1 year has passed since last update.

WebExtensionsでのタブの複数選択APIのつかいかた

by piroor
1 / 30

Tokyo WebExtensions Meetup #3で発表した資料ですが、スライドそのままだと情報量が少なすぎるので(スライド内では表示されない補助テキストの埋め込み機能が欲しい)、スライド1枚目を口頭での説明を踏まえた内容に置き換えておきます。
なお、同内容を自サイトにもクロスポストしています

前提

  • Firefox 63以降の話です。
    • 後述しますが、Firefox 64では最初から使えるようになる予定です。
    • Firefox 63ではabout:configbrowser.tabs.multiselecttrueにすると試せます。
  • Firefox ESR60などの古いバージョンは対象外です。
  • Chromeでもだいたい同じAPIが使えます。

タブの「選択」状態とは?

Firefox 63からタブの複数選択機能が入った事で「選択」という言葉が多義的になってしまったので、まずその点を整理します。
WebExtensions APIにおいては、「選択」という言葉で表されうる状態に以下の2つがあります。

  • アクティブ なタブの事
    • active tab というのがWebExtensions APIの語彙上の言い方
    • current tab という言い方も古くからある
    • foreground tab という言い方もある(background tabとの対比)
  • 選択複数選択) 状態にあるタブの事
    • highlighted tab というのがWebExtensions APIの語彙上の言い方
    • multiselected tab という言い方もされる

「selected tab」という表現は紛らわしいので、ここでは使いません。

Firefox本体のタブの複数選択機能の様子

Firefox 63でabout:configbrowser.tabs.multiselecttrueにすると試せます。Chromeでも同じ操作ができます。

  • タブの上でShift/Ctrl(Command)-クリックでタブを選択できます。
    • 選択されたタブは、上に青い線(Linux版ではシステムカラーに依存する。UbuntuのAmbientテーマではオレンジ色)が出る。(スクリーンショット)
  • タブが選択されていると、コンテキストメニューほか一部の機能が「選択中のタブすべてについてそれをする」ようになります。 (スクリーンショット:メニューのラベルが「Tab*s*」になっている)
    • まだ一部の機能はそのようになっていない。Firefox 64で一通りの機能がすべてタブの複数選択に対応するという事だと予想される。

アドオンからの2つの利用局面

WebExtensionsベースのアドオンにとっては、このタブの複数選択機能に対して2つの関わり方があります。

  • 選択されたタブに何かする
    • タブを処理対象にした便利な機能を提供するアドオンの場合
  • タブを選択する
    • タブを管理する、タブバーの代替となるアドオンの場合

それぞれ順番に紹介します。
なお、基本的にはChromeの拡張機能でも同様のAPIが使えます。

選択されたタブに何かする

タブの選択状態を表すプロパティ

タブの選択状態は、browser.tabs.Tab.highlightedで表されます。

  • 選択されたタブはtrue
  • アクティブなタブは常にtrue
  • この仕様はChrome互換

browser.tabs.*()等のAPIで返ってくるタブのオブジェクトはすべてこの情報を持ちます。

タブが選択されているかどうかの判別

highlightedなタブは、browser.tabs.query()の条件にhighlighted: trueを指定すれば収集できます。

const highlightedTabs = await browser.tabs.query({
  currentWindow: true,
  highlighted:   true
});
if (highlightedTabs.length > 1) {
  // 複数タブ選択時の処理
  for (const tab of highlightedTabs) {
    // 個々の選択されたタブの処理
    if (tab.active) {
      // 選択されていて、且つアクティブなタブの処理
    }
  }
}

1つのウィンドウの中にhighlightedなタブが2つ以上ある場合は、「タブが複数選択されている」と判断できます。

  • アクティブなタブは常にhighlightedなので、「タブが1つも選択されていない」という状態はありません。
    • 言い換えると、highlightedなタブを収集するとその中には必ずアクティブなタブがあります。
  • 「タブが1つだけ複数選択されている」という状態も、論理的にありません。

処理対象のタブを収集する時にするべき事

以上の事を踏まえると、「指定されたタブが選択範囲の一部であれば選択状態のすべてのタブに対して処理をして、そうでなければ指定のタブ単独に対して処理をする」という事をやるには、以下のようなユーティリティ関数を作るとよいという事が言えるでしょう。

async function getMultiselectedTabs(tab) {
  // 与えられたタブが選択されている時は
  // 同じウィンドウの選択されたタブを一緒に返す
  if (tab.highlighted)
    return browser.tabs.query({
      windowId:    tab.windowId,
      highlighted: true
    });
  else // そうでなければ与えられたタブのみを返す
    return [tab];
}

コンテキストメニューのコマンドだと「複数選択されたタブ以外の上でコンテキストメニューが開かれる」という場合があり得るので、そのケースをちゃんと考慮する必要があります。

コンテキストメニューから機能を呼び出す箇所

先のユーティリティ関数を使えば、タブが複数選択されている場合とそうでない場合の実装を容易に共通化できます。

コンテキストメニューのコマンドの場合、リスナにはコンテキストメニューが開かれた対象のタブのオブジェクトが渡ってくるので、それを手がかりにhighlightedなタブを収集できます。

browser.menus.onClick.addListener(async (info, tab) => {
  switch (info.menuItemId) {
    case 'context_reloadTab':
      reloadTab(tab);
      break;
    ...
  }
});

こういう実装になっていたのであれば、先のユーティリティ関数を使って

browser.menus.onClick.addListener(async (info, tab) => {
  const tabs = await getMultiselectedTabs(tab);

  switch (info.menuItemId) {
    case 'context_reloadTab':
      // ここが複数のタブを受け付けるようになっていればよい
      reloadTabs(tabs);
      break;
    ...
  }
});

このようにすれば、もう「複数タブ選択機能に対応完了」という事になります。

キーボードショートカットを実装する箇所

キーボードショートカットが呼び出された場面では、アクティブなタブは不明なので自分で調べる必要があります。
後はコンテキストメニューの場合と同様です。

browser.commands.onCommand.addListener(async command => {
  const activeTab = (await browser.tabs.query({
    active:        true,
    currentWindow: true
  }))[0];

  switch (command) {
    case 'reloadTab':
      reloadTab(activeTab);
      break;
    ...
  }
});

こうだったのであれば、

browser.commands.onCommand.addListener(async command => {
  const activeTab = (await browser.tabs.query({
    active:        true,
    currentWindow: true
  }))[0];
  const tabs = await getMultiselectedTabs(activeTab);

  switch (command) {
    case 'reloadTab':
      // ここが複数のタブを受け付けるようになっていればよい
      reloadTabs(tabs);
      break;
    ...
  }
});

こう書き換えれば、めでたく「タブの複数選択機能に対応完了」です。

タブを選択する

ここまでは選択されたタブを相手に何かする話ですが、今度はAPI経由で任意のタブを選択する話です。

タブの選択状態を変更する方法は2通りあります。

  1. browser.tabs.update()
  2. browser.tabs.highlight()

browser.tabs.update()での選択・選択解除

タブのhighlightedプロパティの値を変更すれば、タブの選択状態を変更できます。

browser.tabs.update(tab.id, {
  highlighted: true
});

ただし、前述の「アクティブなタブは常にhighlighted」という仕様のため、この方法でhighlightedにしたタブは同時にアクティブになってしまうという副作用があります。

じゃあ非アクティブな(バックグラウンドの)タブをアクティブにせず選択することはできないのか?という話なのですが、Firefoxでは以下のようにすればできます。

browser.tabs.update(tab.id, {
  highlighted: true,
  active:      false
});

active: falseを併用するというのがポイントです。
こういう事ができたらいいよねとBugzillaで提案したら、すんなり要望が通りました。言ってみるものですね。
(要望は僕はFirefoxのBugzillaにしか出していないので、Chromeではできないと思います。誰かフィードバックしたらできるようになるかも。)

タブの選択解除も、highlightedの値の変更でできます。

browser.tabs.update(tab.id, {
  highlighted: false
});

アクティブなタブ以外にhighlightedなタブが存在しない状態でこれをやると、前述の「アクティブなタブは常にhighlighted」という仕様のために、操作が無視されます。

他にhighlightedなタブが存在している場合、アクティブなタブの選択状態を解除すると、入れ替わる形で他の選択済みタブのどれか1つがアクティブになります

browser.tabs.highlight()での選択・選択解除

browser.tabs.update()を使う方法はタブを1つ1つ指定しないといけませんが、複数のタブの選択状態をまとめてがばっと設定することもできます。

browser.tabs.highlight({
  windowId: tab.windowId,
  tabs:     tabs.map(tab => tab.index)
});

こうすると、tabsで指定されたタブが選択され、それ以外のタブは非選択になります。
選択対象のタブはIDの配列ではなくindexの配列で指定する必要がある事に注意して下さい。

このメソッドを使うと、指定されたタブの中にアクティブなタブが含まれる場合でも、tabsで指定されたタブの1番目のタブが常にアクティブになります
アクティブなタブを切り替えないようにするためには、以下のようにしてアクティブなタブを配列の先頭に持ってきておく必要があります。

const activeTabs = selectTabs.splice(selectTabs.findIndex(tab => tab.active), 1);
selectTabs = activeTabs.concat(selectTabs);
browser.tabs.highlight({
  windowId: selectTabs[0].windowId,
  tabs:     selectTabs.map(tab => tab.index)
});

このメソッドはウィンドウのIDの指定が必須で、且つタブはIDではなくindexでの指定なので、論理的に1回の操作で1つのウィンドウに対しての操作しか行えません。
複数のウィンドウについてタブの選択状態を変えたければ、ウィンドウの数だけ操作を繰り返す必要があります。

ちなみに、複数のウィンドウがある時にそれぞれで個別にタブの選択状態を設定する事もできます(1つ目のウィンドウではタブが2つ選択され、2つ目のウィンドウではタブが3つ選択されている、というような状態も普通に作れます)。

tabsで指定されなかったタブが非選択になるということは、以下のようにすれば選択を全解除できると思えるかもしれません。

browser.tabs.highlight({
  windowId: tab.windowId,
  tabs:     []
});

が、これはエラーになります。
何故かというと、前述の「アクティブなタブは必ずhighlightedになる」「highlightedなタブが1つも存在しない状態はあり得ない」という仕様があるからです。
なので、最低1つは必ずタブを指定しないといけません。

browser.tabs.highlight({
  windowId: tab.windowId,
  tabs:     [tab.index]
});

アクティブなタブを指定すれば、アクティブなタブ以外の選択が解除されるという結果になります。
アクティブでないタブを指定すれば、そのタブがアクティブになると同時に他のタブの選択が解除されます。

タブの選択状態の変化を検知するには?

タブのactiveの状態変化はbrowser.tabs.onUpdatedでは通知されませんが、それと同様に、highlightedの変化もbrowser.tabs.onUpdatedでは通知されません。

ではどうすればよいかというと、browser.tabs.onHighlightedにリスナを登録する事でタブの選択状態の変化を検知できます。

browser.tabs.onHighlighted.addListener(async highlightInfo => {
  const allTabs = await browser.tabs.query({
    windowId: highlightInfo.windowId
  });
  const highlightedTabs   = allTabs.filter(tab => highlightInfo.tabIds.includes(tab.id));
  const unhighlightedTabs = allTabs.filter(tab => !highlightInfo.tabIds.includes(tab.id));
  // 新しいタブの選択状態に基づいて何かする処理
});

browser.tabs.onHighlightedの通知は、browser.tabs.highlight()の呼び出し1回に対して1回通知されますが、browser.tabs.update()を使った場合は個々のタブの状態が変わるごとに通知されます
大量のタブの選択状態が一気にbrowser.tabus.update()で変更されると、めちゃめちゃ遅くなります。
なので、以下のようにスロットリングする(一定時間内で何度も呼ばれた場合、最後の1回だけ実行するようにする)必要があります。

const timers = new Map();
browser.tabs.onHighlighted.addListener(async highlightInfo => {
  let timer = timers.get(highlightInfo.windowId);
  if (timer)
    clearTimeout(timer);
  timer = setTimeout(() => {
    timers.delete(highlightInfo.windowId);
    // 新しいタブの選択状態に基づいて何かする処理
  }, 150);
  timers.set(highlightInfo.windowId, timer);
});

このAPIが入って何が嬉しいのか?

ということで、タブの複数選択機能の導入と同時にWebExtensions APIからもその情報を扱えるようになったわけですが、それの一体何が嬉しいのでしょうか。

  • 他のアドオンとの間でタブの選択状態が共有される
  • タブの選択状態の変更が他のアドオンに(非同期に)通知される

というのがポイントです。

WebExtensionsではアドオン同士の名前空間が分かれているので、タブに関連する情報でAPIの仕様に含まれていない物は、browser.runtime.sendMessage()で頑張って持ち回る必要がありました。
しかし、この複数選択の状態はAPIの仕様に含まれているため、あるアドオンが選択状態を変更すれば、その結果は他のアドオンにも共有されます。
この事により、タブに関わるアドオン同士が暗黙的に連携できる余地が広がるという効果があります。

タブを対象に何かするアドオンの側

前述した通り、「タブが選択されている場合はすべての選択済みタブを対象にする」という変更を加えるだけで、複数タブ選択の恩恵を受けられるようになります。例えば以下のようなことができます。

未対応のアドオンがあればプルリクエストしていきましょう。
対応のための変更の仕方は、この資料の前半で書いた通りです。

タブ選択に使えるUIを提供するアドオンの側

一方、タブを選択する事に特化したアドオンという物も作れます。
タブを選択するUIを提供するだけで、便利な機能は他のアドオンが提供してくれるというわけです。

Firefox 64以降ではコンテキストメニューを介した暗黙的な連携も可能になるので、「タブ選択UIを提供するだけのアドオン」の利用価値がさらに高まります。

疎結合な単機能アドオン同士の連携がより容易に

今までは、タブ周りで何か便利な機能を提供しようと思うと、各アドオン同士が明示的に連携し合わなければアドオンの機能同士を組み合わせられませんでした
しかし明示的に連携するという事は、アドオン同士の結合度合いが高まってしまうという事です。

あるいは、分割が不能な場合、1つのアドオンにあの機能もこの機能も内包させなくてはならないという事にもなります。これはプロダクトの際限なき肥大化に繋がります。

タブを選択するUIを提供するアドオン」と「選択されたタブをまとめて処理するアドオン」を分けて開発できることで、単機能のアドオン同士がお互いに疎結合の状態を保ったまま暗黙的に連携し合えるようになった。これが、このタブ複数選択APIの重要なポイントというわけです。

ということで、

  • みなさんもっとタブを便利にするアドオンを作っていきましょう。
  • タブに便利な機能を提供するアドオンを見かけたら、タブの複数選択に対応するようにプルリクエストを出していきましょう。
    • 一人で突撃するのが怖いという方は、日本のMozillaコミュニティのSlack等で協力を求めてみてもいいかもしれません。
    • まだOSSにフィードバックした事がなくて自信がないという方は、OSS Gateというイベントのワークショップに来ていただければ、フィードバック経験豊富な人の助言を受けながらバグ報告やプルリクエストをする経験を積めると思います。

という発表でした。
以下はスライドです。


前提

  • Firefox 63以降の話
    • Firefox 64では最初から使える(予定)
    • Firefox 63ではabout:configbrowser.tabs.multiselecttrueすると試せる
  • Firefox ESR60は対象外

タブの「選択」状態とは?


2種類ある

  • アクティブ
    • active tab
    • current tab
  • 選択複数選択
    • highlighted tab
    • multiselected tab

「selected tab」という表現は紛らわしいので、この発表では使いません


デモ

  • タブの上でShift/Ctrl(Command)-クリックで選択
  • タブが選択されているとコンテキストメニューほか一部の機能が「選択中のタブすべてについてそれをする」ようになる

アドオンからの2つの利用局面

  • 選択されたタブに何かする
  • タブを選択する

選択されたタブに何かする


タブの選択状態を表すプロパティ

browser.tabs.Tab.highlighted

  • 選択されたタブはtrue
  • アクティブなタブは常にtrue
  • Chrome互換

タブが選択されているかどうかの判別

const highlightedTabs = await browser.tabs.query({
  currentWindow: true,
  highlighted:   true
});
if (highlightedTabs.length > 1) {
  // 複数タブ選択時の処理
  for (const tab of highlightedTabs) {
    // 個々の選択されたタブの処理
  }
}

アクティブなタブは常に選択されているので
「タブが1つも選択されていない」という状態は無い


処理対象のタブを収集する時にするべき事

async function getMultiselectedTabs(tab) {
  // 与えられたタブが選択されている時は
  // 同じウィンドウの選択されたタブを一緒に返す
  if (tab.highlighted)
    return browser.tabs.query({
      windowId:    tab.windowId,
      highlighted: true
    });
  else // そうでなければ与えられたタブのみを返す
    return [tab];
}

「複数選択されたタブ以外の上でコンテキストメニューが開かれた」場合をちゃんと考慮する


コンテキストメニューから機能を呼び出す箇所

browser.menus.onClick.addListener(async (info, tab) => {
  const tabs = await getMultiselectedTabs(tab);

  switch (info.menuItemId) {
    case 'context_reloadTab':
      reloadTabs(tabs);
      break;
    ...
  }
});

キーボードショートカットを実装する箇所

browser.commands.onCommand.addListener(async command => {
  const activeTab = (await browser.tabs.query({
    active:        true,
    currentWindow: true
  }))[0];
  const tabs = await getMultiselectedTabs(activeTab);

  switch (command) {
    case 'reloadTab':
      reloadTabs(tabs);
      break;
    ...
  }
});

タブを選択する


タブを選択するには?

  1. browser.tabs.update()
  2. browser.tabs.highlight()

browser.tabs.update()での選択

browser.tabs.update(tab.id, {
  highlighted: true
});

タブが常にアクティブになる副作用がある


browser.tabs.update()での選択解除

browser.tabs.update(tab.id, {
  highlighted: false
});

アクティブなタブの選択状態を解除すると他の選択済みタブがアクティブになる


Firefoxのみの特別な挙動

browser.tabs.update(tab.id, {
  highlighted: true,
  active:      false
});

active: falseを併用すれば非アクティブなタブを選択できる
提案したら通った


別解:browser.tabs.highlight()での選択

browser.tabs.highlight({
  windowId: tab.windowId,
  tabs:     tabs.map(tab => tab.index)
});

タブのIDの配列ではなくindexの配列で指定する(全体の選択状態を一気に変更するのに使える)


browser.tabs.highlight()での選択解除:失敗

browser.tabs.highlight({
  windowId: tab.windowId,
  tabs:     []
});

これはエラーになる


browser.tabs.highlight()での選択解除:成功

browser.tabs.highlight({
  windowId: tab.windowId,
  tabs:     [tab.index]
});

最低1つは必ず指定しないといけない
(アクティブなタブのため)


タブの選択状態の変化を検知するには?

browser.tabs.onUpdatedでは通知されない
activeもそう)


browser.tabs.onHighlightedの監視

browser.tabs.onHighlighted.addListener(async highlightInfo => {
  const allTabs = await browser.tabs.query({
    windowId: highlightInfo.windowId
  });
  const highlightedTabs   = allTabs.filter(tab => highlightInfo.tabIds.includes(tab.id));
  const unhighlightedTabs = allTabs.filter(tab => !highlightInfo.tabIds.includes(tab.id));
  // 新しいタブの選択状態に基づいて何かする処理
});

browser.tabs.onHighlightedの通知は個々のタブの状態が変わるごとに届く

大量のタブの選択状態が一気にbrowser.tabus.update()で変更されると飽和する


browser.tabs.onHighlightedのスロットリング

const timers = new Map();
browser.tabs.onHighlighted.addListener(async highlightInfo => {
  let timer = timers.get(highlightInfo.windowId);
  if (timer)
    clearTimeout(timer);
  timer = setTimeout(() => {
    timers.delete(highlightInfo.windowId);
    // 新しいタブの選択状態に基づいて何かする処理
  }, 150);
  timers.set(highlightInfo.windowId, timer);
});

スロットリングすれば飽和しない


このAPIが入って何が嬉しいのか?


できるようになったこと

  • 他のアドオンとの間でタブの選択状態が共有される
  • タブの選択状態の変更が他のアドオンに(非同期に)通知される

タブを対象に何かするアドオンの側

「タブが選択されている場合はすべての選択済みタブを対象にする」という変更を加えるだけで、複数タブ選択の恩恵を受けられる

未対応のアドオンがあればプルリクエストしていきましょう


タブ選択に使えるUIを提供するアドオンの側

タブを選択するUIを提供するだけで、便利な機能は他のアドオンが提供してくれる


つまり

タブを選択するUIを提供するアドオン」と
選択されたタブをまとめて処理するアドオン」を
分けて開発できる

単機能のアドオン同士が再び暗黙的に連携し合えるようになる!


みんなもっとタブを便利にするアドオンを作ってええんやで?

piroor
FirefoxサポートエンジニアでTree Style Tab等のFirefoxアドオンを作ったり「シス管系女子」という漫画 https://system-admin-girl.com/ を日経Linux誌で連載したり。名前の読みが同じですが「数学ガール」の結城浩先生とは別人です。
https://piro.sakura.ne.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした