React + ViteでGoogle Chromeの拡張機能を作成する

## はじめに

今回作成するものをベースにいくつかの機能を追加した拡張機能を公開・開発中です。
ご興味がありましたら、ぜひお試しください。

[bookmari filer - Chrome Web Store](https://chromewebstore.google.com/detail/bookmark-filer/akjhpafliijgbfigfmcngflcfdcpokfi)

## 作成するもの
chrome上で`a`を2回押した時、ブックマークが階層状に表示されるポップアップを作成していきます。

<img width="400" alt="archive.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/665074/8e92a239-636e-8be7-364d-98249626a0ca.png">


## プロジェクトのセットアップ
```sh
 pnpm create vite@latest
✔ Project name: … bookmark-extension
✔ Select a framework: › React
✔ Select a variant: › JavaScript
```
プロジェクト直下に移動して以下のコマンドを実行します。
```sh
 pnpm add -D @crxjs/vite-plugin@beta
```

vite.config.jsを編集します。

```diff_javascript:vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
+ import { crx } from '@crxjs/vite-plugin'
+ import manifest from './manifest.json'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    react(),
+    crx({ manifest }),
  ],
})
```

manifest.jsonを作成します。

```json
{
  "manifest_version": 3,
  "name": "bookmark extension",
  "version": "1.0.0",
  "action": { "default_popup": "index.html" }
}
```

```zsh
 pnpm run dev
```

[拡張機能](chrome://extensions/)>デベロッパーモードをON>パッケージ化されていない拡張機能を読み込むを押下
プロジェクト直下のdistを選択します。

<img width="400" alt="dist.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/665074/7ef18665-2f6f-e9e0-aca3-9b4670752386.png">


読み込まれた拡張機能をクリックするとポップアップが出現します。

<img width="400" alt="popup.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/665074/8e686ed2-349b-1fad-fd03-0d7fd4c7ebaf.png">


[ここまでのコード](https://github.com/monjara/bookmark-extension/tree/0.1.0)

## おまけ
プロジェクト関連の設定を整えます。

formatter・linterにeslintではなく[biome](https://biomejs.dev/ja/)を利用してみます。

```sh
pnpm rm -D eslint \
eslint-plugin-react \
eslint-plugin-react-hooks \
eslint-plugin-react-refresh \
&& pnpm add --save-dev --save-exact @biomejs/biome
```

biomeの設定ファイルを作成します。

```sh
pnpm biome init
```
biome.jsonがプロジェクト直下に生成されます。
```json:biome.json
{
	"$schema": "https://biomejs.dev/schemas/1.7.3/schema.json",
	"organizeImports": {
		"enabled": true
	},
	"linter": {
		"enabled": true,
		"rules": {
			"recommended": true
		}
	}
}
```

こちらをお好みの設定に修正します。
今回は以下のようにしました。

```json:biome.json
{
  "$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
  "organizeImports": {
    "enabled": true
  },
  "linter": {
    "enabled": true,
    "rules": {
      "recommended": true
    }
  },
  "formatter": {
    "indentStyle": "space",
    "indentWidth": 2,
    "lineEnding": "lf"
  },
  "javascript": {
    "formatter": {
      "quoteStyle": "single",
      "jsxQuoteStyle": "single",
      "trailingComma": "es5",
      "semicolons": "asNeeded"
    }
  }
}
```

package.jsonのscriptsも修正します

```diff_json:package.json
-   "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
+   "lint": "biome check --apply src",
```

`pnpm run lint`でformat, lintの両方が行われるようになりました。

エディタの支援が強力になるように`@types/chrome`を導入しておきます。

```sh
pnpm add -D @types/chrome
```

また、srcディレクトリをインポート時に`@`で呼び出せるよう設定を追加します。

```diff_javascript:vite.config.js
export default defineConfig({
  plugins: [...],
+ resolve: {
+   alias: [
+     {
+       find: '@',
+       replacement: `${__dirname}/src`,
+     },
+   ],
+ },
})
```

エディタの支援が強力になるようにプロジェクト直下に`jsconfig.json`を作成します。

```json:jsconfig.json
{
  "compilerOptions": {
    "target": "es2017",
    "allowSyntheticDefaultImports": false,
    "baseUrl": "./",
    "paths": {
      "@/*": [
        "src/*"
      ]
    }
  },
  "exclude": [
    "node_modules"
  ]
}
```

一旦プロジェクト周辺の設定は以上です。

[ここまでのコード](https://github.com/monjara/bookmark-extension/tree/0.2.0)

## コンテンツスクリプトを追加

ここから拡張機能の肝となるコンテンツスクリプトを追加していきます。

基本的な手順としては[CRXJSのドキュメント](https://crxjs.dev/vite-plugin/getting-started/react/add-content-script)に従います。

まず`manifest.json`にコンテンツスクリプトのファイルを定義します。

```json-doc:manifest.json
{
  // 省略
  "content_scripts": [
    {
      "matches": [
        "<all_urls>"
      ],
      "js": [
        "src/content/index.jsx"
      ]
    }
  ],
  // 省略
}
```

`manifest.json`に定義したパスでファイルを作成します。

```jsx:src/content/index.jsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import BookmarkWindow from './components/bookmark-window'
import './index.css'

const root = document.createElement('div')
root.id = 'bookmark-extension-root'
document.body.appendChild(root)

ReactDOM.createRoot(root).render(
  <React.StrictMode>
    <BookmarkWindow />
  </React.StrictMode>
)
```

`src/content/index.jsx`から呼び出す`bookmark-window`と
content scriptで表示するページのcssファイルも作成します。

```jsx:src/content/components/bookmark-window.jsx
export default function BookmarkWindow() {
  return (
    <div className="window">
      <h1>Bookmark Window</h1>
    </div>
  )
}
```

```scss:src/content/index.css
#bookmark-extension-root {
  position: absolute;
  top: 0;
  z-index: 999999999;

  .window {
    height: 20vh;
    background: white;
    color: black;
  }
}
```
ここで画面を確認すると以下のように表示されます。

<img width="400" alt="3.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/665074/aefff97b-d098-535a-4f8c-38cf70840b68.png">


[ここまでのコード](https://github.com/monjara/bookmark-extension/tree/0.3.0)

## 特定のキー入力でコンテントスクリプトの表示・非表示を切り替え

現時点ではコンテンツスクリプトで表示している要素がでっぱなしなので、キー入力で表示・非表示を切り替えられるようにします。
具体的に、以下のような挙動にしていきたいと思います。

- `a`を2回連続して入力でコンテンツスクリプト要素を表示
- `a`が1回入力された後、1000ms`a`の入力が無かった場合、1回目の`a`入力は無かったことにする
- `esc`を入力した時要素を非表示

```diff_jsx:src/content/components/bookmark-window.jsx
+ import { useEffect, useRef, useState } from 'react'

export default function BookmarkWindow() {
+ const [display, setDisplay] = useState(false)
+ const keyCount = useRef(0)

+ useEffect(() => {
+   let timerId
+   const handler = (e) => {
+     if (e.key === 'a') {
+       keyCount.current += 1
+       timerId = setTimeout(() => {
+         keyCount.current = 0
+       }, 1000)
+     }
+     if (e.key === 'Escape') {
+       setDisplay(false)
+       keyCount.current = 0
+     }
+     if (keyCount.current === 2) {
+       setDisplay(true)
+     }
+   }

+   window.addEventListener('keydown', handler)
+   return () => {
+     window.removeEventListener('keydown', handler)
+     if (timerId) {
+       clearTimeout(timerId)
+     }
+   }
+ }, [])

+ if (!display) {
+   return null
+ }

  return (
    <div className='window'>
      <h1>Bookmark Window</h1>
    </div>
  )
}
```

これでコンテントスクリプト要素の表示非表示を切り替えられるようになりました。

[ここまでのコード](https://github.com/monjara/bookmark-extension/tree/0.4.0)

## ブックマークを取得

コンテントスクリプトで表示するブックマークを取得します。
ブックマークはbackgroundで取得しそれをコンテンツスクリプトに渡す必要があります。

また、ブックマーク読み取りのため、`manifest.json`に権限を追加する必要があります。

```diff_jsonnet:manifest.json
{
  
+ "permissions": ["bookmarks"],

+ "background": {
+   "service_worker": "src/background/index.js",
+   "type": "module"
+ }
}
```

`src/background/index.js`を作成し、以下の処理を記述します。

- ブックマークを取得
- コンテンツスクリプトからメッセージを受け取り、取得したブックマークを返すリスナー定義
- ブックマーク取得する関数を呼び出すリスナー定義

```js:src/background/index.js
let tree = []
function getBookmarks() {
  tree = []
  chrome.bookmarks.getTree((treeNode) => {
    for (const node of treeNode) {
      for (const child of node.children) {
        tree.push(child)
      }
    }
  })
}

chrome.runtime.onMessage.addListener((req, _, res) => {
  if (req.type === 'bookmarks') {
    res({ tree })
  }
})

chrome.runtime.onInstalled.addListener(getBookmarks)
chrome.bookmarks.onCreated.addListener(getBookmarks)
chrome.bookmarks.onRemoved.addListener(getBookmarks)
chrome.bookmarks.onChanged.addListener(getBookmarks)
chrome.bookmarks.onMoved.addListener(getBookmarks)
```

コンテンツスクリプトからbackgroundで定義したリスナーにメッセージを送り、ブックマークを渡してもらいます。

```diff_jsx:src/content/components/bookmark-window.jsx
+ const [bookmarks, setBookmarks] = useState([])
+ useEffect(() => {
+   const getBookmarks = async () => {
+     const res = await chrome.runtime.sendMessage({ type: 'bookmarks' })
+     setBookmarks(res.tree)
+   }
+   getBookmarks()
+ }, [])

+ console.log('bookmarks: ', bookmarks)
  // (2) [{…}, {…}]
```

`console.log`でブックマークを含む配列を表示できたら完了です。

[ここまでのコード](https://github.com/monjara/bookmark-extension/tree/0.5.0)

## ブックマークをツリー状に表示

content scriptで取得したブックマークをツリー状に表示します。
再帰的にコンポーネントを呼び出すことでこれを実現します。

取得したブックマークは以下のような形になっています。


<details><summary>bookmarks</summary>


```json
[
    {
        "children": [
            {
                "children": [
                    {
                        "children": [
                            {
                                "dateAdded": 1716681883242,
                                "id": "8",
                                "index": 0,
                                "parentId": "7",
                                "title": "Qiita",
                                "url": "https://qiita.com/"
                            }
                        ],
                        "dateAdded": 1716681873438,
                        "dateGroupModified": 1716681888310,
                        "id": "7",
                        "index": 0,
                        "parentId": "6",
                        "title": "フォルダ"
                    }
                ],
                "dateAdded": 1716681861490,
                "dateGroupModified": 1716682583503,
                "id": "6",
                "index": 0,
                "parentId": "1",
                "title": "フォルダ"
            },
            {
                "children": [
                    {
                        "dateAdded": 1716681696830,
                        "dateLastUsed": 1716683776048,
                        "id": "5",
                        "index": 0,
                        "parentId": "9",
                        "title": "Zenn｜エンジニアのための情報共有コミュニティ",
                        "url": "https://zenn.dev/"
                    }
                ],
                "dateAdded": 1716682761236,
                "dateGroupModified": 1716683777095,
                "id": "9",
                "index": 1,
                "parentId": "1",
                "title": "ddd"
            }
        ],
        "dateAdded": 1716681619944,
        "dateGroupModified": 1716683466876,
        "id": "1",
        "index": 0,
        "parentId": "0",
        "title": "ブックマーク バー"
    },
    {
        "children": [],
        "dateAdded": 1716681619944,
        "dateGroupModified": 1716681696830,
        "id": "2",
        "index": 1,
        "parentId": "0",
        "title": "その他のブックマーク"
    }
]
```

</details>

フォルダーはchildrenを持っており、ブックマークはchidrenを持たずにurlを持っています。
したがって以下のようなコンポーネントを作成していきます。

- childrenを持っていなかった時はリンクを表示するコンポーネントを返す
- childrenを持っている時は再帰的にコンポーネントを呼び出す。また、子コンポーネントの表示・非表示をステートで管理する

```jsx:src/content/components/tree.jsx
import { useState } from 'react'

const LinkRow = ({ item }) => {
  return (
    <div className='link-row'>
      <a href={item.url}>
        <span>{item.title}</span>
      </a>
    </div>
  )
}

const FolderRow = ({ item }) => {
  const [open, setOpen] = useState(false)
  const toggle = () => {
    setOpen((old) => !old)
  }

  return (
    <>
      <div className='folder-row' onClick={toggle} onKeyUp={toggle}>
        <span>{item.title}</span>
      </div>
      {open &&
        item.children.map((child) => <Tree key={child.id} item={child} />)}
    </>
  )
}

export default function Tree({ item }) {
  if (!item?.children) {
    return <LinkRow item={item} />
  }
  return <FolderRow item={item} />

}
```

```diff_jsx:src/content/components/bookmark-window.jsx
    <div className='window'>
+     <div className='container'>
+       {bookmarks.map((item) => (
+         <Tree key={item.id} item={item} />
+       ))}
+     </div>
    </div>
```

併せて見た目も整えていきます。

```diff_scss:src/content/index.css
+ .window {
+   display: flex;
+   position: fixed;
+   top: 0;
+   left: 0;
+   margin-top: 10px;
+   margin-bottom: 10px;
+   margin-left: 5px;
+   background: #dfe2ec;
+   width: 35%;
+   height: 90%;
+   color: #000;
+   padding: 20px;
+   border-radius: 30px;
+ }

+ .container {
+   width: 100%;
+   background: #fff;
+   overflow: auto;
+   padding: 10px;
+   border-radius: 10px;
+ }

+ .folder-row {
+   border-bottom: 1px solid #ccc;
+   cursor: pointer;
+   display: flex;
+   align-items: center;
+   gap: 0.5rem;
+   height: 32px;
+   padding-left: 0.5rem;
+   margin-right: 0.5rem;
+   font-size: 14px;
+ }

+ .link-row {
+   display: flex;
+   align-items: center;
+   gap: 0.5rem;
+   font-size: 12px;
+   border-bottom: 1px solid #ccc;
+   color: inherit;
+   text-decoration: none;
+   height: 32px;
+   padding-left: 0.5rem;
+   margin-right: 0.5rem;
+ }
```

以下のような見た目になります。

<img width="400" alt="6.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/665074/7bfeb045-bc62-8392-8309-7cd35bd80d87.png">

[ここまでのコード](https://github.com/monjara/bookmark-extension/tree/0.6.0)


## アイコン・ファビコンの表示

以下を表示していきます。

- フォルダの左にSVGのアイコン
- ブックマークリンクの左にファビコン

```jsx:src/content/components/folder-icon.jsx
export default function FolderIcon() {
  return (
    <svg
      xmlns='http://www.w3.org/2000/svg'
      width='16'
      height='16'
      fill='#000000'
      viewBox='0 0 256 256'
    >
      <title>folder</title>
      <path d='M216,72H131.31L104,44.69A15.86,15.86,0,0,0,92.69,40H40A16,16,0,0,0,24,56V200.62A15.4,15.4,0,0,0,39.38,216H216.89A15.13,15.13,0,0,0,232,200.89V88A16,16,0,0,0,216,72ZM40,56H92.69l16,16H40ZM216,200H40V88H216Z' />
    </svg>
  )
}
```


```jsx:src/content/components/folder-open-icon.jsx
export default function FolderOpenIcon() {
  return (
    <svg
      xmlns='http://www.w3.org/2000/svg'
      width='16'
      height='16'
      fill='#000000'
      viewBox='0 0 256 256'
    >
      <title>folder-open</title>
      <path d='M245,110.64A16,16,0,0,0,232,104H216V88a16,16,0,0,0-16-16H130.67L102.94,51.2a16.14,16.14,0,0,0-9.6-3.2H40A16,16,0,0,0,24,64V208h0a8,8,0,0,0,8,8H211.1a8,8,0,0,0,7.59-5.47l28.49-85.47A16.05,16.05,0,0,0,245,110.64ZM93.34,64,123.2,86.4A8,8,0,0,0,128,88h72v16H69.77a16,16,0,0,0-15.18,10.94L40,158.7V64Zm112,136H43.1l26.67-80H232Z' />
    </svg>
  )
}
```

```diff_jsx:src/content/components/tree.jsx
const FolderRow = ({ item }) => {
  ...
  return (
    <>
      <div className='folder-row' onClick={toggle} onKeyUp={toggle}>
+       {open ? <FolderOpenIcon /> : <FolderIcon />}
        <span>{item.title}</span>
      </div>
      ...
    </>
  )
}
```

これでフォルダの左側にアイコンを表示できました。

<img width="400" alt="icon.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/665074/827361af-26fc-52f3-dd12-8e6dd6aef044.png">


次にリンクの横にファビコンを表示します。
ファビコンを取得するには`manifest.json`に`favicon`の権限を明記する必要があります。

```diff_jsonnet:manifest.json
  "permissions": [
    "bookmarks",
+   "favicon"
  ]
```

その上で以下の関数を作成し、`<img />`にて呼び出します。

```js
export function getFavicon(u) {
  const url = new URL(chrome.runtime.getURL('/_favicon/'))
  url.searchParams.set('pageUrl', u)
  url.searchParams.set('size', '64')
  return url.toString()
}
```

```diff_jsx
    <a href={item.url} className='link-row'>
+     <img src={getFavicon(item.url)} alt='' style={{ width: 16 }} />
      <span>{item.title}</span>
    </a>
```

これでファビコンを表示できました。

<img width="400" alt="favicon.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/665074/89e1f047-926a-8679-50ff-feef17e4163f.png">


[ここまでのコード](https://github.com/monjara/bookmark-extension/tree/0.7.0)

## 見た目を整える

最後に見た目を整えていきます。

- hover時に列の色を変える
- 階層が深くなった時、左の余白を大きくする

```css
  .folder-row:hover, .link-row:hover {
    background: #f0f0f0;
  }
```

```jsx:src/content/components/tree.jsx
export default function Tree({ item, depth = 0 }) {
  const childDepth = depth + 10
  if (!item?.children) {
    return <LinkRow item={item} depth={childDepth} />
  }
  return <FolderRow item={item} depth={childDepth} />
}
```

```jsx
const FolderRow = ({ item, depth }) => {
  const [open, setOpen] = useState(false)
  const toggle = () => {
    setOpen((old) => !old)
  }

  return (
    <>
      <div
        className='folder-row'
        onClick={toggle}
        onKeyUp={toggle}
        style={{
          paddingLeft: depth,
        }}
      >
        {open ? <FolderOpenIcon /> : <FolderIcon />}
        <span>{item.title}</span>
      </div>
      {open &&
        item.children.map((child) => (
          <Tree key={child.id} item={child} depth={depth} />
        ))}
    </>
  )
}
```

```jsx
const LinkRow = ({ item, depth }) => {
  return (
    <a
      href={item.url}
      className='link-row'
      style={{
        paddingLeft: depth,
      }}
    >
      <img src={getFavicon(item.url)} alt='' style={{ width: 16 }} />
      <span>{item.title}</span>
    </a>
  )
}
```

こちらで完成です！読んでいただきありがとうございました。

<img width="400" alt="archive.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/665074/8e92a239-636e-8be7-364d-98249626a0ca.png">

[最終的なコード](https://github.com/monjara/bookmark-extension/tree/0.8.0)