🧑‍💻

#111 【Chrome拡張機能】ダウンロードフォルダの整理を便利にする拡張機能を作ってみる #3

に公開

はじめに

本記事は、「ダウンロードフォルダを散らかしたくない...」という思いを解決するために、Chromeの拡張機能を作ってみようという試みのPart3です。

前回までのあらすじ

前回 で一通り機能要件自体は満たせました。
しかし、ダウンロード時のフォルダ名をページ情報から拾ってくる都合上、不確定要素でエラーとなる場合があります。
なので今回は、サイドパネルを用いて手動の入力値でもフォルダ名を決定できるよう機能を実装していきます。

ディレクトリ構成

今回の実装でいくつかファイルを追加します。
具体的にはサイドパネルの実装で新規実装する「css、html、js」ファイルを新たに作成し、以下のディレクトリ構成を目指します。

拡張機能名/
  ├── css + 追加ファイル(css)
  │  ├── reset.css
  │  └── sidepanel.css
  ├── background.js
  ├── content.js
  ├── manifest.json
  ├── sidepanel.html + 追加ファイル(html)
  └── sidepanel.js + 追加ファイル(js)

では以上のディレクトリ構成を目指しながら追加の実装を進めていきます。

manifest.json

Part1 で記述した内容に加えて今回は新たに side_panel
に関わる記述を行っていきます。
以下前回からの差分です。

manifest.json
{
  "name": "拡張機能名(任意の名前)",
  "version": "1.0.0",
  "manifest_version": 3,
  "description": "拡張機能説明(任意の説明)",
  "action": {},
  "background": {
    "service_worker": "background.js"
  },
  "content_scripts": [
    {
      "js": ["content.js"],
      "matches": ["https://*/*"]
    }
  ],
+ "side_panel": {
+   "default_path": "sidepanel.html"
+ },
- "permissions": ["contextMenus", "downloads"]
+ "permissions": ["contextMenus", "downloads", "sidePanel"]
}

今回、manifest.jsonに追加した記述はside panelに関する記述です。
具体的には以下の部分でsidepanelを開いた際に出力するhtmlを設定、権限に "sidePanel" を追加しています。

manifest.json
  "side_panel": {
    "default_path": "sidepanel.html" // htmlを設定
  },
  "permissions": ["contextMenus", "downloads", "sidePanel"]
  // 権限に "sidePanel" を追加

※参照:https://developer.chrome.com/docs/extensions/reference/api/sidePanel?hl=ja

sidePanel

manifest.jsonの設定ができたところで、実際にsidepanelの実装に進んでいきます。

  1. html & cssの作成
  2. jsの作成

以上の流れで進めていきます。

HTML & CSS

それではHTMLとCSSを記述し、sidepanelを開いた際の画面を作っていきましょう。
画面に盛り込みたい機能は以下の通りです。

  • テキストエリアを配置し、その情報でフォルダ名を決定
  • テキストエリアは、現在のページ情報を反映することも可能
  • 画面情報の手動リロード

まだピンとこないかもしれませんが、実際の画面を見ると機能自体は単純で理解しやすいと思うので一旦実装を進めます。
また、sidepanelで使うHTMLとCSSは以下をコピペして使用してください。

HTML

sidepanel.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link rel="stylesheet" href="./css/reset.css" />
    <link rel="stylesheet" href="./css/sidepanel.css" />
    <title>フォルダ名設定</title>
  </head>
  <body>
    <div class="settings-container">
      <h2 class="setting-title">フォルダ名設定</h2>
      <button class="reload-button">手動取得</button>

      <div class="setting-content">
        <label class="setting-label">▼ページ内の1hタグ情報</label>
        <p class="info-text h1">情報がありません</p>
        <div class="reflection-button-container">
          <button class="reflection-button h1">反映</button>
        </div>
      </div>

      <div class="setting-content">
        <label class="setting-label">▼ページ内のtitleタグ情報</label>
        <p class="info-text title">情報がありません</p>
        <div class="reflection-button-container">
          <button class="reflection-button title">反映</button>
        </div>
      </div>

      <div class="setting-content">
        <label class="setting-label">ー 手動入力 ー</label>
        <textarea class="filename-textarea"></textarea>
      </div>
    </div>
  </body>
  <script src="/sidepanel.js"></script>
</html>

CSS

拡張機能フォルダ直下に css/ フォルダを作成し、以下のファイルを作成します。

sidepanel.css

