マウスジェスチャと右クリック+ホイールでタブ切り替えができるChromeのアドオンを作った
作ったアドオンのストアページは以下。
作った理由
既存のどのマウスジェスチャアドオンにも不満点があったので欲しい機能を持つものを自作した。
既存のものに対する主な不満点は以下。
ちなみに、自分も開発中にツイッター上で動作しない問題に直面したが、理由はホイールイベントを非標準のmousewheel
で拾っていたことだった。正しくはwheel
。
ただ、なぜそれでツイッターでだけ動かなくなるのかは不明。
Chromeアドオンの作り方
公式ドキュメントは https://developer.chrome.com/docs/extensions/
作成するものは大まかに分けると以下のとおり。
- manifest.json
- https://developer.chrome.com/docs/extensions/mv3/intro/
- アドオンの名前や必要な権限などの情報を書くファイル。
- 作成が必須。これ以外は省略可能。
- コンテンツスクリプト(JavaScript)
- https://developer.chrome.com/docs/extensions/mv3/service_workers/
- ブラウザで表示されているWEBページ内に挿入されるスクリプト。
- サービスワーカー(JavaScript)
- https://developer.chrome.com/docs/extensions/mv3/content_scripts/
- 表示されているWEBページではなくバックグラウンドで動き、ブラウザ全体にかかわる処理を行うスクリプト。
- message.json
- https://developer.chrome.com/docs/extensions/reference/i18n/
- 国際化(i18n = internationalization)を行う場合に作成する。
- オプションページ(HTML, JavaScript)
- https://developer.chrome.com/docs/extensions/mv3/options/
- アドオンに関する設定変更用のページ。
コンテンツスクリプトとサービスワーカーそれぞれで、できることとできないことが違うので役割分担をする。
双方のあいだで情報をやり取りしたい場合、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
イベントをハンドリングする。ここで受け取ったevent
のsource
フィールドに送信元の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にあるが、以下のほうがわかりやすい。
{ 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での公開方法
以下を読んで実施。
最初に開発者アカウントの取得のために$5払う必要がある。
連絡用メールアドレスの登録も必要。登録したメールアドレスはWeb Storeで公開されるので普段使いのものとは別のものを使ったほうが良い。アドオンについての要望メールが届くこともあるが、「あなたのアドオンをもっと有名にしますよ」という胡散臭い詐欺っぽいメールも届く。
ストアページの説明文やスクリーンショットの登録、個人情報に関する宣言等も実施。
そこそこめんどくさかった。特に、手続き内容や何を入力しろと言われているのかを理解するところが。