Help us understand the problem. What is going on with this article?

C#でテキストエディタエンジンをフルスクラッチで実装してWebAssemblyで動かした話

デモ

以下のリンクから試せます。

環境によっては表示まで数秒かかります。二回目以降はキャッシュから読み込まれるので、表示に時間はかかりません。

キャッシュはブラウザの開発ツールから削除可能です。以下はChromeの場合。

  • 開発ツール > Applicationタブ > Cache > Cache Storage

※デモを何度か上げなおしているので、エラーになる場合にはキャッシュを削除してみてください

動作環境

WebAssemblyに対応したブラウザが必要ですが、最近のブラウザであればまず問題ないです。

  • 自分で確認した環境
    • Windows 10 Pro, Chrome/新Edge/Firefox
    • Android 10, Chrome(表示と文字入力のみ確認)

誰向けの記事か

以下に興味がある人。

  • テキストエディタ/テキストエディタエンジンの実装
  • MVPアーキテクチャ(監視コントローラー式)
  • 差分管理によるUndo/Redo
  • IME制御
  • メモリ共有を使用したBlazor WebAssembly/JavaScript相互運用

以下が知りたい人はこの記事はあまり参考にはならないです。

  • Blazorでの一般的なSPA開発の情報がほしい人(今回、Razorを使ったUI制御は行っていません)

何を作ったか

正確にはマルチプラットフォーム対応のテキストエディタエンジンをC#でフルスクラッチで実装し、そのエンジンを使用するサンプルアプリケーションとして、

  • Blazor WebAssemblyアプリ
  • WinFormsアプリ

を作成しました。まだ作りかけですが、一通り動くようになったので公開しました。

タイトルに「WebAssemblyで動かした」とえらそうに書いてしまいましたが、これはBlazor WebAssemblyで簡単に実現できます。ただ、ブラウザでテキストエディタとして動かすにはJavaScript側とやりとりする部分に工夫が必要で、そういった点は参考になると思います。

免責

  • 成果物は実用目的ではなく研究開発目的なので、基本的な機能しか実装していません
  • 既存ライブラリは使用せず、既存のテキストエディタの実装を参考にしたわけでもないので、定石を外している部分があるかもしれません
  • ASP/Blazor/WebAssembly/Webフロントエンドはあまり詳しくないので、へんなことをしているかもしれません
  • メモリ共有を使用したBlazor WebAssembly/JavaScript相互運用ですが、実は公式な情報に基づいた実装は行っていません
    • WebAssembly自体の仕様ではメモリ共有可能になっているようなのですが、Blazorでの使い方は公式には記載されていませんでした
    • いろいろ調べた結果、Emscripten関連の機能が使用可能だったので、今回強引に使いました(なので今後この方法は使用できなくなる可能性があります)
  • 実装説明は「箇条書き+ソースコードへのリンク」なので、ざっくりとした説明になっています

機能

  • 文字入力/編集(日本語入力対応)
  • 一通りのよくあるカーソル移動(Home/End/PageUp/PageDownとか、Ctrlキーとの組み合わせとか)
  • 現在行の強調表示
  • 現在行の位置をスクロールバーに表示
  • 行番号表示
  • タブ挿入
  • 複数行のタブインデント/インデント解除
  • ASCII文字と日本語文字で別々のフォント/サイズの適用
  • フォントサイズの倍率変更
  • Undo/Redo
  • コピー/カット/ペースト
  • ダブルクリックによる単語選択、トリプルクリックによる行選択、フォースクリックによる全選択

開発動機

  • Blazor WebAssemblyが正式リリースになり、やっとプラグインなしでC#がブラウザで動く時代が来た!これは何か作らねば!と思い
  • 以前テキストエディタをWinFormsで実装して一時期公開していたが、デスクトップアプリを実行してみてくれる人は皆無で、日の目を見なかったので
  • テキストエディタをどこまでシンプルでメンテナンス性の高いコードで実装できるか試したい

ソースコード

GitHubにアップしました。開発環境はVisual Studio 2019です。

https://github.com/yoshiheight/Crash.Editor

Blazor WebAssemblyサンプルアプリでは、Webフロントエンドで必要最低限な箇所でjQueryとLodashを使用しているので、package.jsonからパッケージを復元してください。

プロジェクト構成

