BLOG

ブログ

最短5分で完成!JavaScriptとReactで作るGoogle Chrome拡張機能

こちらは、Mavs Advent Calendar 2024の2日目の記事です🐺!

🌲🌲🌲

今日はGoogleの拡張機能を作ってみます。
「文字数カウント」と「ダミーテキスト生成」の機能を持つ拡張機能を作成します。
JavaScriptで作成したものの他に、
Reactで作成したバージョンも書いてみましたので、ぜひ試してみてください!

完成イメージ

JavaScriptバージョン

React(MUI)バージョン

文字数カウント機能は、テキストエリアに入力された文字数をカウントします。
ダミーテキスト生成機能は、日本語、半角カナ、数字、英字を選択し、文字数を入力することでダミーテキストを生成します。また、コピーボタンを押すとクリップボードにコピーに保存されます。

JavaScriptバージョンを作ってみる

フォルダ構成

以下のようにしました。

text-count-extension
 L manifest.json
 L index.html
 L style.css
 L script.js
 L icon.png

設定ファイルの作成

さっそく各ファイルを作っていきます!
manifest.jsonを作成します。

{
  "manifest_version": 3,
  "name": "テキストカウンター",
  "version": "1.0",
  "description": "文字数をカウントしたり、ダミーテキストを生成します",
  "action": {
    "default_popup": "index.html",
    "default_icon": {
      "128": "icon.png"
    }
  },
  "permissions": ["clipboardWrite"],
  "icons": {
    "128": "icon.png"
  }
}

このファイルでは、名前などを設定します。
nameで設定した名前は、ホバーしたときに表示されます。

descriptionは拡張機能一覧画面で表示されます。

今回は、テキストをコピーする機能をつけたので、
“permissions”: [“clipboardWrite”]
も追加してます。
参照:https://developer.chrome.com/docs/extensions/reference/permissions-list

アイコンを設定します。

ツールバーに表示させたいアイコンを設定します。今回はこのようなアイコンにしました。

HTMLを作成する

HTMLで各要素を書いていきます。

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>テキストカウンター</title>
    <link rel="stylesheet" href="./style.css" />
  </head>
  <body>
    <div class="container">
      <h2 class="container__title">テキストカウンター</h2>
      <div class="tab-buttons">
        <button class="tab-button active" id="countTabButton">
          文字数カウント
        </button>
        <button class="tab-button" id="dummyTextTabButton">
          ダミーテキスト作成
        </button>
      </div>
      <div id="countTab" class="tab-content active">
        <textarea
          id="textInput"
          class="tab-content__textarea"
          placeholder="テキストを入力してください"
        ></textarea>
        <button class="button tab-content__button" id="countButton">
          カウント
        </button>
        <p class="tab-content__result">
          文字数: <span id="countResult">0</span>
        </p>
      </div>
      <div id="dummyTextTab" class="tab-content">
        <input
          type="number"
          id="dummyTextLength"
          class="tab-content__input"
          placeholder="生成する文字数"
        />
        <div class="radio-list">
          <label class="radio-item">
            <input type="radio" name="textType" value="japanese" checked />
            日本語
          </label>
          <label class="radio-item">
            <input type="radio" name="textType" value="numeric" />
            半角数字
          </label>
          <label class="radio-item">
            <input type="radio" name="textType" value="alphabet" />
            半角英字
          </label>
          <label class="radio-item">
            <input type="radio" name="textType" value="katakana" />
            半角カナ
          </label>
        </div>
        <div class="dummy-text-wrap">
          <button
            class="button dummy-text__button"
            id="generateDummyTextButton"
          >
            ダミーテキスト生成
          </button>
          <textarea
            id="dummyTextOutput"
            class="dummy-text__textarea"
            placeholder="ここにダミーテキストが表示されます"
          ></textarea>
          <button
            class="button outline-button dummy-text__button"
            id="copyButton"
          >
            コピー
          </button>
        </div>
      </div>
    </div>
    <script src="./script.js"></script>
  </body>
</html>

HTMLだけだとこのような感じになります。

CSSを作成

CSSでスタイリングしていきます。

* {
  box-sizing: border-box;
}

.container {
  background-color: #ffffff;
  width: 300px;
  border: 1px solid #e0e0e0;
  border-radius: 4px;
  padding: 16px;
}

.container__title {
  text-align: center;
  color: #333333;
  font-size: 18px;
  margin-bottom: 16px;
}

