🧑‍💻

#137 AIチャットサイト用の汎用改行設定ツールを作る2

に公開

はじめに

前回の記事では、ChatGPTやperplexityなどの複数のチャットAIサイトで動作可能な改行キー設定ツールの作成が可能であることを検証しました。今回は、実際にChrome拡張機能を作成してみます。

完成品紹介

拡張機能のポップアップから、投稿キーと対象サイトを設定できます。

投稿キーをEnter以外に設定すると、Enterを押したときに投稿ではなく改行が行われるようになります。

URL/CSSセレクタを入力して対象サイトの登録を行います。
URLは、「現在のページ」ボタンを押すと開いているサイトのURLが自動で入るようにしています。
セレクタの入力欄は任意です。空のままにすると、サイト内のどこで入力していてもEnterキーが抑止されます。例えば、検索欄が存在するサイトなどでは、質問文の入力欄のセレクタを指定することで誤動作を防ぐことができます。
指定する際はCSSセレクタの知識が必要になりますが、未指定でも困ることはあんまりないと思います。

コード紹介

manifest.json

{
    "manifest_version": 3,
    "name": "チャットAI改行設定",
    "description": "Enterで改行、Shift/Ctrl/Alt + Enterで送信するよう設定できる機能",
    "version": "1.1.0",
    "content_scripts": [
        {
            "matches": ["*://*/*"],
            "js": ["custom_line_break.js"],
            "all_frames": true
        }
    ],
    "action": {
        "default_title": "改行設定/Options",
        "default_popup": "popup.html"
    },
    "permissions": [
        "storage",
        "tabs"
    ]
}

拡張機能の基本設定です。今回は対象サイトをユーザー側が選べる仕様にしたので、全URLでスクリプトを読み込みます。

popup.html

html
<!DOCTYPE html>
<html>
<head>
    <title>オプション</title>
    <meta charset="UTF-8">
    <link rel="stylesheet" href="popup.css">
    <script src="popup.js" defer></script>
</head>
<body>
    <form name="option">
        <p>投稿キー / Submit key</p>
        <ul>
            <li><input type="radio" name="submitKey" value="enter" />Enter (default)</li>
            <hr>
            <li><input type="radio" name="submitKey" value="shiftKey" />Shift + Enter</li>
            <hr>
            <li><input type="radio" name="submitKey" value="ctrlKey" />Ctrl + Enter</li>
            <hr>
            <li><input type="radio" name="submitKey" value="altKey" />Alt + Enter</li>
            <hr>
            <li><input type="radio" name="submitKey" value="metaKey" />Command + Enter (Mac用)</li>
        </ul>
    </form>
    <form id="siteForm">
        <p>サイトとセレクタの管理</p>
        <input type="text" id="siteInput" placeholder="サイトのURLを入力">
        <input type="text" id="selectorInput" placeholder="CSSセレクタを入力">
        <button type="button" id="fetchDomain">現在のページ</button>
        <button type="submit">追加</button>
    </form>
    <ul id="siteList"></ul>
</body>
</html>

設定ポップアップのHTMLです。投稿キーの選択や対象サイトの登録/削除ができます。

popup.js

// 投稿キーの保存
chrome.storage.sync.get("option").then((result) => {
    var option = result.option !== undefined ? result.option : 'enter';
    document.getElementsByName("submitKey").forEach((e) => {
        if (e.value === option) {
            e.checked = true;
        }
        e.addEventListener("change", (event) => {
            chrome.storage.sync.set({ "option": event.target.value });
        });
    });
});

// 現在のタブのドメイン取得
document.getElementById("fetchDomain").addEventListener("click", () => {
    chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
        if (tabs.length > 0) {
            let url = new URL(tabs[0].url);
            document.getElementById("siteInput").value = url.origin;
        }
    });
});

// サイトとセレクタの追加
document.getElementById("siteForm").addEventListener("submit", (e) => {
    e.preventDefault();
    let site = document.getElementById("siteInput").value.trim();
    let selector = document.getElementById("selectorInput").value.trim();

    if (site) {
        chrome.storage.sync.get({ sites: {} }, (result) => {
            let sites = result.sites;
            sites[site] = selector;
            chrome.storage.sync.set({ sites }, updateSiteList);
            document.getElementById("siteInput").value = "";
            document.getElementById("selectorInput").value = "";
        });
    }
});

