Reactで拡大・縮小・移動が可能なMermaidコンポーネントを実装する
Reactで拡大・縮小・移動が可能なMermaidコンポーネントを実装する
ReactでMermaidを使ってアーキテクチャ図を表示することがありました。小規模な図だととても便利なMermaidなのですが、大規模な図では拡大ができず見にくいと思うことが多々ありました。なので、大規模な図でも見やすいように拡大・縮小・移動が可能なコンポーネントの実装に挑戦しました。
リポジトリ
デモURL
ライブラリ
{
"dependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.511.0",
"mermaid": "^11.6.0",
"next": "15.3.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"tailwind-merge": "^3.3.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.3.2",
"tailwindcss": "^4",
"tw-animate-css": "^1.3.0",
"typescript": "^5"
}
}
実装
ページの実装
export default function Home() {
return (
<div className="w-screen h-screen p-6 overflow-x-hidden">
<Markdown
code={content}
/>
</div>
);
}
ReactMarkdown
今回はMermaidを表示するのが目標なのでcode, preのみ調整しました。
SyntaxHighlighter
など使う場合は適宜変更してください
"use client"
import { cn } from "@/lib/utils"
import ReactMarkdown from "react-markdown"
import React from "react"
export const Markdown = ({ code }: { code: string }) => {
return (
<ReactMarkdown
components={{
// eslint-disable-next-line unused-imports/no-unused-vars
pre: ({ node, ...props }) => <pre {...props} className={cn(props.className, "w-full")} />,
// eslint-disable-next-line unused-imports/no-unused-vars
code: ({ node, children, className }) => {
if (className === "language-mermaid") {
return <Mermaid code={children as string} />
} else {
// ここでSyntaxHighlighterなど
return <></>
}
},
}}
>
{code}
</ReactMarkdown>
)
}
Mermaid
とりあえず最低限の実装は以下の通り
-
mermaid.render()
を使用してSVGに変換したMermaidのコードをref
を使用して表示してます - 文法間違いなどのコードが入力された場合は
Invalid syntax
を表示します - 初回レンダリング時、codeが変更されたときに再度レンダリングされます
"use client"
import { cn } from "@/lib/utils"
import mermaid from "mermaid"
import React from "react"
export const Mermaid = (props: { code: string }) => {
const { code } = props;
const outputRef = React.useRef<HTMLDivElement>(null);
const id = React.useId();
const render = React.useCallback(async () => {
if (outputRef.current && code) {
try {
const { svg } = await mermaid.render(id, code);
outputRef.current.innerHTML = svg;
} catch (error) {
outputRef.current.innerHTML = "Invalid syntax";
}
}
}, [code, id]);
React.useEffect(() => {
render();
}, [render]);
return code ? (
<div style={{ backgroundColor: "#fff" }}>
<div ref={outputRef} className="[&>svg]:h-56" />
</div>
) : null;
};
今のところこんな感じで表示されます。
もうすでに文字が小さくなって見にくい状態です。
ではここから拡大・縮小・移動ができるように改良していきます。
基本的にはMermaid
コンポーネントのみコードを書きます
実装2
ZoomMeter
現在のズームのパーセンテージを表示するコンポーネントです。
interface Props {
zoom: number;
}
export const ZoomMeter = ({ zoom }: Props) => (
<div className="absolute top-2 right-2 z-10 bg-white rounded-md shadow-lg border px-2 py-1 text-sm">
{Math.round(zoom * 100)}%
</div>
)
Manual
簡単な操作説明を表示します。あまり分割する意味がないかもしれないですね。
export const Manual = () => (
<div className="absolute bottom-2 left-2 z-10 bg-black bg-opacity-75 text-white text-xs px-2 py-1 rounded">
ホイール: ズーム | ドラッグ: 移動
</div>
)
Toolbar
拡大・縮小・リセットボタンをまとめたツールバーコンポーネントです。propsから各種操作関数を渡すことで操作が行えるようにします。
import { RotateCcwIcon, ZoomInIcon, ZoomOutIcon } from "lucide-react";
interface Props {
zoomIn: () => void;
zoomOut: () => void;
reset: () => void;
}
export const Toolbar = ({ zoomIn, zoomOut, reset }: Props) => (
<div className="absolute top-2 left-2 z-10 flex gap-1 bg-white rounded-md shadow-lg border p-1">
<button
onClick={zoomIn}
className="p-2 hover:bg-gray-100 rounded transition-colors">
<ZoomInIcon size={16} />
</button>
<button
onClick={zoomOut}
className="p-2 hover:bg-gray-100 rounded transition-colors">
<ZoomOutIcon size={16} />
</button>
<button
onClick={reset}
className="p-2 hover:bg-gray-100 rounded transition-colors">
<RotateCcwIcon size={16} />
</button>
</div>
)
Mermaid
修正して以下のようになりました。
"use client"
import { Manual } from "@/app/_components/manual";
import { Toolbar } from "@/app/_components/toolbar";
import { ZoomMeter } from "@/app/_components/zoom-meter";
import mermaid from "mermaid";
import React, { useCallback, useState } from "react";
export const Mermaid = ({ code }: { code: string }) => {
const id = React.useId();
const outputRef = React.useRef<HTMLDivElement>(null);
const svgRef = React.useRef<SVGElement>(null);
const [zoom, setZoom] = useState<number>(1);
const [position, setPosition] = useState({ x: 0, y: 50 });
const [isDragging, setIsDragging] = useState(false);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const applyTransform = useCallback(() => {
if (svgRef.current) {
svgRef.current.style.transform = `translate(${position.x}px, ${position.y}px) scale(${zoom})`;
}
}, [position.x, position.y, zoom]);
const render = React.useCallback(async () => {
if (outputRef.current && code) {
try {
const { svg } = await mermaid.render(id, code);
outputRef.current.innerHTML = svg;
const svgElement = outputRef.current.querySelector("svg");
if (svgElement) {
svgRef.current = svgElement;
svgElement.style.transition = isDragging ? "none" : "transform 0.2s ease";
}
} catch (error) {
outputRef.current.innerHTML = "Invalid syntax";
}
}
}, [code, id]);
const handleZoomIn = () => setZoom((prev) => Math.min(prev * 1.2, 5))
const handleZoomOut = () => setZoom((prev) => Math.max(prev / 1.2, 0.1))
const handleReset = () => {
setZoom(1);
setPosition({ x: 0, y: 0 });
}
const handleWheel = (e: React.WheelEvent) => {
e.preventDefault();
const delta = e.deltaY > 0 ? 0.9 : 1.1
setZoom((prev) => Math.max(0.1, Math.min(5, prev * delta)))
}
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
setIsDragging(true);
setDragStart({ x: e.clientX - position.x, y: e.clientY - position.y });
if (svgRef.current) {
svgRef.current.style.transition = "none";
}
},
[position.x, position.y],
);
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
if (isDragging) {
requestAnimationFrame(() => {
setPosition({
x: e.clientX - dragStart.x,
y: e.clientY - dragStart.y,
});
});
}
},
[isDragging, dragStart.x, dragStart.y],
);
const handleMouseUp = useCallback(() => {
setIsDragging(false);
if (svgRef.current) {
svgRef.current.style.transition = "transform 0.2s ease";
}
}, []);
React.useEffect(() => {
render();
}, [render]);
React.useEffect(() => {
applyTransform();
}, [position, zoom, applyTransform]);
return code ? (
<div className="relative w-full h-96 border border-gray-300 rounded-lg overflow-hidden bg-white font-geist-sans">
<Toolbar
zoomIn={handleZoomIn}
zoomOut={handleZoomOut}
reset={handleReset}
/>
<ZoomMeter zoom={zoom} />
<div
className="w-full h-full overflow-hidden"
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
style={{
cursor: isDragging ? "grabbing" : "grab",
userSelect: "none",
}}
>
<div ref={outputRef} className="w-full h-full" />
</div>
<Manual />
</div>
) : null;
};
変更点
-
applyTransform
の実装
svg要素の操作に関する責務を担うように実装したつもりです -
handleZoomIn
,handleZoomOut
,handleReset
の実装
ボタン経由でズームイン、ズームアウト、リセットを行う関数を定義しています。状態を変更するだけです -
handleWheel
の実装
deltaY
は垂直スクロール量を表すWheelEventのプロパティです。ホイールを上にスクロールすると正の値が、下にスクロールすると負の値を返します。
最後のsetZoom
のところでMath.min, maxを併用することで最小10%から最大500%までズームが行えるようにしています。 -
handleMouseDown
,handleMouseMove
,handleMouseUp
の実装
mouseDownイベント: マウスボタンがクリックした
mouseUpイベント: マウスボタンを離した
mouseMoveイベント: 要素上でマウスを動かすたびに発生するイベント
ちなみに似たようなのでclickがありますが、それは一番最後に呼ばれるイベントだと知りました。
以下参考サイトより引用
1つのアクションが複数イベントを発生させる場合、順序は決まっています。つまり、ハンドラは mousedown → mouseup → click の順番で呼び出されます。
handleMouseDown
で現在のマウス位置を保存してフラグを立て、handleMouseMove
でマウスを動かしている間svgの位置が操作でき、handleMouseUp
でその位置を保持して終了という感じです。
最終画面
参考
最後に
間違っていることがあれば、コメントに書いていただけると幸いです。
よろしくお願いいたします。
Discussion