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 向けライブラリを使っている場合はどっちが良いか,アプリ次第になるかなと思います.

Issue と PR のテンプレートジェネレータつくった

screenshot

出張の帰りのフライトが10時間以上あったので簡単なツールをつくってみました.

余談ですが Windows PC しかなかったので,Windows PC + golang + gVim で書いてみました.Go 言語は標準ライブラリだけでもぐりぐり書けてなかなか良いですね.久々の Windows でのコーディングでしたが特に問題ありませんでした.

GitHub の issue/PR テンプレート機能

最近 issue / PR のテンプレート機能が実装されたみたいです.リポジトリ直下か .github ディレクトリのどちらかに ISSUE_TEMPLATE.md および PULL_REQUEST_TEMPLATE.md を置いておくと,issue や PR 作成時にそのテンプレートが入力フォームにデフォルトでセットされます.

Issue and Pull Request templates - GitHub Blog

また,以前からある CONTRIBUTING.md.github ディレクトリの中に置けるようです.

.github ディレクトリを生成するコマンド dot-github

github.com

前々からほしいなーと思っていた機能だったのでさっそく使いたいところですが,毎度似たようなテンプレートを各リポジトリにつくるのも手間だなぁということでテンプレートジェネレータをつくりました.これで元になるファイルだけ dotfiles なりで管理しておけば,他の PC などからでも一発でテンプレートが生成できます.

下記のように go get するとインストールできると思います.

$ go get github.com/rhysd/dot-github

テンプレートは ~/.github 内に置きます.他の場所に置きたいときは $DOT_GITHUB_HOME 環境変数をセットすることで置き場所を変更できます.

~/.github 内に置けるテンプレート生成ファイルは次の通りです.ファイルが存在しない場合は単純に生成をスキップします.

ファイル名 説明
ISSUE_TEMPLATE.md Issue 用に使われるテンプレート
PULL_REQUEST_TEMPLATE.md PR 用に使われるテンプレート
ISSUE_AND_PULL_REQUEST_TEMPLATE.md Issue および PR で上記のテンプレートが無い場合に使われる共通のテンプレート
CONTRIBUTING.md コントリビュートガイドライン用のテンプレート

dot-github コマンドによって上記のテンプレートファイルからリポジトリ内に .github が自動生成されます.

$ cd your-repo
$ dot-github

.github 内に生成されるファイルは Go 言語標準の text/template テンプレートを使ってテンプレート生成ファイルから生成されます.

Package template - The Go Programming Language

標準の機能に加えて,下記の変数が使用可能です.

変数名 説明
.IsIssue boolean ISSUE_TEMPLATE.md として展開される時に True
.IsPullRequest boolean PULL_REQUEST_TEMPLATE.md として展開される時に True
.IsContributing boolean CONTRIBUTING.md として展開される時に True
.RepoName string リポジトリ
.RepoUser string リポジトリ所持者の名前

text/template{{if}} などを使って重複を省いて issue と PR 共用のテンプレートを書いても良いですし,最初から別々にテンプレートを書いても良いです.

生成例

使用するテンプレート

  • ~/.github/ISSUE_AND_PULL_REQUEST_TEMPLATE.md
{{if .IsIssue}}
### Expected Behavior


### Actual Behavior


{{end}}
{{if .IsPullRequest}}
### Fix or Enhancement?


- [ ] All tests passed
{{end}}

### Environment
- OS: Write here
- Go version: Write here
  • ~/.github/CONTRIBUTING.md
Thank you for contributing to {{.RepoName}}!
=========================================

Please follow issue/PR template.

生成されたファイル

  • /path/to/your-repo/.github/ISSUE_TEMPLATE.md
### Expected Behavior


### Actual Behavior


### Environment
- OS: Write here
- Go version: Write here
  • /path/to/your-repo/.github/PULL_REQUEST_TEMPLATE.md
### Fix or Enhancement?


- [ ] All tests passed

### Environment
- OS: Write here
- Go version: Write here
  • /path/to/your-repo/.github/CONTRIBUTING.md
Thank you for contributing to my-project!
=========================================

Please follow issue/PR template.

まとめ

GitHub に追加された issue / PR テンプレート機能をさっと使えるようにジェネレータをつくってみました.Go 言語の流儀に則ってちゃんとテストも書いてみたので,僕みたいにリポジトリつくりまくって毎度わざわざテンプレート書くのがめんどいみたいな人はぜひともお試しください.

yak shaving 気味ですが,勢いがあったのでつくってしまいました.新幹線内とか飛行機内だと割と手が動きやすいのはどうしてなんでしょうね…

2015年を振り返る

2015年の自分向けまとめです.

まとめ

目標は達成できたかどうか分からないといけないと思っているので,2015年の趣味での目標は 「とりあえず10万行書くか」 だったんですが,測るのが面倒なので途中から「3000 contributions する」 になりました.(自分の場合は1コミット大体40行ぐらいが多いので,3000 あればまあ10万行行っとるやろという魂胆です)GitHub は仕事で使ってないので,GitHub のプロフィールページを見れば一発で達成状況が分かるという寸法です.

結果はというと…

f:id:rhysd:20151231211127p:plain

でどうにかギリギリ達成できました.来年はもっとパワフルに開発できる年にしたいです.

そんなわけで今年も割と色々書いた気がします.せっかくなので振り返ってみます.

前半

今年の前半は言語処理系まわりを色々触ってました.既存の言語の処理系を実装してみるのも間違いなく楽しいですが,自分で言語のデザインを考えてみるのもプログラミング自体を処理系の側の視点から見ることができてかなり楽しいです.もちろん実装も面白いです.

