Mouse Dictionaryの技術的な話


# Mouse Dictionary

Chrome拡張の高速な英語辞書ツールをつくりました
https://qiita.com/wtetsu/items/c43232c6c44918e977c9

これ↑を作ったときの工夫とかの話になります。

ブラウザ拡張の開発に関する情報は、ふつうのWebフロントエンドの情報と比較するとなかなか見つかりづらいため、役に立つかもしれないと思いここに書き残しておきます。

## ソースコード


https://github.com/wtetsu/mouse-dictionary


おおまかにいうと、ただのWebExtensionsプロジェクトです。が、とくに速度を出すために、ちらほらバッドノウハウ的なものも必要になりました。


## 基本方針

以下を絶対に守る。

- **とにかく一瞬で**ルックアップ～表示の更新を完了させる
- **とにかく幅広く**見出し候補を作成して一度にルックアップする

### 一瞬さ
どれくらい一瞬かというと、マウスうごかす～小窓の表示更新完了まで、60分の1秒を越えないようにします。また、辞書機能は他サイトの上に追加で表示させるという性質上、60分の1秒を超えなくても、更に早くて負荷が少ないに越したことはない、という感じです。

なお最新版での計測では、テキスト解析～辞書データルックアップ～DOM生成処理まで、実用上数ms以内で完了できているようです。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/106401/91b4ab66-7d14-7ac8-b515-3a17d0e49252.png)



### 幅広さ

どれくらい幅広くかというと、"dealt with it"の上にカーソルを乗せると、dealtを自動的に原型dealに変換し、"deal with"や"deal"もルックアップ候補になります。


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106401/8f1828d5-b181-b2be-3014-5ce8d93ef7a4.png)


"on my own"の場合は、"on one's own"や"on someone's own"もルックアップ候補になります。


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106401/5d863957-b0bb-7bb3-d3a2-ff16c6eda15b.png)



実際に小窓の説明としてなにが表示されるかは、インポートされている辞書データによって異なります。幅広く動的に生成した見出し語群の中で、アタリがあったものを優先順位に従って上から表示します。

## 機能の構成

大まかに2つの機能で構成されています。

* メイン(小窓とその裏の辞書参照機能など)
* オプション画面

(メイン機能)
![md_05.gif](https://qiita-image-store.s3.amazonaws.com/0/106401/57de929d-5f4b-22d1-2cfc-3c2cd78ee509.gif)


(オプション画面)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106401/ccac8e9c-d040-c5e9-39c2-2c523b8c767e.png)



オプション画面は初期化や動作が少し遅くなったところで問題ではないので、ライブラリ等はとくに遠慮せずに入れています。逆に辞書機能(小窓とその裏の処理)の方は初期化時間・動作速度は最重要な要素のため、ライブラリは極力利用していません(Mustacheエンジンのみ利用)


## 主に使ったもの

* ビルド
    * webpack + Babel -> esbuild (ビルド高速化のために移行)
* オプション画面
    * React
* テンプレート
    * Hogan.js -> mustache.js (移行)
* テスト
    * Jest
* CSS
    * milligram
* 開発環境 
    * VSCode
    * prettier


普通のWebのフロントと異なり当初Chromeしか対象にしていなかったので、webpackの有り難みは限定的かと思ったのですが、結局なにかと大活躍でした。途中からReact使うことにしたり、途中からFirefox対応することにした上にChromeとFirefoxのビルドを分けるハメになったりしたのですが、そのような場合も難なく対応できました。

2022年に、ビルド時間短縮のためにesbuildに移行しました(4~5秒→1.5秒)


Reactは、オプション画面を楽に作るために利用しました。カワイイUIコンポーネントライブラリを使いたかったというのも動機でした(結局カラーピッカーしか使っていませんが)


## ストレージ

### chrome.storageのlocalとsync

- chrome.storage.local
- chrome.storage.sync

