CMSの改良をした時に、Markdownが使えるようにしました。 私は雑なフォーマットにしかしないので、Markdownくらいがちょうど良いこともあって、基本的にMarkdownを使っています。

Markdownのエディタは当初は単なるTEXTAREAだったのですが、CodeMirrorを使うようにしました。 このことについてはまた別稿で書こうと思います。

エディタをCodeMirrorにして便利にはなったのですが、もう一つの問題である「Markdownはレンダリングしてみないとどうなっているかわからない」というのは解決していませんので、ここを解決してみました。

問題

Markdownをリアルタイムにレンダリングしながらソースを編集するというのは、既にローカルプロジェクトでは何度もやっています。

情報共有システム(3) Monaco vs Ace

なので、それを流用すればいいだけ… と思っていたら、結構ハマってしまいました。

問題は複数あります。

  • レンダリングさせる時にチラつく
  • レンダリング結果が表示される時にスクロールがおかしくなる
  • 結果表示をソースに一致させるのがうまく行かない

ということです。

レンダリングさせる時にチラつく

最初にイラっとしたのはこれです。

元々参考にしたのが、markdown-itのデモで… という話は上のリンク先のエントリを見て下さい。

原理は簡単で、エディタの更新を見ていて、更新されると変換して、プレビューのところに描画するというだけです。

これは、テキストだけだと問題ないのですが、画像があるとその度に画像の再描画がされるので、その時に表示がバタつきます。 なので、見てると結構イラっとしてしまいます。

普通のテキストであればあまり苦にならないと思いますが、ブログのエントリのように画像をいっぱい貼ると気になります

レンダリング結果が表示される時にスクロールがおかしくなる

これも画像がない時には問題とならないのですが、プレビュー領域を再描画させると、スクロールが元に戻ってしまいます。

もちろんそうならないために、「現在表示している状態を調べて、スクロール位置を保存し、再描画後に元のスクロール位置に戻す」ようにするわけですが、Svelteの afterUpdateで戻そうとしても、画像の再描画はその後になってしまっていて、本来の位置にスクロールしてくれません。

これも画像という遅延ロードされる要素が存在しているせいです。

結果表示をソースに一致させるのがうまく行かない

これもやっぱり画像絡みです。

元々のコードは仮想的にmarkdownを描画して、現在のエディタのカーソル位置に一致した辺りにプレビュー領域をスクロールするという処理がありました。面倒臭い処理ではあるのですが、原理がわかってしまえば難しいことではありません。

ところが、この処理は「文字」のことしか考えていませんでした。画像があるとズレてしまいます。これは実に厄介で、「画像の大きさ」はstyleや表示しているwindowの大きさで変化するので、うまく計算するのは容易ではありません。

代案として「ソースのカーソルの相対位置をプレビュー表示の相対位置と同じくする」という考えもあると思うのですが、それをやっていると思われるVSCodeのMarkdownの処理が何となく気に入ってません。

解決

順番に解決して行きます… と言いたいところなんですが、最後の「結果表示をソースに一致させる」ことは諦めました。

これは頑張ってもあまり嬉しくないという話があって、「じゃあやめよう」というのがコード書く前からの私とmayumiの合意事項でした。 正直、便利っちゃー便利と言えなくもないんですが、頑張ってやるほどの価値はないなというのが正直なところです。 結局あまり期待してないし、完璧な動作をしてくれないと結局邪魔になってしまうというのがわかっていたので、諦めました。

って書くと最初からきっぱり諦めたかのように思えますが、実は結構あがいています。 あがいてはみたんですが、「完璧な動作をしてくれないと結局邪魔」というのがあって、諦めることにしました。

原理としては、

  • 表示領域のstyleを獲得して
  • 裏(表示されない場所)で同じような領域を作ってレンダリングして
  • ソーステキストの表示結果がどこにあるか探す

という方法でできるのですが、結局画像含みのスクロール位置の問題があって、多分やってもムダです。

何であれそこそこのサイズの画像を「表示」するとレンダリングに時間がかかってしまい、元々そんなに軽くはない処理を表示位置を求めるためだけに二重にするというのは、あまりコスパが良いとは思えません。 素人が使うプロダクトであればそこも頑張る意味もありますが、これは基本的には社内システム、仮に公開しても「わかってる人」が使うだけですから、ここを頑張る価値はあまりないと思います。

ということで、諦めたことは抜きにして解決させたことを解決した順番に書いて行きます。

レンダリング結果が表示される時にスクロールがおかしくなる

ChatGPTに聞きながら、いろんなことを試してみました。

