noteエディタをどうやって開発している(きた)のか
前の記事で、noteがHTML5のcontenteditableを利用している事、そしてそのままではとても使えない事を書きました。それをどうにか使えるようにしているのがエディタなわけですが、今回はそのエディタの成り立ちや、どういう開発をしてきたのか、といった事を書こうと思います。
noteエディタの歴史
私が開発担当している現行(201810現在)のエディタ(初版201704リリース)は、実はnoteのサービスローンチ時まで遡って数えると、3代目になります。
初代
弊社CTOのkonpyuが、突貫で作り上げた…と聞いています。
曰く、「すぐに引っ込めた」との事。note自体のローンチに向けた開発も多忙な中にあって、エディタに人員を割り当てて実装する、というのはなかなか難しかったのではないかと思います。
2代目
noteローンチメンバーのエンジニアの方(私とほぼ入れ替わり)が開発されたものです。3代目と同じくMediumEditor(後述)をベースにしており、CoffeeScriptで記述され、railsのassetsに組み込まれて動いていました。
3代目
2代目のメンテが誰も出来ない!という事態になり、新たに一から開発する事になったものです。私もエディタをどうにかしたいとの想いが強かったので、手を上げて担当させていただく事になりました。
ベースはMediumEditor
3代目を開発するにあたり、2代目の機能は完全に移植しなければならないので、多くの面で2代目をベンチマークにしました。フルスクラッチにするか、何かをベースにするか迷いましたが、2代目に倣い、MediumEditorをベースとすることに。
これはOSSのMediumライクWYSIWYGエディタです。
公式ページの上の方に書かれていますが、「私達はmedium.comに所属していません。ただのファンです」とあるので、公式ではなく、あくまで「ライク」ですね。
ES5 to ES6
MediumEditorをベースにすると決めたものの、まず困ったのがこれが全てES5で記述されていた事。note独自処理を上書きするために、class extends & overrideしたかったので、ES5のままというのはやりにくいし、何よりテンションが上がらない。
そこで、MediumEditorのコードをES6にする事にしました。
最初はツールによって一括変換してみたものの、当時(2016夏)のツールは精度が低すぎて、文字列連結が軒並みバグってる、varがletになったぐらいしか変化がない等、切ない結果になったので、ツールは諦めて全部手作業で書き換えました。
ES6にするに当たって、トランスパイラは何にしようかという課題も出てきます。エンジニア歴の半分ぐらいをJavaで過ごしてきた身としては、以前からTypeScriptに興味があったので使ってみようと試しに導入。
しかし、既に完成したコードをES6記法に置き換えつつ、型付けも行っていくという作業は辛すぎました。また、RubyMineがindexingにより激重(今は違うかもしれません)になった事もあり、断念。TSを導入するなら後から置き換えではなく、初めから入れておくべきだなと思いました。
よってトランスパイラはbabel(ES2015)に決定。
一連の書き換え作業は、ES5→TS→ES2015という若干無駄な流れに。
また、単純な書き換えの他、ES5で書かれていた独自の継承機構や、それ用のspec等、ES6化してclass導入により不要になるコードを削除。
こうしてまず、MediumEditor ES6(ES2015)版が出来上がります。
ビルド環境・エコシステム周り
2代目はCoffeeScriptで書かれ、railsのassetsに組み込まれていたので専用のエコシステムは不要でした。3代目はnote本体から独立して、note-editorとして個別にリポジトリを立ち上げ、以下のような運用を想定しました。
・localで開発時はエディタ単体スタンドアロンで動かす
・note本体にライブラリとして取り込むためのjsをコンパイルする
そこで、2016夏時点で名が知られてきていたwebpackを使ってみる事にしました。localスタンドアロン時はwebpack-dev-serverを使います。
webpack自体の設定は特に詰まる所もなく、いいんじゃない?と軽いノリでそのまま定着。
(今年になってversion 4.x系にupgradeした際、ハマってとても軽いノリじゃ済まん事になりましたが…)
最初の導入時に多少面倒だったと言えば、MediumEditorのspec(jasmine)を動かすためにどうしてもgruntが必要だったので、webpackとは別にgrunt、grunt-cliなんかも同居させてspec実行可能にする設定をした所ぐらいです。
ビルド環境を整備し、MediumEditorのspecが全部通る状態にして、これでnote独自部分の実装に着手する準備が出来たわけです。
実装する上でのルール
まず、以下のようにMediumEditor部分をlib以下に配置し、note独自の拡張コードをapp以下に配置すると決めます。
そしてlib以下のコードは原則、変更しません。
lib以下に変更が入るのは、本家MediumEditorで更新があった際に、追従してバックポート対応する時のみです。
specが通るMediumEditorの状態は常に維持します。
このため、noteエディタは初期化方法を変えればMediumEditorとしても動作します。
こうしておけば、何かしらバグが発生した際、原因は全て拡張コード側に求める事ができます。MediumEditor側に明らかに不足がある事も多いのですが、そういう場合もnote拡張コード側でoverrideして対応しています。
noteエディタとして開発してきたこと
素のMediumEditorは、動かしてみると分かりますが、noteの操作感によく似ています。(ベースになっているから当然なのですが)
それでも多くのそのままでは使えない部分があり、以下のような独自拡張を加えていきました。
段落IDを付与する
前の記事でnoteの本文DOM構造について触れました。そこでは端折っていましたが、本文の各段落要素には記事内で一意になるIDが付与されています。name属性で与えられているものがそれです。(nameではなくてdata-name、data-token、とかのほうが良いように思うんですが、これは先祖代々の…というやつです)
<p name="sitjg">段落ID</p>
このIDは、有料ラインがどこまでか、を設定するために存在します。
改段落時、コピペ時等、新たに段落が作成される際に必ず付与されるよう、いろいろ広範囲が(アバウトですいません…)変更されています。
日本語入力
日本語入力には「変換」という操作が伴います。変換中のキー入力、変換を確定するEnterと改行・改段落するEnterの区別、このへんは日本語入力をサポートする場合、ある程度お決まりの実装になるかと思いますが、MediumEditorには当然ありませんので、追加しています。
画像貼り付け
以下のnote仕様を満たすため、ベースをそのままは使わず、独自実装になっています。
・p段落内に1枚のみ存在できる
・noteのAPIへアップロードする
また、2代目エディタでは対応できていなかった、D&Dによる画像貼り付けも対応させました。
エンベッド
これはMediumEditorには無い機能なので、完全に新規追加実装です。
twitterやyoutube等の外部メディア記事を貼り付けられる機能で、利用頻度も高いのではないかと思います。201810現在、対応しているのは、note、twitter、youtube、instagram、ニコニコ動画、vimeo、soundcloud、speakerdeck、slideshare、googlemap、です。ニコニコ動画だけは私が勝手に対応させました。
このどれでもない対応外URLをエンベッドとして貼り付けた場合、汎用外部記事エンベッド、というものになります。(例として弊社コーポレートサイト)
コード段落
pre段落とも呼びます。
この段落内でのみ操作フィールが異なり、Enterで段落内改行、段落末尾で2回Enterを入力する(最後に未入力行を作ってそこでEnter)事により改段落します。
これの用途は何と言ってもプログラムコードを書けるようにする事です。
以前より弊社のエンジニアチームでも技術ブロクをやりたい!という声があったのですが、自前でブログサービスを持っているにも関わらず、コードが書けないため他サービスのお世話にならなければ…という悲しい状態でした。主に内部から渇望されていた機能だったわけですが、実装出来た事によって内外からエンジニア系記事が集まるようになり、嬉しい限りです。
class Test {
public static void main(String[] args) {
System.out.println("Hello World");
}
}
Javaコードの例です。シンタックスハイライトにはhighlight.jsを使っています。ただ、ハイライトは編集中には適用できず、表示時のみとなっています。
装飾全般
p段落をh3段落にしたり、太字にしたり、諸々の装飾処理をリメイクしています。というのも、MediumEditorの説明に "Uses contenteditable API …" とあるように、装飾操作全般にcontenteditbleのAPIを使っているんですね。
document.execCommand('formatBlock', false, 'p');
p段落にするAPIの例です。"formatBlock"というコマンドを実行するわけです。すると、選択範囲にあるテキストが、<p>で囲まれる。これを使って、pからh3に、逆にh3からpに、段落を変えたりしているんですね。
ところがこのexecCommandの実行結果が、どうにもクロスブラウザで安定しない。Edgeで特に。酷いと段落要素が入れ子になってnoteフォーマットが壊れたりする。formatBlock以外のコマンドでも、なんと言いますか、ブラウザ(OS)が忖度する余地の大きい、変化の大きいコマンドは軒並み結果が安定しない印象でした。
最終的にどう対処したかというと、execCommandを使うのをやめました。contenteditableのAPIを諦めたわけです。
では代替としてどうやるか。昔ながらのvanillaのDOM操作です。createElement、insertBefore、replaceChild、removeChildなんかを駆使します。ここまで末端まで処理をバラせば、ブラウザ忖度が入り込む余地はなく、全て同じ結果が得られるようになりました。
undo / redo
元に戻す機能です。これもMediumEditorにはありません。というよりも、ブラウザデフォルトで、contenteditableに対して行った編集はundo/redoが効きます。なので、別途実装する必要はないわけですね。
…しかし、それはcontenteditableのAPIを使い、contenteditableに則って編集した操作のみです。先述の通り、execCommandは諦めて自前DOM操作に変えてしまったので、当然そんな変更に対しては一切undo/redoが効きません。
そこで、やむを得ず自前で実装する事にしました。
極力ライブラリは使わない方針なのですが、ここだけは例外でこの機能のためにvirtual-domを使っています。
現在の本文DOMと、前後世代のそれを比較して差分を更新する事により、undoとredoを実現しています。これにより、あらゆる変更が元に戻せるようになり、自前DOM操作がやりたい放題になりました。(本当にこれでよかったのかと思う事はあります…)
コピペ
リリース後しばらくはMediumEditor機能のまま動いていました。当然、contenteditableのAPIを使った実装だったので、クロスブラウザでの動作がとても不安定でした。Edgeで特に。
処理も複雑でセキュリティも求められる機能なので、騙し騙しMedium状態のまま生かしていたのですが、undo/redoが実装されてDOM操作自由度が上がったのを機にリメイクしています。note内でのコピペに限り、装飾書式も維持してペースト出来るようにもなりました。コピーだけでなく、カットにも対応しました。
中身は、本当に愚直に自前でDOMをゴリゴリ操作しています、としか言いようがない実装になっています。「テキストエディタ」なんですが、実装者として見るとこれはもう「DOMエディタ」です…。
このように、undo / redo実装で自由度が上がり、あちらこちらを独自実装に置き換えていった結果、リリース直後は多めだったMediumEditor成分も、今はもうほぼ無い状態になりました。
今後のエディタ
3代目のエディタをどのように開発してきたかを書きました。
では、今後エディタがどうなるのか、先の事にも少し触れておこうと思います。
まず、スマホブラウザでもエディタが使えるようになる予定です。目下、内部でドッグフーディング中です。Android Chrome、iPhone Safariがターゲットです。
Edge対応は、今後もめげずに続けていく……と思います。
Edgeになんとか対応している事が3代目のアイデンティティーのような気もしてきているので、引き続き頑張っていきたいですね。
4代目エディタの話もぼちぼち出始めています。
上で触れたように、ベースはMediumEditorですが、DOM芸に励みすぎた結果、現状もうあまりオリジナル部分は残っていません。なので4代目を開発するとすれば、3代目のノウハウを活かし、フルスクラッチになると思います。
また、3代目のclass構成はMediumEditorを踏襲した事もあり、1classにあらゆる事をやらせすぎな状態になっており、もっと細分化してコードの可読性を上げ、開発参画の敷居を下げたいですね。
機能面でも、「テキスト入力」「文字装飾」等の基本機能をコアとし、例えばnoteで必要なエンベッド等の機能はpluggableにして、note以外でも容易に導入できるような汎用性を出していきたいなと考えています。
終わりに
noteのエディタをどうやって、どのような開発をしてきたのかを今回ご紹介いたしました。undo / redo やコピペ等、それぞれの実装段階においては毎回様々なハマりポイントがありました。それらを全てここで書くと、長くなりすぎてしまうので簡潔に書かせていただきましたが、個別の細かすぎる事柄についてはまた別記事でやっていきたいと思います。
最後にnoteと同じようにcontenteditableを使って(と戦って)エディタを開発されたLINE BLOG様の記事をご紹介します。
書かれている事は、本当に、本当に、同意するところ大です。
カーソルの件はまさにその通りで、今、段落node内のどこにいるのか?という事は常に意識しておく必要がありました。
また、zero-width spaceに利用については、pre段落実装の際に参考にさせていただきました。この場をお借りして御礼申し上げます。
以上、最後まで長文にお付き合いいただき、ありがとうございました。
追記
note engineer meetup にて、前回と今回の記事をまとめたLTをさせていただきました。ご来場いただいた皆様、ありがとうございました。