NaClの前田です。
ブラウザ上でのテキストの編集は、カーソルを左に移動させようとCtrl+b
を押したらMarkdownのマークアップが挿入されたり^1、苦痛の多いものです。
テキストエディタでブラウザ上のテキストを編集できるGhostTextというブラウザ拡張がありますので、私の作っているTextbringerというテキストエディタ用のプラグインを作成してみました。
GhostTextではブラウザとテキストエディタがHTTPで通信し、テキストの同期を双方向で行う仕組みになっています。テキストエディタがサーバ、ブラウザがクライアントです。
ただし、以下のように設定情報を返す通常のHTTP接続とテキストの同期を行うWebSocket接続が分かれています。
Web Browser Text Editor (normal HTTP WebSocket)
| | |
| GET / | |
|--------------------------->| |
| | |
| {"WebSocketPort":1234,...} | |
|<---------------------------| |
| | |
| {"text":"hello world",...} | |
|----------------------------|--------------------------->|
| | |
| | {"text":"hello",...} |
|<---------------------------|----------------------------|
| | |
| | {"text":"hell",...} |
|<---------------------------|----------------------------|
| | |
FTPの通信がコントロールコネクションとデータコククションに分かれているのに似ていますね。
ブラウザ上でGhostTextのボタンを押すと、デフォルトの設定ではまずhttp://localhost:4001
にGETでアクセスし、テキストエディタは以下のようなレスポンスを返します。
HTTP/1.1 200 OK
Content-Type: application/json
{
"WebSocketPort":1234,
"ProtocolVersion":1
}
WebSocketPortはテキストの同期を行うWebSocketサーバのポート番号、ProtocolVersionはGhostTextのプロトコルバージョン(2017年7月8日現在では1)です。
ブラウザは上記のレスポンスを受け取ると、 ws://localhost:<WebSocketPort>
にWebSocketで接続し、以下のようなメッセージがテキストエディタに送信されます。
{
"text": "hello world",
"selections": [{"start": 0, "end": 0}],
"title": "test",
"url": "example.com",
"syntax": ""
}
textは編集対象のテキスト、selectionsは選択されたテキストの開始・終了位置の配列、titleはページのタイトル、urlはページのURLのホスト部、syntaxは推測されたテキストの文法です。
上記のメッセージはブラウザ上でテキストが更新される度にテキストエディタに送信され、テキストエディタ上で表示されます。
また、テキストエディタ上でテキストが更新されると、テキストエディタは以下のようなメッセージをブラウザに送信します。
{
"text": "hello",
"selections": [{"start": 5, "end": 5}]
}
ブラウザは上記のメッセージを受け取ると、ブラウザ上のテキストや選択状態を更新します。
なお、認証の仕組みはありませんので、シングルユーザ環境でbindするアドレスをループバックアドレスにするような使い方が想定されているようです。
では実装してみましょう。コード全体はGitHubに置いてあります。
GhostTextサーバを起動するghost_text_startというコマンドを定義します。
define_command(:ghost_text_start,
doc: "Start GhostText server") do
host = CONFIG[:ghost_text_host]
port = CONFIG[:ghost_text_port]
message("Start GhostText server: http://#{host}:#{port}")
background do
thin = Rack::Handler.get("thin")
app = Rack::ContentLength.new(Textbringer::GhostText::Server.new)
thin.run(app, Host: host, Port: port) do |server|
server.silent = true
end
end
end
最初はpumaを使ってみようと思いましたが、標準出力をつぶすのが面倒だったのでThinを使うことにしました。
define_command
は引数で指定された名前のコマンドを定義します。doc:
はM-x describe_command
で表示するためのヘルプメッセージを指定します。
background
はブロックをバックグラウンドで実行します。バックグラウンド処理はスレッドを使用して実装されていて、例外がブロック内で補足されなかった場合はメインスレッドでエラーメッセージが表示されます。
background
のブロック内ではTextbringer::GhostText::ServerというRackアプリケーションをThin上で実行します。
module Textbringer
module GhostText
class Server
def call(env)
if Faye::WebSocket.websocket?(env)
accept_client(env)
else
json = {
"WebSocketPort" => CONFIG[:ghost_text_port],
"ProtocolVersion" => 1
}.to_json
[200, {'Content-Type' => 'application/json'}, [json]]
end
end
Textbringer::GhostText::ServerがRackアプリケーションです。
call
ではまずWebSocket接続の場合はaccpet_client
を呼び出し、そうでない場合はWebSocketサーバのポート番号を返します。
この実装では同じサーバでWebSocketの接続も処理するので、自分自身のポート番号を返しています。
def accept_client(env)
ws = Faye::WebSocket.new(env, nil,
ping: CONFIG[:ghost_text_ping_interval])
next_tick! do
setup_buffer(ws)
end
ws.rack_response
end
WebSocketの実装にfaye-websocketを使っているため、accept_client
はFaye::WebSocketを生成してws.rack_response
を返しています。
接続を維持するため、ping:
でWebSocketのpingを送信する間隔を指定しています。
next_tick!
はTextbringerが提供するメソッドで、ブロックをTextbringerのメインスレッド上で実行し、その終了を待ちます。
Textbringerではバッファ操作やコマンド実行をメインスレッド上で行う必要があるため、このような機能を提供しています。^2
!
が付かないnext_tick
だと終了を待たずにすぐに返って来ますが、ws
に対してイベントハンドラを設定してからレスポンスを返したいので、ここではnext_tick!
の方を使っています。
def setup_buffer(ws)
buffer = Buffer.new_buffer("*GhostText*")
switch_to_buffer(buffer)
...
end
setup_buffer
では新しいバッファを生成してそのバッファを表示し、後述するようなイベントハンドラの設定を行います。
ブラウザからテキストエディタへの同期を行うため、ws.on :message
でWebSocketでメッセージを受信した際のイベントハンドラを設定します。
syncing_from_remote_text = false
ws.on :message do |event|
data = JSON.parse(event.data)
next_tick do
syncing_from_remote_text = true
begin
buffer.replace(data["text"])
if pos = data["selections"]&.dig(0, "start")
byte_pos = data["text"][0, pos].bytesize
buffer.goto_char(byte_pos)
end
ensure
syncing_from_remote_text = false
end
if (title = data['title']) && !title.empty?
buffer.name = "*GhostText:#{title}*"
end
switch_to_buffer(buffer)
end
end
イベントハンドラはWebサーバのスレッドで実行されるので、ここでもnext_tick
を使う必要があります。今度は終了を待たなくてもよいので、!
なしのバージョンです。
バッファの内容とカーソル位置(Textbringerにはテキストの選択状態という概念はありません)を設定していますが、bytesize
を使っているのはGhostTextでの位置は文字単位なのに対し、Textbringerではバッファ上の位置をバイトで表すためです。
syncing_from_remote_text
については後述しますが、ブラウザ上のテキストの同期中はバッファが更新されてもブラウザにメッセージが送信されないようにするためのものです。
タイトルが空でない場合はバッファ名も更新して、そのバッファを表示するようにしています。
テキストエディタからブラウザへの同期を行うため、buffer.on :modified
でバッファが更新された際のイベントハンドラを設定します。
buffer.on :modified do
unless syncing_from_remote_text
pos = buffer.substring(0, buffer.point).size
data = {
"text" => buffer.to_s,
"selections" => [{ "start" => pos, "end" => pos }]
}
ws&.send(data.to_json)
end
end
ここで先ほどのsyncing_from_remote_text
を参照し、ブラウザからの同期中は何もしないようにしています。
カーソル位置を今度はバイト単位から文字単位にした上で、バッファ全体の文字列とともに送信しています。
ws&.send
のように&.
を使っているのは、後述するようにWebSocket接続の切断時にws
がnil
に設定されるようにしているためです。
WebSocket接続が切断された場合(ブラウザのリロードをしたり、他のページに遷移すると切断されます)、ws
をnil
に設定し、バッファをkillします。
通常はバッファの修正がファイルに保存されていないと警告が出ますが、force: true
を指定しているため強制的にkillします。
ws.on :close do |event|
ws = nil
next_tick do
kill_buffer(buffer, force: true)
end
end
また、バッファがkillされた場合はWebSocket接続を切断します。
buffer.on :killed do
ws&.close
end
ここでも&.
を使っているため、WebSocket接続がすでに切断されていた場合は何もしません。
このようにTextbringerはRubyで拡張できるため、GhostTextプライグインのようなものを簡単に作成することができます。
GhostText自体はとてもシンプルなものですので、他のテキストエディタで実装することも難しくないと思います。ぜひあなたのテキストエディタでも実装してみてください。