(Chrome拡張機能) 右クリックのメニューから現在開いているタブのタイトルとURLをクリップボードに貼り付ける

よんどころなき事情により、新年早々に Chrome拡張機能を作成してみました。

経緯

(Chrome拡張機能) Create Link の挙動が不安定の記事に書いた通り、 Chrome拡張機能 の Create Link – Chromeウェブストア が挙動不安定になってしまいました。

ブログを書く際に参考記事をリストアップしたり、自分の活動にはこの機能はなくてはならないのでそれがままならないというのは不便極まりない。

そこで、代替となる Chrome拡張機能 を自作してみることにしました。

成果物

自作の拡張機能を入れてコンテキストメニューを開いたときのキャプチャ画像
自作の拡張機能を入れてコンテキストメニューを開いたときのキャプチャ画像

機能

Create Link の代替を想定したため、機能はこれに準じます。

ただし、貼り付けるコードのカスタマイズの必要はない (プレーン、HTML、Markdown の3種があれば充分) ので設定画面等の自前でカスタマイズできる機能は全てオミット。

ページを開いている状態で右クリックして、コンテキストメニューから上述3種の形式でリンクをクリップボードに貼り付けられれば良いです。

コード(要点)

manifest.json

{
    "manifest_version": 2,
    "name": "emerald-isle",
    "description": "Make link text of open page.",
    "version": "0.0.1",
    "background": {
        "scripts": [
            "dist/chain.js"
        ],
        "persistent": false
    },
    "content_scripts": [
        {
            "matches": [
                "*://*/*"
            ],
            "js": [
                "dist/smith.js"
            ]
        }
    ],
    "permissions": [
        "tabs",
        "activeTab",
        "clipboardWrite",
        "contextMenus"
    ],
    "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'",
    "icons": {
        "48": "assets/icon_48.png",
        "128": "assets/icon_128.png"
    }
}

特筆すべき事項はあまりないですが

  • Chrome拡張機能 から IE を開く等の実績から、幾分か触り慣れているバージョンの2を選択
  • permissions はタブ、クリップボードへの書き込み、右クリックメニュー(コンテキストメニュー)等
  • 後述しますが、クリップボードへの貼り付けは Background Scripts(Event Scripts) からアクセスはできずに Content Scripts に持ち込む必要があったため、2つのスクリプトに分かれています (結果、 background, content_scripts の2つのフィールドが存在)
    • content_scripts を実行させるURLは全てのURLで良いので *://*/* 指定 (matches フィールド)

といったところでしょうか。

chain.js (Background / Event Scripts)

import { mdLink } from "markdown-function"

/**
 * grace_O_Malley       : Grace O'Malley / titleタグ 文字列のエスケープ
 *
 * @param {string} text : 開いているタブの titleタグ の中身の文字列
 * @param {string} url  : 開いているタブの URL の文字列
 *
 * @returns {string}    : Markdown 記法をエスケープした Markdown 形式のリンク
 */
const grace_O_Malley = (text, url) => {
    return mdLink({
        text: text,
        url: url,
    });
};
/**
 * brianBoru_s_March : Brian Boru's March / メイン処理
 *
 * @returns {void}
 */
const brianBoru_s_March = () => {
    chrome.runtime.onInstalled.addListener(() => {
        chrome.contextMenus.create({
            type: 'normal',
            id: 'CahirCastle',
            title: 'Emerald Isle',
            contexts: [
                'all'
            ],
        });
        chrome.contextMenus.create({
            type: 'normal',
            id: 'plain',
            parentId: 'CahirCastle',
            title: 'Plain Text',
            contexts: [
                'all'
            ],
        });
        chrome.contextMenus.create({
            type: 'normal',
            id: 'html',
            parentId: 'CahirCastle',
            title: 'HTML',
            contexts: [
                'all'
            ],
        });
        chrome.contextMenus.create({
            type: 'normal',
            id: 'md',
            parentId: 'CahirCastle',
            title: 'Markdown',
            contexts: [
                'all'
            ],
        });
    });
    chrome.contextMenus.onClicked.addListener(function(item){
        chrome.tabs.getSelected((tab) => {
            // 現在のタブ
            let clipText = '';
            // クリックされたメニューに応じてテキストを組み立て
            switch (item.menuItemId) {
                case 'plain':
                    clipText = `${tab.title} - ${tab.url}`;
                    break;
                case 'html':
                    clipText = `<a href="${tab.url}" target="_blank" rel="noopener noreferrer">${tab.title}</a>`;
                    break;
                case 'md':
                    clipText = grace_O_Malley(tab.title, tab.url);
                    break;
                default:
                    break;
            }
            chrome.tabs.sendMessage(tab.id, clipText, function(response) {
                console.log(response.value);
            });
        });
    });
};

document.addEventListener('DOMContentLoaded', brianBoru_s_March());
  • chrome.contextMenus.create() でコンテキストメニューの中身を作成
  • chrome.contextMenus.onClicked.addListener() にコンテキストメニューがクリックされた場合の処理を記述
    • コンテキストメニューの各項目に記述した id の値で条件分岐してプレーン,HTML,Markdownの3種のリンクのテキストを生成
    • Markdown 形式に関してはブラケットや括弧をエスケープ処理したかったので、markdown-functionを利用させていただきました
  • 軽く先述した通り、 Background Scripts (Event Scripts) からはクリップボードへのアクセス権限がないため、このスクリプトの中で navigator.clipboard.writeText() を発動させようとしても失敗してしまいます (Uncaught (in promise) DOMException: Document is not focused. というエラー文が出力される)
    • かといってクリップボードへ貼り付ける系の記事で散見された Document.execCommand はDOMの指定が必要でこれも Content Scripts 側の動作と思われる上、Document.execCommand() – Web API | MDN非推奨: この機能は非推奨になりました。 と記載されているため、今からこの方法を採用するのは得策ではないと判断しました
      • しかも開いているタブにフォーカスが当たっている必要がありますが、document.querySelector('document').focus(); 等としても Background Scripts からDOM指定をすると Background Scripts に紐付けられた Background Page を拾ってしまうので実際に開いているタブのDOMは拾えずにエラーとなる
    • Clipboard.js もインスタンス化の際にDOM指定が必要なのでこれも没
    • 色々調べた結果、 Content Scripts へ経由させる必要がありそうなことが分かったため chrome.tabs.sendMessage() でクリップボードへ貼り付けたいテキストを request としてメッセージ送信で投げることにしました

ちなみに markdown-function 使用のため Webpack を使用しました。

smith.js

chrome.runtime.onMessage.addListener(function(request, sender, sendResponse) {
    // メッセージとして送信されたクリップボードに貼り付けたいテキストをそのままレスポンスに設定して返却
    navigator.clipboard.writeText(request);
    sendResponse({
        value: request,
    });
});

ということで、上述した通り Background Scripts (Event Scripts) から投げられたメッセージを受け取って navigator.clipboard.writeText() でクリップボードに貼り付ける側のコード。こちらは述べた通りの役割のみなのでシンプルです。

お作法として元の Background Scripts (Event Scripts) にレスポンスを返す形になるので、 request で投げられたものをそのまま返しています (返却後も console.log() しているだけですが)。

これで望む動作の Chrome拡張機能 を作ることができました。

一度 Background Scripts (Event Scripts) と Content Scripts の違いや権限周りを履修していたのでそこそこスムーズに進めることができたと思います。

(余談) 名前の由来

毎度恒例の名前について。

今回はリンクを生成するということで鎖を連想しました。

で、色々調べたところ

鎖を楽器として使用する吹奏楽の楽曲があることを知りました。冒頭部で本当に鎖を落として音を出していますね……。

この楽曲に着想を得てアイルランドの俗称である「エメラルドの島 (Emerald Isle)」としました。

各種関数やファイル名もこれに関連したものからチョイス。

参考

Content Scirpts, Background Script (Event Scripts)

Content Scripts

chrome.tabs

clipboard API

権限

右クリックメニュー (Context Menu)

Markdown のリンク生成・エスケープ

(未使用) Clipboard.js

(未使用) chrome.clipboard

(未使用) Document.execCommand()

(未使用) その他の clipboard

(未使用) HTMLElement.focus()

(未使用) Markdown のエスケープ

この記事を書いた人

アルム=バンド

フロントエンド・バックエンド・サーバエンジニア。LAMPやNodeからWP、Gulpを使ってejs,Scss,JSのコーディングまで一通り。たまにRasPiで遊んだり、趣味で開発したり。