サンプリアプリ以外はすべて.NET Standard 2.1クラスライブラリプロジェクトです。

  • Crash.Core
    • 特定のプロジェクトに依存しない汎用ライブラリ
  • Crash.Core.UI
    • 1枚のキャンバス内に仮想的にUIエレメント構造を構築する為に即席で作った簡易GUIフレームワーク(2D API描画ベース)
  • Crash.Editor.Engine.Model
    • テキストエディタエンジンのモデル部
  • Crash.Editor.Engine.Presenter
    • テキストエディタエンジンのプレゼンター部
  • Crash.Editor.Engine.View
    • テキストエディタエンジンのビュー部
  • Sample.BlazorWasm.TextEditor
    • Blazor WebAssemblyサンプルアプリ
  • Sample.WinForms.TextEditor
    • WinFormsサンプルアプリ

アーキテクチャ

WinFormsサンプルアプリの方は以下の図のようになっています。

[エンジン側]                                [WinForms側]
TextPresenter <――――― Use ――――――    TextEditorControl
use↓    ↓use       (文字列入力/キー操作)      (UserControl)
     TextDocument
      ↑observe
TextView
      ↓use/observe                            ↑use/observe
    ICanvasContext      <---- Impl ----   CanvasContext
    IOffScreen          <---- Impl ----   OffScreen
    IFont               <---- Impl ----   Font
    IInputMethod        <---- Impl ----   InputMethod
  • キーボード/マウス/フォーカス/IME関連や描画処理は環境に依存する為、その部分をアプリケーション側が提供する仕組み
  • Blazor WebAssemblyサンプルアプリの方の図は省略するが、上記に加えてJavaScriptレイヤーがあり、Blazor WebAssembly/JavaScript相互運用でやりとりしている
  • ビューもエンジンに含めているので、Blazor WebAssemblyアプリとWinFormsアプリでまったく同じ見た目になっている
    • スクロールバーもビューエンジンが描画している
    • Blazor WebAssemblyアプリはHTML Canvas 1枚に、WinFormsアプリはUserControl 1枚にUIすべてのパーツを描画している

全体的な説明

