Electron で Chrome のページ内検索機能を使う

比較的最近,Electron に Chrome のページ内検索を JavaScript から行える API が入りました.最近そのことを知ったので,今日昨日あたりでその機能を使って Shiba に Markdown プレビュー内の検索機能を実装しました.案外手こずってしまったのでまとめてみます.

f:id:rhysd:20160321232118g:plain

github.com

実装コード

今回 Shiba 向けに実装したコードは この diff が全てです.僕の説明よりも実際に動いているコードを見て理解したいという方はそちらを見てください.

ページ内検索 API 概要

WebContents オブジェクトに findInPage()stopFindInPage() の2つのメソッドが生えています.また,WebContents.on() で飛んでくるイベントの中に found-in-page というイベントが追加されています.使う API はコレで全てです.なお,<webview> にもほぼ同じ API が生えています.ですので,<webview> の中の検索をしたい場合はそれらを代わりに使います.今回は BrowserWindow 内の検索を実装しました.

実際に動くコードは上で示した diff を見ていただくとして,概要はこのようになっています.

const previous_text = '';
const webcontents = browser_window.webContents;
webcontents.on('found-in-page', (event, result) => {
    if (result.activeMatchOrdinal) {
        // マッチした箇所を覚えておく
        this.active = activeMatchOrdinal;
    }

    if (result.finalUpdate) {
        // M個のマッチ中 N 番目がアクティブな時,N/M という文字列をつくる
        this.result_string = `${this.active}/${result.matches}`;
    }
});

function search(text) {
    if (previous_text === text) {
        // 前回の検索時とテキストが変わっていないので次のマッチを検索
        webcontents.findInPage(text, {findNext: true});
    } else {
        // 検索開始
        previous_text = text;
        webcontents.findInPage(text);
    }
}

const input = document.querySelector('input');
input.addEventListener('keydown', event => {
    if (event.code === 'Enter') {
        search(input.value);
    }
})

const stop_button = document.querySelector('button');
stop_button.addEventListener('click', () => {
    // マッチした部分のハイライトを消して検索終了
    webcontents.stopFindInPage('clearSelection');
});

found-in-page イベントは1回の検索で2度飛んできます.

  • アクティブな(オレンジにハイライトされる)検索結果が見つかった時点で1回発火
    • この時 activeMatchOrdinalresult に入ってくる
  • ページ全体の検索が完了した時点で1回発火
    • この時マッチの総数を表す matchesresult に入ってくる

これによって JavaScript 側で検索結果の詳細を取れます.ちょっと stateful で使いにくいですが,使えなくはないです.マッチした箇所も result.selectionArea で取れます.

次に例えば <input> 要素にテキストを入力させて Enter キーで検索開始するとします.新規に検索する場合も前回の検索を継続する場合も findInPage() メソッドを使います.これもちょっと stateful です.でも前回の検索テキストを持っておけば上記のように Chrome っぽい挙動で検索が行えます.

最後に検索を終了したい場合は stopFindInPage() メソッドを使います.引数に渡す文字列で検索結果のハイライトを消すかどうかを決められます.

これで終われれば「面倒なページ内検索が Chrome の力を借りて一瞬で実装できる!」で終わったのですが,残念ながらそうはなりませんでした.

ページ内検索 API のハマりどころ

Chrome ではページ内検索はブラウザの UI として C++ で実装されています.ですが,Electron は Chromium のガワを取り払ったブラウザのため検索ウィンドウはありません.そこで JavaScript から触れるようにしようというのが上記 API でした. ですが,ここには1つ実は大きな問題があります.

上記のように,「検索窓」UI を自前で提供してやる必要があります.そこで,<input> タグ(Shiba の場合は Polymer を使っているのでデザインを揃えるために <paper-input>)を使って UI を HTML で書いていきます.いざ完成し,テキストを入れ Enter を押すと異変に気づきます.

最初の検索はうまくいき,最初のマッチがハイライトされますが,次の Enter を入力してもアクティブなマッチが次のマッチに移動しません.ここで僕は remote モジュール越しにレンダラプロセスから API を呼んでいたこともあり,違う箇所を疑いだしてハマってしまいました.

実際の挙動はこうでした.

  1. 最初の検索をする.
  2. ページ内検索はテキストエリアを含めた全ての文字列を検索するので,検索窓の中の <input> のテキストも含めて検索する
  3. 次の検索をしようとして検索窓にカーソルがある状態で Enter を押す.
  4. 検索窓内の <input> のテキストエリアの文字列が更新される(Enter 入力なので見かけ上は変更はない.が,内部的には更新される)
  5. ページ内のコンテンツが更新されると Chrome 組み込みの検索機能はページの最初から検索をやり直す
  6. 結果としてマッチは最初の場所から動かない(毎回 Enter 入力で検索がリセットされてしまう)

Electron はほぼ全ての UI を HTML/CSS でページ内に描画するため,入力フォームも HTML/CSS で書くのが自然です.ですが,ページ内検索機能は上記のように機能しなくなります.これを解決するにはフォームをページ外に持っていくしかありません(もっと良い方法あればぜひ教えて下さい).

さっと思いつく方法は次のどちらかです.

  • 新しい小さい検索窓用の BrowserWindow をつくり,そっちに検索ワードを入力させる
  • <webview> タグで検索窓をラップし埋め込む

どちらが良いかはアプリケーションによると思います.今回 Shiba では後者を採用しました.ウィンドウ右上の検索窓内の <paper-input><webview> でラップされた別のプロセスで動いています.よってページ内検索機能への影響がありません.

欠点としては <webview> を導入するとフォーカスの処理がやや煩雑になります.(ページ内検索をするとフォーカスが <input> から外れてしまうので <webview> の中の <input> にフォーカスを戻す必要がある)

ハマりどころ対応版の実装は JavaScript 部分では下記のようになりました.

本体側

const previous_text = '';
const webview = document.querySelector('webview');
webview.src = 'file:///path/to/search-box.html';
webview.on('ipc-message', event => {
    if (event.channel === 'search:start') {
        const text = event.args[0];
        if (previous_text === text) {
            remote.getCurrentWebContents().findInPage(text, {findNext: true});
        } else {
            remote.getCurrentWebContents().findInPage(text);
        }
    }
});

<webview> で読み込まれた HTML 側

const ipc = require('electron').ipcRenderer;
const input = document.querySelector('input');
input.addEventListener('keydown', event => {
    if (event.code === 'Enter') {
        // send() じゃないので注意!
        ipc.sendToHost('search:start', input.value);
    }
});

まとめ

冒頭にも書きましたが,思っていたより手こずってしまいました.しかし,一度分かってしまえば1時間足らずぐらいでページ内検索を実装してしまえそうです.お手軽感はあります.

飽くまで Chrome のページ内検索 APIJavaScript に露出させているだけです.なので,検索マッチのハイライトを変えられなかったりなど細かい制御は効きません.最初から React.js などの SPA 向けライブラリを使っている場合はどっちが良いか,アプリ次第になるかなと思います.