2017年11月14日、Mozilla Firefoxのバージョン57がリリースされます。

 個人的にはいつもはウキウキ気分で迎えるアップデートなわけですが、今回に限ってはどちらかというとFirefox史上最もアップデートになりそうです。
 なぜなら、Firefox57(Firefox Quantum)からは今まで使えていたアドオンがほとんど使えなくなってしまう、とのこと。うん、ちょっと何言ってるか分からない。

 使えなくなりますよ、という目立った警告はなかったと思うので、おそらく11月14日、世界中のツイッターで「Firefoxは終わった」とか「ご冥福をお祈りします」とかいうツイートがじゃんじゃか流されたり、なんとかグラムでChromeインストール完了画面と自撮りしてキメ顔を晒すのが流行ったりするものと思われます。もうWikiPediaは保護しといた方がいいかもわからんね。

 そんなわけで、マウスジェスチャーアドオン「FireGestures」が動かなくなってしまったので、その代替として私が選んだ「Foxy Gestures」のご紹介です。コピペできるユーザースクリプトつき。

ダウンロード

Foxy Gestures – Add-ons for Firefox
https://addons.mozilla.org/ja/firefox/addon/foxy-gestures/

なぜ Foxy Gestures なのか?

シンプル

 FireGestures はジェスチャーの軌道と実行される機能名が端っこに小さく出るだけのシンプルさでしたが、その点 Foxy Gestures も似ています。
 設定画面はむしろ FireGestures よりわかりやすいかもしれない。

自由にジェスチャーを作れる

 長いジェスチャーでも自由に作れます。(ジェスチャー系アドオンの中には複雑なジェスチャーが作れないものも結構ある。)

ユーザースクリプトでWebExtensionのAPI(の一部)が使える

 おそらくこれが一番の特徴で、WebExtensionのAPIを使うことでフツーのJavaScriptではできないようなことができます。この記事で紹介。

FireGesturesとの主な差異

  • タブバー上でホイール回転でタブ切り替えができない
  • 404などのエラーページ、新しいタブや設定画面などのビルトインページでジェスチャーできない(ホイールジェスチャー、ロッカージェスチャー等も含む)

 など。
 これらはおおよそ現時点での WebExtension API の制限です。ただし、Mozillaの偉い人の話によるとAPIは今後拡充されていくとかいう話らしいので、もしかしたら今後、できるようになるかもしれません。

 そんなん待てるか、という方は、往年のWindows用ソフトウェアを使うという方法も一応あります。
 詳しくは当記事の最後で。

Foxy Gesture用ユーザースクリプト サンプル集

 以下、サンプルを列挙します。コピペしてお使いください。
 なお、一部よそ様からコピペしてきて組み合わせたものもあるような気がしますので、100%オリジナルではないと思います。ご了承ください。

要素を消す

mouseDown.target.parentNode.removeChild(mouseDown.target);

 会員登録を迫るウィンドウがスクロールしてもついてきてウザい時などに。
 あとWeb制作の時にも意外とよく使う。

ウィンドウを最大化・規定サイズに戻す

executeInBackground(() => {
  getCurrentWindow().then(cwin => {
    if(cwin.state == "normal"){
      browser.windows.update(cwin.id,{state: "maximized"});
    }else{
      browser.windows.update(cwin.id,{width: 1024, height: 768});
    }
  });
}, []);

 ウィンドウを最大化したり、XGAサイズにしたり。

現在のウィンドウの全てのタブをリロードする

executeInBackground(() => {
  getCurrentWindowTabs().then(tabs => {
    //tabs に今のウィンドウのすべてのタブの情報が入る
    //配列なので for ループで回す
    for(var i=0; i<tabs.length; i++){
      browser.tabs.reload(tabs[i].id, {bypassCache: false});
    }
  });
}, []);

 browser.tabs.reload関数の第二引数のオブジェクトのfalse部分をtrueにすると「キャッシュを無視してリロード」になります。

タブを閉じる(Ctrlキー同時押しでピン留めタブも閉じる)

executeInBackground(ctrl => {
  getActiveTab(tab => {
    if(!tab.pinned || ctrl){
      browser.tabs.remove(tab.id);
    }
  });
}, [mouseDown.ctrlKey]);

 普通にタブを閉じます。Ctrlキー同時押しでピン留めタブも強制的に閉じます。
 ※もともと「ピン留めタブは閉じない」スクリプトを書いていましたが、デフォルトもピン留めタブを閉じないようになったらしいので、上記スクリプトに変更。

