ScrapboxでMermaid記法を可視化するUserScriptを作った話

| 9 min read
Author: noriyuki-yagi noriyuki-yagiの画像

この記事は夏のリレー連載2023第12日目の記事です。

弊社豆蔵では、Helpfeel社 による Scrapbox を社内公式ツールとして導入しています。
Scrapboxはそのままでも十分に便利で面白いツールなのですが、UserScriptを使うことでさらに自由度の高いカスタマイズができます。

今回は、Scrapbox上でMermaid記法のコードを可視化して表示するUserScriptを作成した話をしたいと思います。

今回作ったもの

#

まず最初に、今回作ったUserScriptのデモ動画をご覧ください。

デモ動画

このような感じでMermaidコードを表示できます。

UserScriptで扱えるオブジェクトを見てみよう

#

まずはUserScriptでどんなオブジェクトを扱えるかを調べてみようと思います。

ブラウザで任意のScrapboxのページを開き、デベロッパーツールのコンソール上で『window』と入力すると、そのページ上で定義されているグローバル変数の一覧が表示されます。
ここで表示されているグローバル変数は基本的にUserScriptで扱うことができます。

デベロッパーツールのコンソール上で『window』と入力

ここで抑えておきたいポイントは、jQueryが使えることと、scrapboxグローバル変数にScrapboxのページ情報が格納されているところです。

jQuery

scrapboxグローバル変数

scrapboxグローバル変数の構造は大まかに下図のようになっています。

scrapboxクラス図

ページ内のコードブロックはscrapbox.Page.linesを辿ることで取得できそうですね。
コードで書くと下記のような感じでしょうか。

const result = [];
let text = "";
for (const line of scrapbox.Page.lines) {
if (line.codeBlock && line.codeBlock.lang === "mermaid") {
if (line.codeBlock.start) {
text = "";
} else {
text += "\n" + line.text;
}
if (line.codeBlock.end) {
text = text.trim();
result.append(text);
}
}
}

Mermaidライブラリの読み込みと呼び出し

#