どちらもキーバリューなのですが、おおまかにという前者はたくさんのデータを格納することができて、後者は容量が限られているものの格納したデータはGoogleアカウント経由で共有することができます。

https://developer.chrome.com/apps/storage

(ちなみに"chrome"とありますが、Firefoxでも使えます)

Mouse Dictionaryでは、以下のように使い分けています。

- localにはインポートした辞書データを保存
- syncにはユーザ設定や前回の小窓位置などを保存

使い分けの理由は、localにたくさんデータが入っている状態でさらにlocalにデータを追加しようとすると、かなり時間がかかるためです(1件追加するのに数秒かかる)。

一度インポートしたら殆ど変更しないであろう辞書データと比較し、利用中にしばしば更新される可能性があり、かつ小さいデータであるユーザ設定を高速に保存完了できるようsyncに格納することにしたという感じです。バッドノウハウ感はあります。


ソースはこの辺:
https://github.com/wtetsu/mouse-dictionary/blob/master/src/main/lib/storage.js

asyncにするためとアプリケーションコードから使いやすくするために、軽くラップしています。

### 速度

そんなchrome.storage.localですが、たくさんのデータが入っている状態でも参照は高速です。どのくらい高速かというと、私の環境では、200万件以上のデータが入った状態で、50件のデータを取り出すのに6msとかで完了できるようです。実用上、一度に50件も取り出す必要はないので、Mouse Dictionaryの目標(1/60秒=約16ms)を考えても十分に高速と言えます。

注意点として、getのAPIがこんな感じなので、50件のデータを引く場合はgetを50回呼ぶのではなく、一回だけ呼ぶようにしましょう。それさえ守れば高速に処理できます。

```js
chrome.storage.local.get(["word1", "word2", "word3"], r => {
  // r.word1 という感じで引いた値を参照できる
});

```



## ルックアップ候補の生成

前述の通り、辞書データの参照は、見出し語候補が数十個あっても一度に引けば十分な速度が出ることが期待できます。

そのため、Mouse Dictionaryは「多少無駄になってもいいので、ルックアップ候補をたくさん作って一度に引いてアタリだけ表示する」という方針を採用できます。アタリがありそうな候補を慎重に生成するよりも、この雑な方針の方がずっと高速で便利なためです。

ルックアップ候補をつくるというのは、動詞の過去形を原型に戻したり、myをsomeone'sとかone'sにしたりとか、そういうのです。たとえば以下のような感じになります。

- "dealt with"にカーソルを置く → ルックアップ候補は["dealt with", "dealt", "deal with", "deal"]
- "on my own"にカーソルを置く → ルックアップ候補は["on my own", "on my", "on", "on one's own", "on one's", "on someone's own", "on someone's"]

当たり前ですが、辞書データの見出しにはふつう"dealt with"とも"on my own"とも書かれていません。"deal with"や"on one's own"なら見出しにある可能性があります。そのため、ルックアップ前に自動的に変換をしています。