以下のようになっています。

  • 共通ライブラリ(Crash.Core
    • 汎用クラスの集まり
  • 即席簡易GUIフレームワーク(Crash.Core.UI
    • UI部品の基底クラス、描画時のクリッピング領域保持、レンダリングの枠組みを提供
    • 汎用UIコントロールとして、スクロールバーはここに用意した
    • UIレイアウトの仕組みはなく、UI部品派生クラスが自分で自分の矩形を返す必要がある
    • オフスクリーンバッファを使用してスクロール時に差分だけ描画するのをサポートするクラスは以下
    • アプリケーション側に提供してもらう機能は以下
  • テキストエディタエンジン
    • MVPアーキテクチャ(監視コントローラー式)
      • ビューがモデルを監視し、モデルの変更に応じて描画を行う
      • プレゼンターはモデルとビューを保持/使用
      • アプリケーション側からはプレゼンター経由で操作する
    • プレゼンター(Crash.Editor.Engine.Presenter
      • もともとテキストエディタによくあるキー操作記録機能をこのレイヤーで実現していた(キー操作記録用コマンド/コマンドパターン)
        • いまのところ不要と思い、削除
    • モデル(Crash.Editor.Engine.Model
      • データを保持するのは、全体を管理するTextDocumentと行を管理するTextLine
        • ビューからは読み取り専用インターフェイスでのみ上記を参照
        • 変更操作を直接行っているのはUndo/Redoコマンドクラス
      • 行の管理はギャップバッファ
        • GapBuffer.cs
        • InternalGapBuffer.cs
          • ギャップバッファとしての実装とC#のIListとしての実装を分けたかったので、クラスを分けた
          • Span/Index/Range等のおかげでかなり簡潔にギャップバッファが実装できた
      • 1行内の文字列の管理はただのstring
      • Undo/Redoはコマンドパターンで、データ変更の差分はここで保持している
        • Common/UndoRedo
        • 実装を楽にするため、レイヤーを2つに分けた
        • 1文字入力するだけでもコマンドを生成するので、単語単位にするとか工夫した方がいいかもしれない
    • ビュー(Crash.Editor.Engine.View
      • 内部ではCrash.Core.UIを使用
      • モデルに変更があった場合、その変更があった範囲のみを再描画
      • 描画範囲の文字を列挙しつつ座標とかサイズとかを計算するクラス
      • Measurementで列挙した文字を以下の描画用の文字列保持クラスに詰めなおす
        • Common/Drawer
        • 同じフォントの文字、同じ色の文字をまとめて1回で描画する。選択範囲の色も同様
      • モデルとビューを分けているので、同一内容を分割表示するようなことも、やろうと思えば可能
  • Blazor WebAssemblyサンプルアプリ
    • 文字列入力やキーバインドはアプリケーション側が行う
    • JavaScript相互運用
      • C#側でJavaScript側オブジェクトの参照を保持し(といっても直接ではなくJavaScript側のMapが実際には保持)、インスタンスメソッドを呼び出す仕組みを用意(その逆の仕組みは標準で用意されているが、これは見当たらなかった)
      • C#側からJavaScript側のイベントをオブザーバーパターン風に捕捉する仕組みを用意
      • 共有メモリを使用したやりとり(ポインタ渡し)
      • 2D描画は描画命令をJSONとして生成し、そのバッファのポインタをJavaScript側に渡す方法にした(JavaScript相互運用は処理が遅いので、なるべく呼び出し回数を減らした方がいいらしいので。効果のほどは計測してないので不明)
    • IMEはブラウザでは直接制御する方法がないのでtextareaで代用。クリップボード処理もtextarea経由で行う(クリップボード処理ではC#側とは文字列バッファのポインタを使用してやりとり)
      • InputMethod.ts
      • Z順で普段はcanvasの後ろにいるが、フォーカスは常にtextareaに当てている。IME変換中のみ、Z順がcanvasより前になるようにしている
      • textareaをIMEに見せかけるため、レイアウト調整用にラッパーとしてdivを用意している
  • WinFormサンプルアプリ
    • 文字列入力やキーバインドはアプリケーション側が行う
    • 2D描画はGDI+を使用
    • IMEはIMM系のWin32APIを使用
    • 今回、独自にオフスクリーンバッファを使用する仕組みにしたため、System.Windows.Forms.Control.Invalidate(System.Drawing.Rectangle)によるクリッピングは使用していない

苦労した点

  • ビューが一番大変
    • どのシステムでもそうだがビューにはいろんなしわ寄せが来る
    • 座標計算が面倒
  • 描画系のバグはなにが原因か特定が難しい
    • 描画が化けるのが一番やっかい
    • ブレークポイントを使ったデバッグだけでは特定できないので、コンソールにログ出力したりとか、一部の処理をコメントアウトして動作の違いから推測したりとか
    • イベント発生のタイミングとかもあるので、想定通り動いている箇所を1つ1つ確認していくことで、原因箇所を絞り込んでいったりとか
  • Blazor WebAssemblyでのデバッグ
    • C#コード/TypeScriptコードの両方ともVisual Studioでデバッグできるのは楽なのだが、デバッグが重いのと現時点ではC#側はローカル変数のみしか値の確認ができない(なのでテキストエディタエンジン自体のデバッグはWinFormsサンプルアプリの方で行った)

今後の対応予定

以下の対応までは終わらせたいです(いつになるかわかりませんが)。

  • リファクタリング、コメント追加、不具合対応
    • 他のテキストエディタの実装を見て、参考になる部分の取り入れ
  • 機能面
    • シンタックスハイライト
      • 定義情報はhighlight.jsとかのをインポートできるようにしたい
    • 文字列検索、ヒット箇所のハイライト表示/スクロールバーへの表示
    • 改行時の自動インデント
    • UI部品の色設定
    • 変更があった行の垂直ルーラーへのマーク表示

以下は、たぶんやらないです。

  • サロゲートペア
  • 印刷向けの描画
  • 矩形選択
  • 行の自動折り返し
  • 横スクロール範囲の計算(現状は行の長さに制限はなく、表示上の限界は1000文字としている)
  • Firefoxで日本語文字のベースライン位置がずれる現象の対応(アプリ側で対応できそうだけど面倒なので)

おわりに

とにかくC#で作ったものがプラグインなしでブラウザで動き、簡単に人に見てもらえるというのが衝撃的でした!

今後もこの仕組みを使用して何か(Excelライクなコントロールとか、ペイントツールとか)作りたいと考えています。

yoshiheight
C#er / 元C++, Java使い / TypeScriptを少々 / DirectX, WebGL初心者 / 趣味ではBlazor WebAssemblyで何かを開発中。
https://crash.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away