HTML・MarkdownへMermaidの図を埋め込む
ブラウザ側スクリプトでMermaidを読み込み、図の定義からSVGを作れば、本体を改修せずに図を表示できます。 HTMLで指定した場所と、説明項目のMarkdownのmermaidブロックを対象にします。
ここではプリザンター1.5.8.1と、同梱のMermaid 11.9.0を使います。元の文字列を保持し、表示用SVGをレコードへ保存する処理は行いません。PlantUMLとLaTeXの埋め込みも用途に応じて使えます。
| 方法 | 描画する場所 | 用途 |
|---|---|---|
| Mermaid | 利用者のブラウザ | フロー図、シーケンス図、ER図など |
| PlantUML | 別途用意する描画サーバー | PlantUMLで書いたUMLなどを画像表示 |
Mermaidでは図の定義を外部の描画サーバーへ送信しません。ライブラリの取得と、図の描画を分けて考えます。
1. スクリプトとスタイルを配置する
対象サイトの**ブラウザ側の「スクリプト」**へJS全文、「スタイル」へCSS全文を入れます。HTML・Markdownを表示する画面で実行してください。全サイトなら拡張スクリプトと拡張スタイルで共通化します。
このJSはMermaidとPlantUMLの両方に対応します。Mermaidだけなら設定オブジェクトは不要です。PlantUMLの追加設定は別の記事で説明します。同じファイルなので、一度だけ配置します。
描画スクリプトの全文
(function () {
'use strict';
if (window.PleasanterDiagrams) {
window.PleasanterDiagrams.refresh();
return;
}
const config = window.PleasanterDiagramsConfig || {};
const states = new WeakMap();
const nonce = (document.currentScript || document.querySelector('script[nonce]'))?.nonce;
let library;
let queued = false;
let sequence = 0;
function loadMermaid() {
if (library) return library;
library = (async function () {
if (!window.mermaid) {
const base = document.getElementById('ApplicationPath')?.value;
if (!base && !config.mermaidUrl) throw new Error('ApplicationPathを取得できません。');
await new Promise(function (resolve, reject) {
const script = document.createElement('script');
if (nonce) script.nonce = nonce;
script.src = config.mermaidUrl || base + 'Extensions/mermaid-11.9.0.min.js';
const timer = setTimeout(function () {
script.remove();
reject(new Error('Mermaidの読み込みがタイムアウトしました。'));
}, 20000);
script.onload = function () { clearTimeout(timer); resolve(); };
script.onerror = function () {
clearTimeout(timer);
script.remove();
reject(new Error('Mermaidを読み込めません。'));
};
document.head.appendChild(script);
});
}
if (!window.mermaid?.render) throw new Error('Mermaid APIがありません。');
window.mermaid.initialize({
startOnLoad: false,
securityLevel: 'strict',
suppressErrorRendering: true,
maxTextSize: 50000,
theme: 'default',
fontFamily: getComputedStyle(document.body).fontFamily
});
return window.mermaid;
})();
return library;
}
function plantUmlUrl(source) {
if (!config.plantUmlServer) throw new Error('plantUmlServerを設定してください。');
const server = new URL(config.plantUmlServer, location.href);
if (!['https:', 'http:'].includes(server.protocol) || server.username ||
server.password || server.search || server.hash) {
throw new Error('PlantUMLサーバーのURLを確認してください。');
}
const encoded = Array.from(new TextEncoder().encode(source), function (byte) {
return byte.toString(16).padStart(2, '0');
}).join('');
const url = server.href.replace(/\/$/, '') + '/svg/~h' + encoded;
if (url.length > 8000) throw new Error('PlantUMLのURLが8000文字を超えています。');
return url;
}
function loadImage(url) {
return new Promise(function (resolve, reject) {
const img = document.createElement('img');
img.alt = 'PlantUMLで描画した図';
img.referrerPolicy = 'no-referrer';
const timer = setTimeout(function () {
img.removeAttribute('src');
reject(new Error('PlantUMLの画像取得がタイムアウトしました。'));
}, 20000);
img.onload = function () { clearTimeout(timer); resolve(img); };
img.onerror = function () {
clearTimeout(timer);
reject(new Error('PlantUMLの画像を取得できません。'));
};
img.src = url;
});
}
async function update(owner, type, source, pre) {
const key = type + '\n' + source;
const previous = states.get(owner);
if (previous?.key === key) return;
previous?.output?.remove();
if (pre) {
pre.hidden = false;
pre.classList.remove('pleasanter-diagram-source');
} else owner.textContent = source;
const state = { key: key, output: null };
states.set(owner, state);
if (!source.trim()) return;
const current = function () {
return owner.isConnected && states.get(owner) === state &&
(pre ? owner.textContent : owner.getAttribute('data-source') || '') === source &&
(pre || owner.getAttribute('data-diagram') === type);
};
try {
if (source.length > 50000) throw new Error('図の定義が50000文字を超えています。');
const output = document.createElement('div');
output.className = 'pleasanter-diagram-output';
if (type === 'mermaid') {
const mermaid = await loadMermaid();
if (document.fonts) await document.fonts.ready;
if (!current()) return;
const result = await mermaid.render('pleasanter-diagram-' + (++sequence), source);
if (!current()) return;
output.innerHTML = result.svg;
} else if (type === 'plantuml') {
output.appendChild(await loadImage(plantUmlUrl(source)));
} else throw new Error('図の種類を確認してください。');
if (!current()) return;
if (pre) {
const block = owner.closest('.md-code-block');
if (!block) return;
block.appendChild(output);
pre.hidden = true;
pre.classList.add('pleasanter-diagram-source');
} else {
owner.textContent = '';
owner.appendChild(output);
}
state.output = output;
} catch (error) {
if (current()) console.warn('[PleasanterDiagrams] 図を確認してください', error);
}
}
function refresh() {
if (queued) return;
queued = true;
requestAnimationFrame(function () {
queued = false;
document.querySelectorAll('.pleasanter-diagram[data-diagram]').forEach(function (element) {
void update(element, element.getAttribute('data-diagram'), element.getAttribute('data-source') || '');
});
document.querySelectorAll('.md-viewer .md-code-block > pre > code.language-mermaid, ' +
'.md-viewer .md-code-block > pre > code.language-plantuml').forEach(function (code) {
const type = code.classList.contains('language-mermaid') ? 'mermaid' : 'plantuml';
void update(code, type, code.textContent || '', code.parentElement);
});
});
}
const api = { refresh: refresh, ready: null };
window.PleasanterDiagrams = api;
api.ready = new Promise(function (resolve) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', resolve, { once: true });
} else resolve();
}).then(function () {
const observer = new MutationObserver(function (mutations) {
if (mutations.some(function (mutation) {
if (mutation.type === 'attributes') return true;
const target = mutation.target.nodeType === 1 ? mutation.target : mutation.target.parentElement;
return !target?.closest('.pleasanter-diagram-output, .pleasanter-diagram');
})) refresh();
});
observer.observe(document.body, {
childList: true, subtree: true, attributes: true,
attributeFilter: ['data-source', 'data-diagram']
});
refresh();
return true;
});
})();スタイルの全文
.md-code-block > pre.pleasanter-diagram-source {
display: none !important;
}
.pleasanter-diagram-output {
max-width: 100%;
overflow-x: auto;
padding: 0.75em;
background: #fff;
color: #222;
}
.pleasanter-diagram-output > svg,
.pleasanter-diagram-output > img {
display: block;
max-width: 100%;
height: auto;
margin: 0 auto;
}図が見つかったときに、Hidden要素ApplicationPathの値とExtensions/mermaid-11.9.0.min.jsを結合して読み込みます。サブパス配置にも対応します。ライブラリは一度だけ読み込み、既存のwindow.mermaidがあれば再利用します。
本体もサイト設定の可視化ページで、このファイルを読み込んでいます。ただし、通常のレコード画面のMarkdownを自動で図へ変換する機能ではありません(可視化ページの読み込み)。
別の配信先なら、JSより前に次を実行します。利用する版と配布構成はMermaidの読み込み方法で確認し、版を変えた場合は図も再検証します。
window.PleasanterDiagramsConfig = {
mermaidUrl: '/diagram-assets/mermaid-11.9.0.min.js'
};初期化ではstartOnLoad: false・securityLevel: 'strict'・theme: 'default'を指定します。同じ画面ですでにMermaidを使っている場合は、共通インスタンスの設定も変わるため、既存の図との設定を揃えます。
2. HTMLの指定場所へフロー図を入れる
テーブル管理のHTMLや拡張HTMLへ、次を入れます。種類はdata-diagram、図の定義はdata-sourceです。
<div class="pleasanter-diagram" data-diagram="mermaid"
data-source="flowchart LR
A[申請] --> B{確認}
B -->|承認| C[処理]
B -->|差戻し| A"></div>属性の改行も図の文字列になります。属性内の&と二重引用符は、&・"へエスケープします。JavaScriptからはsetAttribute('data-source', source)へ文字列を渡せば、変更後に描き直します。
このHTMLをMarkdown本文へ貼っても同じ動作にはなりません。本体のMarkdownは生のHTMLをエスケープするため、次のコードブロックを使います。
3. Markdownにシーケンス図を書く
説明項目の先頭を[md]とし、言語名をmermaidにします。
[md]
## 受付の流れ
```mermaid
sequenceDiagram
participant U as 利用者
participant P as プリザンター
U->>P: 申請を登録
P-->>U: 登録結果を表示
```ER図なら、ブロックの中を次へ置き換えます。
erDiagram
PROJECT ||--o{ TASK : contains
PROJECT {
int id
string title
}
TASK {
int id
int projectId
}説明項目のビューアが無効の場合、リッチテキストの場合、外部へのMarkdown出力では、このスクリプトは適用されません。
本体のDOMを利用する
本体は言語名からlanguage-mermaidのクラスを作り、表示用コードとコピー用コードをmd-code-blockにまとめます。
return `<div class="md-code-block">
<button class="md-code-copy">
<span class="md-btn-icon material-symbols-outlined">content_copy</span>
<pre class="md-code-copy-item" name="copy_data">${this.escapeHtml(text)}</pre>
</button>
<div class="md-code-copied">Copied!</div>
<pre><code class="hljs ${lang ? `language-${lang}` : ''}">${highlighted}</code></pre>
</div>`;.md-viewer .md-code-block > pre > code.language-mermaidからtextContentを読み、描画成功後に表示用preを隠してSVGを追加します。コピー用コードとボタンは残るので、図の表示後も元の定義をコピーできます。構文エラーの場合は元のコードを残し、Consoleへ理由を出します。
Ajax後の再描画と非同期処理
本体はサニタイズしたMarkdownをinnerHTMLへ入れ直します(更新処理)。後から追加したSVGも消えるので、再描画が必要です。
図を読み込み中…
監視はdata-editableだけに依存せず、子ノードの差し替えとdata-source・data-diagramを扱います。後から追加されたコメントやダイアログも、セレクタが合えば対象になります。
WeakMapで要素ごとの状態を持ち、同じ文字列は繰り返し描きません。処理中に定義が変わったり、項目が取り外されたりした場合は、古い処理結果を適用しません。JSの再実行では、初期化済みのPleasanterDiagramsを使います。
document.fonts.readyも待ちます。日本語フォントへ切り替わる前にラベルを計測すると、文字と箱の寸法が合わない場合があるためです。
動作を確認する
| 症状 | 確認すること |
|---|---|
| コードのまま | 実行画面、[md]、言語名、ビューア設定、Consoleの構文エラー |
| ライブラリが読めない | ApplicationPath、配信ファイル、Network、CSPのscript許可。修正後は再読み込み |
| 図とコードが両方出る | CSS全文を設定したか |
| 図が暗い背景に埋もれる | CSSは白背景、図はdefaultテーマ。画面と図の配色を揃える |
| 長い定義を描けない | サンプルは50000文字まで。図を分割する |
| クリックが動かない | strict指定のため、図のクリック機能を使う実装ではない |
strictはラベル内のHTMLとクリック機能を制限します(securityLevel)。生成SVGは挿入しますが、付随するbindFunctionsは呼びません。
await window.PleasanterDiagrams.readyは監視の準備完了を示し、全ての図の描画完了を示す値ではありません。refresh()で対象を再確認できます。同じ定義で失敗した図は再試行しないので、修正するかページを再読み込みします。
配置後は初期表示、ビューア切り替え、Ajax差し替え、定義変更、構文エラー、コピーを確認します。本体改修との違いはMarkdownフィールドの拡張で説明しています。
検証では本体同梱版の描画APIで上記3種類の図を生成し、ビューアDOMを模した環境でコピー元の保持・差し替え・競合対策を確認しています。DOM検証環境ではSVGの寸法計測を補っているため、実際の文字と箱の配置は設置先のブラウザ・フォントで確認してください。