単子葉類プログラマーのメモ

プログラミング関連の自分用メモだけど他の人の役に立つかもしれないので公開しておく感じのブログ

マウスジェスチャと右クリック+ホイールでタブ切り替えができるChromeのアドオンを作った

作ったアドオンのストアページは以下。

chrome.google.com

作った理由

既存のどのマウスジェスチャアドオンにも不満点があったので欲しい機能を持つものを自作した。

既存のものに対する主な不満点は以下。

  • 右クリック+ホイールの機能がないか、あっても期待通りでない。
  • ツイッターで動作しない
  • IFRAME上にマウスポインタがあるときに動作しない

ちなみに、自分も開発中にツイッター上で動作しない問題に直面したが、理由はホイールイベントを非標準のmousewheelで拾っていたことだった。正しくはwheel。 ただ、なぜそれでツイッターでだけ動かなくなるのかは不明。

Chromeアドオンの作り方

公式ドキュメントは https://developer.chrome.com/docs/extensions/

作成するものは大まかに分けると以下のとおり。

コンテンツスクリプトとサービスワーカーそれぞれで、できることとできないことが違うので役割分担をする。
双方のあいだで情報をやり取りしたい場合、chrome APIを使用する。
コンテンツスクリプトからサービスワーカーに送る場合はchrome.runtime.sendMessage()
サービスワーカーからコンテンツスクリプトに送る場合はchrome.tabs.sendMessage()
受け取るときはどちらでもchrome.runtime.onMessage.addListener()

マウスジェスチャの実装

ソース
https://github.com/shining-corn/MouseGestureAndWheelAction

全部は説明しきれないので要所だけ。

マニフェスト

今回つくったマウスジェスチャの場合、マニフェストは以下の通り。

{
    "name": "Mouse Gesture and Wheel Action",
    "version": "1.0.0",

アドオンの名前とバージョン。

    "manifest_version": 3,

マニフェストファイルのバージョン。

    "description": "__MSG_extensionDescription__",

アドオンの説明。
__MSG_メッセージID__形式の値を指定すると、message.jsonで定義したメッセージIDの値に置き換えられる。 __MSG_extensionDescription__ならmessage.json"extensionDescription""message"の値に置き換えられる。

    "default_locale": "en",

デフォルトロケール。 このアドオンは日本語と英語に対応しており、日本以外では英語で表示するので英語を指定。

    "icons": {
        "16": "icon/16.png",
        "48": "icon/48.png",
        "128": "icon/128.png"
    },

アイコン用のpngファイル。
それぞれ16x16、48x48、128x128のサイズのファイルを、アドオン用フォルダ直下から見た相対パスで指定する。

    "content_scripts": [
        {
            "matches": [
                "http://*/*",
                "https://*/*",
                "file://*/*"
            ],

アドオンを動作させるWEBページのURLを指定。
あらゆるページでマウスジェスチャを動作させるため、*ですべてのURLに一致するようにする。
なお、こうするとChrome Web Storeにアドオンを登録するときの審査期間が長くなると、申請時に警告される。
*を使わなくても、permissionsにactiveTabを指定すると、全URLでユーザがブラウザで特定の操作を行ったときにアドオンが動作するようにできる。しかし、マウスジェスチャは特定の操作を行う前から動作させる必要があるのでactiveTabは使えなかった。

            "js": [
                "common.js",
                "content.js"
            ],

コンテンツスクリプトのパス。
配列に書いた順に読み込まれるので、こう書いた場合はcontent.jsからcommon.jsスクリプトを参照できる。

            "run_at": "document_start",

コンテンツスクリプトをWEBページに挿入するタイミング。
このアドオンでは、マウスの右ボタン+ホイールで別タブに移動した後にマウスの右ボタンを離したときにコンテキストメニューが表示されるのを抑制している。しかし、コンテンツスクリプトの挿入が遅いと、別タブでページを開いてすぐにそのタブに移動したときにコンテキストメニューの抑制が間に合わない。コンテンツスクリプトの挿入を速くするためにdocument_startを指定。
これでも操作が早すぎる場合は間に合わないが、それはどうしようもない。

            "all_frames": true,
            "match_about_blank": true
        }
    ],

IFRAME内でもアドオンを動作させるため、"all_frames": trueを指定。 about:blankのIFRAMEでも動作させるため、"match_about_blank": trueを指定。

参考: https://developer.chrome.com/docs/extensions/mv3/manifest/content_scripts/#frames

実は「about:blankでも動作させる」ということの意味を理解していないが、これを指定しないと動かないIFRAMEがあるので指定している。

    "background": {
        "service_worker": "service-worker.js"
    },

サービスワーカーのパス。

    "permissions": [
        "storage",
        "sessions",
        "bookmarks"
    ],

アドオンに付与する権限。
https://developer.chrome.com/docs/extensions/mv3/declare_permissions/

オプションページでカスタマイズされた設定情報を保存するためにchrome.storage.localを使用するのでstorage権限を付与。
閉じたタブを開く機能のためにchrome.sessionsを使用するのでsessions権限を付与。
ブックマークの追加、削除機能のためにchrome.bookmarksを使用するのでbookmarks権限を付与。

なお、タブ切り替え等のためにchrome.tabsを使用するが、特定のフィールドにアクセスするのでなければtabs権限は不要。
WEBページのURLやタイトルをクリップボードにコピーする機能も実装しているが、clipboard権限は不要。この権限はchrome.clipboardのために必要だが、このアドオンで使用しているのはnavigator.clipboardなので。

    "options_ui": {
        "page": "options.html",

オプションページ用のHTMLファイルのパス。
このHTMLから読み込むjavascriptファイルのパスは書かなくても動いた。

        "open_in_tab": true
    }
}

