Zennのエディタとプレビューのリアルタイム反映・スクロール同期に対応しました
はじめに
従来の Zenn のエディタは、プレビューを確認するためにエディタとプレビューを切り替える必要がありました。

今回対応した新しいエディタでは、切り替えなしに常にプレビューが表示され、ほぼリアルタイムでエディタの内容が反映されます。また、エディタのスクロール位置に合わせてプレビューのスクロール位置も同期されます。

実はこの機能、Zenn 本体よりも先に、有志の方により VSCode 拡張の方に対応していただきました。
この時の対応で、エディタのテキスト(Markdown)から変換される HTML の p タグなどに、テキストに対応する行番号の情報(以下の PR ではソースマップと呼んでいます)を埋め込む対応も行われました。
この行番号の情報は Zenn のエディタでも参照することができるので、理論上は Zenn のエディタでもスクロール同期の実装が可能!と思っていたのですが、ようやく対応することができました。
本記事では、プレビューのスクロール位置の同期について、技術的なトピックをいくつか紹介しようと思います。
前提知識
技術的な紹介をする前に、Zenn のエディタがどのようにプレビュー(いま読んでいるこのページ)を生成しているかについて、基本的な部分をざっくりと説明します。
Zenn のエディタで編集しているデータは Markdown 形式のテキストデータです。これを、markdown-it というライブラリを使い、Markdown の装飾を含むテキストデータを HTML に変換します。
例えば、Markdown の Heading2 要素は、
## Heading2
HTML の h2 タグに、
<h2>Heading2</h2>
変換される、といった具合です。
Zenn 独自の記法も、markdown-it に変換ルールを追加することで HTML に変換されるようになっています。その他、KaTeX など一部はブラウザ上で HTML に変換されたり、iframe で別途描画するものもあります。
プレビューのリアルタイム反映
エディタの内容をほぼリアリタイムでプレビューに反映するには、
- エディタの変更を監視
- Markdown から HTML に変換
- HTML をプレビューに反映
というステップのループになります。
これを素朴に実装すると、HTML をプレビューに反映したときに、HTML 全体が更新されるため、プレビュー画面のチラツキが目立ってしまいます。また iframe なども更新のたびに読み込みが発生してしまいます。
素朴な実装:
<div dangerouslySetInnerHTML={{ __html: bodyHtml }} />
画面のチラツキを防ぐために、HTML の更新箇所を最小限にしたいです。そこで導入したのが morphdom というライブラリです。
morphdom の使い方は非常にシンプルで、
morphdom(beforeNode, afterNode);
第 1 引数に元の DOM Node、第 2 引数に変更後の DOM Node を指定することで、第 1 引数と第 2 引数の差分だけを、第 1 引数の DOM Node に反映します。React のような仮想 DOM でも利用が可能です。
さらにオプションとしてライフサイクルフックが提供されているので、条件付きで更新させることも可能です。例えば Zenn の場合、iframe の src のフラグメントにユニークな id が付与されるのですが、それが毎回ランダム文字列で更新されてしまうため、差分として更新され、再ロードされてしまいました。そこで、src はチェックせずに中身のデータに変化があるかで判定するようにしています。
プレビューのスクロール同期
スクロール位置の同期については、
- エディタのスクロール位置の監視
- 変化があったらエディタのスクロール位置を特定
- プレビューのスクロール位置を移動
というステップのループになります。
エディタのスクロール位置については、Zenn のエディタのベースとして使用しているライブラリ codemirror のインターフェイスを使って取得できます。詳細は割愛しますが、このとき、エディタの現在の行数だけでなく、次の行までの進捗率を小数点以下の精度で取得することで、より滑らかなスクロール同期が実現できます。
次に、プレビューのスクロール位置を移動します。プレビュー側の HTML には、エディタの行数に対応する行番号の情報が含まれますが、すべての対応する行が存在するわけではありません。
例えばこの記事の冒頭のテキスト(わかりやすいように行番号を入れています)では、
1| ## はじめに
2| 従来のZennのエディタは、プレビューを確認するためにエディタとプレビューを切り替える必要がありました。
3|
4| (イメージ)
5|
6| 今回対応した新しいエディタでは、切り替えなしに常にプレビューが表示され、ほぼリアルタイムでエディタの内容が反映されます。また、エディタのスクロール位置に合わせてプレビューのスクロール位置も同期されます。
7|
8| (イメージ)
以下のように変換されます。 data-line の値がエディタと対応する 0 始まりの行番号です。
<h2 id="%E3%81%AF%E3%81%98%E3%82%81%E3%81%AB" data-line="0">はじめに</h2>
<p data-line="1" class="code-line">
従来のZennのエディタは、プレビューを確認するためにエディタとプレビューを切り替える必要がありました。
</p>
<p data-line="3" class="code-line">(イメージ)</p>
<p data-line="5" class="code-line">
今回対応した新しいエディタでは、切り替えなしに常にプレビューが表示され、ほぼリアルタイムでエディタの内容が反映されます。また、エディタのスクロール位置に合わせてプレビューのスクロール位置も同期されます。
</p>
<p data-line="7" class="code-line">(イメージ)</p>
例えばエディタのスクロール位置が 4.5 の場合、プレビューの方は 0 始まりなので 3.5 の上下の位置(この例では data-line が 3 と 5)の DOM を探し、DOM の高さを計算して、3.5 の位置に相当するところまでスクロールを移動させます。
実際にはもっと細かい調整が必要だったり、エッジケースの対応も多々ありますが、基本的な仕組みとしては以上になります。
おわり
Zenn の新しいエディタの、プレビューのリアルタイム反映とスクロール同期の仕組みについて説明しました。何かの参考になれば幸いです。
また、エディタに関するフィードバックは zenn-community の issue でお寄せください。
お読みいただきありがとうございました。
Discussion