sidepanel.css
body {
  display: flex;
  justify-content: center;
  min-height: 100vh;
  background-color: #f3f4f6;
}
.settings-container {
  width: 90%;
  min-height: 80vh;
  margin-top: 1.5rem;
  margin-bottom: 3rem;
  padding-left: 3rem;
  padding-right: 3rem;
  background-color: white;
  border-radius: 0.5rem;
  box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
  text-align: center;
  position: relative;
}
.setting-content {
  margin-bottom: 1rem;
}
.setting-title {
  padding: 1rem 0;
  font-size: 1.25rem;
  font-weight: 600;
}
.reload-button {
  position: absolute;
  right: 0.5rem;
  top: 0.5rem;
  background-color: #111827;
  color: white;
  font-weight: 500;
  border-radius: 0.25rem;
  transition: background-color 0.2s;
  outline: none;
  border: none;
}
.reload-button:hover {
  background-color: #282f3c;
  cursor: pointer;
}
.setting-label {
  display: block;
  margin-bottom: 0.5rem;
  font-size: 1rem;
  font-weight: 540;
}
.info-text {
  color: #6b7280;
  margin-bottom: 0.5rem;
  font-size: 0.875rem;
}
.reflection-button-container {
  display: flex;
  justify-content: center;
}
.reflection-button {
  width: 100%;
  padding: 0.2rem 0;
  background-color: #111827;
  color: white;
  font-weight: 500;
  border-radius: 0.25rem;
  transition: background-color 0.2s;
  outline: none;
  border: none;
}
.reflection-button:hover {
  background-color: #282f3c;
  cursor: pointer;
}
.filename-textarea {
  width: 100%;
  height: 5rem;
  resize: none;
}

css/reset.cssを作成し以下のリンクのものを使用します。
https://www.joshwcomeau.com/css/custom-css-reset/

以上でHTMLとCSSの記述は完了です。

sidepanel.js

sidepanelの機能としてはこのjsファイルを記述して終了となります。

sidepanel.js
// 書き換え先の要素を取得しておく
const h1Info = document.querySelector(".info-text.h1");
const titleInfo = document.querySelector(".info-text.title");
const filenameTextarea = document.querySelector(".filename-textarea");

// Button群のnode取得
const reloadButton = document.querySelector(".reload-button");
const h1ReflectionButton = document.querySelector(".reflection-button.h1");
const titleReflectionButton = document.querySelector(
  ".reflection-button.title"
);

// content.js に情報を返す
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
  if (request.type === "getSidePanelValue") {
    sendResponse({
      value: filenameTextarea.value,
    });
  }
});

let isTabChange = false;

// タブの切り替えを検知(連続の操作が想定されるので0.5秒の待機時間)
chrome.tabs.onActivated.addListener(() => {
  if (!isTabChange) {
    isTabChange = true;
    viewGetPageInfo();
    // 重複されて実行されることを回避するために0.5秒の待機時間
    setTimeout(() => {
      isTabChange = false;
    }, 500);
  }
});

// URL遷移を検知(連続操作が頻繁に起こらないかつ同時に実行される場合が多いので待機時間は長め)
chrome.tabs.onUpdated.addListener(() => {
  if (!isTabChange) {
    isTabChange = true;
    viewGetPageInfo();
    // 重複されて実行されることを回避するために1.5秒の待機時間
    setTimeout(() => {
      isTabChange = false;
    }, 1500);
  }
});

// 現在のアクティブなtabIdを返す
const getCurrentTabId = async () => {
  const [tab] = await chrome.tabs.query({
    active: true,
    lastFocusedWindow: true,
  });
  return tab.id;
};

// pageInfoを取得し表示する
const viewGetPageInfo = () => {
  getCurrentTabId().then((tabId) => {
    chrome.tabs.sendMessage(tabId, { type: "getPageInfo" }).then((pageInfo) => {
      h1Info.textContent = pageInfo?.h1 || "情報がありません";
      titleInfo.textContent = pageInfo?.title || "情報がありません";
    });
  });
};

// 各反映Buttonのclickイベント;
h1ReflectionButton.addEventListener("click", () => {
  filenameTextarea.value = h1Info.textContent;
});

titleReflectionButton.addEventListener("click", () => {
  filenameTextarea.value = titleInfo.textContent;
});

// 手動取得ボタン押下時のclickイベント
reloadButton.addEventListener("click", viewGetPageInfo);

// 初期表示
viewGetPageInfo();

全体的な処理の流れとては、

  1. 各要素のnodeを取得しておく
  2. タブの切り替えでsidepanel内の情報も更新
  3. ダウンロードやコンテキストメニューイベント時に content.js から情報がリクエストされるので(後述)sidepanelの情報を返す
    となっています。

特に、2の "タブの切り替えで情報を更新" する箇所については新たにでてくる機能を使用しているので説明します。

sidepanel.js
let isTabChange = false;

// タブの切り替えを検知(連続の操作が想定されるので0.5秒の待機時間)
chrome.tabs.onActivated.addListener(() => {
  if (!isTabChange) {
    isTabChange = true;
    viewGetPageInfo();
    // 重複されて実行されることを回避するために0.5秒の待機時間
    setTimeout(() => {
      isTabChange = false;
    }, 500);
  }
});