オプションページを独立したタブで開くようにするため、trueを指定。

マウスの移動方向の判定

マウスジェスチャでマウスを上下左右のどの方向に動かしたのかを判定するためにMath.atan2()を使用。
学校以外で初めて三角関数が役に立った。
斜め45°付近で動かされたときに、↑→↑→↑→↑→のような入力が大量に連続しないようにするため、斜め方向の角度は無視するようにした。
また、同じ方向の連続した入力は一個分としてカウントする。つまり→→のようなマウスジェスチャは認めない。

WEBページ上のマウスイベントを拾って判定するので、コンテンツスクリプト側に実装。

マウスジェスチャの軌跡の描画

マウスジェスチャ中、マウス移動の軌跡を画面上に描画するため、document.createElement('canvas')<canvas>タグを作成し、parentElement.appendChild(element)でWEB画面に挿入。
領域を画面いっぱいにし、背景を透明にするために以下のようなスタイルを指定。

element.style.width = '100vw';
element.style.height = '100vh';
element.style.position = 'fixed';
element.style.left = '0px';
element.style.top = '0px';
element.style.margin = '0px';
element.style.padding = '0px';
element.style.border = 'none';
element.style.backgroundColor = 'transparent';

また、画面の最前面に表示するためにstyle.zIndex = 16777271を指定。

window.addEventListener()mousemoveイベントを拾い、(event.buttons & 2) === 2で右ボタンを押しながらの移動かどうかを判定してから、以下で線を描画。

const ctx = this.canvasElement.getContext('2d');
ctx.lineWidth = 4;
ctx.strokeStyle = '#408040';
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
ctx.closePath();

この時にマウスの移動距離も取得して、移動方向を判定する。

mouseupイベントが来て、event.button === 2だったらマウスジェスチャが完了したとみなす。
mouseupのときはevent.buttonsではなくevent.buttonを見る点に注意。mouseupのとき、event.buttonには離されたボタンが、event.buttonsにはそのときにまだ押されているボタンの情報が入っている。

WEBページにタブを挿入し、そこに描画する処理なのでコンテンツスクリプト側に実装。

IFRAMEとの通信

このアドオンは、IFRAME内でマウスジェスチャを行った場合、その情報を親windowに伝えて、親window側で処理を行うという仕組みにしている。
これは、IFRAME内で「一番下にスクロール」を行った場合、IFRAME内をスクロールするのではなく親ウィンドウをスクロールするため。これは広告のIFRAME内でスクロールしてもうれしくないのでそれを防ぐための仕様。だが、逆に意図的に配置されている長い縦スクロールを持つIFRAME(例えばはてなブログの記事の編集ページ)に対しては使えないので一長一短の仕様である。広告が邪魔なケースの方が多いだろうと判断してこの仕様にした。

