LINE BLOGアプリ開発で contenteditable と戦った話

こんにちは、LINE Fukuoka の tarunon です。LINE BLOG iOSのリリースまで、クライアントとエディタの開発を担当していました。昨年11月に、LINE BLOG は一般開放と、iOS/Androidクライアントの公開を行いました。ほぼ1年がかりの開発だったのですが、クライアント側で最も大変だったのがエディタの開発でした。この記事では LINE BLOG のエディタの根幹を支えている Workaround について解説します。普段は Swift を書いていて、ほぼその話しかしていないのですが、今回は HTML と JavaScript の話になります。

LINE BLOG はこういったサービスです。
LINE BLOG – 芸能人・有名人ブログ

contenteditable

LINE BLOGエディタはWebブラウザの上で動作しています。開発は iOS/Android 共通のソースコードで、 iOS と Android の担当者1人ずつで協力して開発を行いました。 HTML5 ではcontenteditableという標準化された仕様が存在するので、これを利用すれば、 iOS/Android で共通して、簡単に HTML を編集できるエディタが作れます。作れるはずだったんです。実際のところ、contenteditable は機能不足であり、かつ、 API に多くのバグ(私的には仕様の問題ではないのか、という思いが強いです)があり、多くの WebFrontendエンジニアも知るところで、泣いた経験のある方もいらっしゃるのではないでしょうか。

Medium の技術ブログに”Why ContentEditable is Terrible“という有名な記事があり、そこでも触れられていますが、とてもつらい世界です。 我々が contenteditable を採用したのも、決して前向きな理由ではなく、過去のデータのしがらみと求められる仕様から、最終的に採用せざるを得ない状況だったというわけです。
LINE BLOGエディタでは、contenteditable が提供する標準のAPI群のうち文字装飾に関しては直接利用せず、カーソルの制御とDOMの生成を行うことで、既知の問題の多くを回避しました。最も重要で大変だったのが、カーソルの制御です。今回は contenteditable の上でのカーソル制御の難しさと、それに対抗する Workaround について紹介していきます。

HTMLのどこにカーソルが存在するのか

contenteditable を使うことで、我々は一般にWISYWIGエディタと呼ばれるものを簡単に作ることが出来ます(ということになっています)。WISYWIGエディタでカーソルを移動させる場合、そのカーソルは1文字毎に移動が可能です、が、文字以外のコンテンツやタグはどのような扱いになるのでしょうか。HTML の構造は Node と Element から成る木構造です。そして、我々は JavaScript においてRangeを通してHTML上のカーソルを操作することが出来ます。Range は開始位置と終了位置を Node と Offset の組み合わせで表現しています。新しい Element を挿入する場合、ここで示された Node の Offset の位置に挿入されることになります。

では、具体的な HTML として次のものを考えてみましょう。

緑色でのテキストは全て1文字ずつで Element に相当しますが、今回は割愛しています。
そして、この HTML の上でカーソルが移動する場合に、そのカーソルが木構造のどこに存在しているのか、わかりやすいようにパラパラ漫画を用意しました。青枠が Node、数字が Offset です。

HTML上でカーソルが移動するとき、カーソルの見た目の位置と、木構造上の位置は必ず1:1に対応しています。もし、JavaScript を使って存在し得ない値を入力したとしても、必ず木構造上の位置は決まった位置に移動してしまいます。しかし、上記で紹介したルールでは、木構造上にカーソルが存在出来ない位置がありますね。例えば、Node “World”における Offset 0は存在しません。そしてそれこそが、全ての悲しみの始まりでもあります。

タグとカーソルの掟

Range は幾つかのルールに基づいて、カーソルの見た目の位置と木構造上の位置を一意に保っています。そのルールを確認していきましょう。

  1. Nodeの境界線上では必ず手前側のノードの末尾にカーソルが存在する。
    例えば、”Hello <b>World</b>”という2つのTextNodeが存在する状態で、”W”の手前にカーソルを合わせた場合は、TextNode “Hello “の末尾にカーソルが存在することになります。このHTMLでは、TextNode “World”のOffset 0にカーソルを合わせることは出来ません。
  2. 親のタグがNodeとして解釈されるタグ。
    img や br はこの分類になり、RangeはNodeが親ノード、 Offset は親ノードから見た自身の Index として表現されることになります。
  3. カーソルを合わせることが出来ないタグ。
    カーソルを合わせることが出来ないタグがあります。video や空の span, div 等がそれに該当します。これらのタグは、カーソルを合わせると、必ず前後の Node の末尾か0番目として表現される、直感的には1と2のルールを無視して動作しているように感じます。非常に厄介で例えば空の span, div 等は、その前後で文字列が変更されると、その拍子に消えてしまいますし、 video に関しては、カーソルの見た目の位置が img と同等なのに Range の構造が違う、という状態になってしまいます。