afterUpdateを使う方法は、結局何をやってもダメでした。 前述のように、画像の描画はafterUpdateよりも後にされるので、そこでスクロール位置をどうこうしてもダメです。 ですから、諸々の処理は画像が全部ロードされた後にしなければなりません。

そこで、

function waitForAllImages(container) {
  const images = container.querySelectorAll('img');
  const promises = Array.from(images).map((img) => {
    return new Promise((resolve) => {
      if (img.complete) {
        resolve();
      } else {
        img.addEventListener('load', resolve);
        img.addEventListener('error', resolve);
      }
    });
  });

  return Promise.all(promises);
}

afterUpdate(async () => {
  const result = document.getElementById('markdown');
  const previousScrollTop = result.scrollTop;

  // HTMLを更新
  result.innerHTML = renderMarkdown(value);

  // すべての画像のロードを待機
  await waitForAllImages(result);

  // スクロール位置を再設定
  result.scrollTop = previousScrollTop;
});

というコードを使いました。上のコードはChatGPTの提案で、実際のコードはちょっと違います。そもそも今時functionとか使わないんですけど、なぜか奴は使いたがる。

つまり、

  • 元々のスクロール位置を保存し(previousScrollTop)
  • HTMLの描画をさせて
  • 画像要素(img)を全部取得する(か失敗する)まで処理を待って
  • その後にスクロール位置を元に戻す

という処理になっています。 処理は結局afterUpdateの中に書いていますが、実際の処理はafterUpdateと非同期になっているのはわかりますね。

単純な処理ですがこれは有効でした。 いい感じに元々のスクロール位置のままになってくれます。

レンダリングさせる時にチラつく

これは、DOMの再描画が行われる時に画像も書き直しているせいです。 再描画する時にどたばたするのはしょうがないと思うのですが、高々数文字書き換えただけで全体を再描画するというのが、そもそもの間違いです。

そこでChatGPTの提案は、innerHTMLセットするのではなくて、差分更新をするということです。 全部更新する必要性なんてないですからね。

差分更新もちょっと考えると大変そうに思えるのですが、これにはライブラリがあります。

morphdom

    既存の DOM ノード ツリーを変形してターゲット DOM ノード ツリーと一致させる軽量モジュールです。高速で実際の DOM で動作します。仮想 DOM は必要ありません。

これを使うだけ。 実際のコードは、innerHTML = htmlとする代わりに、

import morphdom from 'morphdom';
...
morphdom(result,`<div>${html}</div>`);

とします。 ここに挙げたコードもChatGPTの提案そのままなので、実際には一工夫必要だったりするのですが、要するにinnerHTMLを書き換える代わりにmorphdomを使うわけです。

これにより、レンダリング時のチラつきはなくなり、当初の目標が達成されました。

まとめ

書いてしまうと簡単なことなのですが、一番ハマったのは画像はすぐには表示されないということです。 ブラウザを見ていれば当たり前と言えばそうなんですが、「処理」から見ると忘れがち。ChatGPTも教えてくれないし。

それに以前書いたものがそれなりにちゃんと動いていたので、よけいに原因がわかりませんでした。 まぁ、「ちゃんと動いていた」が嘘だったって話なんですけど、使い方次第で気がつかないと言うか。

近頃は「ChatGPT使えばすぐ」みたいな勘違いをする人は少なくないですが、ChatGPTは複数の手法を提案して来ますし、それが全て正解というわけでも、定石通りというわけでもありません。 結局、提案された手法のうち有効そうなものを選ぶのは自分の仕事なので、時短にはなっていますが何も考えないで出来るほどは簡単ではありませんね。 そもそも提案のうちでちゃんと機能してくれるものが少なかったり。

さて、後はスクロール位置を合わせられたら完璧なのですけどね。

最近のエントリー

ブラウザでの画面表示と印刷の描画差異に関する実践的考察

RSSリーダを作りました

Puppeteerは過去の遺物だったのでPlaywrightにした話

💥 Prisma vs Sequelize:実戦投入してわかった本当の話

第14回 人工知能は「第五の火」である

DeepSeek-R1-Distill-Qwen-14B-Japaneseの量子化ビット数による答えの精度の違い

使われていないMaix Amigo を活用して、バーコードリーダーにする

TinySwallow-1.5B-Instruct

cyberagent/DeepSeek-R1-Distill-Qwen-14B-Japanese

パスワードマネージャの一手法(公知化情報)

中華安お掃除ロボット(650円)を買ってみました

phi-4とDeepSeek-R1の翻訳性能比較

phi-4

DeepSeek-R1-Distill-Qwen-14B

新オフィスの様子

基本のパン

社会制度のこと

気候のこと

新春のお散歩

あけましておめでとうございます