import React, { useEffect, useRef, useState } from 'react'; import mermaid from 'mermaid'; // We'll use dynamic import for svg-pan-zoom // Initialize mermaid with defaults - Japanese aesthetic mermaid.initialize({ startOnLoad: true, theme: 'neutral', securityLevel: 'loose', suppressErrorRendering: true, logLevel: 'error', maxTextSize: 100000, // Increase text size limit htmlLabels: true, flowchart: { htmlLabels: true, curve: 'basis', nodeSpacing: 60, rankSpacing: 60, padding: 20, }, themeCSS: ` /* Japanese aesthetic styles for all diagrams */ .node rect, .node circle, .node ellipse, .node polygon, .node path { fill: #f8f4e6; stroke: #d7c4bb; stroke-width: 1px; } .edgePath .path { stroke: #9b7cb9; stroke-width: 1.5px; } .edgeLabel { background-color: transparent; color: #333333; p { background-color: transparent !important; } } .label { color: #333333; } .cluster rect { fill: #f8f4e6; stroke: #d7c4bb; stroke-width: 1px; } /* Sequence diagram specific styles */ .actor { fill: #f8f4e6; stroke: #d7c4bb; stroke-width: 1px; } text.actor { fill: #333333; stroke: none; } .messageText { fill: #333333; stroke: none; } .messageLine0, .messageLine1 { stroke: #9b7cb9; } .noteText { fill: #333333; } /* Dark mode overrides - will be applied with data-theme="dark" */ [data-theme="dark"] .node rect, [data-theme="dark"] .node circle, [data-theme="dark"] .node ellipse, [data-theme="dark"] .node polygon, [data-theme="dark"] .node path { fill: #222222; stroke: #5d4037; } [data-theme="dark"] .edgePath .path { stroke: #9370db; } [data-theme="dark"] .edgeLabel { background-color: transparent; color: #f0f0f0; } [data-theme="dark"] .label { color: #f0f0f0; } [data-theme="dark"] .cluster rect { fill: #222222; stroke: #5d4037; } [data-theme="dark"] .flowchart-link { stroke: #9370db; } /* Dark mode sequence diagram overrides */ [data-theme="dark"] .actor { fill: #222222; stroke: #5d4037; } [data-theme="dark"] text.actor { fill: #f0f0f0; stroke: none; } [data-theme="dark"] .messageText { fill: #f0f0f0; stroke: none; font-weight: 500; } [data-theme="dark"] .messageLine0, [data-theme="dark"] .messageLine1 { stroke: #9370db; stroke-width: 1.5px; } [data-theme="dark"] .noteText { fill: #f0f0f0; } /* Additional styles for sequence diagram text */ [data-theme="dark"] #sequenceNumber { fill: #f0f0f0; } [data-theme="dark"] text.sequenceText { fill: #f0f0f0; font-weight: 500; } [data-theme="dark"] text.loopText, [data-theme="dark"] text.loopText tspan { fill: #f0f0f0; } /* Add a subtle background to message text for better readability */ [data-theme="dark"] .messageText, [data-theme="dark"] text.sequenceText { paint-order: stroke; stroke: #1a1a1a; stroke-width: 2px; stroke-linecap: round; stroke-linejoin: round; } /* Force text elements to be properly colored */ text[text-anchor][dominant-baseline], text[text-anchor][alignment-baseline], .nodeLabel, .edgeLabel, .label, text { fill: #777 !important; } [data-theme="dark"] text[text-anchor][dominant-baseline], [data-theme="dark"] text[text-anchor][alignment-baseline], [data-theme="dark"] .nodeLabel, [data-theme="dark"] .edgeLabel, [data-theme="dark"] .label, [data-theme="dark"] text { fill: #f0f0f0 !important; } /* Add clickable element styles with subtle transitions */ .clickable { transition: all 0.3s ease; } .clickable:hover { transform: scale(1.03); cursor: pointer; } .clickable:hover > * { filter: brightness(0.95); } `, fontFamily: 'var(--font-geist-sans), var(--font-serif-jp), sans-serif', fontSize: 12, }); interface MermaidProps { chart: string; className?: string; zoomingEnabled?: boolean; } // Full screen modal component for the diagram const FullScreenModal: React.FC<{ isOpen: boolean; onClose: () => void; children: React.ReactNode; }> = ({ isOpen, onClose, children }) => { const modalRef = useRef(null); const [zoom, setZoom] = useState(1); // Close on Escape key useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { onClose(); } }; if (isOpen) { document.addEventListener('keydown', handleKeyDown); } return () => { document.removeEventListener('keydown', handleKeyDown); }; }, [isOpen, onClose]); // Handle click outside to close useEffect(() => { const handleOutsideClick = (e: MouseEvent) => { if (modalRef.current && !modalRef.current.contains(e.target as Node)) { onClose(); } }; if (isOpen) { document.addEventListener('mousedown', handleOutsideClick); } return () => { document.removeEventListener('mousedown', handleOutsideClick); }; }, [isOpen, onClose]); // Reset zoom when modal opens useEffect(() => { if (isOpen) { setZoom(1); } }, [isOpen]); if (!isOpen) return null; return (
{/* Modal header with controls */}
図表表示
{Math.round(zoom * 100)}%
{/* Modal content with zoom */}
{children}
); }; const Mermaid: React.FC = ({ chart, className = '', zoomingEnabled = false }) => { const [svg, setSvg] = useState(''); const [error, setError] = useState(null); const [isFullscreen, setIsFullscreen] = useState(false); const mermaidRef = useRef(null); const containerRef = useRef(null); const idRef = useRef(`mermaid-${Math.random().toString(36).substring(2, 9)}`); const isDarkModeRef = useRef( typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ); // Initialize pan-zoom functionality when SVG is rendered useEffect(() => { if (svg && zoomingEnabled && containerRef.current) { const initializePanZoom = async () => { const svgElement = containerRef.current?.querySelector("svg"); if (svgElement) { // Remove any max-width constraints svgElement.style.maxWidth = "none"; svgElement.style.width = "100%"; svgElement.style.height = "100%"; try { // Dynamically import svg-pan-zoom only when needed in the browser const svgPanZoom = (await import("svg-pan-zoom")).default; svgPanZoom(svgElement, { zoomEnabled: true, controlIconsEnabled: true, fit: true, center: true, minZoom: 0.1, maxZoom: 10, zoomScaleSensitivity: 0.3, }); } catch (error) { console.error("Failed to load svg-pan-zoom:", error); } } }; // Wait for the SVG to be rendered setTimeout(() => { void initializePanZoom(); }, 100); } }, [svg, zoomingEnabled]); useEffect(() => { if (!chart) return; let isMounted = true; const renderChart = async () => { if (!isMounted) return; try { setError(null); setSvg(''); // Render the chart directly without preprocessing const { svg: renderedSvg } = await mermaid.render(idRef.current, chart); if (!isMounted) return; let processedSvg = renderedSvg; if (isDarkModeRef.current) { processedSvg = processedSvg.replace(' { mermaid.contentLoaded(); }, 50); } catch (err) { console.error('Mermaid rendering error:', err); const errorMessage = err instanceof Error ? err.message : String(err); if (isMounted) { setError(`Failed to render diagram: ${errorMessage}`); if (mermaidRef.current) { mermaidRef.current.innerHTML = `
Syntax error in diagram
${chart}
`; } } } }; renderChart(); return () => { isMounted = false; }; }, [chart]); const handleDiagramClick = () => { if (!error && svg) { setIsFullscreen(true); } }; if (error) { return (
図表レンダリングエラー
図表に構文エラーがあり、レンダリングできません。
); } if (!svg) { return (
図表を描画中...
); } return ( <>
{!zoomingEnabled && (
Click to zoom
)}
{!zoomingEnabled && ( setIsFullscreen(false)} >
)} ); }; export default Mermaid;