.tab-buttons {
  display: flex;
  justify-content: space-between;
  gap: 8px;
  margin-bottom: 16px;
}

.tab-button {
  flex: 1;
  background-color: #f8f8f8; 
  color: #333333;
  border: 1px solid #e0e0e0;
  border-radius: 4px;
  padding: 8px 0;
  font-size: 13px;
  text-align: center;
  cursor: pointer;
  transition: background-color 0.2s ease, color 0.2s ease;
}

.tab-button.active {
  background-color: #333333;
  color: #ffffff;
}

.tab-button:hover {
  background-color: #e0e0e0;
}

.tab-content {
 display: none;
}

.tab-content.active {
  display: block;
}


.tab-content__input,
.tab-content__textarea,
.dummy-text__textarea {
  width: 100%;
  padding: 10px;
  border: 1px solid #e0e0e0;
  border-radius: 4px;
  background-color: #f8f8f8;
  font-size: 14px;
  margin-bottom: 16px;
  color: #333333;
  resize: none;
}

.tab-content__input:focus,
.tab-content__textarea:focus,
.dummy-text__textarea:focus {
  outline: none;
  border-color: #333333; 
}

.radio-list {
  display: grid;
  grid-template-columns: 1fr 1fr;
  margin-bottom: 16px;
}

.radio-item {
  display: flex;
  align-items: center;
  margin-right: 8px;
  font-size: 13px;
  color: #333333;
}

input[type="radio"] {
  appearance: none;
  width: 16px;
  height: 16px;
  border: 1px solid #e0e0e0;
  border-radius: 50%;
  margin: 0 4px 0 0;
  background-color: #f8f8f8;
  cursor: pointer;
  transition: background-color 0.2s ease, border-color 0.2s ease;
}

input[type="radio"]:checked {
  background-color: #333333;
}

.button {
  width: 100%;
  background-color: #333333;
  color: #ffffff;
  border: none;
  border-radius: 4px;
  padding: 12px 0;
  font-size: 14px;
  cursor: pointer;
  transition: background-color 0.2s ease;
}

.button.outline-button {
  background-color: #ffffff;
  color: #333333;
  border: 1px solid #333333;
}

.button:hover {
  background-color: #555555;
}

.button.outline-button {
  background-color: #ffffff;
  color: #333333;
  border: 1px solid #333333;
}

.button.outline-button:hover {
  background-color: #333333;
  color: #fff;
}

.tab-content__result {
  font-size: 14px;
  color: #333333;
  text-align: center;
  margin-top: 8px;
}

.dummy-text-wrap {
  display: flex;
  flex-direction: column;
  gap: 16px;
}

このようになりました!

JavaScriptを作成

// タブ切り替えの設定
document.getElementById("countTabButton").addEventListener("click", (event) => {
  switchTab("countTab", event.target);
});
document
  .getElementById("dummyTextTabButton")
  .addEventListener("click", (event) => {
    switchTab("dummyTextTab", event.target);
  });

function switchTab(tabId, clickedButton) {
  document.querySelectorAll(".tab-content").forEach((tab) => {
    tab.classList.remove("active");
  });
  document.getElementById(tabId).classList.add("active");
  document.querySelectorAll(".tab-button").forEach((button) => {
    button.classList.remove("active");
  });
  clickedButton.classList.add("active");
}

// 文字数カウントのロジック
document.getElementById("countButton").addEventListener("click", () => {
  const text = document.getElementById("textInput").value;
  document.getElementById("countResult").textContent = text.length;
});

// ダミーテキスト生成のロジック
document
  .getElementById("generateDummyTextButton")
  .addEventListener("click", () => {
    const length = parseInt(
      document.getElementById("dummyTextLength").value,
      10
    );
    const textType = document.querySelector(
      'input[name="textType"]:checked'
    ).value;

    let baseText;
    if (textType === "japanese") {
      baseText =
        "あいうえおかきくけこさしすせそたちつてとなにぬねのはひふへほまみむめもやゆよらりるれろわをん";
    } else if (textType === "numeric") {
      baseText = "1234567890";
    } else if (textType === "alphabet") {
      baseText = "abcdefghijklmnopqrstuvwxyz";
    } else if (textType === "katakana") {
      baseText = "アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン";
    }

    let dummyText = "";
    for (let i = 0; i < length; i++) {
      dummyText += baseText[i % baseText.length];
    }

    document.getElementById("dummyTextOutput").value = dummyText;
  });

