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

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

Chrome Web Store:

https://chrome.google.com/webstore/detail/csv2md-shortcut/fakcffdpcdlhgphdbcanlningmnoigoe

GitHub リポジトリ:

https://github.com/yamamoto-yuta/csv2md-shortcut

## 作ったもの

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

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

![Videotogif.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/489030/a0b8e592-9181-de9d-750f-c620616bcd0e.gif)

## どうやって作ったのか？

### 使用技術

- Plasmo
- Docker & Docker Compose

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

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

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

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

https://zenn.dev/nado1001/articles/plasmo-browser-extension

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

https://yamamoto-yuta.github.io/articles/18

### 仕組み

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

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

#### 「 `chrome.commands` API」って？

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

[^1]: [chrome.commands - Chrome Developers](https://developer.chrome.com/docs/extensions/reference/commands/)

今回の場合、 `package.json` の `manifest` の項目に次のような記述を追加しました。この記述により、 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 が実行されます。

[^2]: [About extension service workers - Chrome Developers](https://developer.chrome.com/docs/extensions/mv3/service_workers/)
[^3]: [Background Service Worker – Plasmo](https://docs.plasmo.com/framework/background-service-worker)

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

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

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

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

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

```js
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/489030/a3d315e3-d7b5-91c4-a101-2c631e49674a.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]。

[^4]: [Chrome Extensions content scripts - Chrome Developers](https://developer.chrome.com/docs/extensions/mv3/content_scripts/)

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

- 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 で機能を実装し、 `activeTab` と `scripting` の 2 つの permission を活用すれば実現できることが分かりました。

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

| permission                                                                          | 効果                                                                         |
| ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| [`activeTab`](https://developer.chrome.com/docs/extensions/mv3/manifest/activeTab/) | ユーザが拡張機能を呼び出した時、現在開いているタブへの一時アクセスを許可する |
| [`scripting`](https://developer.chrome.com/docs/extensions/reference/scripting/)    | Web サイトに JavaScript や CSS を挿入できる                                  |

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

> This serves as an alternative for many uses of "<all_urls>", but displays no warning message during installation:
>
> 引用: [Chrome Extensions: The activeTab permission - Chrome Developers](https://developer.chrome.com/docs/extensions/mv3/manifest/activeTab/)

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

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

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

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

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

```diff
-     "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](https://developer.chrome.com/docs/extensions/reference/commands/#key-combinations)

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

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

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/489030/c06beaa1-6d67-92fd-785c-c3261fd158b8.png)

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

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

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/489030/9d3de1b7-e708-8f54-38ef-2a33fe475ac0.png)

### Mac だと、`suggested_key` の `Ctrl` は 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](https://developer.chrome.com/docs/extensions/reference/commands/#key-combinations)

一方、JavaScript では、 Mac のCtrl キー は `e.ctrlKey` 、 Command キーは `e.metaKey` で認識します[^5][^6]。

[^5]: [MouseEvent.ctrlKey - Web API | MDN](https://developer.mozilla.org/ja/docs/Web/API/MouseEvent/ctrlKey)
[^6]: [MouseEvent.metaKey - Web API | MDN](https://developer.mozilla.org/ja/docs/Web/API/MouseEvent/metaKey)

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

| 設定方法                      | Windows の場合 | Mac の場合   |
| ----------------------------- | -------------- | ------------ |
| `suggested_key` の `Ctrl`     | Ctrl キー      | Command キー |
| `suggested_key` の `MetaCtrl` | （エラー※）    | Command キー |
| JS の `e.ctrlKey`             | Ctrl キー      | Ctrl キー    |
| JS の `e.metaKey`             | Win キー       | Command キー |

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

> Using `MacCtrl` in the combination for another platform will cause a validation error and prevent the extension from being installed.
>
> 引用: [chrome.commands - Chrome Developers](https://developer.chrome.com/docs/extensions/reference/commands/#key-combinations)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/489030/296df8ea-355f-198c-5af5-fb5ec7346bee.png)

## 今後の改善点

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

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

```js
currentElement.tagName === "TEXTAREA";
```

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/489030/100ba430-94c0-e5f5-f2ab-663193920bb8.png)

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

### 設定機能を実装する

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

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

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

## 結び

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

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

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