それで、この仕様を実現するためには親windowとIFRAME内のスクリプトが情報のやりとりを行う必要がある。 Cross-Origin Resource Policyがあるので直接やりとりすることはできない。IFRAME内でもコンテンツスクリプトを動作させ、window.postMessage()で親windowと情報をやりとりする必要がある。
IFRAME内からはwindow.parentで上位のフレームのwindowオブジェクトが得られる。最上位のウィンドウではwindow.parent === windowになるので、そうなるまで再帰的にさかのぼって最上位のウィンドウを探す。

受け取るときはwindow.addEventListener()messageイベントをハンドリングする。ここで受け取ったeventsourceフィールドに送信元のwindowオブジェクトが入っている。

WEBページにもとからあるスクリプトwindow.postMessage()を使用している可能性があるので、アドオン自身から送信されたメッセージかどうかを識別するために、送信するメッセージの中にアドオンのIDchrome.runtime.idを埋め込んでおく。

WEBページに関する処理なのでコンテンツスクリプト側に実装。

他のスクリプトによるstopImmediatePropagation()への対処

マウス関連のイベントを拾うため、個別のHTML Elementではなくwindowに対してaddEventListener()している。 その場合に、ページ内の要素上で他のスクリプト(WEBページの本来のスクリプトや他のアドオン)にイベントをstopImmediatePropagation()されると、自分のイベントハンドラまでイベントが来なくなる。

これに対処するため、addEventListener()の第二引数に{ capture: true }を指定。

captureの説明はhttps://developer.mozilla.org/ja/docs/Web/API/EventTarget/addEventListenerにあるが、以下のほうがわかりやすい。

ja.javascript.info

{ capture: true }を指定しないと、例えばgithubのtextareaタグ上マウスジェスチャが動作しない。

解決できなかった問題

右クリック+ホイールでのタブ切り替えがスムーズに動かない場合がある

新しいタブで開くで複数のタブを開いた後、右クリック+ホイールでそれらのタブを連続で切り替えようとしても、一つ移動するたびにいったん操作を止めて、再度右クリック+ホイールをやり直さないと次のタブに切り替えられない。 あるいは、途中で反対方向にホイール回転させてからやり直すと次のタブに移動できる。
スクリプトの処理が重いせいかとも思ったが、最小限のシンプルな実装で実験しても結果は変わらなかった。
対処不可能なので放置。

IFRAMEをまたぐマウスジェスチャの完全なサポート

ほとんどのIFRAMEでは問題なくマウスジェスチャが動作する(IFRAME内からジェスチャを始めた場合、マウスの軌跡がIFRAME内にしか描画されないが、動きはする)。
しかし、他のスクリプトの影響次第では、動かなくはないが使いにくくなる。
例えば、特定のブラウザゲームのサイトでは、ゲーム画面上でマウスジェスチャを始めた場合、ゲーム画面の外側に出るとマウスジェスチャ機能が反応しなくなる(ゲーム画面内に戻ってそこでマウスの右ボタンを離せば動作させることは可能)。

通常、IFRAMEと親windowの両方にaddEventListener('mousemove')をしている場合、IFRAME内からマウスのドラッグを始めて外にでても、IFRAME内のイベントハンドラmousemoveイベントを拾い続ける。
しかし、mousedownイベントをpreventDefault()しているCANVASタグがIFRAME内にある場合、そのCANVASから始めたドラッグは上記のようにならない。IFRAMEの外にでると親windowのイベントハンドラがイベントを拾うようになることがある。さらに他の条件が絡むとこうならないこともあるようだが、その条件は不明。
また、CANVASではなくDIVタグの場合はこの現象が起こらない。

これらの挙動を把握してIFRAMEの内外で通信してうまく処理すれば対処できそうではあるが、そこまでして対処する価値を感じないので放置。

なお、chromeのアドオンの話なので、上記はすべてchromeの場合での話。

Chrome Web Storeでの公開方法

以下を読んで実施。

developer.chrome.com

最初に開発者アカウントの取得のために$5払う必要がある。
連絡用メールアドレスの登録も必要。登録したメールアドレスはWeb Storeで公開されるので普段使いのものとは別のものを使ったほうが良い。アドオンについての要望メールが届くこともあるが、「あなたのアドオンをもっと有名にしますよ」という胡散臭い詐欺っぽいメールも届く。
ストアページの説明文やスクリーンショットの登録、個人情報に関する宣言等も実施。
そこそこめんどくさかった。特に、手続き内容や何を入力しろと言われているのかを理解するところが。