選択範囲をURLとして開く・検索する

var selstr = window.getSelection().toString();
//プロトコルで始まる場合は開く それ以外は検索
if(!selstr.match(/^(http|file)/i)){
  selstr = 'https://www.google.co.jp/search?q=' + selstr;
}
if (selstr) {
  executeInBackground(selstr => {
    getActiveTab(tab => browser.tabs.create({
      url: selstr,
      index: tab.index + 1,
      active: true
    }));
  }, [ selstr ]);
}

 選択範囲がURLなら、そのURLを開く。それ以外はGoogle検索。

タイトルとURLをコピー

※2017/11/12 – クリップボードが使えるようになったので修正

var ctrl = mouseDown.ctrlKey,
delim_plain = "\n", //タイトルとURLの間に入れる区切り 改行は\n
delim_markdown = "  \n", //上のMarkdown用
promise = executeInBackground((ctrl, dp, dm) => {
  return getActiveTab(tab => {
    if(ctrl){
      return "[" + tab.url + "]" + dm + "(" + tab.title + ")";//Markdown形式
    }else{
      return tab.title + dp + tab.url;
    }
  });
}, [ ctrl, delim_plain, delim_markdown ]);

promise.then(str => {
    var textarea = document.createElement("textarea");
    document.body.appendChild(textarea);
    textarea.value = str;
    textarea.select();
    textarea.focus();
    document.execCommand("copy");
    textarea.parentNode.removeChild(textarea);
});

 Ctrlキーを押しながらやると、Markdown形式でコピーするようにしてみた。
 ただ私はMarkdown形式を使ったことないので、コピペして爆発したらごめん。

同じウィンドウの全てのタブのタイトルとURLをコピー

※2017/11/12 – クリップボードが使えるようになったので修正

var delim_title_url = " ", //タイトルとURLの間の区切り 改行は\n
delim_tabs = "\n", //タブとタブの間の区切り
promise = executeInBackground((d1, d2) => {
  return getCurrentWindowTabs().then(tabs => {
    var s = "";
    for(var i=0; i<tabs.length; i++){
      s += (s!=="" ? d2 : "") + tabs[i].title + d1 + tabs[i].url;
    }
    return s;
  });
}, [ delim_title_url, delim_tabs ]);

promise.then(str => {
    var textarea = document.createElement("textarea");
    document.body.appendChild(textarea);
    textarea.value = str;
    textarea.select();
    textarea.focus();
    document.execCommand("copy");
    textarea.parentNode.removeChild(textarea);
});

 上述のジェスチャーを全部のタブでやってると地味につらいので、とりあえず全部取得するタイプ。

選択部分のリンクを開く

※2017/11/12 – タブを開く順番がおかしかったので修正

var selectionDOM = window.getSelection().getRangeAt(0).cloneContents(),
links = selectionDOM.querySelectorAll('a'), hrefs = [], i;
for(i=0; i<links.length; i++){
  hrefs.push(links[i].href);
}
hrefs = Array.from(new Set(hrefs));
for(i=0; i<hrefs.length; i++){
  var href = hrefs[i];
  if (href && href.match(/^(http|file)/i)) {
    executeInBackground((href,i) => {
      getActiveTab(tab => browser.tabs.create({
        url: href,
        index: tab.index + 1 + i,
        active: false
      }));
    }, [ href, i ]);
  }
}

 選択部分に含まれるリンクを一度に開く。かなり便利。
 http と file で始まるURLのみ開きます。mailto: とか tel: とかは無視。

右のタブを全てダウンロード

executeInBackground(() => {
  getActiveTab(tab => {
    var index = tab.index;
    getCurrentWindowTabs().then(tabs => {
      for(var i=index+1; i<tabs.length; i++){
        browser.downloads.download({url: tabs[i].url});
      }
    });
  });
}, []);

 画像まとめブログで真価を発揮するおとこのジェスチャーです。

画像を新しいタブで開く

var src = data.element.mediaInfo && data.element.mediaInfo.source;
//console.log(data);
if (src) {
  executeInBackground(src => {
    getActiveTab(tab => browser.tabs.create({
      url: src,
      index: tab.index + 1,
      active: !true
    }));
  }, [ src ]);
}

 99%公式サイトからコピペ。
 ただし新しいタブがアクティブにならないように active は false にしてある。

一つ上の階層(またはサブドメイン)に移動

※2017/12/23 – サブドメインにも対応

