ブラウザベースのサービスをChrome機能拡張でカスタマイズする時の工夫ポイント

この記事は[Goodpatch Advent Calendar 2021](https://qiita.com/advent-calendar/2021/goodpatch) 19日目の記事です。

フロントエンドの<a href="https://qiita.com/ikezzz" target="_blank">池澤</a>です。
先日オンラインホワイトボード[Strap](https://product.strap.app/)を[Chrome機能拡張を使ってカスタマイズする](https://goodpatch-tech.hatenablog.com/entry/2021/11/17/150105)という記事を書きました。
簡単に説明するとホワイトボードツール自体はそのままで、別途Chrome機能拡張を使ってタイマー機能や任意ボタンのショートカットキー割り当て機能を追加するものです。

今回は、ホワイトボードツールのようなブラウザベースのSaasサービスをChrome機能拡張でカスタマイズする際に、私が工夫したポイントについてお話したいと思います。

## 目的
毎日のようにStrapを使う中で、丁度趣味でChrome機能拡張をいじっており、せっかくなので自分用にカスタマイズしてみようというのが最初の動機でした。
そこで以下機能を実装することにしました。

|欲しい機能|詳細|
|--|--|
|手のひらモードボタンのショートカットキー設定|私はPhotoshop等グラフィック系ツールに慣れていたので、ドラッグでの画面移動モードは「h」キーを押すごとにトグル式にモードが切り替わる操作感にしたい<br>[→MyStrap記事へリンク](https://goodpatch-tech.hatenablog.com/entry/2021/11/17/150105#%E6%A9%9F%E8%83%BD2-%E6%89%8B%E3%81%AE%E3%81%B2%E3%82%89%E3%83%A2%E3%83%BC%E3%83%89%E3%82%B7%E3%83%A7%E3%83%BC%E3%83%88%E3%82%AB%E3%83%83%E3%83%88%E8%A8%AD%E5%AE%9A)|
|カウントダウンタイマー|MTG時にさくっとタイムキーピングするのに使いたい<br>[→MyStrap記事へリンク](https://goodpatch-tech.hatenablog.com/entry/2021/11/17/150105#%E6%A9%9F%E8%83%BD1-%E3%82%AB%E3%82%A6%E3%83%B3%E3%83%88%E3%83%80%E3%82%A6%E3%83%B3%E3%82%BF%E3%82%A4%E3%83%9E%E3%83%BC)|

**[Strap](https://product.strap.app/)イメージ**
<img width="700" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/866624/935c85dc-8cf4-a883-c061-1a76a3585687.png">

## 工夫したポイント
ブラウザベースのホワイトボードツール（Strapやmiro）やグラフィック系ツール（FigmaやWhimsical）では、ボード部分は通常canvasでできています。
このcanvas内はChrome機能拡張等の外部から修正は基本できません。そのため修正できるのはヘッダーやボタン等のDOM要素でできた部分になります。
このDOM要素を外から修正するために以下の問題への対応が必要になります。

|問題|詳細|
|--|--|
|DOM修正タイミングのキャッチ|各UIは遅延ロードされることが多くDOMContendLoadedハンドラは使えない。修正したい部分のDOMの読み込み完了を判別する工夫が必要。|
|修正対象DOMの特定|外部からDOM修正をする場所、対象部分の特定ができない。どうやって場所を指定するかの工夫が必要。|
|URL変更をキャッチ|URLが変更されたことをキャッチし、ボード画面やスペース一覧等、画面毎に機能のON/OFFを切り替える。|

### DOM修正タイミングをキャッチする方法
Strapでカスタマイズする際、私は `<button>` 要素に着目しました。
それは次の理由からです。

- 今回の目的の一つが「手のひらモードボタンのショートカットキー設定」で、buttonが関連してくること
- button要素は画面に固定数表示されており、UIの性質上なくなる可能性が少ないこと
- ボタンラベルや周辺の説明ツールチップ等が静的表示されることが多い。DOM特定に利用しやすいこと

  <img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/866624/6869eca8-dced-7ad7-2e20-7ae4560207dc.png">


DOM修正タイミングのキャッチをするためにこのように実装を行いました。

1. jsが読み込まれたら2秒毎のループで`button`要素が読み込まれたかをチェックする。
1. 読み込まれていたら任意のコールバック関数を実行する。
1. 読み込まれていなければ2秒ループへ戻る。

[GitHub > ikezaworld/my-strap/src/components/contentMain.js](https://github.com/ikezaworld/my-strap/blob/main/src/components/contentMain.js)

```javascript
/**
 * ページ内にbutton要素が読み込まれるまで2秒ごとに監視する。button要素が読み込まれたらcallback関数を実行する。
 * @param {Function} callback - コールバック用関数を指定する。
 */
export const buttonElementLoaded = (callback) => {
  const elm = document.querySelector("button");
  if (!elm) {
    setTimeout(() => {
      buttonElementLoaded(callback);
    }, 2000);
    return;
  }
  if (typeof callback === "function") {
    callback();
  }
};
```

### 修正対象DOMを特定する方法

例えば「手のひらモードボタン」のショートカットキー設定では、任意のキーを押したら「手のひらモードボタン」button要素に対してclickのMouseEventを発火させて動作させています。

しかし肝心の「手のひらモードボタン」はclass名が[styled-components](https://styled-components.com/)等で乱数に変換されていて、そのままでは判別できません。
そこで着目したのが「手のひらモードボタン」button要素の隣接要素にある`手のひらモード\nSpace`という静的テキストです。
ちょっと泥臭いかもしれませんが、全button要素の中から隣接要素のテキストをベタに調べることで「手のひらモードボタン」の隣接要素を探し出しています。

<img width="500" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/866624/3bf86dee-c167-efb6-2da0-f0f760f04d38.png">

ここまで特定できれば、後はbutton要素から必要なclass名を取得して、目的だった「手のひらモードボタン」button要素が取得できます。
[GitHub > ikezaworld/my-strap/src/components/board/handButton.js](https://github.com/ikezaworld/my-strap/blob/main/src/components/board/handButton.js)

```javascript
// buttonタグかつツールチップのラベルが「手のひらモード」のelementからClass名を抽出
export const selectHandBtn = () => {
  if (elmStore.handButton) {
    return elmStore.handButton;
  }
  const divAry = Array.from(document.querySelectorAll("button+div"));
  const handToolTip = divAry.filter((el) => el.innerText === "手のひらモード\nSpace");
  const handBtn = handToolTip[0]?.previousElementSibling;
  const handBtnAllClassAry = handBtn ? Array.from(handBtn?.classList) : [];
  // styled-componentでのクラス名のみ抽出。ONOFFトグルclassは不要のため
  const handBtnClass = handBtnAllClassAry?.filter((el) => el?.includes("sc-"));
  const searchClassName = handBtnClass?.length ? `.${handBtnClass.join(".")}` : "";
  elmStore.handButton = searchClassName ? document.querySelectorAll(searchClassName)[0] : null;
};
```

### URL変更をキャッチする方法
[React Router](https://reactrouter.com/)のように[History.pushState](https://developer.mozilla.org/ja/docs/Web/API/History/pushState)、[History.replaceState](https://developer.mozilla.org/ja/docs/Web/API/History/replaceState)のAPIを使ったURL変更の場合、外部からハンドラで変更をキャッチが難しいです。

Web APIには[onpopstate](https://developer.mozilla.org/ja/docs/Web/API/WindowEventHandlers/onpopstate)イベントハンドラというもありますが、ブラウザの戻るや進むボタンをクリックしたり、 `history.forward()` `history.back()` を実行した時でないとトリガーされず呼び出されない制限があります。

それではどうやってURLの変更をキャッチするのか？
以下の2案があります。（もっとスマートなやり方もあるのかもしれませんが現在ベタなやり方しか思いつかない）

|URL変更のキャッチ案|
|--|
|案1. 一定時間毎のループを回してURLを比較する|
|案2. [MutationObserver](https://developer.mozilla.org/ja/docs/Web/API/MutationObserver)でDOM更新を監視。DOMが更新されたらURLを比較する|

案1番はシンプルです。
ロード完了時のURLを保持しておき、一定時間ごとのsetInterval等でループを回しロード完了時と現在のURLが変わっていたらURL変更されたとみなします。

私がStrapカスタマイズで使ったのは案2番の[MutationObserver](https://developer.mozilla.org/ja/docs/Web/API/MutationObserver)によるDOM更新の監視です。
理由としては、ホワイトボードツールなので無操作時間も割とあるかなと思ったことと、タイマーループだとちょっとベタかなと感じたからですw。

なおこれは監視対象のDOMを最初に指定したり、子孫ノードのDOM変更も監視対象とするかなど各サービスによって調整が必要になります。

試しにブラウザのconsoleに以下コードを実行してみてください。DOMが更新されMutationObserverが呼ばれるとコンソールログに表示されます。
**MutationObserverの動作確認用コード**  

```javascript
const target = document.body; // 監視の元となるノードを指定
new MutationObserver((e) => {
  console.log("MutationObserver更新あり： e=",e);
}).observe(target, {
  attributes: true, // 属性変化の監視
  childList: true, // 子ノードの変化を監視
  subtree: true, // 子孫ノードも監視対象に含める
});
```

MutationObserverでの確認する点は以下になります。

- 画面遷移やURL切替時にMutationObserverが呼ばれているか？
    - 呼ばれない場合は？
        - 監視元ノードの外で更新が発生しているケース
        - subtree設定で子孫監視がfalseになっているケース
        - そもそも`pushState`を使わず普通にaタグや`location.href`で遷移しているケース
- どのノードを監視すればよいか
    - DOM範囲が広くない方が監視の負荷が少ない

#### StrapでのMutationObserver対応
StrapはSPA形式でボードとスペース一覧画面間での遷移が発生してもブラウザリロードはされません。
またbody直下に`id="root"`があるのでここを`MutationObserver`監視対象の起点にしました。
<img width="500" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/866624/40684a15-8edf-4a52-a78b-120bb84485fa.png">

実際に書いたコードは以下の通りです。
なお、URL比較の部分は関数で外出ししています。ホワイトボードでカードやアイコンを触ると、エレメント参照用のパスが追加されるため正規表現で整形処理をしています。
[GitHub > ikezaworld/my-strap/src/components/contentMain.js](https://github.com/ikezaworld/my-strap/blob/main/src/components/contentMain.js)

```javascript
/**
 * Observerを使いroot要素配下でDOM更新が行われたかをキャッチし、URLも変わっていたらコールバック関数を実行する。
 * React等ルーターでのhistory変更をDOMとURL変更で判別する。
 * @param {Object.<[key:string]: Function>} {callback} - コールバック用関数を指定する。
 */
export const routeObserver = ({ callback }) => {
  var currentLocation = location.href;
  const target = document.getElementById("root"); // root要素を監視
  const observer = new MutationObserver(() => {
    const formatUrl = urlFormat(location.href);
    if (currentLocation !== formatUrl) {
      currentLocation = formatUrl;
      if (typeof callback === "function") {
        callback();
      }
    }
  });

  // 監視を開始
  observer.observe(target, {
    attributes: true, // 属性変化の監視
    childList: true, // 子ノードの変化を監視
    subtree: true, // 子孫ノードも監視対象に含める
  });
};
```

## 機能の追加
これで問題だった

- DOM修正タイミングのキャッチ
- 修正対象DOMの特定
- URL変更をキャッチ

をクリアすることができました。

ここまでくれば`createElement`でセレクトボックスやボタンで[カウントダウンタイマー](https://github.com/ikezaworld/my-strap/blob/main/src/components/board/timer.js)を作ったり、hキーを押した時に[「手のひらモードボタンのショートカットキー設定」](https://github.com/ikezaworld/my-strap/blob/main/src/components/board/handButton.js)を実行するなど、色々いじれます。

各機能については下記の記事でgif動画と共にご紹介していますので、合わせてご覧ください。
Gootpatchテックブログ：『[My Strap!! オンラインホワイトボードStrapをChrome機能拡張でカスタマイズしてみた](https://goodpatch-tech.hatenablog.com/entry/2021/11/17/150105)』

## まとめ
いかがでしたでしょうか。
外部からChrome機能拡張でいじるという都合上、アクセスできないことや制限されている部分、トリッキーな方法で対処することなど壁も色々ありました。
それでもブラウザ上のDOMで動作しているのだから、アイデア次第では様々な開発ができるということを楽しみつつ気づけたように思いました。

##  参照
-  『[My Strap!! オンラインホワイトボードStrapをChrome機能拡張でカスタマイズしてみた](https://goodpatch-tech.hatenablog.com/entry/2021/11/17/150105)』
    -  私がGootpatchテックブログで掲載した記事で今回のお話の機能紹介や導入方法を書いています。
- [Strap](https://product.strap.app/)
    - 直感的に分かる操作性と学習コストなく馴染めるUIが特徴のオンラインホワイトボードツールです。先日も待望のボード内テキスト検索やチュートリアル機能等をリリースしどんどん進化していますのでぜひご参照ください。
- [Chrome機能拡張](https://developer.chrome.com/docs/extensions/)
    - Chrome機能拡張の開発やAPIリファレンスです。Manifestという設定ファイルが必要で現在version 3が推奨。v2で作られた機能拡張は徐々に廃止されるそうなのでご注意ください。

---

Goodpatchではデザイン好きなエンジニアの仲間を募集しています。
少しでもご興味を持たれた方は、ぜひ一度カジュアルにお話ししましょう！

- 新卒採用エンジニア [22卒](https://hrmos.co/pages/goodpatchhr/jobs/0000094) / [23卒](https://hrmos.co/pages/goodpatchhr/jobs/0000097)
- [中途採用フロントエンドエンジニア](https://hrmos.co/pages/goodpatchhr/jobs/0000005)
- [中途採用Androidデベロッパー](https://hrmos.co/pages/goodpatchhr/jobs/0000007)
- [中途採用iOSデベロッパー](https://hrmos.co/pages/goodpatchhr/jobs/0000006)
- [【Product Div/業務委託】フロントエンドエンジニア](https://hrmos.co/pages/goodpatchhr/jobs/0000134)
- [【Product Div】バックエンドエンジニア](https://hrmos.co/pages/goodpatchhr/jobs/0000135)
- [【Strap】プロダクトデザイナー](https://hrmos.co/pages/goodpatchhr/jobs/0000146)
- [【Strap】UIデザイナー](https://hrmos.co/pages/goodpatchhr/jobs/0000173)