Chrome拡張機能でYoutube検索を楽にする

私はゲーム音楽を聞いていつも癒やされてます。
[みんなで決めるゲーム音楽ベスト100のwiki](https://w.atwiki.jp/gamemusicbest100/)を眺めて知らない曲をYoutubeで試聴したりするのですが、いちいち曲名をコピペして検索するのが面倒でした。
google翻訳の拡張機能を見てこんな風にYoutube検索できたら楽そうと言うことで作りました。

[前回](https://qiita.com/engabesi/items/3a05b27f3152999ef0d4)と同じ部分は説明を省きます。

# 完成物
https://github.com/engabesi/context-search
![odwwz-w4usa.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/469399/708f25c2-f909-c83b-a63d-f9cae8490b57.gif)


# やること
- コンテクストメニューに検索機能追加
- ポップアップボタン作成

# 作成&解説
作っていきます。chrome.*APIに関しては簡単な解説を入れます。

## `manifest.json`
まずは`manifest.json`を作成します。

```json:manifest.json
{
  "manifest_version": 2,
  "version": "1.0",
  "name": "Context Youtube Search",
  "description": "Add Youtube Search to Context Menu",
  "icons": {
    "16": "icons/icon16.png",
    "48": "icons/icon48.png",
    "128": "icons/icon128.png"
  },
  "content_scripts": [
    {
      "matches": ["http://*/*", "https://*/*"],
      "css": ["searchStyles.css"],
      "js": ["search.js"]
    }
  ],
  "background": {
    "persistent": false,
    "scripts": ["background.js"]
  },
  "permissions": ["contextMenus", "storage"],
  "options_page": "options/options.html"
}
```
今回新しく出てきた要素の簡易説明
詳細かつ正確に知りたい場合は[公式Doc](https://developer.chrome.com/extensions/manifest)へ

<dl>
  <dt>content_scripts</dt>
  <dd><code>matches</code>にマッチしたURLのページ内で <code>css</code>と<code>js</code>に記載したものを実行&適用</dd>
  <dt>background</dt>
  <dd><code>scripts</code>に記載したjsをbackgroundで実行する</dd>
  <dd><code>persistent</code>をfalseにすると必要な時のみjsを実行する</dd>
  <dd>trueにするとずっと動いてメモリを食い続ける</dd>
  <dd>余程の理由がある場合を除きfalse</dd>
  <dt>permissions</dt>
  <dd>使いたい<code>chrome.* API</code>をここで宣言</dd>
  <dd><code>tabs</code>に関しては宣言不要</dd>
  <dt>options_page</dt>
  <dd>そのまんまoption_pageのhtml</dd>
</dl>

## ContextMenu
それではコンテクストメニューに自作項目を追加します。
manifestのbackgroundに記載したjsに書き込んでいきます。

```javascript:background.js
// 以下の時に実行される。
// 最初のインストール時、拡張機能 or Chrome自体のバージョン更新時
chrome.runtime.onInstalled.addListener(() => {
  // コンテクストメニューの項目を作成、追加します。
  // runtime.onInstalled内に記載し、無駄に実行されるのを防いでいます。
  chrome.contextMenus.create({
    id: "youtube_search_menu",
    title: "Youtubeで「%s」を検索",
    // 文字が選択されている時のみ表示されてほしいのでselectionを指定
    contexts: ["selection"]
  });
});
// クリックイベント。
// 今回は項目が一つだけなので問題ありませんが複数項目を追加する場合、
// クリックされた項目のIDをチェックしてあげないといけません。
// createのoptionでclickイベントを紐付ける事も可能ですが、
// persistentをfalseにしている場合この書き方でないとエラーを吐いて動きません。
chrome.contextMenus.onClicked.addListener(info => {
  const query = info.selectionText;
  if (query.length > 300) {
    alert("文字が多すぎます。");
    return;
  }
  const url = `https://www.youtube.com/results?search_query=${query}`;
  // urlを指定すると新しいタブが立ち上がりそこで指定したURLを開いてくれます。
  chrome.tabs.create({ url: url });
});
```
これだけです。
存在しないファイルがあるとエラーを吐く為、`manifest.json`に記載したまだ作成していないファイルについては空ファイルを作ります。
後はインストールし、何か文字列を選択して右クリックするとYoutubeで検索の項目が追加されています。

## ポップアップボタン
クリック数を減らす為に文字を選択した際にポップアップでボタンが出るようにします。
`content_scripts`に記載したjsに書きます。
ちょっと長いですが全文貼ります。

```javascript:search.js
// localStorageと一緒
// localをsyncにすると別PCでもGoogleアカウントが同じであれば共有されます。
// getの第一引数で取りたい値を指定。default値もつけられます。
chrome.storage.local.get({ isPopupEnable: true }, result => {
  if (!result.isPopupEnable) return;

  let searchQuery = "";
  const setSearchQuery = text => (searchQuery = text);
  const getSearchQuery = () => searchQuery;

  const popUpId = "cys-search";
  const popUpIconClass = "cys-search-icon";
  const createPopUpHtml = () => {
    const pop = document.createElement("div");
    pop.id = popUpId;
    pop.style.position = "absolute";
    const icon = document.createElement("div");
    icon.className = popUpIconClass;
    pop.appendChild(icon);
    return pop;
  };
  const popUp = createPopUpHtml();

  const addPopupEvent = () => {
    popUp.addEventListener("mousedown", e => {
      e.preventDefault();
      e.stopPropagation();
    });
    popUp.addEventListener("mouseup", e => {
      e.stopPropagation();
      window.open(
        `https://www.youtube.com/results?search_query=${getSearchQuery()}`
      );
      document.getElementById(popUpId).remove();
    });
  };
  addPopupEvent();

  const addDocumentEvent = () => {
    let [fromX, fromY] = [0, 0];
    document.addEventListener("mousedown", e => {
      const popElm = document.getElementById(popUpId);
      if (popElm) popElm.remove();
      [fromX, fromY] = [e.pageX, e.pageY];
    });

    document.addEventListener("mouseup", e => {
      // input, textarea, contentEditable属性が付与されている場合
      // ボタンを表示しないようにする
      const activeElement = document.activeElement;
      if (
        ["INPUT", "TEXTAREA"].includes(activeElement.tagName) ||
        activeElement.attributes.getNamedItem("contentEditable")
      )
        return;

      const selectionText = document.getSelection().toString();
      if (selectionText === "" || selectionText.length > 300) return;

      const [clickedX, clickedY] = [e.pageX, e.pageY];
      if (fromX === clickedX && fromY === clickedY) return;

      const popElm = document.getElementById(popUpId);
      if (popElm) return;

      setSearchQuery(selectionText);

      const posX = computePositionOffset(fromX, clickedX);
      const posY = computePositionOffset(fromY, clickedY);
      setElementPosition(popUp, posX, posY);
      document.body.appendChild(popUp);
    });
  };
  addDocumentEvent();

  /** 移動方向からオフセットを設定 */
  const computePositionOffset = (from, to) => (from > to ? to - 20 : to + 5);
  const setElementPosition = (element, x, y) => {
    element.style.left = `${x}px`;
    element.style.top = `${y}px`;
  };
});
```

次にCSS

```css:searchStyles.css
#cys-search {
  background-color: rgb(245, 245, 245);
  box-sizing: content-box;
  cursor: pointer;
  height: 19px;
  width: 19px;
  z-index: 2147483647;
  border-width: 1px;
  border-style: solid;
  border-color: rgb(220, 220, 220);
  border-image: initial;
  border-radius: 5px;
  padding: 3px;
}