executeInBackground(() => {
  getActiveTab(tab => {
    let f = true;
    let c_url = tab.url.replace(/\/$/,"");
    let parts = c_url.split("/");
    parts.pop();
    let url = parts.join("/");
    if(/:\/\/?$/.test(url)){
      parts = c_url.split("/");
      let domain = parts.pop();
      let parts2 = domain.split(".");
      parts2.shift();
      if(parts2.length <= 1){
        f = false;
      }else{
        url = parts.join("/") + "/" + parts2.join(".");
      }
    }
    if(f){browser.tabs.update(tab.id, {url: url});};
  });
});

 ディレクトリを登り切ったらサブドメインも登ります。
 なお、.jpとかのトップレベルドメインに到達してしまう場合は何もしません。なぜなら、トップレベルドメインまで突っ切ってしまうと「http://jp」となってしまいますが、この場合Firefoxさんが気を利かせて「http://www.jp.com」という罠しか待ち受けていなさそうなサイトにアクセスしようとするためです。これって仕様としてどうなんだろう。危なくね?

【2018/09/10追加】現在のタブと同じドメインのタブを新しいウィンドウに移動

executeInBackground(ctrl => {
  getActiveTab(tab => {
    var m = tab.url.match(/:\/\/([^\/]+)\/?/);
    if(m){
      let qi = {url: "*://"+m[1]+"/*"};
      if(!ctrl){qi["currentWindow"] = true;}
      var querying = browser.tabs.query(qi);
      querying.then(tabs =>{
        var movetabs = [];
        for (let atab of tabs) {
          movetabs.push(atab);
          browser.tabs.update(atab.id,{pinned: false});
        }
        var creating = browser.windows.create({
          tabId: movetabs[0].id
        }).then(nw => {
          for(var i=1; i<movetabs.length; i++){
            browser.tabs.move(movetabs[i].id, {windowId: nw.id, index: i});
          }
          for(var i=0; i<movetabs.length; i++){
            browser.tabs.update(movetabs[i].id, {
              active: movetabs[i].active,
              pinned: movetabs[i].pinned
            });
          }
        });
      });
    }
  });
}, [mouseDown.ctrlKey]);

 現在のタブと同じドメインのタブを新しいウィンドウに移動させます。サブドメインが違う場合は、同じドメインとはみなしません。
 個人的には、Google検索結果のページが大量に散在している時に使っている気がします。
 Ctrlキーを押しながらで、すべてのウィンドウのタブが対象になります。

【2018/09/10追加】ページタイトルを検索して新しいウィンドウに移動

var searchword = window.prompt('マッチしたタブを新しいウィンドウに移動します');
if(searchword){
  executeInBackground(searchword => {
    getCurrentWindowTabs().then(tabs => {
      var movetabs = [];
      for(var i=0; i<tabs.length; i++){
        if(tabs[i].title.toLowerCase().indexOf(searchword)>=0){
          movetabs.push(tabs[i]);
          browser.tabs.update(tabs[i].id,{pinned: false});
        }
      }
      var creating = browser.windows.create({
          tabId: movetabs[0].id
      }).then(nw => {
        for(var i=1; i<movetabs.length; i++){
          browser.tabs.move(movetabs[i].id, {windowId: nw.id, index: i});
        }
        for(var i=0; i<movetabs.length; i++){
          browser.tabs.update(movetabs[i].id, {
            active: movetabs[i].active,
            pinned: movetabs[i].pinned
          });
        }
      });
    });
  }, [searchword.toLowerCase()]);
}

 タイトルを検索して、ヒットしたタブを新しいウィンドウに移動。
 Ctrlキー同時押しで全ウィンドウを対象にします。

【2018/09/10追加】現在のタブと同じコンテナータブを新しいウィンドウに移動

executeInBackground(ctrl => {
  getActiveTab(tab => {
    var m = tab.cookieStoreId;
    if(m && m!=="firefox-default"){
      let qi = {cookieStoreId: m};
      if(!ctrl){qi["currentWindow"] = true;}
      var querying = browser.tabs.query(qi);
      querying.then(tabs =>{
        var movetabs = [];
        for (let atab of tabs) {
          movetabs.push(atab);
          browser.tabs.update(atab.id,{pinned: false});
        }
        var creating = browser.windows.create({
          tabId: movetabs[0].id
        }).then(nw => {
          for(var i=1; i<movetabs.length; i++){
            browser.tabs.move(movetabs[i].id, {windowId: nw.id, index: i});
          }
          for(var i=0; i<movetabs.length; i++){
            browser.tabs.update(movetabs[i].id, {
              active: movetabs[i].active,
              pinned: movetabs[i].pinned
            });
          }
        });
      });
    }
  });
}, [mouseDown.ctrlKey]);

 Firefoxのコンテナータブを新しいウィンドウに移動。現在のタブがコンテナータブでない場合(普通のタブの場合)は何もしません。
 これもCtrlキー同時押しで全ウィンドウ対象。