この場合、もし video が div の中にひとつだけ存在した場合は、video にカーソルを合わせると、次の行にカーソルが飛んでしまう、という挙動を見ることになります。
或いは、 contenteditable なタグの子がvideo一つだけの場合、そもそもカーソルが出現しなくなってしまいます。正直な感想としては、この挙動はcontenteditableの欠陥そのものだと思っています。

救世主 Zero-width space

LINE BLOGエディタでは、これらの問題を、Zero-width spaceを活用したWorkaroundによって回避しています。元々はベースとなったlivedoor blogのエディタで、空のspanを生成した時に、カーソルを合わせるためのWorkaroundとして利用されていました。このWorkaroundはstackoverflowでも共有されています。

LINE BLOGエディタでは、テキストスタイルを変更する場合、execCommandを利用するのではなく、適切なスタイルをあてた span を生成し、その中に Zero-width space を挿入して、カーソルを移動させています。
こうすることで、 execCommand によって発生する問題を回避し、また、編集中には TextNode の境界に Zero-width space が存在することで、ユーザは前後の TextNode どちらにもカーソルを合わせることが可能になっています。

また、videoを始めとしたカーソルが合わないタグについては、その後ろにZero-width spaceを付加することで、Zero-width spaceにカーソルが移動したということを、video等のタグが選択されたものとして判断することで解決しています。

Attachmentモジュール

LINE BLOGエディタでは、Zero-width space を用いた Workaround のうち、特に video等のタグについてより制御しやすく、ユーザに解りやすい UI を提供するために、Attachment モジュールを開発し、活用しています。LINE BLOGエディタで画像を挿入し、カーソルを合わせると通常のカーソルの代わりに緑の枠線が表示されます。この枠線で囲まれている範囲が、Attachmentモジュールの担当になります。

Attachmentモジュールは、contenteditable の上で、任意のタグを包んで使うことが出来ます。Attachmentモジュール内におけるタップ操作の禁止、カーソルの制御、フォーカス時のキーボードイベントの処理、データ書き出し時の desanitize を、一括して担当しています。

Attachmentモジュールに任意の HTMLテキストを与えると、Attachment Node が生成されます。Attachment Node の root Node は div で、data-origin に元々のHTMLテキストが格納されています。エディタからHTMLを取得する時には、Attachmentモジュールが Attachment Node と data-origin に格納されたHTMLテキストを置換することで、desanitize が完了します。タッチイベントは全てimgが取得します。contenteditable での img へのタッチイベントは、カーソルの移動扱いになります。そして、Attachment Node の内部にカーソルが存在する場合に、画面上からカーソルを消して、Attachment Node に active 属性を付加して、CSS でカーソルが合っている状態を表現しています。

スマートフォンで contenteditable 上でドラッグアンドドロップをすると、カーソルの移動が行われます。Attachment Node の上でドラッグアンドドロップしている場合は、必ず展開された HTML か、内部の img、Zero-width space のいずれかにカーソルが存在しているので、安定してカーソルの制御が可能となります。キーボードの動作は、Backspace が Attachment Node の削除、それ以外は次の行にフォーカスを移動する、と統一されています。
Attachment モジュールを実装したことで、任意の HTML を編集不可能な領域として contenteditable の上に簡単に追加できるようになりました。汎ゆるHTMLテキストを同じ枠組みで取り扱えるこの機能は、LINE BLOGエディタを実現する上での要であったと言っても過言ではないでしょう。

実際に Youtube や Instagram の embed-frame、その他のOGPリンクも Attachment モジュールの枠組みに乗って動作しています。ぜひ、LINE BLOGアプリを触って確かめて見てください。

終わりに

今回はLINE BLOGエディタの解説として、Zero-width space を使った Workaround を紹介しました。いかがでしたでしょうか。私自身、学生時代にiOSでブラウザアプリを開発していたこともあって、その経験が非常に生きて、良いエディタを作れたのではないかなと感じています。今回は紹介できませんでしたが、実際には JavaScript と Swift の世界を密に連携させてエディタを完成させているので、Web側とネイティブ側の開発者が同じであった、というのは非常に強かったのだと思います。とは言え、HTML, JavaScript のプロフェッショナルではないので、及ばない点も多かったのですが…
次回は Swift の型の話か、contenteditable の文字装飾APIの作り直しの話を書くかもしれません。

LINE BLOGアプリはこちらからダウンロードが可能です、是非使ってみてください。
iOS / Android

Related Post