Content EditableでWYSIWYGエディタ作るの楽しい!

こんにちは丸山@h13i32maruです。 僕は今、Ubie Discoveryで医療従事者向けのカルテエディタを作っています。人生で初めてContent Editableを使ってエディタを作ってるんですが、それがすごく楽しいです!というのも、エディタを作るには色々技術的な課題があります。例えば、テキストをパースするには?ASTからHTMLをビルドするには?パフォーマンスのよい更新方法は?などなど。それらの技術的な課題を解決していくのが単純に楽しいという感じです。また、車輪の再発明は極力抑えつつ、自分たちのプロダクトでやりたいことを実現できるような工夫もしています。

というわけで、今回はそんなエディタ作りで取り組んだ課題と解決策を紹介していきたいと思います。
(訳: 楽しかったので、誰かに聞いてもらいたい!)

エディタの概要

まずはじめに作ってるカルテエディタを簡単に紹介します。いわゆるWYSIWYGエディタとして作っています。ただ、Google DocsのようなWYSIWYGというよりはNotionのように「いくつかの記法を持っていいて、それを入力するとスタイルが適用される」みたいな感じのものです。例えば今回作っているカルテエディタでは【】を入力すると見出しになり、を入力するとラベルになります。このあたりの記法はカルテでよく使われる記号を元にしています。他にも特定の記法でReactコンポーネントを埋め込めたり、空セクションのグレーアウトなどの装飾が適用されるようになっています。

余談ですがこういうタイプのエディタってなんて言うんだろ?個人的にはWYSIWYG2.0ぽいなと思ってます(調べても出てこなかった)。

処理の流れはざっくり以下のような感じです。

f:id:h13i32maru:20210620132330p:plain:w600

というわけで、以降では具体的な話をしていきます。

メンテしやすいテキストパーサ - PEG.js

f:id:h13i32maru:20210620134240p:plain:w600

今回テキスト(カルテ)のパースをどうするか悩んでいました。というのも過去にすごく単純なテキストパーサは作ったことはあり、その時の経験からある程度の規模のパーサをパーサ素人が書く/メンテするのはかなり難しいとわかっていたので。そこで以前に名前を聞いたことがあったPEG.jsというパーサジェネレータを調べてみたところ、どうやら宣言的な構文でルールを書くとテキストパーサを自動生成してくれるものだとわかりました。

というわけで、実際にPEG.jsを使ってパーサを生成してしてみたのですが、宣言的にパーサを書けるのは思った以上に体験がよかったです。命令的なコード(if, forなどの通常のプログラミング)と違い、状態管理が発生しないのが特に良いですね。それにPEG.js自体の仕様は凄くコンパクトで学習コストもそんなにかかりませんでした。今後テキストパーサを実装するならPEG.jsを積極的に使っていこうと思います。

ただし、PEG.jsでは以下の点に気をつける必要があります。

  • 宣言的な構文でルールを書くのは、いつもと違った脳みそを使う感覚があり、慣れは必要
  • PEG.jsの正規表現(ぽい構文)はバックトラックや控えめマッチがなくて、貪欲マッチのみしか使えない
  • PEG.jsの開発は結構前に止まってるようなので、開発が活発な他のパーサジェネレータに乗り換えたほうがいいかもしれない

メンテしやすいHTMLビルダー - ReactDOMServer

f:id:h13i32maru:20210620134335p:plain:w600

テキストをパースした結果のAST(Abstract Syntax Tree)からHTMLを組み立てるのに当初は文字列としてがちゃがちゃ組み立てていました。ただそれだと見通しが悪かったり操作しづらかったりで、実装やメンテのコストが高くなるのが目に見えていました(幸いにも補完はIDEの力でなんとかなりましたが)。

そこで、JSXを文字列に変換できればHTMLを組み立てるのがすごく楽そうだなと思って調べたら、ReactDOMServerというどんぴしゃのものが公式にあったので使うことにしました(ReactDOMServerとありますが、サーバサイドやSSRとは関係なく使用することができます)。これによって、HTMLの組み立てを文字列操作や命令的コードで実装するのではなく、宣言的コードとして実装できるようになり、実装コストも体験(IDEの支援や通常のUI構築との親和性)もすごく良かったです。

ライフサイクルをまたがる処理 - プラグイン機構

f:id:h13i32maru:20210620134114p:plain:w600

エディタを作り始めたころはいろんなコンテキストのコードが一箇所にごちゃまぜになっていて、だいぶ見づらくなっていました。例えば「HTMLを更新した直後」では「キャレット位置の復元をする」「エディタ内にReactコンポーネントをマウントする」などのコンテキストが異なるコードを同じ場所に書かざるを得なかったのです。これはかなり辛いなと思って試行錯誤をしてるうちにライフサイクルをフックできるプラグイン機構作ればよさそう!とひらめきました。

