Integration #2

そういうわけで、設定のうち例外リストとなっていたものを site overrides とした。こんな感じで記述する:

http://example.org/* * block
http://example.net/*.html * writeas=p

ディレクティブとして有効な行は、3つの要素からなる。左から順に URL パターン、CSS セレクタ、アクション。まず URL パターンと CSS セレクタで要素を特定する。例外リストの段階では CSS セレクタは省略可能で、省略した場合ユニバーサルセレクタを指定したことになっていたが、省略不可能になった。サイトすべての編集可能要素を対象にしたい場合はユニバーサルセレクタを明示的に記述する必要がある。

最後の要素がアクションで、これが [cci]block[/cci] であった場合は、そのパターンに合致する要素上では wasavi の起動が抑制される。それ以外であった場合、set コマンドの引数として wasavi 起動時に評価される。つまり、exrc の最後に付加される。

 * * *

これとは直接は関係ないのだけど、バックグラウンド側での設定の持ち方を変えた。従来は各ブラウザの差異を吸収するような抽象的なクラスを経由していたが、単に chrome の API を直接呼ぶようにした。

Integration

wasavi のオプションに、writeas というものがあり、こういう仕様になっている。つまり、contentEditable な要素に対して wasavi のバッファを書き戻す際、どのような DOM の構造にするかを指定する。たとえば [cci]p[/cci] なら、各行を [cci]p[/cci] 要素として書き戻す。

しかし、当然ながら contentEditable な要素の内部構造をどのように保持しているかはサイトごとにまちまちであり、ひとつに決められるものではない。そこで、writeas には 連想配列を表す json 文字列を代入することも許している。こんな感じで:

set writeas='{ \
"http://example.com/*": "div", \
"http://example.net/*": [ \
{ \
"selector": "#any-css-selector", \
"writeas": "textAndBreak" \
} \
] \
}'

連想配列のキーが URL パターンで、値が writeas という形式だ。または、さらに連想配列の配列を入れ子にして、CSS セレクタを指定することもできる。これにより、つまりサイトごとに writeas の定義を分けることができる。

ただ……見てのとおり exrc 中に改行のエスケープしまくりで書かないといけないので、とても面倒くさい。また [cci]set all[/cci] した時の見栄えもとてもよくない。もっと洗練された形式で保持する必要がある。

ところで、サイトごとに定義を振り分けているものは wasavi はもうひとつ持っている。サイト自身が提供するスクリプトとコンフリクトするか何かでうまく動かない場合に、wasavi の起動をしないという例外リストである。これは普通のプレインテキストで

http://example.com/
http://exmaple.org/ #some-id

のように行ごとに URL を書き連ねる。URL のあとに任意の個数の空白を挟んで CSS セレクタを記述することもできる。これと writeas の定義を統合できないだろうか。つまり例外リストではなく per-site overrides という扱いにしたい。

override := URL-pattern (CSS-selector)? action
action := allow-actions
"block"
allow-actions := allow-action ("," allow-action)*
allow-action := "writeas" "=" writeas-value
writeas-value: "div" | "p" | "textAndBreak" | "plaintext" | "html"

という感じだ。

Mapping more modes #2

