Chrome拡張機能作成(サイドパネル) selectionchange

# Chrome拡張機能(Vite + React + TypeScript)
![ダウンロード.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2777404/b6e90779-8a4a-30e1-893b-7a72c290e134.gif)

## 内容
ブラウザで選択された文字をサイドパネルに表示する
- useStateを使いたい
- JSON形式で値を受け取りたい
:::note alert
 お試し簡易実装のため多少バグがあると思います。
 ご了承ください。
:::

## 開発環境
:::note info
OS : macOS Sonoma 14.2.1
node : 18.15.0
npm : 9.8.1
:::

## 環境構築
```terminal:terminal
Viteで作成
> npm create vite@latest
> product name : productName (任意の名前)
> React
> TypeScript + SWC
> cd productName
> npm i 

ここで拡張機能に必要なものをインストール(* @crxjs/vite-pluginには@betaをつけてください)
> npm i　-D @crxjs/vite-plugin@beta @types/chrome

> mkdir src/background
> touch src/background/background.ts
```

## 機能説明
chrome.scripting.executeScriptの中ではReactの関数など使用できないので注意。
background.tsを中継することでuseStateの値を更新できるようになります。

```plantuml
Chromeタブ->background.ts: 拡張機能起動
App.tsx-->Chromeタブ:selectionchangeイベント実行
Chromeタブ->background.ts: イベント検知時データ送信
background.ts->App.tsx: Chromeタブからのデータ送信
```

## 実装
manifest.jsonを作成させます。

```ts:vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
import { crx, defineManifest } from "@crxjs/vite-plugin"

const manifest = defineManifest({
  manifest_version: 3,
  name: "chrome-extension", // 拡張機能の名前 
  version: "1.0.0",
  icons: { 
    // 128: "icon-128-active.png" // 拡張機能のアイコン
  },
  action: {
    default_popup: "index.html"
  },
  side_panel: {
    default_path: "index.html", // サイドパネルに表示するHTML
  },
  permissions: [ // 権限を付与
    'sidePanel',
    'tabs',
    'activeTab',
    'storage',
    'scripting'
  ],
  host_permissions: ["<all_urls>"], // 拡張機能が使えるURLを指定(今回は全てにしています)
  background: {
    service_worker: "src/background/background.ts"
  }
})

export default defineConfig((comand) => {
  return {
    server: {
      port: 5173,
      strictPort: true,
      hmr: {
        port: 5173
      },
    },
    build: {
      rollupOptions: {
        input: {

        },
        output: {

        },
      },
      watch: {

      }
    },
    plugins: [react(), crx({ manifest })],
  }
})
```

```ts:src/background/background.ts

// Chromeで更新があった場合に実行される
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
  // chrome://から始まるURLでは拡張機能は使えないみたいです
  if (tab.url?.startsWith("chrome://")) return

  if (changeInfo.status === "complete") {
    console.log(tabId, changeInfo, tab);
    send(JSON.stringify({ tabId: tabId, title: tab.title, update: true }))
  }
})

// Chromeのアクティブなタブが切り替わった際に実行される
chrome.tabs.onActivated.addListener((activeInfo) => {
  send(JSON.stringify({ ...activeInfo, update: true }))
})

// 拡張機能を実行した際、サイドパネルを開く
chrome.sidePanel
  .setPanelBehavior({ openPanelOnActionClick: true })
  .catch((error) => console.error(error))

// message受け取り用
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  let json = JSON.parse(message)
  if (json?.selectedText) {
    send(message)
  } else {
    send("background receive message [Success]")
  }
})


// message送信用
const send = (message: any) => {
  chrome.runtime.sendMessage(message)
}

```

```tsx:App.tsx
import { useEffect, useState } from 'react'

function App() {
  const [firstInit, setFirstInit] = useState<boolean>(false)
  const [tabId, setTabId] = useState<number>(null!)
  const [tabTitle, setTabTitle] = useState<string>(null!)
  const [selectedText, setSelectedText] = useState<string>("")
  const [update, setUpdate] = useState<boolean>(false)

  useEffect(() => {
    init()
    chrome.runtime.onMessage.addListener(receiveMessage)
  }, [])

  // tabIdまたはtabTitleが変更された場合
  useEffect(() => {
    // Chromeタブが更新された際、再度selectionchangeイベントを実行する
    init()
    setUpdate(false)
  }, [!!firstInit && !!update ])

  // background.tsからmessageを受け取る
  const receiveMessage = (v: any) => {
    let json = JSON.parse(v)
    if (json?.title) { setTabTitle(json.title) }
    if (json?.selectedText) { setSelectedText(json.selectedText) }
    if (json?.update) { setUpdate(json.update) }
  }
  
  // Chromeタブ内でイベントを実行させる
  const init = async () => {
    setFirstInit(true)
    // Chromeタブの情報を取得
    let [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
    setTabId(tab.id!)
    setTabTitle(tab.title!)
    // Chromeタブに対してScriptを実行
    chrome.scripting.executeScript({
      target: { tabId: tab.id! },
      func: () => {
      
        /** -----------------------------------------
        * ここではReactの関数など使用できないので注意してください。
        ------------------------------------------- */
      
        // Chromeタブ内でのselectionchangeイベントを検知させます
        document.addEventListener("selectionchange", () => {
          let str = window.getSelection()?.toString()
          let json = { selectedText: str }
          // 検知した場合にから文字でなければbackground.ts側にmessageを送ります
          str && chrome.runtime.sendMessage(JSON.stringify(json))    
        })

      }
    })
  }

  return (
    <>
      <div>
        <div style={{ padding: "1rem" }}>
          <div>tabID:</div>
          <div style={{ background: "green" }}>&nbsp;{tabId}</div>
          <div>tabタイトル:</div>
          <div style={{ background: "rgb(0,120,154)" }}>&nbsp;{tabTitle}</div>
        </div>
        <div style={{ padding: "1rem" }}>
          <div>
            <textarea
              name=""
              id=""
              cols={30}
              rows={10}
              value={selectedText}
              onChange={(e) => setSelectedText(e.currentTarget.value)}
              style={{ width: "100%" }}
            />
          </div>
        </div>

      </div>
    </>
  )
}

export default App
```


## 拡張機能を実行してみる
[Chrome拡張機能](chrome://extensions/ "拡張機能")

`npm run dev`または`npm run build`を実行し、distフォルダを作成しておいてください。

- デベロッパー モードをONにします。
- パッケージ化されていない拡張機能を読み込む
    - 今回作成したdistファイルを選択
    - Chromeの拡張機能に追加される
- Chromeの拡張機能から実行

Chromeタブ内(chrome://以外)で選択された文字がサイドパネル上に表示されれば成功です。