Date: 2023-11-25
Tags: vue, virtualdom, markdown, mermaid

Vue MarkdownエディタにMermaidを組み込んで仮想DOMでレンダリング

MermaidJS をサイトに組み込む場合、多くのケースではMermaidが pre.mermaid を認識してグラフ等をレンダリングします。

しかし、この方法をMarkdownエディタのプレビューに組み込むと、エディタ側を1文字変更するごとにMermaid記法をHTMLの pre.mermaid タグで出力して、改めてSVGに置き換える処理を行うことになります。その結果、 preSVG に差し替わるタイミングでエレメントの高さが変わってしまい、プレビュー側のスクロール位置がズレてしまう問題があります。

先日のblog VueでMarkdownのプレビューをVueの仮想DOMで表示する でMarkdownエディタのプレビューを仮想DOMでレンダリングしました。 この延長で、MermaidのSVG出力も仮想DOMに含めるよう実装します。 そうすれば、変更がないエレメントは更新されないし、変更があるエレメントもはじめからSVGでレンダリングされるため、不要な高さ変更やそれに伴うスクローズ位置のズレを防止できます。

実装した結果、以下のデモ動画のように期待する動作が得られました。

エディタに入力したとき、innnerHTMLによる更新(プレビューの左側)ではMermaidの高さが変化してスクロール位置が変わってしまっています。 しかし、仮想DOMによる更新(プレビューの右側)では高さが変わらずスクロール位置への影響もありません。

前提

  • Node 18

  • Vue 3.3.4

  • TypeScript

  • markdown-it 13.0.2

  • Mermaid 10.6.1

コードはGitHubにあります。

https://github.com/shimizukawa/vue-md-editor-vdom/tree/2023.11.25

Mermaidレンダーコンポーネント

Mermaid記法を受け取ってSVGをレンダリングする MarkdownRendererMermaid を実装しました。 コード全体は https://github.com/shimizukawa/vue-md-editor-vdom/blob/2023.11.25/src/components/MarkdownRendererMermaid.vue にあります。

Mermaid記法のSVGレンダリングでは、Vueプラグインで初期化した $mermaid を使います。 $mermaid.parse() で記法のチェックを行い、エラーがあればそのまま画面に表示します。 エラーがなければ、 $mermaid.render() 関数でSVGをレンダリングします。

const render = async () => {
  rendered.value = await renderMermaid(index.value, content.value);
};

const renderMermaid = async (index: number, code: string): Promise<string> => {
  try {
    await $mermaid.parse(code);
  } catch ({ message }: any) {
    return message as string;
  }

  const { svg } = await $mermaid.render(`mermaid${index}`, code);
  return svg;
};

$mermaid.render() の第一引数はキャッシュキー(という理解)です。同じ値にするとキャッシュされたSVGを返してくるので、コンテンツ毎に変えます。キーに使っている index は記法が登場したエレメントの位置で、コンポーネントの外から受け取っています。

レンダリング結果は rendered に格納し、 v-html で表示します。 正常な場合はSVGが出力され、エラーがある場合は parse() が例外出力する message が画面に表示されます。

<template>
  <pre v-html="rendered" />
</template>

仮想DOMへのMermaidコンポーネント追加

仮想DOMの構築時に、HTMLタグではなく任意のコンポーネントの指定もできます。 詳細は公式ドキュメント レンダー関数と JSX | Vue.js にあります。

MarkdownRendererMermaid コンポーネントを仮想DOMの構築に組み込むには、以下のように実装します。

import { h } from 'vue'

const index = !node.parentNode ? 0 : (
  [
    ...node.parentNode.querySelectorAll(".mermaid")
  ].findIndex((_node) => _node === node)
);
const vnode = h(
  MarkdownRendererMermaid, // type
  {
    content: node.textContent,
    index,
  }, // props
  null, // children / slot
)

markdown-itの実装で、mermaidコードブロックはコードハイライトなどせずそのまま出力しています。 そのため、 node.textContent にはMermaid記法がそのまま格納されています。 また、一意なindexを用意するため、ノード全体での登場位置を算出してコンポーネントに渡します。

これで、Mermaid記法のコードブロックを書き替えたときに、スクロール位置への影響を無くせました。 また、SVGの生成を自前のコードで行っているため、連続で生成する場合にインターバルを設けて生成負荷を下げるといったことも可能になりました。