そういうわけでいろいろ変更している。

  • 特定のキー入力を別の入力に置き換えるために、最新の wasavi は以下の 3 種のマップを保持している:
    1. normal マップ: normal モード時に参照される
    2. bound マップ: bound/bound_line モード時に参照される
    3. input マップ: insert/overwrite モード時に参照される

    これ以外のモードにおいてはリマップはできない。これは技術的にできないのではなく、単に現状ではそういうふうにしていないというだけだ。

  • マップに対するルールの操作は、ex コマンド [cci]map[/cci]、および [cci]map![/cci] で行う。既定では、前者は normal と bound マップの両方を、後者は input マップを対象にする。どのマップを対象にするかは、後述のアトリビュートで変更できる。
  • [cci]map[/cci] [cci]map![/cci] は、最小で 0、最大で 2 つの引数を取ることができる。さらに、引数の前にアトリビュートを置くことができる。
  • アトリビュートは、先頭が [cci][[/cci]、末尾が [cci]][/cci] であるカンマ区切りの文字列である。アトリビュートのコンポーネントとして有効なものは、上記のマップ名か、もしくは [cci]clear[/cci] [cci]final[/cci] [cci]noremap[/cci] のいずれかである。
  • アトリビュートにマップ名が含まれる場合、デフォルトマップの代わりにそのマップが選択される。
  • アトリビュートに [cci]clear[/cci] が含まれる場合、選択されたマップに定義されたルールをすべて削除する。引数は使用されない。
  • 引数が 0 個の場合、選択されたマップに定義されたルールをすべて表示する。
  • 引数が 1 個の場合、lhs に引数 1 が部分的にマッチするルールをすべて表示する。
  • 引数が 2 個の場合、引数 1 を lhs、引数 2 を rhs として、選択されたマップ全てに対してルールを登録する。この際、アトリビュートに
    [cci]final[/cci] または [cci]noremap[/cci] が含まれる場合は、再帰展開しないマップとして登録される。

 * * *

ところでここまで修正したのを Selenium でテストしようと思ったら、何やらおかしい。wasavi を起動させたあと、キー入力が行われない。つまり textarea に対する sendKeys() は動くのだが、wasavi 本体の iframe への sendKeys() が動作しないようだ。

以下の chromium の issue が関係しているかもしれない:
https://bugs.chromium.org/p/chromedriver/issues/detail?id=1777
https://bugs.chromium.org/p/chromedriver/issues/detail?id=1819

Mapping more modes

wasavi の issue として、bound/bound_line に normal ではない個別のマッピングを行いたい、というものがある。

現状では、normal/bound/bound_line は MapManager 内部の command マップに収斂、insert/overwrite は edit マップに収斂、それ以外のモードはマップ不可、という形になっている。したがって、要件に応えるには、単に bound/bound_line 用の個別のマップを設けるだけでいい。

ただ、インターフェースの問題がある。ex コマンド [cci]map[/cci] は command マップを操作する。一方 [cci]map![/cci] は edit マップを操作する。[cci]![/cci] の有無の二者択一のため、第3のマップが入り込む余地がない。

vim では、これを nmap/vmap/xmap/smap/omap/imap/lmap/cmap… とむやみに ex コマンドを増やすことで対処している。さらに再帰的なマップ展開をしない版である nnoremap/vnoremap/xnoremap/snoremap/onoremap/inoremap/lnoremap/cnoremap… という群もあり、総数としてはかなりのものになっている。

これ、果たしてわかりやすい仕様なんだろうか。だいたいなんなの onoremap とか inoremap って。初見じゃあ己マップと祈れマップとしか読めないよ。何をするコマンドなのかさっぱりわからない。

どうもこんな感じに ex コマンドをホイホイ新設するのは良くない設計に思える。加えて自己記述性の低いコマンド名はもっと良くない。

そんなわけで、wasavi では noremap かどうかを指定するのは map コマンドに与えるアトリビュートという形式にしてある。アトリビュートとはつまり
:map [noremap] gh ^
などと LHS の前に角括弧つきで指定する部分のことだ。ちなみに noremap というアトリビュート名もなんかいまいちなので、例えば final にしようかと考えている。

で、定義先のマップもアトリビュートで指定するようにしてはどうだろうか。例えば
:map [bound,final]
みたいな感じ。map コマンドを投入してかつアトリビュートでマップを指定しなかった場合に選択されるデフォルトのマップは normal と bound の和集合になる。一方、map! コマンドの場合は対象は edit マップ。

また、引数なしで map/map! コマンドを投入した場合は現在定義されているマップを表示するが、その場合には対象となるマップも表示したほうがいいかもしれない。

Screen Casting