Dachs

犬言語.一応まだ死んでないつもりです.

Ruby テイストな構文ですがクラスのインスタンスに対するメソッド呼び出しは UFCS による構文糖衣で,全て関数です.関数型言語周りの機能と親和性を良くしたいなというのが狙いだったりします.変数はデフォルトで const で,const な変数のみ参照で渡せるようになっています.基本的に型はほとんど書かず頑張って推定(deduction)します.

今年の前半までは継続してつくっていて,今年は generic function type とかコピーやキャストの挙動を定義できるようにしたり,しょぼいモジュールの初期実装を入れたり,Boehm GC を乗っけたりしました.

ただ,やはりパーサのビルド時間がつらいのでそこをどうにかしないといけない感じです.犬言語では AST のノードの定義に boost::variant を使っていて,末端のノードは普通の struct ですが,例えば式のノードは using Expr = boost::variant<Literal, BinaryExpr, CallExpr, ...> のような実装になっています.これと Boost.Spirit V2 が合わさって型がめちゃくちゃ巨大になるのが一番の原因な気がしてます.別言語に乗り換えを試みるのも悪くなさそうです.

特に焦ることもないので,どういう言語機能があるとどうして自分のコーディングが楽しくなるのかとかそういったことを考えながら引き続き楽しんでやっていきたいです.

Crisp

Make a Lisp をベースにした Lisp の方言の処理系です.Crystal で書きました.動的言語の言語機能のアイデアを試すのに使えたらなーと思ってつくったので,特に実用は考えてませんが,Crystal と何かうまく連携できると良いなぁと思ってます.(が,現状思っているだけです…)

Make a Lisp で Lisp 処理系を学んでつくる (with Crystal)

後半

今年の後半はほぼ Electron アプリを JavaScript とか TypeScript で書いていた気がします.JavaScript はこれまでその場しのぎのコードをちょっと書いたことぐらいしかなかったですが,せっかくなので ES2015 ネイティブ(!)で勉強しました.TypeScript も JavaScript でありがちなケアレスミスを拾ってくれたり程良い感じがして結構気に入ってます.

NyaoVim

NyaoVim logo

Web Component で UI を拡張できる NeoVim フロントエンドです.以前は拡張するのが難しかった UI を HTML/CSS, JavaScript, Node.js, Electron API などを使って拡張する仕組みが用意されています.Neovim 本体も <neovim-editor> というコンポーネントの1つとして実装されています. まだなかなか基本機能が安定しなかったりしますが,継続して開発していきたいと考えています.

サンプルプラグインもいくつか書いてみました.

僕は基本的にはコマンドライン大好きなので Vimコマンドラインで使ってます.僕にとって GUIVimコマンドラインVim とは用途が違っていて,GUI のエディタはもっと強力な UI の表現力を持っていても良いのかなと思ったのが最初でした.

また,このフロントエンド実装は Proof of Concept な側面があると思っていて,例えば補完のインターフェースとして(既存の一覧表示ではなく)もっと良いアイデアが思いついた時に,それを簡単に試す事ができます.(例えば Vim 側で補完の候補一覧だけ生成して UI 側に渡し,UI 側でユーザからの入力を受け付けて結果を Neovim 側に戻すなど).実際に動く形で自分のアイデアが簡単に実装できるというのは大きいんじゃないかなと思っていて,僕自身も色々試してみたいと思ってます.

Web Components と Electron でつくる Neovim フロントエンドの未来

Shiba

shiba logo

マークダウンプレビューアプリです.管理しているパス(ディレクトリ/ファイル)のファイル保存を検知して自動でプレビューを更新します.Electron + Polymer でつくりましたが,色々実装が良くないところもあるのでぼちぼち作り直したいと思ってます.

  • タブを付けて複数パスを監視できるようにする
  • Markdown -> HTML の変換のパフォーマンスアップ
  • コンポーネント追加でプレビュー拡張(例えば新しいフォーマットをプレビューの対象に加えられるようにしたりとか)

Electron と Polymer と TypeScript でリッチなマークダウンプレビュアー Shiba つくった

Trendy

Trendy logo

GitHub のトレンドリポジトリのページを監視して新規リポジトリを通知したり管理したりできます.メニューバーにアイコンを置いて,通知があるときはアイコンの色を変えることで通知します.メニューバーからアクセスできるので,気になった時にさっと見ることができます.

GitHub のトレンドリポジトリを見逃さない,Trendy をつくりました

Tilectron

Tilectron logo

ウィンドウ分割可能なブラウザです.タイル型のウィンドウ管理で広いディスプレイを活用しつつ,スクロールやカーソル移動などすべての操作をキーボードのみでやることが目的です.すでにポインタ動かしたりクリックを入力したりとかは技術的にはできるのはわかってるんですが,なかなか手を付けられていない状態です…

ぼちぼち進めて行きたい.

発表

東京 Node 学園祭,VimConf 2015,Effective Modern C++ 読書会で発表させてもらいました.

  • 東京 Node 学園祭

東京Node学園祭 2015 で Electron について話した

  • VimConf 2015

vimconf でブラウザ上で Vim を使う方法を発表してきた

  • Effective Modern C++読書会

原著を一通り読んで合計 4 items 分ぐらい発表させてもらいました.C++11 や C++14 時代に必須な内容もあったりでおすすめできる内容でした.邦訳も出ました.

その他つくったものリスト

その他にも色々つくったりした気がします.結構つくって忘れているものもあるので,「こんなのもあったなー」と思い出すのもかねてリストアップ.(完全につくりかけは除く)