// サイトとセレクタの一覧更新
function updateSiteList() {
    chrome.storage.sync.get({ sites: {} }, (result) => {
        let siteList = document.getElementById("siteList");
        siteList.innerHTML = "";

        Object.keys(result.sites).forEach((site) => {
            let li = document.createElement("li");
            li.classList.add("site-entry");

            let span = document.createElement("span");
            span.innerHTML = `<strong>${site}</strong><br>セレクタ: ${result.sites[site]}`;

            let deleteBtn = document.createElement("button");
            deleteBtn.textContent = "削除";
            deleteBtn.classList.add("delete-btn");
            deleteBtn.addEventListener("click", () => {
                delete result.sites[site];
                chrome.storage.sync.set({ sites: result.sites }, updateSiteList);
            });

            li.appendChild(span);
            li.appendChild(deleteBtn);
            siteList.appendChild(li);
        });
    });
}

// 初回読み込み
updateSiteList();

ポップアップでの設定の保存や、現在のタブのドメイン取得を行うスクリプトです。

popup.css

body {
    width: 300px;
}

ul {
    list-style: none;
}

li > input {
    vertical-align: sub;
}

.site-entry {
    width: 100%;
    display: flex;
    justify-content: space-between;
    align-items: center;
}

input[type=text] {
    width: -webkit-fill-available;
}

input[type=text], button {
    margin: 5px;
}

ポップアップの見た目を整えるCSSです。

custom_line_break.js

// 入力欄セレクタ
let selector;
// 投稿キー
let postKey;

chrome.storage.sync.get('sites', (result) => {
  selector = result.sites[window.location.origin]
});

chrome.storage.sync.get('option', (result) => {
  postKey = result.option
});

// 変換中
let isComposing = false;

// IME開始時
window.addEventListener('compositionstart', () => {
  isComposing = true;
});

// IME終了時(変換確定時)
window.addEventListener('compositionend', () => {
  isComposing = false;
});

window.addEventListener("keydown", handler, {capture: true});

function handler(event) {
  if (selector === undefined) {
    // 設定されていないサイトなら何もしない
    return;
  }
  if (selector.length && !event.target.matches(selector)) {
    // 対象外の要素なら何もしない
    return;
  }
  if (isComposing) {
    // 変換の最中なら何もしない
  }
  if (!postKey || postKey == 'enter') {
    // 改行設定がデフォルト通りなら何もしない
    return;
  }
  if (event.key !== "Enter") {
    // Enterキー以外は何もしない
    return;
  }
  if (event.isModified) {
    // 無限ループ防止
    return;
  }

  if (event[postKey]) {
    // 投稿キー + Enter の場合
    // Enter 単体イベントを発火させて投稿を実行
    const properties = [];
    for (const key in event) {
      properties[key] = event[key];
    }
    properties.shiftKey = false;
    properties.ctrlKey = false;
    properties.altKey = false;
    properties.metaKey = false;
    const modifiedEvent = new KeyboardEvent("keydown", properties);
    modifiedEvent.isModified = true;
    event.target.dispatchEvent(modifiedEvent);
  } else {
    // Enter 単体の場合
    // 改行実行
    if (event.target.tagName == 'TEXTAREA') {
      document.execCommand("insertLineBreak");
    } else {
      let newEvent = new KeyboardEvent("keydown", {
        key: "Enter",
        shiftKey: true,
        bubbles: true,
        cancelable: true
      });
      newEvent.isModified = true;
      event.target.dispatchEvent(newEvent);
    }
  }

  // デフォルトの動作を抑止
  event.preventDefault();
  event.stopImmediatePropagation();
}

サイト上で設定された送信キーを適用するスクリプトです。

最後に

最後まで読んでいただき、ありがとうございます。
ひとまず自分で使う分には満足のいくものが作れました。
もし、使っていく中で新たな改善点が見つかれば、今後も修正していきたいと思います。

Discussion