Bug 1500479が解決され、Firefox 65以降のバージョンで、WebExtensionsのタブ関連APIに以下の変更が入る事になりました。
-
tabs.onActivated
のリスナに通知されるactiveInfo
に、previousTabId
というプロパティが追加されました。 -
tabs.get()
やtabs.query()
などで取得されるタブのオブジェクトにsuccessorTabId
というプロパティが加わりました。- このプロパティの値は
tabs.update()
で変更可能です。 - このプロパティの値の変更は
tabs.onUpdated
では通知されません。tabs.onHighlighted
のような専用のリスナもありませんので、変更を動的に検知する方法はありません。
- このプロパティの値は
- 複数のタブの
successorTabId
をまとめて変更するtabs.moveInSuccession()
メソッドが追加されました。
Firefoxでアクティブな(現在の)タブをCtrl-Wなどで閉じると、右隣や左隣、あるいはそのタブを開いた親のタブにフォーカスを切り替えるようになっています。上記の新機能は、この挙動に介入するための物です。「successor」とは「後継者」という意味で、つまり、アクティブなタブを閉じられた後に次にフォーカスされるタブを指定する仕組みという事になります。
今までの問題
今までは、WebExtensionsのAPI経由でこの挙動に介入する方法がなかったため、例えば「タブを閉じたら、必ずそのタブの直前に見ていたタブにフォーカスを移す」というような事をアドオンで実現しようとすると、
- Firefoxによって何らかのタブがフォーカスされる。
- その後改めて、別のタブにフォーカスし直す。
という手順を踏む必要がありました。これは見た目に美しくない(一瞬無関係のタブがフォーカスされてしまう)のもさることながら、現在のFirefoxの初期設定である「Ctrl-Tab/Ctrl-Shift-Tabで最近フォーカスされた順にタブのフォーカスを切り替える」という挙動にも悪影響を与えます。
拙作アドオンのツリー型タブも、ツリーの最後の子を閉じたら、右隣のタブ(=下にある別のツリーの親タブ)ではなく1つ前の兄弟タブまたは親タブにフォーカスを移すという機能があり、これを実現するにあたっては前述の点がずっと未解決のままでした。今回のAPI追加によって、ようやくこの問題を解決する目処が立ったと言えそうです。
基本:現在のタブを閉じた後にアクティブになるタブを明示する
一番単純な使い方は、特定のタブを指定して「そのタブがアクティブな時に閉じられたら、次はこのタブをアクティブにする」という事を明示するという物です。
例えば、以下は「現在のタブが閉じられたら、必ずそのタブを開いた元の(親の)タブをアクティブにする」という事をする例です。
// タブのフォーカスが切り替わった時に処理を行う
browser.tabs.onActivated.addListener(async (activeInfo)
=> {
// 新たにアクティブになったタブにopenerTabIdがあったら、
// それをsuccessorTabIdに設定する
const activeTab await browser.tabs.get(activeInfo.tabId);
if (activeTab.openerTabId)
browser.tabs.update(activeTab.id, {
successorTabId: activeTab.openerTabId
});
// 1つ前にアクティブだったタブは、successorTabIdを未設定に戻す
if (activeInfo.previousTabId)
browser.tabs.update(activeInfo.previousTabId, {
successorTabId: -1 // nullではなく明示的に-1を指定する必要がある
});
});
Firefox本体にはbrowser.tabs.selectOwnerOnClose
という設定があり、元々「現在のタブを開いたら親のタブに戻る」という動作が行われるようになっていますが、タブが開かれた後でフォーカスを切り替えていると、この機能が作用しなくなる事があります(そのように設計されています)。上記のサンプルコードは、Firefox本体の機能をあてにしないで強制的に・常にその動作を行うという例になっています。
successorTabId
の効果はbrowser.tabs.selectOwnerOnClose
や既定のフォーカス移動の挙動より優先して反映されます。そのため、「browser.tabs.selectOwnerOnClose
の挙動をなるべく尊重しつつ、browser.tabs.selectOwnerOnClose
が機能しない場面でだけ任意のフォーカス移動を行わせる」という事は、残念ですができません。そのような振る舞いを実現するためには、これらのAPIを使って、現在のタブを閉じた後のすべてのフォーカス移動を完全に自分で制御しきる必要があります。
発展:複数のタブのsuccessorTabId
をまとめて変更する
タブのsuccessorTabId
は、tabs.moveInSuccession()
というメソッドでも変更できます。「successor」は「後継者」ですが、「succession」は「継承順位」です。このメソッドを使うと、tabs.update()
を何度も呼び出さなくても複数のタブのsuccessorTabId
をまとめて変更することができます。
ですがこのAPI、APIのスキーマ定義の説明だけ読んでも、どう使えばいいのかが非常に分かりにくいです。一応それぞれの引数やオプションに説明は付いているのですが、「insert
がtrue
ならこうなる、append
がtrue
ならこうなる、そうでなければこうなる」という細かい動作の変化については述べられているものの、そもそもそのオプションが何のためにあるのかという事までは書かれていません。また、APIの自動テストの方もエッジケースの細かい挙動のテストがほとんどで、「普通の使い方」がどういう物なのか皆目見当が付きません。
そこでAPI設計の最も初期の提案内容を見返してみた所、このAPIは**「現在のタブを閉じたら、そのタブの前に見ていたタブをアクティブにする」という挙動を実現するアドオンのために考えられた物だ**という事が分かりました。この事実を前提に置くと、tabs.moveInSuccession()
の動作を格段に理解しやすくなります。というか、そうしないと理解できないと思います。
以下、そのようなアドオンで考えられるシナリオとして
- 複数のタブの関係を初期化する時
- アクティブなタブが切り替わった時
- アクティブなタブのセットが切り替わった時
- 管理対象のタブが増えた時
- アクティブなタブより手前にタブが増えた時
の5つを例に挙げつつ、このメソッドを使うと何が起こるのかを図示していきます。
前提
非常に重要な事なので最初に述べておくと、tabs.moveInSuccession()
が有用な場面というのは、すべてのタブについてsuccessorTabId
が常に設定されていて、タブのフォーカス制御が完璧に行われているという場面です。
この図は、AからHまでの8個のタブの間に相互の参照関係がある状態を示しています。矢印はsuccessorTabId
での参照を表しており、この図であれば、Aがアクティブな状態で閉じられたらBがアクティブになり、Bが閉じられればCがアクティブになり……という結果になります。tabs.moveInSuccession()
は原則として、この状態でtabs.onActivated
、tabs.onCreated
、およびtabs.onAttached
のリスナー内で使う物と考えておくと良いでしょう。
tabs.moveInSuccession()
はこういう状態を前提とした機能のため、前述のtabs.update()
を使った例のような*「一時的にsuccessorTabId
を設定し、用事が終わったら情報をクリアする」使い方にはマッチしません*。この事を常に念頭に置いておいて下さい。
なお、以下の例はすべて、検証用のスクリプトをtabs
の権限を持つアドオンのデバッガーコンソール上で実行することで、手元で実際に試す事ができます。リンク先のスクリプトをコンソール上で実行すると、新しいウィンドウ内にAからHまで8個のタブが開かれた状態になります。その状態で、以下の例のコードを実際に実行してみて下さい。
シナリオ1:複数のタブの関係を初期化する時
これは、アドオンがインストールされた直後や、Firefox自体のセッション復元機構などによって一度にウィンドウごと複数のタブが復元されたというような場合が該当します。
前提のように8つのタブがある状態で、全タブの参照関係を一度に初期化する時にアドオンが行うべき操作は、以下のようになります。
// テストのために関係を初期化
setSuccessorsById([]);
// 最初のタブ(A)が現在フォーカスされていて、
// 閉じたら右へフォーカスが移っていくという関係を設定する場合
browser.tabs.moveInSuccession(
[A, B, C, D, E, F, G, H],
A
)
browser.tabs.moveInSuccession()
の第1引数はタブのIDの配列です。参照関係は第1引数の並び順の通りに設定されます。タブが表示された順番などを何らかの形で保持しておき、(await browser.tabs.query({ windowId })).sort(sortFunction).map(tab=> tab.id)
のようにしてソートした結果の順番でIDの配列を作って渡す、というのが典型的な使い方になるでしょう。
第2引数は、この配列の最後のタブ(ここではH)からの参照先となるタブのIDを明示するために使います。第1引数には同じIDを複数回書く事はできません(書くとエラーになります)ので、循環する参照関係は第2引数を使って設定する必要があります。
第1引数の並び順の通りに参照関係が設定されるので、上の例とは逆の関係にする操作も、以下のようにして一回で行えます。
// テストのために関係を初期化
setSuccessorsById([]);
// 最後のタブ(H)が現在フォーカスされていて、
// 閉じたら左へフォーカスが移っていく、という関係を設定する場合
browser.tabs.moveInSuccession(
[H, G, F, E, D, C, B, A],
H
)
言うまでも無く、これは、tabs.update()
を愚直に何度も呼び出す事でも同じ結果を得られます。
シナリオ2:アクティブなタブが切り替わった時
これは、シナリオ1のようにして関係が初期化された後の状態では最もよくあるシナリオです。
すべてのタブについてsuccessorTabId
での参照関係がある状態で、その中のあるタブがクリックされるなどしてアクティブになった時には、「新たにアクティブになったタブ」と「その直前にアクティブだったタブ」、およびそれらに関わるすべてのタブのsuccessorTabId
を振り直さなくてはなりません。tabs.moveInSuccession()
は、それを一回の操作で行います。
Cが今アクティブで、Dをクリックしてフォーカスを切り替えたとしましょう。この時にアドオンが行うべき操作は以下のようになります。
// テストのために関係を初期化
setSuccessorsById([B, C, D, E, F, G, H, A]);
// tabs.onActivatedのリスナーに対し、{ tabId: D, previousTabId: C } が渡ってきた
browser.tabs.moveInSuccession(
[D],
C
)
新たにアクティブなタブとなったDを閉じると、次はCがアクティブになります。その後は元の関係どおり、E→F→Gとアクティブになっていきます。
ただ、これだと元々あった「他のタブからDを参照する流れ」が失われてしまっているので、どのタブを閉じても決してDはアクティブにならないという事になります。
そこで登場するのが第3引数(オプション)のinsert
オプションです。第3引数にinsert:true
を指定すると、B→C→D→Eという関係性の中にD→Cという流れが自然な形で組み込まれます。
// テストのために関係を初期化
setSuccessorsById([B, C, D, E, F, G, H, A]);
// tabs.onActivatedのリスナーに対し、{ tabId: D, previousTabId: C } が渡ってきた
browser.tabs.moveInSuccession(
[D],
C,
{ insert: true }
)
B→C→D→EがB→D→C→Eになるという形で、元々あった全体的な流れの一貫性が保たれた事が分かります。
browser.tabs.moveInSuccession()
の第1引数は常に配列の形を取る必要があります。操作したいタブが1つだけの場合でも、個別のタブを渡す事はできません。
シナリオ3:アクティブなタブのセットが切り替わった時
アクティブなタブは1つのウィンドウ内で常に1つだけですが、「アクティブなタブのセット」という考え方が必要な場面もあります。
例えば、Sync Tab GroupsやPanorama Viewなどによって一部のタブのみが表示されている状態では、不可視状態のタブが不用意にアクティブにされる事でグループが切り替わってしまってはまずいです。
例えば「A, B, C」「D, E, F」「G, H」という3つのグループがあって、Cがアクティブな状態から、Dが新たにアクティブになったとします。それまではA, B, Cだけが表示されていたのが、D, E, Fが表示されそれ以外が非表示に切り替わりました、という場面でアドオンが行うべき操作は以下のようになります。
// テストのために関係を初期化
setSuccessorsById([B, C, D, E, F, G, H, A]);
// tabs.onActivatedのリスナーに対し、{ tabId: D, previousTabId: C } が渡ってきて、
// tabs.query({ windowId: D.windowId, hidden: false }) の結果がD, E, Fであるというような場面
browser.tabs.moveInSuccession(
[D, E, F],
C
)
DだけでなくEとFにも参照関係が再設定され、D→E→Fとグループ内でフォーカスが移っていく状態になりました。最後にFを閉じれば、それより前にアクティブだったCにフォーカスが戻ります。
この時、元々あった「他のタブからDを参照する流れ」が失われてしまっていますので、どのタブを閉じても決してDはアクティブにならないという事になります。第3引数にinsert:true
を指定すると、B→C→D→E→Gという関係性の中にD→E→F→Cという流れが自然な形で組み込まれます。
// テストのために関係を初期化
setSuccessorsById([B, C, D, E, F, G, H, A]);
// tabs.onActivatedのリスナーに対し、{ tabId: D, previousTabId: C } が渡ってきて、
// tabs.query({ windowId: D.windowId, hidden: false }) の結果がD, E, Fであるというような場面
browser.tabs.moveInSuccession(
[D, E, F],
C,
{ insert: true }
)
このように、元の流れを壊さずに極力維持したいという時には必ずinsert:true
を指定する必要があるという事が言えます。
シナリオ4:管理対象のタブが増えた時
browser.tabs.moveInSuccession()
は、まだ参照関係が管理されていなかったタブを新たに参照関係の中に組み込む場面……つまり、タブが新たに開かれたか、別のウィンドウから移動されてきたかして、ウィンドウの中に新しくタブが増えた場面でも使われます。
元々AからEまでの5つのタブがあった所に、新たにFというタブが加わったとします。この時、アドオンがFを既存のタブ同士の参照関係の中に組み込むために行うべき操作は、以下の通りです。
// テストのために関係を初期化
setSuccessorsById([B, C, D, E, A]);
// tabs.onCreatedまたはtabs.onAttachedのリスナーに対しFの情報が渡ってきた
// Fが開かれると同時にアクティブになったとして、
// Cはその前にアクティブだったタブ
browser.tabs.moveInSuccession(
[F],
C
)
これにより、Fを閉じるとCにフォーカスが戻るという関係が設定されます。
ただ、これだとどのタブを閉じてもFにはフォーカスが移らないという状態になっています。既存の参照関係の中に無理なく組み込むためには、やはりinsert:true
の指定が必要です。
// テストのために関係を初期化
setSuccessorsById([B, C, D, E, A]);
// tabs.onCreatedまたはtabs.onAttachedのリスナーに対しFの情報が渡ってきた
// Fが開かれると同時にアクティブになったとして、
// Cはその前にアクティブだったタブ
browser.tabs.moveInSuccession(
[F],
C,
{ insert: true }
)
同時に複数のタブがウィンドウ内に増えた、というケースも同様です。この場合は、追加されたすべてのタブをbrowser.tabs.moveInSuccession()
の第1引数に指定します。
// テストのために関係を初期化
setSuccessorsById([B, C, D, E, A]);
// tabs.onCreatedまたはtabs.onAttachedのリスナーで、
// F, G, Hがウィンドウ内に増えた事を検知した
// Fが開かれると同時にアクティブになったとして、
// Cはその前にアクティブだったタブ
browser.tabs.moveInSuccession(
[F, G, H],
C,
{ insert: true }
)
これにより、新たに増えたタブの中でまずはフォーカスが移動し、その後元のタブにフォーカスが戻るという関係が構築されます。
ちなみに、タブが増えた時とは逆の、タブの削除や他のウィンドウへの移動によってウィンドウ内のタブが減った場合については、アドオンは特に何もする必要はありません。このようなケースでは、
この図で示したように、抜けた穴を埋める形でFirefox自身が適切にタブの参照関係を振り直してくれます。
シナリオ5:アクティブなタブより手前にタブが増えた時
browser.tabs.moveInSuccession()
のオプションには、insert
以外にappend
という物もあります。これは、タブを開き直す操作によって「何らかのタブを閉じたらそのタブがアクティブになって欲しい」タブ(いわゆる親タブなど)がウィンドウ内に復元された場面で使われます。
元々A, B, C, E, Fの5つのタブがあった所に、Dというタブが復元されたとします。この時、アドオンがDを既存のタブ同士の参照関係の中に組み込むために行うべき操作は、以下の通りです。
// テストのために関係を初期化
setSuccessorsById([B, C, E, null, F, A]);
// tabs.onCreatedまたはtabs.onAttachedのリスナーに対しDの情報が渡ってきた
// Cは「そのタブを閉じたらDをアクティブにして欲しい」タブ
browser.tabs.moveInSuccession(
[D],
C,
{ append: true }
)
先ほどの「タブが単純に追加された場合」とは異なり、「他のタブから、増えたタブに対して戻ってくる流れ」が復元された事が分かります。
ただ、このままだとDを閉じた後にアクティブになるべきタブの情報がありません。元のC→Eという流れの中にDが組み込まれた事で、Eに至る流れが断ち切られてしまっています。これを維持するためにはinsert:true
オプションを同時に指定する必要があります。
// テストのために関係を初期化
setSuccessorsById([B, C, E, null, F, A]);
// tabs.onCreatedまたはtabs.onAttachedのリスナーに対しDの情報が渡ってきた
// Cは「そのタブを閉じたらDをアクティブにして欲しい」タブ
browser.tabs.moveInSuccession(
[D],
C,
{ append: true,
insert: true }
)
これでやっと、C→Eの中にDを無理なく組み込む事ができました。
復元されたタブの数が複数の場合も同様です。今度はA, B, C, F, G, Hがある所にD, Eが復元された場合を想定してみます。
// テストのために関係を初期化
setSuccessorsById([B, C, F,null, null, G, H, A]);
// tabs.onCreatedまたはtabs.onAttachedのリスナーで、
// D, Eがウィンドウ内に増えた事を検知した
// Cは「そのタブを閉じたらDをアクティブにして欲しい」タブ
browser.tabs.moveInSuccession(
[D, E],
C,
{ append: true,
insert: true }
)
こちらも、元の流れの中に無理なく組み込まれました。
この例を見て気がついた人もいるかもしれませんが、これは通常の「タブが追加された場合」の操作と共通する部分が多いです。実際に、第2引数の指定を変えると、append:true
を付ける場合と付けない場合で同じ結果になります。
// テストのために関係を初期化
setSuccessorsById([B, C, D, E, A]);
// F, G, Hがウィンドウ内に増えた場合で、
// それらの後にCがアクティブになって欲しいと分かっている場合
browser.tabs.moveInSuccession(
[F, G, H],
C,
{ insert: true }
)
// または、それらの前にBがアクティブになって欲しいと分かっている場合
browser.tabs.moveInSuccession(
[F, G, H],
B,
{ append: true,
insert: true }
)
どちらの方法を使えば良いのかは、増えたタブの後にアクティブになって欲しいタブと、増えたタブの前にアクティブになって欲しいタブ、どちらが判明しているかによって変わるという事になります。
結論
以上、Firefox 65で追加されたSuccessor Tabs APIの使い方を解説してみました。
新たに追加された機能のうちtabs.moveInSuccession()
だけは、非常にハイコンテキストな機能です。長々と図まで使って説明しましたが、自分には上記の例以外の局面では使える気がまるでしません。ここまで用途が限られているAPIがトリアージを経てWebExtensionsに採用されたというのが自分には意外に感じすらします。何せ、今までなら、こういう事は「プリミティブなAPIを組み合わせて頑張ってね」というのがWebExtensionsの方針だと思っていたので。
ただ、最初期の提案のコメントを見ると、このような複雑なAPIが提案された背景には、「プリミティブなtabs.update()
やtabs.query()
などだけでこれと同じ事をやろうとすると、各ケースでその度に猛烈な量のAPI呼び出しが発生し、プロセス間通信のオーバーヘッドも相まって、とんでもなく低速あるいはCPUを使う事になってしまうから」という事情があったようです。
また、この記事を受けて実装者の方が説明してくださっていますが、大量の非同期処理が進行中にユーザーが何かタブのフォーカス移動を伴う操作などを行うと、設定中の関係と新しい関係とが中途半端に混ざって関係がめちゃくちゃに壊れてしまいます(それを防ぐには、すべての操作をキューに溜めておいて順番に処理するというような仕組みを自分で整備しないといけません)。複数のタブの関係をアトミックに一度で設定できる事には、そういった問題を回避できるという意義もあります。
ともかく、このような複雑なAPIでも採用に至ったという事を考えると、「根拠を示して説明する事」「実際に(自動テストも含めた)パッチを書いて提出する事」が、WebExtensionsへのAPIの提案にあたっていかに大きな影響を与えるかという事を、改めて意識させられます。Twitterで愚痴るだけに終始せず、自分も積極的にパッチを書いていきたいものですね。