![image.png](https://qiita-image-store.s3.amazonaws.com/0/106401/5f7c23a1-c40e-05f2-d101-1fa0c894a35c.png)

![image.png](https://qiita-image-store.s3.amazonaws.com/0/106401/ad872ec2-f14e-4f61-d14b-5e78cfd27b5c.png)


各ルックアップ候補の優先順位は自明ではないのですが、Mouse Dictionaryは以下のルールで作っています。

- マウス下にあるテキストそのものを高い優先度とする
- 単語が長い表現の方を高い優先度とする

よきにはからって変換した方の文字列は、優先度を下げてルックアップします。このルールなら、おかしな順序で説明が表示されると感じる場面はあまりないと思います。

たとえば、dealt withにカーソルを合わせた際に、dealtがdeal withより上に表示されているのは、このルールに従っているためです。

ちなみに不規則動詞は、↓みたいな対応関係を保持して地道に変換しています。

```json
{
  dealt: "deal",
  did: "do",
  done: "do",
  dove: "dive",
  drank: "drink",
  ...
}
```

### 日本語

英語を引くときをほど気は利いていないと思いますがいちおう日本語もいけます。

「多く」から「多く」「多い」「多」と、活用もまあ扱えます。これは[deinja](https://github.com/wtetsu/deinja)を利用して実現しています。まあこれも私がつくったやつですが…。(いい感じの既存ライブラリが見つからなかった)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106401/668c3b8c-7a5f-eeb8-8240-d1dda7719745.png)


あと、日本語の単語をちゃんと認識して、カーソル位置が最初の文字に合わさっていなくても良きにはからってくれます。たとえば↓はカーソルを端にかざしているのにもかかわらず「ウィキペディア」という語を第一に表示してくれているの図です。

<img width="400" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106401/6bcfcd96-b57a-ea64-0405-3f1f5660136f.png"/>

これは、Intl.v8BreakIteratorで実現しています。

## 発音

スピーカーアイコンアイコンをクリックすると音声が流れるようにしました。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106401/0fac4e66-f8e7-0a3e-598b-0407eeb17aef.png" width="600" />

発声は[Web Speech API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Speech_API)で実現しています。


語の意味表示と比較すると、音声再生は滅多に使用しない機能ということで、マウスオーバーしないと表示されないようになっています。少しでもメイン機能の表示ノイズを増やさないようにするための工夫です。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106401/c748c00d-dea3-4e56-6618-beaf6a90c88d.gif" width="400" />


なお、発音機能実装前からのユーザは、古い設定が残っているせいでスピーカーアイコンが現れてくれないかもしれません。その場合は設定画面で「初期状態に戻す」→「設定を保存する」してください。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106401/8e2872b7-6691-ac08-ae30-d54c6415ea9a.png)



## テキスト抽出力(YouTube等)

Mouse Dictionartyはテキストの抽出力の面でも強力です。

以下のような場所にあるテキストも取得することができます。

- YouTube字幕
- inputやtextarea
- EvernoteやConfluenceの編集エリア
- Google Documentも

これは、たまたまなんとなくうまく動いているわけではなく、**上記のような場所でも動くように工夫して作られている**ためです。

たとえば以下のYouTubeの字幕に使用している画像を御覧ください。"get"と"a very good commission"は(場所的な意味でもDOM的な意味でも)離れているのに、問題なく"get a commission"の意味を表示することができていますね。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106401/2dd03d2e-1130-98bb-9440-0e52bcf292d5.png)

※ついでにいうと"get a very good commission"から見出し語の"get a commission"をとれるよう、うまく判断していますね

※動画は[This is what happens when you reply to spam email | James Veitch](https://www.youtube.com/watch?v=_QdPW8JrYzQ)より

※EvernoteやConcluence上で動かすには、[Mouse Dictionary iframe support](https://chrome.google.com/webstore/detail/mouse-dictionary-iframe-s/nigglogmamjbcnljijokibobpcfgmdfn)のインストールが必要です。


ソースはこの辺:
https://github.com/wtetsu/mouse-dictionary/blob/master/src/main/lib/traverser.js


## HTML生成
### ~~やっぱHoganよ~~ -> mustache.js

ルックアップして発見した説明文字列からHTMLを生成する際には、テンプレートエンジンを利用しています。

動機:

- プログラムの見通しが悪くなりがちな適当文字列連結を避けテンプレート化
- そのテンプレートをユーザ変更可能にすればカスタマイズ機能の提供になる

ユーザによる変更も可能なので、独自形式ではなく幅広く使われているものがベター。また複雑な分岐や変換を目的としてものではないので、ロジックレスでシンプルなものがよい。ということでMustacheのテンプレートエンジンから選定しました。 

* [Hogan.js](http://twitter.github.io/hogan.js/)
* [mustache.js](https://www.npmjs.com/package/mustache)
* [Handlebars](http://handlebarsjs.com/)

~~で、速度を計測してみたら(Mouse Dictionaryの用途においては)Hogan.jsが最も高速で、APIもかつ使いやすかったので、速度重視の方針からHoganを選びました。もうほぼメンテされていないという点は気になりましたが、とくにセキュリティリスクが報告されているわけでもないので良しとしました。~~

2022年にmustache.jsに移行しました。Hogan.jsは早くて小さくて良かったのですが、[もうメンテナンスされていない](https://github.com/twitter/hogan.js/issues/248)というのと、内部でeval系機能を使っていてブラウザ拡張だと余分なContent Security Policy設定する必要があったり審査でハネられたりするというのが理由です。


ちなみに説明テキスト表示部分のテンプレートはこんな感じになっています。これはオプション画面からカスタマイズできるので、多少HTMLの知識があれば、たとえば見出し({{head}})をクリックするとその単語をGoogleで検索する、といったことも可能です。キミだけのMouse Dictionaryを作り上げろ！

```
<div style="all:initial;">
  {{#words}}
    {{^isShort}}
      {{! 通常の単語 }}
      <span style="font-size:{{headFontSize}};font-weight:bold;color:{{headFontColor}}">{{head}}</span>
      <br/>
      <span style="font-size:{{descFontSize}};color:{{descFontColor}};">
        {{{desc}}}
      </span>
    {{/isShort}}
    {{#isShort}}
      {{! 短い単語 }}
      <span style="font-size:{{headFontSize}};font-weight:bold;color:{{headFontColor}}">{{head}}</span>
      <span style="color:#505050;font-size:x-small;">{{shortDesc}}</span>
    {{/isShort}}
    {{^isLast}}
      <br/><hr style="border:0;border-top:1px solid #E0E0E0;margin:0;height:1px;" />
    {{/isLast}}
  {{/words}}
</div>
```

ソースはこの辺:
https://github.com/wtetsu/mouse-dictionary/blob/master/src/main/core/generator.js

イメージ検索や類語検索に飛ばす例:
https://github.com/wtetsu/mouse-dictionary/wiki/HTML-templates

## 内蔵PDFビューア

[PDF.jsベースのビューア](https://github.com/wtetsu/pdf.js)を内蔵し、Web上のPDFドキュメントをとても簡単に開けるようになっています。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106401/57de29d2-fdb7-a5b5-1446-979bb927829a.gif" width=600/>

ユーザ視点での操作は極めて簡単ですが、拡張機能のデータやりとりは制約が多く、内部の処理はなかなか大変です。この内部処理の複雑さを感じさせない操作性にこだわりました。


```mermaid
sequenceDiagram
    participant MD as Mouse Dictionary
    participant W as Web
    participant B as Mouse Dictionary (Background)
    participant O as Mouse Dictionary (Options Page)
    participant V as PDF Viewer

    MD->>W: Request PDF file
    W->>MD: PDF file
    MD->>MD: Encode PDF to Base64
    MD->>B: open_pdf(Base64 data)

    activate B
    B->>B: Generate unique ID, save to QueueDict
    B->>O: openOptionsPage()
    deactivate B

    alt Options page is new
        O->>B: shift_pdf_id (on initialization)
    else Options page already exists
        B-->>O: prepare_pdf received
        O->>B: shift_pdf_id
    end
    activate B
    B->>B: Get oldest ID from QueueDict
    B->>O: sendResponse(ID)
    deactivate B

    activate O
    O->>V: Open PDF viewer (URL?id=ID)
    deactivate O

    activate V
    V->>B: get_pdf_data(ID)
    activate B
    B->>B: Get Base64 corresponding to ID from QueueDict
    B->>V: sendResponse(Base64 data)
    deactivate B
    V->>V: Decode Base64 to binary, display PDF
    deactivate V
```

## 複数ブラウザ対応

WebExtensionsはChrome専用ではなく共通規格のようなものなので、FirefoxでもEdgeでも動かすことができます。

...という名目になっているのですが、同じビルドが複数ブラウザでなにもせずに動くとかそんなうまい話があるわけはないと確信していたので、Mouse DictionaryはChrome専用機能として開発していました。その予想は半分合っていて半分間違っていました。

### Firefox対応

いくつかプルリクいただいた結果、Firefoxでも動くようになりました。

- https://github.com/wtetsu/mouse-dictionary/pull/1
- https://github.com/wtetsu/mouse-dictionary/pull/3

意外と互換性あるな！という感じで嬉しい誤算でした。


### Edge/Vivaldi/Opera/Brave

特別なことは一切なにもせずにChrome版が動いた。


### Safari

プルリクをいただきSafariにもいちおう対応しました。

https://github.com/wtetsu/mouse-dictionary/pull/53

GitHub ActionsでmacOSランナーからxcrun safari-web-extension-converterで変換をかけている感じです。GitHub Actions便利ですね。

App Storeには公開していないので、[Actionsの成果物](https://github.com/wtetsu/mouse-dictionary/actions?query=workflow%3A%22Build+for+Safari%22)からダウンロードして、自力ビルドが必要です。

### Android対応

まだ正式対応という感じではないですが、Androidでも動くようにしました。
PDFビューワも動くので、電車内での読書等に便利かもしれません。

詳しくはこちらを
https://github.com/wtetsu/mouse-dictionary/issues/44


## その他

### プレビュー画面

Reactでつくったオプション画面も、軽快に動くように工夫しています。

テンプレートを編集するとリアルタイムでプレビューが変更されます。ただ、キーボードを高速に叩いた時など、1キーごとにプレビューを更新するのは無駄なので、高頻度で変更した場合は、そのうちの最後の一回に更新が入るようになっています。これはdebounceで実現しています。

![プレピュー画面更新.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106401/d8f3f856-78d5-8b8f-e3d5-a7a5ce59143a.gif)


なお、debounceのためだけにlodashに依存するのが嫌だったので、自前debounceで実現しています。必要な機能だけに切り詰めたところ20~30行くらいで実装できました。

ソースはこの辺:
https://github.com/wtetsu/mouse-dictionary/blob/master/src/options/logic/debounce.ts


### Cross-extension messaging

iframe対応のために、Cross-extension messagingを活用しています。詳細は[こちら](https://qiita.com/wtetsu/items/ec2b0fd0c5e379e9bb1a)。

### resize/draggable

小窓を移動したりリサイズしたりする仕組みです。仕組みといっても既存の軽くていい感じのライブラリは見つからなかったので、完全に自前で実装しています。自前で書いた分、自分の好きなように挙動をつくることができました。実は端っこをダブルクリックでワープする機能とかもあります。

![02-jump.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/106401/38e7a5cd-19f2-b879-9c93-06eef935c036.gif)

ソースはこの辺:
https://github.com/wtetsu/mouse-dictionary/blob/master/src/main/lib/draggable.js

### ShortCache

一回マウスが通ったテキストと、そこからいろいろ処理して生成したDOMの対応を、短期的にメモリにキャッシュする、ShortCacheという仕組みを動かしています。

これにより、カーソルが同じテキストを複数回通過したときはストレージへのアクセスも必要なしに超高速で処理が完了します。

Mouse Dictionaryを利用中のユーザのマウス操作には「カーソルを狭い範囲でいったりきたりする」という傾向があると思っていて、結果として短時間内に何度も同じ語を引くことも多くなると思われます。そのようなときに、このキャッシュが負荷軽減に効いてくるでしょう。

…という狙いがあったのですが、そもそもキャッシュなしでもあまりにも処理が一瞬で完了するので、幸か不幸か期待していたほどの効果はありませんでした。しかし前述の通りMouse Dictionaryの負荷は少なければ少ないほどいいという考えがあるため、この仕組は残しています。

ソースはこの辺:
https://github.com/wtetsu/mouse-dictionary/blob/master/src/main/lib/shortcache.js