// URL遷移を検知(連続操作が頻繁に起こらないかつ同時に実行される場合が多いので待機時間は長め)
chrome.tabs.onUpdated.addListener(() => {
  if (!isTabChange) {
    isTabChange = true;
    viewGetPageInfo();
    // 重複されて実行されることを回避するために1.5秒の待機時間
    setTimeout(() => {
      isTabChange = false;
    }, 1500);
  }
});

タブの切り替えで情報を更新する部分のコードです。
新たに使用している機能については以下の2つです。

onActivated:アクティブなタブの変更を検知
※参照:
https://developer.chrome.com/docs/extensions/reference/api/tabs?hl=ja#event-onActivated

onUpdated:タブの更新を検知(URL)
※参照:
https://developer.chrome.com/docs/extensions/reference/api/tabs?hl=ja#event-onUpdated

基本的には上記の2つを使用して、タブの切り替えを検知しsidepanel内の情報を書き換えます。
また、isTabChange を定義して setTimeout でコントロールすることで重複実行を回避しています。
ここまで実装できたらsidepanelの機能は完了です。
次は既存機能とsidepanelの繋ぎこみを行っていきます。

background.js

background.js への追記は以下の1行だけです。ファイルの末尾にでも以下のコードを追記してください。

background.js
// 拡張機能アイコンを押下時にsidePanelを展開する
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });

この1行で拡張機能のアイコンを押下時にsidepanelを展開することが可能になります。
※参照:
https://developer.chrome.com/docs/extensions/reference/api/sidePanel?hl=ja#open

content.js

content.js への追記はsidepanelの情報を取得、sidepanelへ情報を返す処理を追記しました。

content.js
  chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
    if (request.type === "getShortcutInfo") {
      // ページ内の h1タグ or titleタグ からファイル名候補を取得する
      const pageTitle = (
        document.querySelector("h1") || document.querySelector("title")
      ).textContent;
  
      // shortcutのBlobURLと、fileanmeを返却する
+     getSidePanelValue().then((sidePanelValue) => {
+       // sidePanelValue に値があればそれを使用する
+       const filename = charEscapeToFilename(sidePanelValue.value || pageTitle);
        sendResponse({
          tabId: request.tabId,
          url: URL.createObjectURL(createShortcutBlob(document.URL)),
-         filename: charEscapeToFilename(pageTitle),
+         filename,
        });
+     });
    }
  
    // sidePanelへpage情報を返す
    if (request.type === "getPageInfo") {
-     const h1 = document.querySelector("h1").textContent;
-     const title = document.querySelector("title").textContent;
+     const h1 = document.querySelector("h1").textContent.trim();
+     const title = document.querySelector("title").textContent.trim();
      sendResponse({ h1, title });
    }
  
    // 生成したshortcutBlobURLを解放する
    if (request.type === "revokeBlobUrl") {
      URL.revokeObjectURL(request.url);
    }
  
+    return true; //非同期処理なのでtrueを返す
  });
  
+ // sidePanelから手動入力値を取得
+ const getSidePanelValue = async () => {
+   return await chrome.runtime.sendMessage({ type: "getSidePanelValue" });
+ };
  
  // shortcutのBlobを作成
  const createShortcutBlob = (pageUrl) => {
    // [InternetShortcut]
    // URL=ページURL
    const text = "[InternetShortcut]" + "\n" + "URL=" + pageUrl;
    return new Blob([text], { type: "text/plan" });
  };
  
  // 入力文字をファイルやフォルダ名で扱える文字にエスケープ
  const charEscapeToFilename = (char) => {
    const escapeChar = char
      .replace(/\\/g, "")
      .replace(/\//g, "")
      .replace(/:/g, "")
      .replace(/\*/g, "")
      .replace(/"/g, "")
      .replace(/</g, "")
      .replace(/>/g, "")
      .replace(/\|/g, "");
  
    return escapeChar.trim();
  };

以上で今回実装の全ての記述が完了しました。

動作確認

では実際に動作確認してみてsidepanelでフォルダ名を指定できるかを試してみましょう。

  1. 任意のダウンロードページで拡張機能アイコンをクリックしてsidepanelを展開
  2. ページ内のh1タグ情報とtitle情報が表示
  3. 各「反映」ボタンで表示の情報を引き継げる
  4. 手動入力欄を好きに編集し、ダウンロード

以上の手順でフォルダ名が設定した値になっていることが確認できたら成功です。

さいごに

私自身、初めてChromeの拡張機能を作ってみましたができることが多く、ある程度簡単なものならすぐに作成できるので今後何か不便さを感じた際の引き出しが増えたと感じています。

今回作成した拡張機能も完璧なものではないのでご自身の使いやすいようにカスタマイズしてみてください。
合計3回と長くなってしまいましたがここまで読んでいただきありがとうございました。

Discussion