【2018/09/10追加】全てのウィンドウの全てのタブを新しいウィンドウに移動

executeInBackground(ctrl => {
  getActiveTab(tab => {
      var m = true;
      if(m){
          let qi = {};
          var querying = browser.tabs.query(qi);
          querying.then(tabs =>{
              var movetabs = [];
              for (let atab of tabs) {
                  movetabs.push(atab);
                  browser.tabs.update(atab.id,{pinned: false});
              }
              var creating = browser.windows.create({
                  tabId: movetabs[0].id
              }).then(nw => {
                  for(var i=1; i<movetabs.length; i++){
                      browser.tabs.move(movetabs[i].id, {windowId: nw.id, index: i});
                  }
                  for(var i=0; i<movetabs.length; i++){
                      browser.tabs.update(movetabs[i].id, {
                          active: movetabs[i].active,
                          pinned: movetabs[i].pinned
                      });
                  }
              });
          });
      }
  });
}, [mouseDown.ctrlKey]);

 複数のウィンドウのタブを全部、新しいウィンドウに移動。
 やりたいと思うことはあまりありませんが、いざやろうとすると手間なので。

 以上です。時々追加・修正します。

スクリプトを書こうと思っている人向けの解説

 解説コーナー。

 別に専門家ではないので、もしかしたら言ってることに多少間違いが含まれていたり大雑把すぎる点があるかもしれませんが、まあ動けばいいじゃんということで。

普通のJavaScriptは普通に書けば動く

 普通のJavaScriptは、普通に書けば動きます。

alert('ほえー');

 と書けばダイアログで「ほえー」と出てきます。

ブラウザーの機能を直接扱う場合は「Background」でやる

 ブラウザーの機能を追加するような感じのジェスチャーを書くには、WebExtension APIの関数を使います。
 その関数を書く際に使うのが、executeInBackground()関数です。

 公式のUser Scripts のページの解説によると、

To access privileged APIs, the user script must execute in the background script environment. Foxy Gestures provides a executeInBackground() function to allow user scripts to execute code in the background.

(意訳)特権が必要な関数を使うため、ユーザースクリプトはバックグラウンドなスクリプト動作環境で実行される必要があります。Foxy Gestures はそのために executeInBackground() 関数を用意しています

 とのこと。

 その他の関数やオブジェクトについては、公式Wikiに全部載ってます。
 英語ですが、レッツエンジョイ。

参考1:promise について

 getCurrentWindow()は promise を返すので、続きの処理をthen()で書いています。
 promise というのは比較的新しい(?)技術ですが、要するに非同期処理です。

 仮に以下のようなスクリプトを考えてみます。

var cwin = 今のウィンドウを返す関数();
browser.windows.update(cwin.id,{state: "maximized"});

 cwinに今のウィンドウの情報を代入し、browser.windows.updateでその情報を使おうとしています。
 しかしこれでは、cwinにデータが入る前に、browser.windows.updateが実行されてしまうかもしれません。cwinにデータが入るまで、次の文の実行を待ってほしいと思うわけです。

 そこで、JavaScriptに promise が導入されました。
 promise.then()の中の関数は、promiseにデータが入るまでは絶対に実行されません。

var promise = getCurrentWindow();
promise.then(cwin => {
  browser.windows.update(cwin.id,{state: "maximized"});
});

 これで、promise にデータが入ってから、次の関数が実行されます。
 ちなみにpromiseがない時代はどうしていたかというと、例えばこう。

var t, cwin;
t = setInterval(function(){
  cwin = 今のウィンドウを返す関数();
  if(cwin){
   clearInterval(t);
   browser.windows.update(cwin.id,{state: "maximized"});
  }
},100);

参考2:アロー関数について

 スクリプト内に出てきた謎の=>についてですが、これはアロー関数というものらしいです。

アロー関数 – JavaScript | MDN
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/arrow_functions

