4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

お題は不問!Qiita Engineer Festa 2023で記事投稿!

CSV テキストと Markdown Table をキーボードショートカットで相互変換できる Chrome 拡張機能作ってみた

Last updated at Posted at 2023-07-21

CSV テキストと Markdown Table をキーボードショートカットで相互変換できる Chrome 拡張機能作ってみました。この記事では、作ったものの仕組みや開発にあたって工夫・苦労した点などについて記します。

Chrome Web Store:

GitHub リポジトリ:

作ったもの

今回作った Chrome 拡張機能には次の機能があります:

  • CSV テキストを範囲選択して Alt/Cmd + j を入力すると、 Markdown Table へ変換されたテキストがクリップボードに入る
  • Markdown Table の テキストを範囲選択して Alt/Cmd + j を入力すると、 CSV 形式 へ変換されたテキストがクリップボードに入る
  • それ以外で Alt/Cmd + j を入力すると、 Markdown Table テキストのひな型がクリップボードに入る

Videotogif.gif

どうやって作ったのか?

使用技術

  • Plasmo
  • Docker & Docker Compose

Plasmo はブラウザ拡張機能を作成するための React フレームワークです。採用した理由は、以前に触れてみた際に使用感が良かったからです。

個人的には次の点が便利に感じました:

  • ライブリロード、 React HMR が標準で付いている
  • dev/prod 環境の切り替えに加えて、ZIP 化も行ってくれる
  • package.jsonmanifest という項目があり、必要な Manifest をここへ記述すれば manifest.json を意識せずに設定できる

Plasmo の使用方法については、以下の日本語記事が非常に参考になりました:

今回は Plasmo を Docker 化して利用しました。詳細は下記記事をご覧ください:

仕組み

主な仕組みは次の通りです:

  • Chrome の chrome.commands API でテキスト変換処理をトリガーするキーボードショートカットを追加
  • テキスト変換処理は Background Service Worker (BSW) で実行するよう実装
  • activeTabscripting の 2 つの permission を使用して、現在開いているタブでテキスト変換処理を実行

chrome.commands API」って?

Chrome 拡張機能では chrome.commands API を用いてキーボードショートカットを追加することができます1

今回の場合、 package.jsonmanifest の項目に次のような記述を追加しました。この記述により、 Windows 環境だったら Alt + J 、 Mac 環境だったら Command + J を入力するとトリガーされるようになります。

package.json
~~(略)~~

    "commands": {
      "convert": {
        "suggested_key": {
          "windows": "Alt+J",
          "mac": "Command+J"
        },
        "description": "Convert CSV to Markdown Table"
      }
    }

~~(略)~~

「 Background Service Worker (BSW) 」って?

BSW は、 Chrome 拡張機能のバックグラウンドで動かせる JavaScript 環境です2。 Plasmo では background.ts というファイルを作成すれば、それが BSW として実行されます3。今回の場合、 Alt/Cmd + j が入力されると BSW が実行されます。

BSW でアクティブタブの情報を取得する

BSW にテキスト変換処理を実装するにあたって、現在フォーカスされている要素を取得する必要があります。なぜなら、テキスト変換処理を次の流れで行っているからです:

  1. 現在フォーカスされている要素を取得する
  2. フォーカスされている要素が textarea であれば、範囲選択されている文字列を取得
  3. 取得した文字列が CSV テキスト、Markdown Table テキスト、それ以外のいずれであるかを判別
  4. 判別結果に応じて変換処理を行う
  5. 変換結果の文字列をクリップボードに書き込む

BSW では chrome.commands.onCommand() イベントを使用して、登録されているショートカットがトリガーされたときにアクティブになっているタブの情報を取得できます。こちらと chrome.scripting.executeScript() を組み合わせることで、アクティブタブにスクリプトを実行することができます。これにより、実行するスクリプト内でアクティブタブの情報を参照でき、現在フォーカスされている要素の取得が可能となります。

実装例を下記に示します:

chrome.commands.onCommand.addListener((command, tab) => {
  console.log(command); // 登録済みショートカットの内、どれがトリガーされたかを表示
  if (!tab.url.includes("chrome://")) {
    // Chrome の設定ページ等では実行しないよう条件付け
    chrome.scripting.executeScript({
      target: { tabId: tab.id },
      func: execEvent, // ここに実行したい関数を記述
    });
  }
});

