豪鬼メモ

一瞬千撃

ポップアップ辞書の実装の解説

Chrome拡張機能としてポップアップ辞書を作成して公開したが、その実装方法についてメモがてら書いておこう。Chrome拡張機能としての実装をする前に、まずは各ページに明示的にロードしてポップアップ辞書として機能するJavaScriptのライブラリを実装する。それで機能性を確認してから、Chrome拡張機能としてパッケージし直す。
f:id:fridaynight:20210418004924p:plain

詳しいことは、ソースを読めばわかるはず。そこそこ簡潔に書いたつもりだ。ソースはGitHubでも公開されている。


まずは単体のJavaScriptライブラリを実装する。任意のページのhead要素にて、CSSJavaScriptの二つのファイルをロードしておいて、body要素のonloadイベントでunion_dict_activate関数を呼び出せば、bodyの中身のテキストを対象としたポップアップ辞書が仕掛けられるようにする。

<html>
<head>
<link rel="stylesheet" href="https://dbmx.net/dict/union_dict_pane.css">
<script src="https://dbmx.net/dict/union_dict_pane.js"></script>
</head>
<body onload="union_dict_activate()">
<p>Fetch me my hat.</p>
</body>
</html>

ポップアップ辞書は、絶対配置のdiv要素として表現される。union_dict_paneというIDにCSSスタイルを設定するが、その内容は以下のようになる。この要素は任意のページに追加されるので、どんなスタイルを継承するか事前に予測できない。よって、all属性をinitialにすることで実質的に継承を無効化している。その上で、position属性をabsoluteにして絶対配置にし、display属性をnoneにして初期状態で非表示になるようにしている。常に最前面に来るように、z-indexの値をint32の最大値にしている。

#union_dict_pane {
    all: initial;
    font-size: 11pt;
    display: none;
    position: absolute;
    overflow-x: hidden;
    overflow-y: scroll;
    z-index: 2147483647;
}

union_dict_pane.jsがロードされた際には、まず以下のコードが実行され、上述のスタイルを持ったdiv要素を作っている。この時点では要素は作られただけで、どこにも所属していない。ポップアップの処理はページ内の本文上でのmouseupイベントで行われるのだが、ポップアップ上でのmouseupで同じ処理をされると困るので、イベントを上書きして親に伝搬しないようにしている。

let union_dict_pane = document.createElement("div");
union_dict_pane.id = "union_dict_pane";
union_dict_pane.last_query = "";
union_dict_pane.addEventListener("mouseup", function(event) {
  event.stopPropagation();
}, false);

ページがロードされた際にはunion_dict_activate関数が呼ばれる。文書上の任意の位置でのmouseupイベントを補足して、ポップアップの処理を行う。テキストを選択した後に発動したいので、clickでなくてmouseupを使っている。

function union_dict_activate() {
  document.addEventListener("mouseup", union_dict_mouseup, false);
}

union_dict_mouseup関数では単にunion_dict_toggle_popup関数を呼んで処理を委譲し、その中で具体的な処理を行なっている。

function union_dict_mouseup() {
  union_dict_toggle_popup(true);
}

function union_dict_toggle_popup(dom_check) {
  ...
}

union_dict_toggle_popupの中身を少しずつ追っていこう。まず、初回の呼び出しでのみ、既に作ってあるdiv要素をbodyの子要素として追加している。スクリプトのロード時にbody要素が存在しているとは限らないので、いわゆるlazy initializationをしている。

if (!document.has_union_dict) {
  document.body.appendChild(union_dict_pane);
  document.body.has_union_dict = true;
}

それから、ポップアップをまず隠して、テキストが選択されているか調べる。選択されていなければ何もしないで終わる。よって、ポップアップ以外の場所をクリックした場合にはポップアップは消えることになる。

union_dict_pane.style.display = "none";
let selection = window.getSelection();
if (selection.rangeCount < 1) {
  return;
}

dom_checkパラメータが真の場合、DOMを調べて、フォーム内のテキストが選択された場合には何もしないようにする。検索窓とかのテキストを編集するための選択でポップアップが出るとうざいからだ。

if (dom_check) {
  if (selection.focusNode) {
    for (let elem of selection.focusNode.childNodes) {
      let node_name = elem.nodeName.toLowerCase();
      if (node_name == "input" || node_name == "textarea") {
        return;
      }
    }
  }
}