wasavi の Readme の最初の画像2つは、素の textarea と、それに対して wasavi を起動した状態の対となるものにしてある。

ところで Twitter でたまに wasavi について tweet される際、それは最初の画像を添付してあったりする。前述の通り最初の画像は単に素の textarea の画像なので、wasavi についての説明になっていない。例えば:

これを解消するために、2つの画像をまとめ、スクリーンキャストによる gif アニメーションをこさえたい。素の textarea から wasavi を起動し、編集し、書き込んで終了するまでを録画すればいい。というわけで、gtk-recordmydesktop をインストールし、録画し、ogg theora 動画を生成した。それを適当なオンラインのコンバータで gif に落とした。

gif に落とす際のオプションとかはなかったのでディザが目立つが、まあこんなもんだろう!

* * *

適当なオンラインのコンバータに頼るのはやめ、ogv → gif 変換はローカルで ffmpeg により行うようにした。見苦しいディザは追放した、はず。

Opening an options page

wasavi のオプションページというものを開くために、manifest.json に options_page キーを含めているが、実はこれは旧式である。現行は options_ui キーになっている。options_ui でどのようにオプションページを開くかどうかについてさらに新旧があり、古い方はオプションページをタブで開き、新しい方は chrome://extensions ページにオーバーレイする形で開く。これは TabQueue などの他の Chrome エクステンションでも出てきた話題だ。

さて wasavi を WebExtensions で動かした場合、options_page キーを認識しないようだ。そこで options_ui の、旧式の方に移行した。旧式の方は公式に obsolete であり、今にも削除されそうな勢いである…ということは、ちょっと気にかけておかないといけない。

WebExtensions で動かした場合、options_ui 旧式の場合は、拡張の詳細ページにオプションページを開くボタンが追加される。新式の場合は、オーバーレイではなく、拡張の詳細ページにオプションページが埋め込まれた形で表示される。へー。

Chrome では個々のエクステンションの設定をエクステンション一覧の特別な、モーダルな状態として捉えているが、Firefox ではエクステンション一覧から遷移する独立したページとして捉えている。どちらかというと Firefox の設計のほうが優れてるように思える。ブラウザの設定も昨今の多くのブラウザでは Web ページの1つとして扱われている。であればやはり様々なページを遷移する UI であることを強く意識して設計すべきであり、オーバーレイというモーダルな状態に安易に頼るべきでない。

Reloading extensions

ブラウザのエクステンションの開発における基本的なサイクルは、ソースを書き換えて、エクステンションをリロードし、実行結果を確認する…というものだ。このサイクルを延々と繰り返す。したがってサイクルを構成する基本的な要素をちょっと最適化するだけでも、その分だけ開発は快適になる。この内、エクステンションのリロードについて考えてみたい。

かつて、Presto Opera が wasavi のファーストクラスブラウザだった頃は、リロードプロセスは実は最も洗練されていた。というのは、ページを普通にリロードすれば、ページにアタッチされている拡張の injected script 群も自動的にリロードされたからだ。賢すぎる。もちろんバックグラウンド側に修正を入れた場合はエクステンション管理ページから wasavi 全体をリロードする必要はあったものの、そういうケースはそんなにあるわけではないので、フロントエンド側がスマートにリロードされるだけで十分快適なのだった。

しかし、それはもう過去の話。現在はどうかというと、wasavi はまず Chrome で動かしてそれから Firefox や Blink Opera で動作確認という流れになっているのだが、しかしとても残念なことにいずれのブラウザも Presto Opera ほど賢くない。content script を書き換えたとしても必ずエクステンション管理ページから手動でリロードしないといけない。とても面倒くさい。とてつもなく面倒くさい。

そういうわけで、wasavi 自身に reload コマンドを実装してある。これを投入すると、メッセージがバックグラウンドとエージェントの両方に送信される。バックグラウンド側では自身を [cci]chrome.runtime.reload()[/cci] によりリロードする。エージェント側ではメッセージの着信後1秒待ってページをリロードする。これでリロードの面倒臭さがかなり解消される。