工夫した点

処理が完了した際にその旨を通知する Toast を実装した点

テキスト変換処理が完了したらその旨をユーザへ通知したほうが体験が良いかなと思い、次のような通知用 Toast を実装しました。

image.png

この Toast は次のようにして実装しています:

  • document.body に Toast 用に document.createElement() した div 要素を .appendChild()
  • クラス名に csv2md- という独自 prefix を設定することで、開いているページに既に設定されているクラスと名前が重複して意図しない CSS が適用されてしまうのを防止
  • 左下への表示は CSS で position: fixed; bottom: 20px; left: 20px を設定することで実現
background.ts
const createToast = (): HTMLDivElement => {
    const toast = document.createElement('div');
    toast.className = 'csv2md-toast';
    ~~()~~
    toast.style.position = 'fixed';
    toast.style.bottom = '20px';
    toast.style.left = '20px';
    ~~()~~
    return toast;
};

~~()~~

const toastElement = createToast();
document.body.appendChild(toastElement);
  • 表示期間の設定は Element.remove()setTimeout() で呼び出すことで実現
  • フェードアウトは Toast 要素に CSS で transition: opacity 1s を設定し、 toastElement.style.opacity = '0'setTimeOut() で呼び出すことで実現
background.ts
const createToast = (): HTMLDivElement => {
    ~~()~~
    toast.style.transition = 'opacity 1s';
    ~~()~~
};

~~()~~

setTimeout(() => {
    toastElement.style.opacity = '0';
}, 1000);
setTimeout(() => {
    toastElement.remove();
}, 2000);

document.createElement().appendChild() など JavaScript/TypeScript で document を操作する方法を取っていますが、これは BSW では JSX/TSX は利用できなかった(要検証)ためです。

苦労した点

どうやってキーボードショートカットを動作させるか

どうやってキーボードショートカットを動作させるかが一番苦労しました…。いくつかの方法を検討した結果今の方法に行きついたので、どんな方法を検討したのかを以下に記します。

① 最初の実装: Content Script で keydown イベントを追加

最初は BSW ではなく Content Scripts で実装していました。 Content Scripts は Web ページの DOM を操作できる機能です4

この実装の仕組みは次の通りです:

  • Content Scripts で .addEventLisner()keydown イベントを追加
  • 追加するイベントは「押されたキーが Ctrl + j だったらテキスト変換処理を実行する」

サンプル実装を以下に示します:

manifest.json
{
    "manifest_version": 3,
    "version": "0.1.0",
    "name": "Sample 1 | Using Content Scripts",
    "content_scripts": [
        {
            "matches": [
                "<all_urls>"
            ],
            "js": [
                "content-scripts.js"
            ]
        }
    ]
}
content-scripts.js
console.log("content-scripts.js loaded");

const handleKeyDown = (e) => {
    if (e.ctrlKey && e.key === "j") {
        console.log("Ctrl + j pressed");
    }
};

window.addEventListener("keydown", handleKeyDown);

この実装の利点は次の通りです:

  • OS 、ブラウザ既定のキーボードショートカットを上書き可能

Chrome だと Ctrl + j はダウンロードを開くキーボードショートカットですが、この Chrome 拡張機能のキーボードショートカットが優先されます。

  • ショートカットキー変更の実装が容易(要検証)

(e.ctrlKey && e.key === "j") の部分を可変にすれば実装できると思います(今回、そこまでは検証しなかったので要検証です :bow: )。

しかし、この実装は下記の理由で断念しました:

  • 任意のサイトへの Content Scripts の実行を許可してもらう必要があったため

この Chrome 拡張機能は性質上、様々なサイトの textarea で動作できる必要があります。 これを Content Scripts で実現するには、 matches<all_urls> を設定する=全ての Web サイトへのアクセス権限をユーザから許可してもらう必要があります。

manifest.json
~~(略)~~
    "content_scripts": [
        {
            "matches": [
                "<all_urls>"
            ],
            "js": [
                "content-scripts.js"
            ]
        }
    ]
~~(略)~~

しかし、これはユーザーに対して強力な権限を要求することになり、ストアの審査が長引く可能性があり、また審査も厳しくなります。そこで、最小限の権限で実現できる方法はないか検討することにしました。

② 次の実装: BSW の Basic Command で keydown イベントを追加