Mermaidライブラリの読み込みは、下記のようにjQueryのgetScript関数を使うことで簡単に読み込めます。

 $(() => {
$.getScript("https://cdnjs.cloudflare.com/ajax/libs/mermaid/8.14.0/mermaid.min.js")
.done((script, textStatus) => {
mermaid.mermaidAPI.initialize({ startOnLoad: false })
/** Mermaidを使った処理を記述(詳細割愛) **/
})
.fail((jqxhr, settings, exception) => {
console.error(exception)
})

今回の記事で使用しているMermaidライブラリのバージョンは8.14.0です。
Mermaidの新しいバージョンとは互換性が無いと思うので参考にする場合は注意してください。

下記のようにAPIを呼び出すことで、Mermaidコードからダイアグラム(SVG形式)を生成できます。

const svgId = /* SVG要素のidとなる一意の値 */
const mermaidCode = /* Mermaidコード */
const svg = mermaid.mermaidAPI.render(svgId, mermaidCode);

ScrapboxではLineのidの先頭に"L"をつけた値がLineを表示するHTML要素のidと一致するため、下記のようにすることでダイアログラムをコードの下部に表示させることができます。

const mermaidCodeLastLineId = /* Mermaidコードの最終行のid */
$("#L" + mermaidCodeLastLineId).after(svg)

また、下記のようにダイアグラムのClickイベントを拾ったり、マウスカーソルのアイコンを変更したりもできます。

$("#" + svgId).on("click", () => onSvgClicked()).css("cursor","pointer")

最終的なコードリスト

#

今回のUserScriptを作る上での基本的なポイントは以上になります。

最終的には下記の機能をUserScriptに埋め込みました。

  • ページ表示後にMermaidコードブロックを探してダイアグラムを生成
  • ページ編集時に差分を検知して、Mermaidコードブロックが変更されたらそれに対応するダイアグラムのみ再生成
  • ダイアグラムのマウスクリックでMermaidコードブロックの表示/非表示の切替(デフォルト非表示)

コードリストは下記になります。

$(() => {
$.getScript("https://cdnjs.cloudflare.com/ajax/libs/mermaid/8.14.0/mermaid.min.js")
.done((script, textStatus) => {
mermaid.mermaidAPI.initialize({
startOnLoad: false
})
const mermaidViewer = new MermaidViewer()
mermaidViewer.onScrapboxPageChanged()
scrapbox.on("page:changed", () => mermaidViewer.onScrapboxPageChanged())
scrapbox.on("lines:changed", () => mermaidViewer.onScrapboxLinesChanged())
})
.fail((jqxhr, settings, exception) => {
console.error(exception)
})

const MermaidViewer = function () {
const DEFAULT_SHOW_CODE = false
this.recentMermaidCodes = new Map()
this.codeViewStatusRepository = new MermaidCodeViewStatusRepository()

this.onScrapboxLinesChanged = function () {
if (scrapbox.Page.lines) {
this.updateDiagrams()
}
}

this.onScrapboxPageChanged = function () {
if (scrapbox.Page.lines) {
this.updateDiagrams()
this.setAllCodeViewStatus(DEFAULT_SHOW_CODE)
}
}

// すべてのコードブロックの表示ステータスを変更
// 引数: value 表示ステータス (true|false)
this.setAllCodeViewStatus = function (value) {
for (const [id, code] of this.recentMermaidCodes) {
code.setCodeViewStatus(value)
}
}

// 変更があればダイアグラムを更新
this.updateDiagrams = function () {
const newCodes = this.findMermaidCodes()
const diff = MermaidViewerUtils.diffMermaidCodes(this.recentMermaidCodes, newCodes)
for (const item of diff) {
if (item.op === "delete") {
item.code.deleteDiagram()
} else {
item.code.updateDiagram()
}
}
this.recentMermaidCodes = newCodes
}

// mermaidコードをページ内から検索
// 戻り値: Map型
// キー: コードブロックのID(最初の行ID)
// 値: MermaidCode
this.findMermaidCodes = function () {
const result = new Map()
var text, filename, id, lastLineId, lineIds
for (const line of scrapbox.Page.lines) {
if (line.codeBlock && line.codeBlock.lang === "mermaid") {
if (line.codeBlock.start) {
text = ""
id = line.id
lineIds = new Set()
} else {
text += "\n" + line.text
}
lineIds.add(line.id)
if (line.codeBlock.end) {
lastLineId = line.id
text = text.trim()
result.set(id, new MermaidCode(id, text, lastLineId, lineIds, this.codeViewStatusRepository))
}
}
}
return result
}
}

const MermaidCode = function (id, text, lastLineId, lineIds, codeViewStatusRepository) {
const MERMAID_SVG_ID_PREFIX = "mermaid-"
this.id = id
this.text = text
this.lastLineId = lastLineId
this.lineIds = lineIds
this.codeViewStatusRepository = codeViewStatusRepository
this.svgId = MERMAID_SVG_ID_PREFIX + id

// mermaidダイアグラムを更新
this.updateDiagram = function () {
try {
const svg = mermaid.mermaidAPI.render(this.svgId, this.text)
$("#" + this.svgId).remove()
$("#L" + this.lastLineId).after(svg)
} catch (e) {
console.error(e)
$("#L" + this.lastLineId).after($("#" + this.svgId))
}
$("#" + this.svgId)
.on("click", () => this.onSvgClicked())
.css("cursor", "pointer")
}

// mermaidダイアグラムを削除
this.deleteDiagram = function () {
$("#" + this.svgId).remove()
}

// mermaidダイアグラム(SVG)がクリックされたときのイベントハンドラ
// コードブロックの表示ステータスを変更
this.onSvgClicked = function () {
this.codeViewStatusRepository.changeStatus(this.id)
this.applyCodeView()
}

// コードブロックの表示ステータスを適用
this.applyCodeView = function () {
const status = this.codeViewStatusRepository.getStatus(this.id)
for (const lineId of this.lineIds) {
if (status) {
$("#L" + lineId).show(100)
} else {
$("#L" + lineId).hide(100)
}
}
}

// コードブロックの表示ステータスを変更
this.setCodeViewStatus = function (value) {
this.codeViewStatusRepository.setStatus(this.id, value)
this.applyCodeView()
}
}

const MermaidCodeViewStatusRepository = function () {
this.status = new Map()
this.defaultValue = true

this.changeStatus = function (id) {
const old = this.status.has(id) ? this.status.get(id) : this.defaultValue
this.status.set(id, !old)
}

this.getStatus = function (id) {
return this.status.has(id) ? this.status.get(id) : this.defaultValue
}

this.setStatus = function (id, value) {
this.status.set(id, value)
}
}

const MermaidViewerUtils = {}
// 2つのMap型に格納されたコードの差分を返す
// 引数: oldMap 古い値(Map型)
// 引数: newMap 新しい値(Map型)
MermaidViewerUtils.diffMermaidCodes = function (oldMap, newMap) {
const result = []
const intersection = new Set()
for (const [key, val] of newMap) {
if (!oldMap.has(key)) {
result.push({
op: "new",
key: key,
code: newMap.get(key)
})
} else {
intersection.add(key)
}
}
for (const [key, val] of oldMap) {
if (!newMap.has(key)) {
result.push({
op: "delete",
key: key,
code: oldMap.get(key)
})
intersection.delete(key)
}
}
for (const key of intersection) {
const oldVal = oldMap.get(key)
const newVal = newMap.get(key)
if (oldVal.text !== newVal.text) {
result.push({
op: "changed",
key: key,
code: newMap.get(key)
})
}
}
return result;
}
})

おわりに

#

今回作成したUserScripはここで公開しています。

ScrapboxでMermaid記法を可視化するUserScriptを作った話は以上となります。

豆蔵デベロッパーサイト - 先週のアクセスランキング
  1. 基本から理解するJWTとJWT認証の仕組み (2022-12-08)
  2. AWS認定資格を12個すべて取得したので勉強したことなどをまとめます (2022-12-12)
  3. Nuxt3入門(第4回) - Nuxtのルーティングを理解する (2022-10-09)
  4. Backstageで開発者ポータルサイトを構築する - 導入編 (2022-04-29)
  5. Nuxt3入門(第8回) - Nuxt3のuseStateでコンポーネント間で状態を共有する (2022-10-28)
  6. Viteベースの高速テスティングフレームワークVitestを使ってみる (2022-12-28)
  7. ORマッパーのTypeORMをTypeScriptで使う (2022-07-27)
  8. Nuxt3入門(第1回) - Nuxtがサポートするレンダリングモードを理解する (2022-09-25)
  9. GitHub Actions - 構成変数(環境変数)が外部設定できるようになったので用途を整理する (2023-01-16)
  10. Jest再入門 - 関数・モジュールモック編 (2022-07-03)