ところで、Firefox の WebExtensions ベースのエクステンションを開発するための web-ext というツールがあるのだが、これを経由して Firefox を起動するとエクステンションのソースディレクトリを監視して、いずれかのファイルが更新されたら自動的にエクステンションをリロードするという機能を持っている。これはこれで便利なのだが、最新バージョンだとバグってて動かないという…。[cci]–no-reload[/cci] オプションをつけないと起動しない。安心と信頼の Mozilla クオリティ。

Move by wrapped line

Chrome と Gecko が持っている document.getSelection().modify() というメソッドがある。これは、編集可能な要素上のカーソルを任意の位置へ移動させる機能を持っている。上下左右のカーソルキー、HOME キー、END キー、およびそれらを SHIFT 併用で押下した場合の動作をコードから行わせることができる。カーソル移動の単位は文字、ワード、折り返し行の先頭及び末尾…などがある。

さて、wasavi は [cci]gj[/cci]、[cci]gk[/cci] で物理的な行単位ではなく、表示上の折り返された行ごとにカーソルを上下させる機能があるのだが、従来この機能はかなりめんどくさい複雑なコードで実現していた。あまりに複雑すぎてバグがあってもおいそれと手を入れられないレベルである。実際 issue がいくつか来ているのだが、そういうわけでどうしたものか困っていた。

そこで、上記の modify() を使ってシンプルに書き直した。なんで最初からそうしなかったのかといえば、modify() は Presto Opera には実装されていないからだ(たぶん)。しかしもう Presto Opera 対応は終了したので、これを期に使うようにしたということだ。このほか、let や template literal、arrow function、そしてもちろん Promise や Generator なんかもぼちぼち使い始めている。

WebExtensions and clipboard operation

