Help us understand the problem. What is going on with this article?

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

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

前回と同じ部分は説明を省きます。

完成物

https://github.com/engabesi/context-search
odwwz-w4usa.gif

やること

  • コンテクストメニューに検索機能追加
  • ポップアップボタン作成

作成&解説

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

manifest.json

まずはmanifest.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

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

ContextMenu

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

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に書きます。
ちょっと長いですが全文貼ります。

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

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を作成。

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>
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買ってね!(発売されてないのも多いけど)

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした