【TypeScript初心者】選択したテキストをコンテキストメニューを使ってクリップボードにコピーするChrome拡張機能

Code

はじまり

リサちゃん
リサちゃん

これでどうだあああああああっ!!

135ml
135ml

どうだあああああああああっ!!

リサちゃん
リサちゃん

ダメだ。

135ml
135ml

これでどうだあああああああっ!!

リサちゃん
リサちゃん

どうだあああああああああっ!!

135ml
135ml

ダメだ。

リサちゃん
リサちゃん

これでどうだあああああああっ!!

135ml
135ml

これでどうだあああああああっ!!

リサちゃん
リサちゃん

いけたわ。

135ml
135ml

これでいけるのか。

この記事について

日々、色々と調べ物をするわけですが、その情報のソースを保存したい時にその題名とURLをいちいち2回選択してメモ場所(僕の場合はNotionかGoogleスプレッドシート)に行ったり来たりするのが億劫になってきました。もう限界です。

よし、Chrome拡張機能を開発しよう。

今回、開発したChrome拡張機能の概要

そこで、Chrome拡張機能を開発して、Chromeブラウザのページ内で選択したテキストを、Chromeのコンテキストメニューを使って、クリップボードにコピーする方法を紹介します。コピーする内容は、選択したテキスト、および現在いるタブが表示しているページのURLです。

コンテキストメニューを呼び出すとこんな感じになります。

想定する読者

この記事は、Chrome拡張機能を作ったことがない人や、最近作り始めた人を対象に解説する記事になります。

利用したツール

今回のChrome拡張機能を開発する際には、TypeScript(Node.js上で)を使用してみます。

いつも、Google Apps Scriptをバニラで使っているので型チェックがダルかった・・・。まあその厳しさが後々効いてくるんでしょうけど。

TypeScript | TypeScript入門『サバイバルTypeScript』
TypeScript入門『サバイバルTypeScript』〜実務で使うなら最低限ここだけはおさえておきたいこと〜

Chromeの拡張機能には、基本的にChrome APIを使います。

やはりGoogle様のリファレンスは分かりやすい。

Chrome Extensions  |  Chrome for Developers
Learn how to develop Chrome extensions.

サービスワーカー内でコンテキストメニューを設定する

まずは、コンテキストメニューの開発です。Chromeのコンテキストメニューへの機能追加は、サービスワーカーを使用します。

サービスワーカーは、拡張機能のロジックをバックグラウンドで処理する役割を担います。Chrome APIを使った処理や、他のAPIへのリクエストはサービスワーカーが基本的に担当します。

@types/chromeライブラリの追加

まず、TypeScriptでChrome拡張機能を開発するときには、このライブラリがあると型チェックへの対応が楽になります。package.jsonにこんなライブラリがあることを確認します。

{
  "name": "web-ext-react-template",
  "devDependencies": {
    "@types/chrome": "^0.0.266",
  }

  // 後は略...

}

manifest.jsonでの設定

次に、manifest.jsonで使いたいChrome APIをこんな感じで設定します。

{
  "manifest_version": 3,
  "name": "Landmaster's Army Knife",
  "version": "1.0.0",
  "permissions": [
    "tabs",
    "storage",
    "contextMenus"
  ],
  "content_scripts": [
    {
      "all_frames": true,
      "matches": [
        "<all_urls>"
      ],
      "js": [
        "src/content.js"
      ]
    }
  ],
  "background": {
    "service_worker": "src/background/background.js"
  }

  // 後は略...

}

サービスワーカーにコンテキストメニューを設定

そしたら、"background"で指定したbackground.tsがサービスワーカーなる存在ですので、コイツにコンテキストメニューを追加する処理を書きます。

interface CommandDict {
  [cid: string]: string;
}

const commandsDict: CommandDict = {
  'copyMd': 'Copy selected text as Markdown',
  'copyTsv': 'Copy selected text as TSV'
};

/**
 * @description This event listener is triggered when the extension is installed or updated.
 * It creates context menu items for each command defined in the commandsDict object.
 * @param {chrome.runtime.OnInstalledDetails} details - Details of the installation or update event.
*/
chrome.runtime.onInstalled.addListener(async () => {
  for (const [cid, description] of Object.entries(commandsDict)) {
    chrome.contextMenus.create({
      id: cid,
      title: description,
      type: 'normal' as const,
      contexts: ['selection'],
    });
  }
});

なんとこれだけでコンテキストメニューに独自に追加メニューが追加できてしまいます。思ったより楽だ。

chrome.runtime.onInstalled.addListener()で、コンテキストメニューに追加するメニューの内容を設定します。

サービスワーカーにコンテキストメニューにおけるアクションを設定

そして、background.tsに、この処理を書きます。

/**
 * @description This event listener handles the click event on the context menu items.
 * It sends a message to the content script in the active tab with the ID of the clicked menu item.
 * @param {chrome.contextMenus.OnClickData} item - Details of the clicked context menu item.
 * @param {chrome.tabs.Tab | undefined} tab - Details of the tab where the context menu was clicked.
*/
chrome.contextMenus.onClicked.addListener((item: chrome.contextMenus.OnClickData, tab: chrome.tabs.Tab | undefined) => {
  console.log('chrome.contextMenus.onClicked.addListener() process START: --------');
  if (!tab) {
    throw new Error('tab not found.');
  }
  const cid = item.menuItemId;
  console.log('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa--------');
  const tabId = tab.id!;
  console.log('bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb--------');
  chrome.tabs.sendMessage(tabId, {
    sender: 'contextmenu',
    message: cid,
  })
});

chrome.tabs.sendMessage()で、コンテキストメニューでクリックされた時に、「コンテンツスクリプト」にメッセージを送って呼び出す処理を行います。

「コンテンツスクリプト」とは、Web側で動作するスクリプトの事です。

どうして、そのコンテンツスクリプトを呼び出す必要があるのかというと、サービスワーカー内では、ページの中に干渉することが出来ず、選択したテキストを取得できないためです。

なので、ページ内のDOMを参照編集したい場合は、コンテンツスクリプトに処理を記述する必要があります。

(ちなみに、JavaScriptスタンドアロンではないサードパーティのAPIへのリクエストをWeb側(コンテンツスクリプト)で行うと拡張機能の管理画面でエラーを吐き出すようになるので、そういったAPIを呼び出す処理は、基本的にバックグラウンド側で行う方が無難です。)

サービスワーカーからコンテンツスクリプトを呼び出す

それでは次に、コンテンツスクリプトの中にテキストを取得する処理を書いていきます。

manifest.jsonで指定した、content.tsの中にchrome.tabs.sendMessage()で送ったメッセージを受け取る処理を記述します。その処理を担う関数が、chrome.runtime.onMessage.addListener()です。

この関数内で、先程サービスワーカーから送ったメッセージオブジェクトの内容を確認して、コンテキストメニュー内のどの項目がクリックされたか、どの処理を行うのかどうかを分岐させます。

/**
 * @description This event listener handles messages from the content script or other parts of the extension.
 * It performs different actions based on the sender and the message received.
 * @param {chrome.runtime.Message} message - The message received from the sender.
*/
chrome.runtime.onMessage.addListener((message) => {
  console.log('chrome.runtime.onMessage.addListener() process START: --------');
  const sender = message.sender;
  const msg = message.message;
  console.log(sender)
  console.log(msg)
  if (sender === 'contextmenu') {
    if (!Object.keys(commandsDict).includes(msg)) {
      throw new RangeError('The message from the service worker is invalid.');
    }
  }
  if (msg === 'copyMd') {
    copySelectedTextAsMarkdown()
  } else if (msg === 'copyTsv') {
    copySelectedTextAsTsv()
  }
})

copySelectedTextAsMarkdown()でMakdownを、copySelectedTextAsTsv()でTSVの形式でクリップボードにコピーします。主な処理は双方とも同じですので、Markdownの方で今後の処理を見ていきます。

コンテンツスクリプト内でクリップボードにコピペする

Web側の処理に入ります。

コードの概要

選択したテキストをクリップボードにコピーする処理になります。

getSelectedTextAndUrl()で選択したテキストおよびURLを取得して、copyAsMarkdown()でMarkdownの形でクリップボードにコピーします。

/**
 * @description Retrieves the currently selected text and the current URL.
 * @returns An object containing the selected text and the URL.
*/
function getSelectedTextAndUrl(): { selectedText: string; url: string } {
  console.log('getSelectedTextAndUrl() process START: --------');
  if (typeof window !== 'undefined') {
    //クッキーに値をセット
    document.cookie = 'クッキー';
  }
  let selectedText = window.getSelection()?.toString();
  selectedText = !selectedText ? '' : selectedText;
  console.log('selectedText');
  console.log(selectedText);
  const url = window.location.href;
  console.log('getSelectedTextAndUrl() process END: --------');
  return { selectedText, url };
}

/**
 * @description Copies the provided text as a markdown link to the clipboard.
 * @param selectedText The text to be formatted.
 * @param url The URL to be included in the markdown link.
*/
function copyAsMarkdown(selectedText: string, url: string): void {
  console.log('copyAsMarkdown() process START: --------');
  const markdownLink = `[${selectedText}](${url})`;
  navigator.clipboard.writeText(markdownLink).then(function () {
    // onFulfilled: clipboard successfully set
  }, function () {
    // onRejected: clipboard write failed
  });
}

/**
 * @description This function copies the selected text and URL in Markdown format to the clipboard.
 * @returns {void} - This function does not return any value.
 */
function copySelectedTextAsMarkdown(): void {
  console.log('copySelectedTextAsMarkdown() process START: --------');
  const { selectedText, url } = getSelectedTextAndUrl();
  copyAsMarkdown(selectedText, url);
}

navigator.clipboard.writeText()は非同期処理であり、処理を完了させる必要がある

このコピー処理で、注意したい点があります。

それは、navigator.clipboard.writeText()が動作するためには、Webページがフォーカスされている状態でなければならないことです。

フォーカスが当たっていないと、このようなエラーが発生してクリップボードにコピーすることが出来ません。

Uncaught (in promise) NotAllowedError: Failed to execute 'writeText' on 'Clipboard': Document is not focused.

どういう時にこういうエラーが起こるのかと言うと、navigator.clipboard.writeText()周りでこのような処理を書いた時です。

/**
 * @description Copies the provided text as a markdown link to the clipboard.
 * @param selectedText The text to be formatted.
 * @param url The URL to be included in the markdown link.
*/
function copyAsMarkdown(selectedText: string, url: string): void {
  
  // 略...

  navigator.clipboard.writeText(markdownLink);

  // 略...

}

この処理には、.then()が書いてありません。

navigator.clipboard.writeText()は非同期処理であり、この書き方になると、コンテキストメニューからWebページへフォーカスが戻る前にクリップボードへのコピー処理を終了させることになるためにエラーになります。

そのため、.then()で、navigator.clipboard.writeText()の非同期処理をしっかり完了させてあげる必要があります。

完成です

この一連の処理を記述して、JavaScriptにビルドすればChrome拡張機能として使えるようになります!

ビルドの詳細は省きますが、この拡張機能があれば、GitHubなどで選択したテキストからMarkdownをクリップボードに出力できるようになります!(GitHubでは、キーを押したらどこかにフォーカスが飛びがちなので。)

まとめ

この記事では、選択したテキストをクリップボードにコピーするChrome拡張機能の開発方法を紹介しました。

  1. 開発環境: TypeScriptを使用、Chrome APIとの連携
  2. 拡張機能の流れ: サービスワーカー設定、コンテキストメニューの設定、コンテンツスクリプトの呼び出し
  3. 主要機能: ページ内テキストとURLを選択し、MarkdownまたはTSV形式でコピー
  4. エラー処理: navigator.clipboard.writeTextは非同期処理を適切にハンドリングする必要あり

機能としては簡単でしたが、サービスワーカーとコンテンツスクリプトの間の呼び出しは基本なので、ここを抑えられたら今後の開発がかなりスムーズに進められるような気がします。

ぜひ本記事をご参考下さい。

おしまい

リサちゃん
リサちゃん

フォーカス問題で少し沼ったな

135ml
135ml

execCommandはもう非推奨だからなあ・・・

以上になります!

コメント

タイトルとURLをコピーしました