なんとか最小限の権限で実現できないか調査したところ、 Content Script ではなく BSW で機能を実装し、 activeTabscripting の 2 つの permission を活用すれば実現できることが分かりました。

それぞれの permission の効果は次の通りです:

permission 効果
activeTab ユーザが拡張機能を呼び出した時、現在開いているタブへの一時アクセスを許可する
scripting Web サイトに JavaScript や CSS を挿入できる

公式ドキュメントには下記のように記載されており、 activeTab<all_urls> の多くの用途の代替として機能しますがインストール中に警告メッセージは表示されないそうです。

This serves as an alternative for many uses of "", but displays no warning message during installation:

引用: Chrome Extensions: The activeTab permission - Chrome Developers

つまり、キーボードショートカットに関してはユーザから許可をもらう必要が無くなります。

この実装の仕組みは次の通りです:

  • Chrome chrome.commands API で次の処理をトリガーするキーボードショートカットを追加
    • Ctrl + j が押されたらテキスト変換処理を実行する関数を keydown イベントとして .addEventListener() する
  • 処理は Background Service Worker (BSW) で実行するよう実装
  • activeTabscripting の 2 つの permission を使用して、現在開いているタブで処理を実行

サンプル実装は次の通りです:

manifest.json
{
    "manifest_version": 3,
    "name": "Sample 2 | Using BSW & Event Listener",
    "version": "0.1.0",
    "action": {},
    "permissions": [
        "activeTab",
        "scripting"
    ],
    "background": {
        "service_worker": "service-worker.js"
    },
    "commands": {
        "sample": {
            "suggested_key": {
                "default": "Alt+J"
            },
            "description": "Sample command"
        }
    }
}
service-worker.js
console.log("service-worker.js loaded");

function execScript() {
    console.log("execScript");

    const handleKeyDown = (e) => {
        console.log(e);
        if (e.ctrlKey && e.key === "j") {
            console.log("Ctrl + j pressed");
        }
    }
    document.addEventListener("keydown", handleKeyDown);
}

chrome.commands.onCommand.addListener((command, tab) => {
    console.log("command: " + command);
    if (!tab.url.includes('chrome://')) {
        chrome.scripting.executeScript({
            target: { tabId: tab.id },
            function: execScript
        });
    }
});

この実装の利点は次の通りです:

  • キーボードショートカットに関してはユーザからの許可が不要になる

しかし、この実装は下記の理由で断念しました:

  • タブを読み込んだ後に一度 .addEventListener() を実行するステップを挟む必要がある

上記サンプル実装だと、タブを読み込んだ後に一度 Alt + j を入力して .addEventListener() を実行しないと、 Ctrl + j でテキスト変換する処理が実行されません。また、 suggested_key とテキスト変換を実行するショートカットキーを同じにしても chrome.commands の方が優先されるのでテキスト変換は実行されません。

タブ読み込みの度に初回操作が必要となるのは使い勝手が悪いので、別の方法を検討することにしました。

③ 最終的な実装: BSW の Basic Command で実装

先の ② の実装をよくよく見てみると、そもそも .addEventListener() する必要が無いことに気づきました…。ということで、 .addEventListener() しない方法で実装しました。

この実装の仕組みは次の通りです(前述の「仕組み」セクションで書いたものを再掲):

  • Chrome chrome.commands API でテキスト変換処理をトリガーするキーボードショートカットを追加
  • テキスト変換処理は Background Service Worker (BSW) で実行するよう実装
  • activeTabscripting の 2 つの permission を使用して、現在開いているタブでテキスト変換処理を実行

サンプル実装は次の通りです:

manifest.json は先の ② の実装と同じです。ただ、混同しないよう name だけ下記のように変更しておきます:

-     "name": "Sample 2 | Using BSW & Event Listener",
+     "name": "Sample 3 | Using BSW",
service-worker.js
console.log("service-worker.js loaded");

function execScript() {
    console.log("Alt + j pressed");
}

chrome.commands.onCommand.addListener((command, tab) => {
    console.log("command: " + command);
    if (!tab.url.includes('chrome://')) {
        chrome.scripting.executeScript({
            target: { tabId: tab.id },
            function: execScript
        });
    }
});

ただし、この方法には次の留意点があります:

  • OS やブラウザ、インストール済みの他の Chrome 拡張機能ですでに使用されているショートカットキーは上書きできない