//無名関数で書いたもの
function(src){ alert(src); }
//アロー関数で書いたもの
(src) => { alert(src); }
//1つの場合は()省略可能
src => { alert(src); }

 単純に短く書けると言うだけでなく、いろいろと動作上のメリットがあるらしいですが、ここでは「ユーザースクリプトはアロー関数で書くもんなんだよ」と割り切ればいいと思います。
 なおサンプルのうち、=>の右側が{}で囲まれてないものがありますが、これは以下の通りなので大丈夫らしい。

//等価
(param1, param2, , paramN) => expression
(param1, param2, , paramN) => { return expression; }

参考3:Foxy Gestures のユーザースクリプトで使えるWebExtensionのAPI

 browser.tabs.createとかbrowser.downloads.downloadとかがWebExtension APIの関数なわけですが、あらゆる関数が使えるわけではなく、Foxy Gesturesが内蔵している設定ファイルに記載されている分だけが使えるようになっています。

 Foxy Gesturesの設定ファイルにはどう記載されているのかというと、

"permissions": [
  "clipboardWrite",
  "cookies",
  "downloads",
  "sessions",
  "storage",
  "tabs"
], ..

 ということなので、使えるのは以下に紹介されているAPIということになります。

cookies
https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/cookies
参考:https://developer.chrome.com/extensions/cookies
downloads
https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/downloads
参考:https://developer.chrome.com/extensions/downloads
sessions
https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/sessions
参考:https://developer.chrome.com/extensions/sessions
storage
https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/storage
参考:https://developer.chrome.com/extensions/storage
tabs
https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/windows
参考:https://developer.chrome.com/extensions/windows
https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/tabs
参考:https://developer.chrome.com/extensions/tabs

「clipboardWrite」は、document.execCommand(“copy”)document.execCommand(“cut”)を使うためのパーミッションです。

※どのパーミッションを追加すればどのAPIが使えるようになるかの解説
https://developer.chrome.com/extensions/declare_permissions

Foxy Gestures のその他ハマった点

 ハマったというほどでもないかもしれませんが、ああーと思った点をいくつか。

設定したジェスチャーを削除する方法

 削除ボタンがありませんが、ジェスチャーの設定領域で右クリックだけすれば消えます。
 このやり方で正しいんだろうか?

ユーザースクリプトが表示されない・追加ボタンを押しても反応がない

 時々管理画面上で、ユーザースクリプトが表示されなかったり、追加ボタンを押しても何も起こらないことがあります。この場合、内部的には消えていないし、きちんと追加もされているっぽいです。ただ再起動したりしても直らない……
 私の場合は、「Backup & Restore」タブの「Backup Settings」でバックアップファイルをダウンロードして、それを「Restore Setting」で復元すれば戻りました。
 もしかしたら定期的にバックアップしといた方がいいのかもしれない。

タブバー上のマウスホイール回転でタブ切り替えは「かざぐるマウス」で可能っちゃ可能

 余談。

 どうしてもタブバー上でマウスホイールを回転させてタブ切り替えがしたい場合やビルトインページ上でもジェスチャーをしたい場合は、往年のWindows用フリーソフト「かざぐるマウス」を使うという手もあります。
 すでに更新が止まっていますが、私の環境(Windows 10)では普通に動きました。

かざぐるマウス 最新版1.66を手に入れる : わすれなぐさ
http://forgetmenots.doorblog.jp/archives/34094626.html

 基本的なマウスジェスチャーもできるようになるので(というかそれがメイン機能のソフトですが)、あまりマニアックなマウスジェスチャーを欲していない、という場合はむしろFoxy Gesturesよりこちらを使ったらいいかもしれません。
 WebExtensionと全く関係ないので、ビルトインページ上でも動きます。

 なお、かざぐるマウスのマウスジェスチャーが有効になっていると Foxy Gestures のジェスチャーは無効になるので、普段はかざぐるマウスを有効にしておき、ここぞという時にはかざぐるマウスを無効化して Foxy Gestures を使う、という方法もありかもしれません。

 あとタブバーのホイールを有効にすると、ウィンドウの右上に人の形をした不気味なマークが出てきますが、これは以下の方法で消せます。

タブバー上の人型アイコンを消す(追記あり) – Mozilla Flux
http://rockridge.hatenablog.com/entry/2017/10/29/234337

 要注意事項:かざぐるマウスのreadmeやバージョン情報に書いてある公式サイトは既に閉鎖されていますが、うっかりアクセスすると迷惑サイトにつながってしまうので、アクセスしないようにご注意ください。