.cys-search-icon {
  background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAADT0lEQVRoQ+1ZTWhUVxg9575JJjNESxMNptgoQ7BQVDCjpMsslToLlfusSAotpWs34sKNLsWVuhPBlQQdCC4UXboSFzFUEMpImhmjGLBYq4ZMYua9I2+SCfWn0fGNY67k27zNvd93zv3u/d757iUcNzqOHysEPncGVzIggGO7drWaubm29nS6bdqYFJPJFIAUwjBZzZAxswDKmp0tp8OwPDU9PRO2tMz0Xrv2koDiZPG9GbhkrddXqXQmPK839LxeI/WEwAZIG0j2AOgG0B7BrBNICGAKwKSkCZD3DXA/JCdMEIxVgmBsNJF44ufzwVJ+3yLw2Nr2MvAdgCzIvkDaTmATyFV1Aow3XHoh4J5HjkAaBXA7BRS68vmI9KItEoiAP5O2e+QBkrsBfBMPQcNnP5J0JZCGviJHakSqBEp793YHnnfAkL9jfvWXsxVC6awXBEMbh4cnOZLNtnRkMgMEDouMvi3LGb2AOUo3BJz8Z3z8Bh9Y21uRfiN5UOT65Qy+ho3SQ0kXEuQ5Fvft64cxR0DurJY+N6wM6TrC8ASL1u4HeQjAD25gX0R5C8Axlqw9GpI/EdjsEgEBd01EoGjteZADADa6RCAqnoF0nCXfv6T57fOtYwQecIHAVUnbQEaSII49lXQTxhQgbYXUz0/595YmsUAgqqnfA1gbBz2ApwAuQzptguC5jDkI8meQGdWvkz4Eyt8kj3Hc2hGSGQBff8isJcZUCYg8lbl48c6jXC49k0xmYcwggRyAdTH9vzk9ilc9xH+CjHTP6pgBXiNQ8/Vwz57OiuftDMlfDNmveeXaCHtePQNF358A0AkgHdPrOwnUfP5lbY8HDAr4NdpWMWNF06cBVKvQk4VVaY3pdEkCke/iwEBb0NGR9YyJSOwG2RUj5svqFhq3dpZk4iMaknftycUzsBSwQi63pjWV+hFRNoAdHylhQn0RBJzfQs4fYufLaKN/ZHPS6U35/B9N+5GVfL8hUoI1KREEZyg9a5qUKPl+Y8Sc9C+AmyALArY0U8y5Lafdb2i+gJbS7abe+WsV5y+2nL9ajGSv05e7Nd3u9PX6f5sPZx84/q+Dcu6Jqd4eddk/8tVLqNnj3/tK2WxA9cZbIVDvijV6vPMZeAUgiiBtP5u7QwAAAABJRU5ErkJggg==);
  background-size: 19px;
  width: 19px;
  height: 19px;
}
```

これで文字選択時にボタンがポップアップされ、クリックするとYoutubeの検索画面に飛ぶようになったはずです。

ただ残念なことにgoogle翻訳のアイコンボタンと被ってしまうことが多い…
offset値が違うからか完全に重なることはないので放置。

## オプションページ
拡張機能の設定画面を作ります。
ポップアップボタンのON/OFFが欲しいのでそれだけ作成。
見た目を捨ててさくっとhtmlとjsを作成。

```html:options/options.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Context Youtube Search Options</title>
  </head>
  <body>
    <h1>ポップアップのON/OFF</h1>
    <form>
      <div>
        <input type="radio" id="popupTrue" name="popup" value="true" />
        <label for="popupTrue">ON</label>
        <input type="radio" id="popupFalse" name="popup" value="false" />
        <label for="popupFalse">OFF</label>
      </div>
      <div><button type="submit">Save</button></div>
    </form>
    <script src="options.js"></script>
  </body>
</html>
```

```javascript:options.js
const form = document.querySelector("form");
form.addEventListener("submit", e => {
  saveOptions();
  e.preventDefault();
});

const toBoolean = str => (str.toLowerCase() === "true" ? true : false);

const saveOptions = () => {
  const isPopupEnable = toBoolean(form.popup.value);
  // setで保存
  chrome.storage.local.set({
    isPopupEnable: isPopupEnable
  });
};

const fetchOptions = () => {
  // getで取得
  chrome.storage.local.get({ isPopupEnable: true }, result => {
    if (result.isPopupEnable) {
      form.popup[0].checked = true;
      form.popup[1].checked = false;
    } else {
      form.popup[0].checked = false;
      form.popup[1].checked = true;
    }
  });
};
fetchOptions();
```
これでオプションページを作成できました。

# まとめ
使ってるうちに検索するサイト(URL)を設定画面で追加できたら利便性上がるなって感じたのでそのうち機能追加します。

ゲーム音楽いいよ！聞いて気に入ったらOST買ってね！(発売されてないのも多いけど)