こちらは公式ドキュメントに下記のように記載されています:

Certain operating system and Chrome shortcuts (e.g. window management) always take priority over Extension command shortcuts and can not be overwritten.

引用: chrome.commands - Chrome Developers

例えば、 suggested_keyCtrl+V を設定した場合、ペーストできない箇所では Chrome 拡張機能が機能しますが、ペーストできる箇所ではペースト処理の方が優先され Chrome 拡張機能は動作しません。

これにより、同じショートカットキーでも Windows/Mac の環境の違いで動かない場合があります(今回作った Chrome 拡張機能のショートカットキーが Alt/Cmd + j になっているのはそれが理由です…)。

また、ショートカットキーが Alt+J に設定されている Chrome 拡張機能をインストールした状態で、同じくショートカットキーが Alt+J に設定されている別の Chrome 拡張機能をインストールした場合、後からインストールしたほうのショートカットキー設定が無効になるようです。

image.png

  • ショートカットキー変更は chrome://extensions/ から行う必要がある

(「それはそう」という感じではありますが…)

manifest.json にショットカットキーを記述しているため、「 Chrome 拡張機能の popup からサクッと変更する」といったことは実現困難かと思います(要検証)。

image.png

Mac だと、suggested_keyCtrl は Ctrl キーではなく Command キーで認識される

(地味に混乱したポイントでした…)

Chrome Developers 公式ドキュメントには「 Mac では Ctrl が Command キーとして認識される。 Mac のCtrl キーを認識したい場合は mac の項目に MacCtrl と記述する」と記載にされています:

  • On macOS Ctrl is automatically converted into Command .
    • To use the Control key on macOS, replace Ctrl with MacCtrl when defining the "mac" shortcut.

引用: chrome.commands - Chrome Developers

一方、JavaScript では、 Mac のCtrl キー は e.ctrlKey 、 Command キーは e.metaKey で認識します56

Windows/Mac 、 suggested_key / JavaScript での設定方法と実際に認識するキーの関係を表にまとめるとこのようになります:

設定方法 Windows の場合 Mac の場合
suggested_keyCtrl Ctrl キー Command キー
suggested_keyMetaCtrl (エラー※) Command キー
JS の e.ctrlKey Ctrl キー Ctrl キー
JS の e.metaKey Win キー Command キー

※ 公式ドキュメントにもある通り、 Mac 以外のプラットフォームの suggested_keyMetaCtrl を指定すると下記のようなエラーが出てしまい、そもそもインストールすることができません:

Using MacCtrl in the combination for another platform will cause a validation error and prevent the extension from being installed.

引用: chrome.commands - Chrome Developers

image.png

今後の改善点

高機能なエディタでも動くようにする

現在の実装では、フォーカスしている要素が textarea 要素の場合に Chrome 拡張機能が動作するようになっています。

currentElement.tagName === "TEXTAREA";

しかし、この実装だと高機能なエディタでは動かなかったり、テキスト変換結果が想定通りにならなかったりと、正常に動作しません。なぜなら、高機能なエディタは基本的に textarea 要素で実装されていないからです。例えば、 Qiita の記事編集画面は textarea 要素ではなく div 要素で実装されています:

image.png

ですので、 Chrome 拡張機能の動作条件を改善し、より多くのエディタで動くようにしたいと考えています。

設定機能を実装する

popup に設定画面を用意し、 Chrome 拡張機能の動作をカスタマイズできる機能を実装したいと考えています。

今のところ、下記の機能が実装できればと考えています:

  • Markdown Table テキストのひな型をカスタマイズ可能にする
  • Markdown Table テキストを整形して生成するかどうかのオプションを追加する

結び

この記事では、CSV テキストと Markdown Table をキーボードショートカットで相互変換できる Chrome 拡張機能について、その仕組みや開発するにあたって工夫・苦労した点などについて記しました。

なお、バグがあったり謎の実装をしていたりする個所があったりするかと思いますので、その場合はコメントや Pull Request 等をいただけるとありがたいです:bow:

ここまでお読みいただきありがとうございました!

  1. chrome.commands - Chrome Developers

  2. About extension service workers - Chrome Developers

  3. Background Service Worker – Plasmo

  4. Chrome Extensions content scripts - Chrome Developers

  5. MouseEvent.ctrlKey - Web API | MDN

  6. MouseEvent.metaKey - Web API | MDN

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?