選択された領域の情報はSelectionオブジェクトとして取得されている。getRangeAtを呼ぶと任意のインデックスのDOM要素が得られる。今回は最初の要素を得て、その座標をポップアップの座標の計算に使っている。また、SelectionオブジェクトのtoStringを呼ぶと選択された領域のテキストが得られる。その中に英字や漢字や仮名が含まれている場合には、ポップアップが発動するようことになる。ポップアップの表示は、スタイルのdisplay属性をblockに変えることでなされ、その後に座標が調整される。選択領域の下端とページ下端の間にポップアップの高さ分の隙間がある場合には、選択領域の下端のちょっと下にポップアップを表示する。そうでなければ、選択領域の上端のちょっと上にポップアップを表示する。左右の位置関係に関しても同じようなことを行う。ポップアップの中身の描画はunion_dict_update_pane関数に委譲している。

let range = selection.getRangeAt(0);
let rect = range.getBoundingClientRect();
let left = rect.x;
let top = rect.y + rect.height;
let text = selection.toString();
text = text.replaceAll(/[^-'\d\p{Script=Latin}\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}ー]+/gu, " ");
text = text.trim();
if (text.length == 0 || text.length > 50) {
  return;
}
union_dict_pane.style.display = "block";
let pane_left = Math.min(rect.left, window.innerWidth - union_dict_pane.offsetWidth - 8);
pane_left += window.pageXOffset;
union_dict_pane.style.left = pane_left + "px";
let pane_top = rect.top + rect.height * 1.5;
if (pane_top + union_dict_pane.offsetHeight + 3 > window.innerHeight) {
  pane_top = rect.top - union_dict_pane.offsetHeight - 8;
}
pane_top += window.pageYOffset;
union_dict_pane.style.top = pane_top + "px";
union_dict_update_pane(text);

union_dict_update_pane関数では、XMLHttpRequestで辞書サイトを呼び出して、取得したデータを適当にレンダリングすれば良い。冒頭で、直前に利用した検索語と現在の検索語を比較して、それが同じなら何もしないようにしている。連写した際にリクエストが乱発されるのを防ぐためだ。その後、ポップアップの中身の要素を全て消して、検索語をヘッダとして描画している。これは検索処理中の待ち時間に何もフィードバックがないとユーザが不安になるからだ。

function union_dict_update_pane(query) {
  if (query == union_dict_pane.last_query) {
    return;
  }
  while (union_dict_pane.firstChild) {
    union_dict_pane.removeChild(union_dict_pane.firstChild);
  }
  let article = document.createElement("div");
  article.className = "union_dict_article";
  let header = document.createElement("h2");
  header.textContent = query;
  header.className = "union_dict_header";
  article.appendChild(header);
  union_dict_pane.appendChild(article);
  ...
}

XMLHttpRequestによる検索リクエストを発行し、完了時のコールバックで描画をする。コールバックの中では、サーバから得たデータのJSONをオブジェクトとして復元して、結果が一つ以上あれば、ポップアップ内の要素を再び全消去した上で、結果のレコードを一つずつunion_dict_fill_entryに渡して描画させている。エラーの際には適宜エラーメッセージを出す。

let url = union_dict_search_url + "?x=popup&q=" + encodeURI(query);
let xhr = new XMLHttpRequest();
xhr.open('GET', url);
let renderer = function() {
  if (xhr.status == 200) {
    let result = JSON.parse(xhr.responseText);
    if (result.length > 0) {
      while (union_dict_pane.firstChild) {
        union_dict_pane.removeChild(union_dict_pane.firstChild);
      }
      for (let entry of result) {
        union_dict_fill_entry(entry);
      }
    } else {
      let note = document.createElement("p");
      note.className = "union_dict_note";
      note.textContent = "No results.";
      article.appendChild(note);
    }
    union_dict_pane.last_query = query;
  } else {
    let note = document.createElement("p");
    note.className = "union_dict_note";
    note.textContent = "Error: " + xhr.status;
    article.appendChild(note);
  }
};
xhr.addEventListener('load', renderer, false);
xhr.send(null);

union_dict_fill_entryの中身は退屈なので省略しよう。結果のデータをDOM要素としてひたすら追加していくだけだ。


そんなわけで、任意のページにポップアップ辞書を埋め込むためのライブラリは完成している。まずは単体のJavaScriptライブラリとして作り込んでおく方が、テストやデバッグや調整が楽なのだ。あとは、ユーザが訪れた全てのページにそのライブラリの起動スクリプトを埋め込むようなChrome拡張機能を書けばよい。

Chrome拡張機能の全てのファイルは、あるディレクトリの下に入れる。ファイルの数が多い場合にはサブディレクトリに分けてもよいが、今回のように単純な場合は単一階層に置く方が楽だろう。そして、個々のファイルがどのような役割を持つかは、マニフェストファイルで決められる。マニフェストファイルの名前はmanifest.jsonと決まっている。今回のマニフェストファイルの中身は以下のようになっている。

{
  "manifest_version": 2,
  "name": "English-Japanese Union Dictionary",
  "description": "WordNetとWiktionaryの英和統合辞書。ポップアップでも検索できる。",
  "version": "1.0",
  "homepage_url": "https://dbmx.net/dict/",
  "background": {
    "scripts": ["background.js"],
    "persistent": false
  },
  "browser_action": {
    "default_title": "統合英和辞書",
    "default_popup": "browser_action.xhtml"
  },
  "content_scripts": [{
    "matches": ["http://*/*", "https://*/*"],
    "all_frames": true,
    "js": ["union_dict_pane.js", "content_script.js"],
    "css": ["union_dict_pane.css"]
  }],
  "permissions": [
    "https://dbms.net/*",
    "storage", "tabs", "contextMenus"
  ],
  "icons": {
    "128": "icon128.png"
  }
}

manifest_versionは2で決め打ちだ。name、description、version、homepage_url、iconsの役割は名前から自明だろう。background、browser_action、content_scriptsが肝の設定である。名前の通り、backgroundはバックグラウンドスクリプトを設定し、browser_actionはブラウザアクションを設定し、content_scriptsはコンテントスクリプトを設定する。permissionは、通信できる外部サーバのURLや、利用できるChrome拡張APIを設定する。

今回の例では、コンテントスクリプトが主役と言っていいだろう。ブラウザがロードしたページのURLがmatchesのパターンに合致した場合には、jsとcssで記述されたJavaScriptCSSのファイルがそのページに強制的に埋め込まれるのだ。matchesに <all_urls> と記述しても良いらしいのだが、私は敢えてhttpとhttpsを明示している。all_framesは、iframesなどの埋め込みフレームにも影響を及ぼすかを指定する。

ブラウザアクションとは、アドレスバーの横のアイコンをクリックした際に表示されるポップアップの内容である。default_titleは、そのアイコンの上にポインタをホバーさせた際に表示されるツールチップの内容である。default_popupは、ポップアップの中身としてロードされるデータを指定する。JavaScriptを仕掛けたい場合は、ロードされるHTMLからscript要素の外部参照で呼び出すことになる。

バックグラウンドスクリプトは、主にブラウザアクションとコンテントスクリプト等の協調動作のために使われる。persistentは特別な理由がない限りはfalseで良いらしい。そうすると、必要な場合にロードされて不必要な場合にアンロードしてくれるそうな。

さて、全てのページで強制的にポップアップ機能を埋め込むだけならば、実はコンテントスクリプトの設定だけでOKだ。今回は、ポップアップ機能のオンオフをブラウザアクションで切り替えられるようにしたい。そのためだけにちょっと複雑なことをしなきゃいけない。

まずはブラウザアクションデータから見ていこう。browser_action.xhtmlというXHTMLファイルを読み込む。HTMLとして書いても良い。肝は以下のフォームである。ポップアップ辞書の有効と無効を切り替えるラジオボタンを表示するとともに、browser_action.jsというスクリプトをロードしている。スクリプトのロードがbody要素の最後に来ているのが重要だ。そうすることで、JavaScript側からフォームのDOM要素が取得できる。

<form name="config_form" action="#" id="config_form">
<div class="form_row">ポップアップ検索:
<input type="radio" name="popup_enable" value="on" id="popup_enable_on"/><label for="popup_enable_on">有効</label>
<input type="radio" name="popup_enable" value="off" id="popup_enable_off"/><label for="popup_enable_off">無効</label>
</div>
</form>

<script src="browser_action.js"></script>

browser_action.js側の実装を見てみよう。フォームの内容が変更されたら、update_config関数が呼ばれるようにしている。ちなみに、HTML側でonchange属性としてスクリプトを書くのは推奨されない。セキュリティポリシーの設定が面倒になるので。そして、update_config関数の中では、フォームの設定内容をChromeのローカルストレージAPIでkey-valueデータベースに書き込む。今回はpopup_enableというキーを使っている。書き込み処理が終了した際に呼ばれるコールバックの中では、バックグラウンドスクリプトのupdage_config関数を呼んでいる。updage_config関数は、設定が変更されたのでそれを反映すべきであるということを、コンテントスクリプトに通知する。

let config_form = document.getElementById("config_form");

function update_config(notify) {
  chrome.storage.local.set({"popup_enable": config_form.popup_enable.value}, function(value) {
    chrome.runtime.getBackgroundPage(function(background_page) {
      background_page.update_config();
    });
  });
}

config_form.addEventListener("change", function(event) {
  update_config();
});

さらに、ブラウザアクションが表示された際には、ローカルストレージを読んで、フォームのデフォルト値を設定するようにしている。ストレージAPIの結果確認が徹底して非同期コールバックになっているあたりと、それを簡潔に書けるJavaScriptが素敵だ。

chrome.storage.local.get(["popup_enable"], function(value) {
  if (value.popup_enable == "off") {
    config_form.popup_enable.value = "off";
  } else {
    config_form.popup_enable.value = "on";
  }
});

バックグラウンドでは、update_config関数を以下のように実装している。現在開かれているタブを全て参照して、そのURLがhttpかhttpsであり、またロードが完了しているならば、そのタブにunion_dict_update_configという文字列のメッセージを送る。なお、何らかのメッセージが受領されないとエラーログが出されるので、それを抑制するために、レスポンスを受け取るコールバックの中で無駄にchrome.runtime.lastErrorを参照している。

function update_config() {
  chrome.tabs.query({}, function(tabs) {
    for (let tab of tabs) {
      if ((tab.url.startsWith("http://") || tab.url.startsWith("https://")) &&
          tab.status == 'complete') {
        chrome.tabs.sendMessage(tab.id, "union_dict_update_config", function(response) {
          chrome.runtime.lastError;
        });
      }
    }
  });
}

バックグラウンドでは、コンテキストメニューやショートカットキーの処理も行なっている。実装についてはソースファイルをご覧いただけば分かると思う。各種のイベントを捕捉してコンテントスクリプト側にメッセージとして通知しているだけだ。

最後に、コンテントスクリプトを見てみよう。マニフェストをもう一度確認してほしいのだが、先にunion_dict_pane.js、その後にcontent_script.jsをロードするようになっている。後者が前者を実行する関係なので、この順序は重要だ。ポップアップ辞書のライブラリであるunion_dict_page.jsについては既に説明したので、それを起動するcontent_script.jsを見てみよう。ページがロードされた際と、バックグラウンドからメッセージを受け取った際には、update_page_state関数が呼ばれる。その中では、ローカルストレージの設定を調べて、オフであればunion_dict_deactivedeでポップアップを無効化し、そうでなければ(つまり、オンであるか未設定である場合)、union_dict_activateでポップアップを有効化する。

function update_page_state() {
  chrome.storage.local.get(["popup_enable"], function(value) {
    if (value.popup_enable == "off") {
      union_dict_deactivate();
    } else {
      union_dict_activate();
    }
  });
}

chrome.extension.onMessage.addListener(function(request, sender, send_response) {
  if (request == "union_dict_update_config") {
    update_page_state();
  }
  send_response("OK");
});

update_page_state();


以上で実装は終わりだ。めちゃ単純。あとは、ブラウザで chrome://extensions にアクセスして、デベロッパーモードをオンにして、そのディレクトリを「Load unpacked」で読み込めばよい。やりたければ、「Pack Extension」してcrxアーカイブを作って、Chrome Web Storeに登録してもいいだろう。

ポップアップ辞書はChrome拡張機能としてはかなり単純な部類だろうが、この実装方法を知っているだけでもなかなか面白いことができる。選択したテキストに対して何か情報を得るといった類の機能は、これを応用すれば簡単に書ける。Web検索をしてもいいし、シソーラス検索をしてもいいし、単語数を数えてもいいし、機械翻訳をしてもいいし、注釈を記録してもいい。ブラウザアクションからオンオフを切り替えるという、本来の機能とあんまり関係ないところが一番面倒くさい。しかし、それもやり方さえ知っていれば大したことはない。良い時代だ。