😄

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"
  }
}

実装

ページの実装

app/page.tsx
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など使う場合は適宜変更してください

@/app/_components/markdown.tsx
"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が変更されたときに再度レンダリングされます
@/app/_components/mermaid.tsx
"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

現在のズームのパーセンテージを表示するコンポーネントです。

@/app/_components/zoom-meter
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

簡単な操作説明を表示します。あまり分割する意味がないかもしれないですね。

@/app/_components/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から各種操作関数を渡すことで操作が行えるようにします。

@/app/_components/toolbar
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

修正して以下のようになりました。

@/app/_components/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;
};

変更点

  1. applyTransformの実装
    svg要素の操作に関する責務を担うように実装したつもりです

  2. handleZoomIn, handleZoomOut, handleResetの実装
    ボタン経由でズームイン、ズームアウト、リセットを行う関数を定義しています。状態を変更するだけです

  3. handleWheelの実装
    deltaYは垂直スクロール量を表すWheelEventのプロパティです。ホイールを上にスクロールすると正の値が、下にスクロールすると負の値を返します。
    最後のsetZoomのところでMath.min, maxを併用することで最小10%から最大500%までズームが行えるようにしています。

  4. handleMouseDown, handleMouseMove, handleMouseUpの実装
    mouseDownイベント: マウスボタンがクリックした
    mouseUpイベント: マウスボタンを離した
    mouseMoveイベント: 要素上でマウスを動かすたびに発生するイベント

ちなみに似たようなのでclickがありますが、それは一番最後に呼ばれるイベントだと知りました。

以下参考サイトより引用

1つのアクションが複数イベントを発生させる場合、順序は決まっています。つまり、ハンドラは mousedown → mouseup → click の順番で呼び出されます。

handleMouseDownで現在のマウス位置を保存してフラグを立て、handleMouseMoveでマウスを動かしている間svgの位置が操作でき、handleMouseUpでその位置を保持して終了という感じです。

最終画面

最終画面

参考

最後に

間違っていることがあれば、コメントに書いていただけると幸いです。
よろしくお願いいたします。

GitHubで編集を提案

Discussion