Chrome の拡張でクリップボードを読み書きするには、manifest.json の permissions に clipboardRead/clipboardWrite を追加した上で

  • 読み出す場合: バックグラウンドで適当な textarea に対して focus() し、document.execCommand(‘paste’) する。適当な textarea のその内容がクリップボードの内容で埋められる
  • 書き出す場合: バックグラウンドで適当な textarea に対して focus() し、内容を全選択した後 document.execCommand(‘copy’) する
  • つまり操作の実体はバックグラウンドになる。コンテントスクリプト側で読み書きの必要が発生したとしたら、バックグラウンドにメッセージを投げ、戻ってくるのを待つことになる。

    これが、WebExtension だとどうなるのかというと、ドキュメント自体は提供されているのだが、それによるとなんかどういう意図なのか知らないが、かなり Chrome の流儀と違う。

    まず short-lived event handler なる独自の概念が出てくる。これはつまり、ユーザーにより発生したイベント、のことらしい。しかしそれなら interactive event handler とか、あるいはそのまんま user generated event handler などと呼ぶべきものではないのか? 微妙によくわからない。何がどう short-lived なの? 意味がわからない。またもうひとつ独自の仕様があり、WebExtensions ではバックグラウンドスクリプト側でのクリップボードへの書き込みはできない。従って、コンテントスクリプト側で適当な textarea 要素に対して execCommand() しないといけない。つまり、Chrome と正反対なのである。

    で、この short-lived event handler 内であれば、clipboardWrite 権限なしにクリップボードへの書き込みは可能である。一方 short-lived ではないもの、つまりタイマによって起動された処理など、あるいはユーザーにより発生したイベント内で生成された Promise の一連の連鎖内も当てはまると思うが、そういう処理からはクリップボードへ書き込みを行うには clipboardWrite 権限を manifest.json に記述することが必要。

    ここまではドキュメントにそう書いてある通りで、その通りに wasavi を修正したならば、その通りに動作した。困るのはクリップボードからの読み出しなのであった。どうもまず、ドキュメントが言葉足らずで微妙に言いたいことがよくわからない。ドキュメントから読み取れるのは:

    • short-lived event handler 内で実行されるか否かに関係なく、常に clipboardRead 権限が必要
    • 読み込みも基本的にコンテントスクリプト側で処理?(サンプルが、適当な要素のクリックイベントを使用しているので)
    • 対象の要素は contentEditable モードでないといけない
    • コンテントスクリプト側で実行する場合、現状では textarea 要素のみに対応
    • バックグラウンド側ではいずれの要素も contentEditable モードにすることができる(できるから、何?)

    そもそもバックグラウンド側で実行できるのかどうかが明示されておらずよくわからない。クリップボードへの書き込みがバックグラウンド側では不可というのは、つまりバックグラウンドでは要素にフォーカスを当てるということができないからだそうだが、それなら読み込みも不可なんじゃないのか? しかし不可と明示されてはいないのである。わからん、ぜんぜんわからん。

    いろいろ試行錯誤してみたところ、以下のような手順でクリップボードの内容を取得することができた。

    • コンテントスクリプト側で実行する
    • ドキュメントに適当な textarea 要素を追加する。この要素のスタイルを [cci]display:none[/cci] とかにはできない。フォーカスを当てる必要があるので。その代わりにスクリーン外の適当な場所に追いやる必要がある
    • textarea 要素は contentEditable 属性を true にしておく必要がある。そもそも最初から編集可能なのに、なんでこの属性が必要なのか意味不明
    • textarea にフォーカスし、document.execCommand(‘paste’) を実行する
    • しかし、Chrome と違い、textarea の内容がクリップボードの内容で埋められたりは「しない」。なんと、なんと、その代わりに document に対して paste イベントが発生する。従って、execCommand 呼び出しの前に paste イベントハンドラを追加し、その中でクリップボードの内容を取り出しておく必要がある
    • なお paste イベントは、同期的に発生する

    どうも execCommand() により paste イベントが発生するという点がミソのようだ。その点を以ってバックグラウンドでも動作可能と匂わせているのかもしれない。ということは、コンテントスクリプト側で動かす際も textarea 要素に focus() する必要はないのか? そこまでは試してない。

    とりあえず、こんな感じのコードになる。

    function getClipboard () {
    let s = '';
    function handlePaste (e) {
    s = e.clipboardData.getData('text/plain');
    }
    let buffer = $('id_of_any_textarea');
    buffer.contentEditable = true;
    buffer.value = '';
    buffer.focus();
    document.addEventListener('paste', handlePaste, false);
    document.execCommand('paste');
    document.removeEventListener('paste', handlePaste, false);
    return s;
    }

    もちろん、すでに paste イベントを他の場所でハンドリングしている場合は、そのハンドラは一旦取り外し、事が済んだ後に再度追加する…的な小細工は必要。また、これはあくまで現状での動作なので、時が過ぎればころっと変えられる恐れは十二分にある。

    あの、なんで、こんなにも Chrome の流儀と全然違うデザインにしたんですか? WebExtensions 自体が Chrome の拡張に恐ろしく馴れ馴れしく擦り寄った代物であるのに、部分的に見るとまるで作法が異なるというのはどういう意図があるのかいまいち…というか、さっぱりわからない。技術的に何か制限があってこうなっているのならわからなくもないが、そうではないとしたら一言「アホじゃないの?」としか言いようがない。

visual in vi

エラーではないが、ユーザに何か通知すべき事柄が発生する場合がある。例えばカーソルが行頭にいる時に [cci]h[/cci] を押したとか。

そういう状況では、vi はビープ音を鳴らす。これが「ビービー鳴らすモード」などと揶揄されているわけだが、この動作は errorbells オプションで制御することができる。これをオフにするとビープ音は鳴らない。

ただこれだと、「ビービー鳴らすのはうっとうしいが通知があったことは知りたい」という要件に応えられない。そういうわけで、visualbell オプションを新設した。[cci]:set errorbells visualbell[/cci] の状態ではビープ音の代わりに wasavi の画面がフラッシュする。