というわけで、エディタのいろんな処理タイミング(ライフサイクル)に外部からコードを差し込めるようにするためのプラグイン機構を作りました。例えば「エディタからテキストを読み出す直前」「HTMLを更新した直後」などなどのタイミングで外部からコードを差し込めるようになっています。プラグイン機構はTypeScriptのインターフェースとして提供して、実装側はそのインターフェースを実装してエディタに渡すようになっています。

実際にこのプラグイン機構を使って、外部からReactコンポーネントをエディタ中に埋め込んだり、キャレットや改行の処理をするプラグインを作りました。ただ、欠点としてプラグインが提供しているフックタイミングをちゃんと把握してないと理解が難しくなります。現在はコードにコメント書いたりドキュメントでカバーしているという感じです。以前にESDocでプラグイン機構を作ったときもこのあたりはちょっと難しかったなぁというのを思い出しました。

パフォーマンスの良い更新処理 - インクリメンタルビルド

f:id:h13i32maru:20210620134520p:plain:w600

最初の実装ではユーザが文字を入力するたびに、エディタ内のHTMLを全て作り直していました。ただ、それだと文字入力があるたびにエディタ内に埋め込んだReactコンポーネントのunmount/mountが走ってしまいます。さらに画像があると文字入力のたびにその画像がちらついていました(再読み込みが行われるため)。この問題を解決するには全HTMLを作り直すのではなく、必要な部分だけ作り直すインクリメンタルビルドの仕組みが必要です。

とはいえ、これは世に云う早すぎる最適化な気もします。悩んだのですが結局、インクリメンタルビルドを採用することにしました。というのも、これはエディタ設計の根幹に関わることなので、後から実装するとしても多分エディタの設計からやりなおしになるだろうなと。とはいえ最初から作り込みすぎるのは良くないので、まずはインクリメンタルビルドのキモである「そのユーザ入力による更新範囲の判定ロジック」「部分更新の仕組み」をざっくり作ってリリースすることにしました。細かいチューニングは後々必要になったときにやればよさそうなので。

しかし、やっぱり実装は複雑で難しい感じに。。。このような仕組みはこれまで一度も作ったことのない部分なので、何度かリライトしないと洗練されないなと思っています。あとはOSSなエディタから学習するというのもありかもしれません(それも難しそうだけど)。

キャレットのリセット回避 - キャレットプラグイン

Content Editableではキャレットの制御がめちゃくちゃ面倒です。なぜかというと、キャレットが存在する場所のHTMLを書き換えるとキャレット位置がリセットされ、親要素の最初の文字位置に移動してしまいます。これでは「ユーザが文字を入力→ 新しくHTML作成して書き換える」のたびにキャレット位置がリセットされて使い物になりません。そこで、HTMLを書き換える前にキャレット位置を覚えておいて、HTML書き換え後に適切な位置に戻すようにしました。このときにHTML書き換え前後でキャレット位置を保存・復元するのが難しかったのですが、最終的には以下のようにしました。

  • HTMLを更新をする直前にキャレット位置にマーカー文字を挿入する: 例 foo{{caret}}bar
  • それを含んだテキストをパースしてHTMLを組み立てるときに、キャレット位置をHTML要素として書き出す: 例 <div><span>foo</span><span class="caret"></span><span>bar</span></div>
  • このHTMLをエディタに書き込んだ後に、class="caret"を持つDOM要素を見つけて、そこにキャレットを移動する

実際はもう少し泥臭い実装が入っているんですが、それでもだいぶシンプルにできたと思います。それと、キャレット制御は編集処理のいろんなタイミングに実装を入れる必要があるんですが、前述したプラグイン機構を使うことで実装を一箇所に集約することができたのは良かったです。けど、そもそもキャレット制御せずにいい感じにできないのだろうか??それともやっぱりContent Editable使う以上は必須になってくるのかな🤔

責務と依存の明確化 - レイヤードアーキテクチャ

f:id:h13i32maru:20210620132633p:plain:w600

エディタを作るにはいろいろな処理を実装する必要があります。テキストのパース、ASTからHTMLの組み立て、編集処理、ユーザ入力のハンドリングなどなど。これらの処理をきっちり責務ごとに分割し、さらに依存関係を明確にすることは重要です。そこで、今回は「責務ごとにレイヤーを切って、依存関係をレイヤーからレイヤーへ単方向にする」というレイヤードアーキテクチャの設計をかなり意識しました。

責務ごとにレイヤーに分けるときに注意したのは「エディタのレイヤー」と聞いたときに、なるべく自然と想像できるような切り方になるようにしたことです。ただ現在のレイヤー整理が正解なのかは正直わからないです。というのも僕は今回初めてエディタを作ったので、エディタについてのドメイン知識がまだ浅いためです。このあたりは今後も開発をしていくことでより良いものが見えてきそうです。ちなみに現時点で成功したなと思う責務分割は「ユーザ入力のハンドリング」と「テキストの編集処理」をきっちり分けたことです。このあたりは意識しないと境界が曖昧になり、処理がスパゲティになるので特に注意する必要がありました。