// コピー機能
document.getElementById("copyButton").addEventListener("click", () => {
  const dummyTextOutput = document.getElementById("dummyTextOutput").value;
  if (dummyTextOutput) {
    navigator.clipboard.writeText(dummyTextOutput);
    // コピーをした時に「コピーされました」などのアラートを表示したいときはここに追加
  }
});

動作確認

拡張機能としてアップロードする前にindex.htmlをブラウザで開くとローカルで動作確認ができますので、見てみましょう。

タブの切り替えや、テキストの生成、テキストのカウントができることを確認しました。

Google Chromeで読み込む

Chromeを開き、[Chrome]→[設定]→[拡張機能]をクリックします。
右上の[デベロッパーモード]をONにすると、「パッケージ化されていない拡張機能を読み込む」ボタンが表示されるので、クリックします。

先ほど作成した拡張機能が入ったディレクトリを選択します。
(Zipにする必要はありません)

追加されました!

使ってみた

完成です!最初に載せた通り動きます!

更新したいとき

Chromeで読み込んだ後も、ファイルの変更が反映されます。
変更が容易な代わりに、削除してしまうと拡張機能も使えなくなってしまうので注意が必要です。

Reactバージョンを作ってみる

次に、Reactで作ってみました。
タブやラジオボタンを使用しているので、MUIを使って作成してみます。

プロジェクトの作成

npx create-react-app react-text-count-extension 

プロジェクトが作成されます。
react-text-count-extension の部分は好きに名前をつけてください。

MUIを使うので、インストールしておきます。

cd react-text-count-extension
npm install @mui/material

MUIはデフォルトでEmotionをスタイリングエンジンとして使用しますので、以下のライブラリもインストールします。

npm install @emotion/react @emotion/styled

フォルダ構成

react-text-count-extension
  L public
    L manifest.json
    L index.html
    L icon.png
  L src
    L App.js
    L index.js
    L components
      L CharacterCount.js
      L DummyTextGenerator.js
      L TabButtons.js
      L TextTool.js

設定ファイルの作成

JavaScriptの時と同じように、manifest.jsonを作成します。

{
  "manifest_version": 3,
  "name": "React版 テキストカウンター",
  "version": "1.0",
  "description": "テキストの文字数をカウントします。また、ダミーテキストを生成します。",
  "action": {
    "default_popup": "index.html",
    "default_icon": {
      "128": "icon.png"
    }
  },
  "permissions": ["clipboardWrite"],
  "icons": {
    "128": "icon.png"
  }
}

先ほど作成したJavaScriptのものとほぼ同じです。nameのみ変えています。

アイコンの設定

アイコンは色違いのものを作成しました。

各ファイルの作成

public/index.html

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>テキストカウンター</title>
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>

src/App.js

import React from "react";
import TextTool from "./components/TextTool";

function App() {
  return <TextTool />;
}

export default App;

src/index.js

import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(<App />);

src/components/CharacterCount.js

テキストをカウントする機能を持つコンポーネントです。

import React, { useState } from "react";
import { TextField, Button, Typography, Box } from "@mui/material";

const CharacterCount = () => {
  const [text, setText] = useState("");
  const [count, setCount] = useState(0);

  const handleCount = () => {
    setCount(text.length);
  };

  return (
    <Box sx={{ mt: 2 }}>
      <TextField
        fullWidth
        multiline
        rows={4}
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="テキストを入力してください"
        variant="outlined"
      />
      <Button
        fullWidth
        variant="contained"
        color="primary"
        onClick={handleCount}
        sx={{ mt: 2 }}
      >
        カウント
      </Button>
      <Typography sx={{ mt: 2 }}>文字数: {count}</Typography>
    </Box>
  );
};

export default CharacterCount;

src/components/ DummyTextGenerator.js

ダミーテキストを生成する機能を持つコンポーネントです。

import React, { useState } from "react";
import {
  TextField,
  Button,
  Radio,
  RadioGroup,
  FormControlLabel,
  FormLabel,
  Box,
} from "@mui/material";

