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接続の切断時にwsnilに設定されるようにしているためです。

終了処理

WebSocket接続が切断された場合(ブラウザのリロードをしたり、他のページに遷移すると切断されます)、wsnilに設定し、バッファを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自体はとてもシンプルなものですので、他のテキストエディタで実装することも難しくないと思います。ぜひあなたのテキストエディタでも実装してみてください。


  1. 宗派に応じて「カーソルを左に移動させようとhを押したらhが挿入されたり」のように適宜読み替えてください。 

  2. ちなみに他のスレッドでそういった操作を実行してもエラーにはならず、何かが壊れたり壊れなかったりします。