リグレッションや不具合の検知 - ユニットテスト

f:id:h13i32maru:20210629171223p:plain:w600

前述したようにエディタはいろいろなコンポーネントで成り立っています。それらのコンポーネントはなるべく疎結合を意識して作られていますが、とはいえある変更が思いもよらぬ影響や不具合を生み出すことがあります。それがメイン実装者ではなく他のメンバーも手を入れていくならなおさらのことです。そこで、ユニットテストはわりと充実させました。具体的にはパーサ、HTMLビルダー、編集処理あたりを中心にテストを書きました。特にASTやHTMLのテストはそのまま書くと大変なので、Jestのスナップショットテストをガッツリ使ってみました。カバレッジはあまり重視してなかったですが、さくさく書ける部分で大体80%ぐらい。これ以上はコスパが悪そうだったので、一旦このぐらいまでにしました。

Jestをしっかり触るのは今回が初めてだったのですが、スナップショットテストは超便利だと思いました。ただし、テストコードとスナップショットが離れてるのでPRレビューはしづらいですね。。WebStormだとテストコードからスナップショットへ一発で飛べるようになってるので多少はましですが(ただし、一つのテストの中で複数のスナップショットテストしてる場合には対応してなさそう)。インラインスナップショットという手もありますが、それはそれでテストコードがみづらくなりそうなので見送りました。あとはやはり入力がユーザインタラクションになる部分(= UI側)に近くなればなるほどテストはしづらいのを改めて感じました。

f:id:h13i32maru:20210620133851p:plain
WebStormのスナップショット連携機能

有益な設計議論やレビュー - ドキュメント

f:id:h13i32maru:20210620132923p:plain f:id:h13i32maru:20210620132935p:plain

今回のWYSIWYGエディタは僕がメインで開発していたのですが、やっぱりチームメンバーと設計について議論してフィードバックをもらったり、実装をレビューしてもらいたいというのは当然あります。しかし、ここまで紹介してきたとおり、WYSIWYGエディタはどしても設計や実装が複雑になってしまいます。そうなると有意義な議論やフィードバックをもらったりするのも難しくなりますし、レビュー負荷も高くなってしまいます。

そこで、今回はドキュメントをしっかり書くようにしました。具体的にはARCHITECTURE.mdを書いたり、文字だけでは分かりづらい仕様やアルゴリズムを図示しました。例えばカルテテキストから生成したASTの仕様や、そのASTからHTMLを生成する流れ。他にもインクリメンタルビルドの大まかな戦略や実装方法についてもドキュメントを書きました。ドキュメントを書くときに気をつけていたのは、エディタ全体について俯瞰的なメンタルモデルを構築してもらえるようにしたことです。他にもコード上には書かなかったり汲み取れなかったりすることは、ドキュメント化しました。具体的には「なぜこのような仕様・実装になっているのか?」「棄却した選択肢はなにか?」などです。

このドキュメントを使って実際に設計議論をしたのですが、そのときに「エディタに機能追加するのが現状だと大変そうなので、改善したほうが良さそう」とか「Reactコンポーネントを追加していくときはプラグインの形で実装していくとよさそう」などの有益なフィードバックをもらうことができました。結果としてより良いものが作れたと思います。それと、チームメンバーからはドキュメント自体に対して、このようなフィードバックをもらうこともできたので、書いてよかったな〜と思っています。 f:id:h13i32maru:20210629172847p:plain

ちなみにドキュメントを書く工数は多少かかってしまいました。多分トータルで、2~3日ぐらいはかけたと思います。ドキュメントって書きすぎるのも良くないというのは理解していますが、今回はこのドキュメントを書くことで得られるメリット(チームメンバーの理解度アップ、有益な議論やフィードバック、今後のメンテナンス時のサポートコスト低減など)が僕一人の工数よりも大きいと判断したため書くことにしました。それと僕はもともとこういったドキュメントを書くのは好きな性格なので、書く事自体が楽しみだったというのもあります。ただし、今後のドキュメントのメンテナンスについてはどうするか検討中という感じです。


以上、エディタ作りの楽しかったところ(課題・解決策)を紹介しました。エディタ特有のものもあれば、ソフトウェア開発一般的なこともあったと思います。「ここ参考になった」「自分ならこうする」などの意見があれば、教えてもらえると喜びます。

最後に宣伝。最近meetyでカジュアル面談(実際は面談というか会社紹介)をオープンしたので、興味ある人は連絡ください!テキストエディタについて語りたい人も是非!