const DummyTextGenerator = () => {
  const [dummyLength, setDummyLength] = useState("");
  const [dummyText, setDummyText] = useState("");
  const [textType, setTextType] = useState("japanese");

  const handleDummyGenerate = () => {
    let baseText;
    switch (textType) {
      case "japanese":
        baseText = "あいうえおかきくけこさしすせそたちつてとなにぬねのはひふへほまみむめもやゆよらりるれろわをん";
        break;
      case "numeric":
        baseText = "1234567890";
        break;
      case "alphabet":
        baseText = "abcdefghijklmnopqrstuvwxyz";
        break;
      case "katakana":
        baseText = "アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン";
        break;
      default:
        baseText = "";
    }

    let generatedText = "";
    for (let i = 0; i < parseInt(dummyLength || "0", 10); i++) {
      generatedText += baseText[i % baseText.length];
    }
    setDummyText(generatedText);
  };

  const handleCopy = () => {
    navigator.clipboard.writeText(dummyText);
  };

  return (
    <Box sx={{ mt: 2 }}>
      <TextField
        fullWidth
        type="number"
        value={dummyLength}
        onChange={(e) => setDummyLength(e.target.value)}
        placeholder="生成する文字数"
        variant="outlined"
        sx={{ mb: 2 }}
      />
      <FormLabel component="legend">文字種を選択</FormLabel>
      <RadioGroup
        row
        value={textType}
        onChange={(e) => setTextType(e.target.value)}
        sx={{ mb: 2 }}
      >
        <FormControlLabel value="japanese" control={<Radio />} label="日本語" />
        <FormControlLabel
          value="numeric"
          control={<Radio />}
          label="半角数字"
        />
        <FormControlLabel
          value="alphabet"
          control={<Radio />}
          label="半角英字"
        />
        <FormControlLabel
          value="katakana"
          control={<Radio />}
          label="半角カナ"
        />
      </RadioGroup>
      <Button
        fullWidth
        variant="contained"
        color="primary"
        onClick={handleDummyGenerate}
        sx={{ mb: 2 }}
      >
        ダミーテキスト生成
      </Button>
      <TextField
        fullWidth
        multiline
        rows={4}
        value={dummyText}
        placeholder="ここにダミーテキストが表示されます"
        variant="outlined"
        InputProps={{
          readOnly: true,
        }}
      />
      <Button
        fullWidth
        variant="contained"
        color="secondary"
        onClick={handleCopy}
        sx={{ mt: 2 }}
      >
        コピー
      </Button>
    </Box>
  );
};

export default DummyTextGenerator;

src/components/TabButtons.js

タブコンポーネントです

import React from "react";
import { Tabs, Tab } from "@mui/material";

const TabButtons = ({ activeTab, setActiveTab }) => {
  const handleChange = (event, newValue) => {
    setActiveTab(newValue);
  };

  return (
    <Tabs value={activeTab} onChange={handleChange} centered>
      <Tab label="文字数カウント" value="count" />
      <Tab label="ダミーテキスト作成" value="dummy" />
    </Tabs>
  );
};

export default TabButtons;

src/components/TextTool.js

タブの親となるコンポーネントです。

import React, { useState } from "react";
import CharacterCount from "./CharacterCount";
import DummyTextGenerator from "./DummyTextGenerator";
import TabButtons from "./TabButtons";
import { Box, Typography } from "@mui/material";

const TextTool = () => {
  const [activeTab, setActiveTab] = useState("count");

  return (
    <Box sx={{ p: 2 }}>
      <Typography variant="h4" align="center" gutterBottom>
        テキストカウンター
      </Typography>
      <TabButtons activeTab={activeTab} setActiveTab={setActiveTab} />
      {activeTab === "count" && <CharacterCount />}
      {activeTab === "dummy" && <DummyTextGenerator />}
    </Box>
  );
};

export default TextTool;

ビルドしてみる

各ファイルが作れたので、以下のコマンドでビルドしてみましょう。

npm run build

buildフォルダが出来上がっていると思います。

ビルド時にModule not found: Error: Can’t resolve ‘@emotion/styled’ in ‘~~~~/node_modules/@mui/styled-engine’というエラーが出たら、Emotionがインストールされていないので、確認してみてください。

Google Chromeで読み込む

読み込み方はJavaScriptバージョンとほぼ同じです。
選択するフォルダは「build」フォルダです。

動作確認

動きました!

さいごに

私の参画しているプロジェクトではよくダミーテキストを使用しています。
フォームの項目ごとに何文字まで入れるか決まっているので、
そのテストをしたり、最大文字列までのダミーテキストを作ったりしていますが、手入力だとかなりめんどくさいです。
また、使いたい機能があるサービスや拡張機能を探すより作った方が早いかも・・と思い、作成してみました。

今回作成したものは文字数でカウントしていますが、
バイト数でカウントしたりランダムな文字列を生成したり、
好きなようにアレンジして使ってみてください!

RELATED ARTICLE