Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🏗️ Build (push) Failing after 14s
Build & Deploy / 🧪 QA (push) Failing after 1m48s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🩺 Health Check (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
351 lines
12 KiB
TypeScript
351 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import React, { useEffect, useRef, useState } from "react";
|
|
import mermaid from "mermaid";
|
|
import { ComponentShareButton } from "./ComponentShareButton";
|
|
import { Reveal } from "./Reveal";
|
|
|
|
interface MermaidProps {
|
|
graph?: string;
|
|
children?: React.ReactNode;
|
|
id?: string;
|
|
title?: string;
|
|
showShare?: boolean;
|
|
fontSize?: string;
|
|
nodeFontSize?: string;
|
|
labelFontSize?: string;
|
|
actorFontSize?: string;
|
|
messageFontSize?: string;
|
|
noteFontSize?: string;
|
|
titleFontSize?: string;
|
|
sectionFontSize?: string;
|
|
legendFontSize?: string;
|
|
}
|
|
|
|
class MermaidErrorBoundary extends React.Component<{ children: React.ReactNode }, { hasError: boolean }> {
|
|
constructor(props: { children: React.ReactNode }) {
|
|
super(props);
|
|
this.state = { hasError: false };
|
|
}
|
|
|
|
static getDerivedStateFromError(_error: any) {
|
|
return { hasError: true };
|
|
}
|
|
|
|
componentDidCatch(error: any, errorInfo: any) {
|
|
console.error("MermaidErrorBoundary caught an error:", error, errorInfo);
|
|
}
|
|
|
|
render() {
|
|
if (this.state.hasError) {
|
|
return (
|
|
<div className="p-4 bg-red-50 border border-red-100 rounded text-red-600 text-sm font-mono text-center my-8">
|
|
Diagram rendering crashed.
|
|
</div>
|
|
);
|
|
}
|
|
return this.props.children;
|
|
}
|
|
}
|
|
|
|
const MermaidInternal: React.FC<MermaidProps> = ({
|
|
graph,
|
|
children,
|
|
id: providedId,
|
|
title,
|
|
showShare = false,
|
|
fontSize = "20px",
|
|
nodeFontSize,
|
|
labelFontSize,
|
|
actorFontSize,
|
|
messageFontSize,
|
|
noteFontSize,
|
|
titleFontSize,
|
|
sectionFontSize,
|
|
legendFontSize,
|
|
}) => {
|
|
const [id, setId] = useState<string | null>(null);
|
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
const [svgContent, setSvgContent] = useState<string>("");
|
|
const [isVisible, setIsVisible] = useState(true); // Forced true specifically to bypass lazy loading bug
|
|
const [isRendered, setIsRendered] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// Extract text from React children nodes (MDX parses multi-line content as React nodes)
|
|
const extractTextFromChildren = (node: React.ReactNode): string => {
|
|
if (typeof node === 'string') return node;
|
|
if (typeof node === 'number') return String(node);
|
|
if (Array.isArray(node)) return node.map(extractTextFromChildren).join('\n');
|
|
if (React.isValidElement(node)) {
|
|
const props = node.props as { children?: React.ReactNode };
|
|
if (props.children) {
|
|
return extractTextFromChildren(props.children);
|
|
}
|
|
}
|
|
return '';
|
|
};
|
|
|
|
const rawGraph = graph || extractTextFromChildren(children) || "";
|
|
|
|
// MDXRemote double-escapes \n in plain string props (e.g., "graph TD\\nA-->B" becomes "graph TD\\\\nA-->B")
|
|
// We need to unescape these back to real newlines for Mermaid to parse
|
|
const sanitizedGraph = rawGraph
|
|
.trim()
|
|
.replace(/^`+|`+$/g, '')
|
|
.replace(/\\n/g, '\n')
|
|
.replace(/\\"/g, '"')
|
|
.replace(/\\'/g, "'");
|
|
|
|
// Early return for empty graph — show fallback instead of blank space
|
|
if (!sanitizedGraph || sanitizedGraph.length < 5) {
|
|
return (
|
|
<div className="not-prose my-8 p-6 bg-slate-50 border border-slate-200 rounded-xl text-center">
|
|
<p className="text-sm text-slate-400 font-medium">Diagramm konnte nicht geladen werden</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
useEffect(() => {
|
|
const generatedId = providedId || `mermaid-${Math.random().toString(36).substring(2, 11)}`;
|
|
setId(generatedId);
|
|
}, [providedId]);
|
|
|
|
// Observer to detect when the component is actually in view and layout is ready
|
|
useEffect(() => {
|
|
if (!wrapperRef.current) return;
|
|
|
|
const observer = new IntersectionObserver(
|
|
(entries) => {
|
|
entries.forEach((entry) => {
|
|
if (entry.isIntersecting) {
|
|
setIsVisible(true);
|
|
observer.disconnect(); // Render once per diagram
|
|
}
|
|
});
|
|
},
|
|
{ rootMargin: "200px" } // Start rendering 200px before it scrolls into view
|
|
);
|
|
|
|
observer.observe(wrapperRef.current);
|
|
return () => observer.disconnect();
|
|
}, [id]);
|
|
|
|
useEffect(() => {
|
|
if (!isVisible || !id || isRendered) {
|
|
return;
|
|
}
|
|
|
|
mermaid.initialize({
|
|
startOnLoad: false,
|
|
theme: "base",
|
|
darkMode: false,
|
|
htmlLabels: false,
|
|
flowchart: {
|
|
useMaxWidth: true,
|
|
htmlLabels: false,
|
|
curve: 'basis',
|
|
nodeSpacing: 50,
|
|
rankSpacing: 60,
|
|
padding: 15
|
|
},
|
|
sequence: {
|
|
useMaxWidth: true,
|
|
showSequenceNumbers: false,
|
|
height: 40,
|
|
actorMargin: 30,
|
|
messageMargin: 30
|
|
},
|
|
gantt: { useMaxWidth: true },
|
|
pie: { useMaxWidth: true },
|
|
state: { useMaxWidth: true },
|
|
themeVariables: {
|
|
primaryColor: "#f8fafc", // slate-50
|
|
primaryTextColor: "#334155", // slate-700
|
|
primaryBorderColor: "#cbd5e1", // slate-300
|
|
lineColor: "#64748b", // slate-500
|
|
secondaryColor: "#f1f5f9", // slate-100
|
|
tertiaryColor: "#e2e8f0", // slate-200
|
|
background: "#ffffff",
|
|
mainBkg: "#ffffff",
|
|
secondBkg: "#f8fafc",
|
|
tertiaryBkg: "#f1f5f9",
|
|
textColor: "#1e293b",
|
|
labelTextColor: "#475569",
|
|
nodeBorder: "#cbd5e1",
|
|
clusterBkg: "#f8fafc",
|
|
clusterBorder: "#cbd5e1",
|
|
edgeLabelBackground: "#ffffff",
|
|
fontFamily: "var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
|
|
fontSize: fontSize || "18px",
|
|
nodeFontSize: nodeFontSize || fontSize || "18px",
|
|
labelFontSize: labelFontSize || fontSize || "16px",
|
|
actorFontSize: actorFontSize || fontSize || "18px",
|
|
messageFontSize: messageFontSize || fontSize || "16px",
|
|
noteFontSize: noteFontSize || fontSize || "16px",
|
|
titleFontSize: titleFontSize || "24px",
|
|
sectionFontSize: sectionFontSize || fontSize || "18px",
|
|
legendFontSize: legendFontSize || fontSize || "18px",
|
|
pie1: "#0f172a",
|
|
pie2: "#334155",
|
|
pie3: "#64748b",
|
|
pie4: "#94a3b8",
|
|
pie5: "#cbd5e1",
|
|
pie6: "#1e293b",
|
|
pie7: "#475569",
|
|
pie8: "#000000",
|
|
pie9: "#e2e8f0",
|
|
pie10: "#020617",
|
|
pie11: "#525252",
|
|
pie12: "#262626",
|
|
},
|
|
securityLevel: "loose",
|
|
});
|
|
|
|
const renderGraph = async () => {
|
|
if (!wrapperRef.current) return;
|
|
if (wrapperRef.current.clientWidth === 0) return;
|
|
|
|
const maxRetries = 3;
|
|
let attempt = 0;
|
|
let success = false;
|
|
|
|
while (attempt < maxRetries && !success) {
|
|
attempt++;
|
|
try {
|
|
if (!sanitizedGraph) return;
|
|
if (!mermaid.render) return;
|
|
|
|
const uniqueSvgId = `${id}-svg-${Date.now()}`;
|
|
const tempDiv = document.createElement('div');
|
|
document.body.appendChild(tempDiv);
|
|
tempDiv.style.position = 'absolute';
|
|
tempDiv.style.left = '-9999px';
|
|
tempDiv.style.visibility = 'hidden';
|
|
|
|
let rawSvg: string;
|
|
try {
|
|
const result = await mermaid.render(uniqueSvgId, sanitizedGraph, tempDiv);
|
|
rawSvg = result.svg;
|
|
} finally {
|
|
if (document.body.contains(tempDiv)) {
|
|
document.body.removeChild(tempDiv);
|
|
}
|
|
}
|
|
|
|
let scaledSvg = rawSvg;
|
|
const svgStartEnd = rawSvg.indexOf('>');
|
|
if (svgStartEnd > -1) {
|
|
const svgTag = rawSvg.substring(0, svgStartEnd + 1);
|
|
const rest = rawSvg.substring(svgStartEnd + 1);
|
|
const newSvgTag = svgTag.replace(/width="[^"]*"/, 'width="100%"').replace(/height="[^"]*"/, 'height="auto"');
|
|
scaledSvg = newSvgTag + rest;
|
|
}
|
|
|
|
setSvgContent(scaledSvg);
|
|
setIsRendered(true);
|
|
setError(null);
|
|
success = true;
|
|
} catch (err) {
|
|
if (attempt >= maxRetries) {
|
|
setError("Diagramm konnte nicht geladen werden (Render-Fehler).");
|
|
setIsRendered(true);
|
|
} else {
|
|
await new Promise(resolve => setTimeout(resolve, attempt * 200));
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const resizeObserver = new ResizeObserver((entries) => {
|
|
for (const entry of entries) {
|
|
if (entry.contentRect.width > 0 && !isRendered) {
|
|
requestAnimationFrame(() => renderGraph());
|
|
resizeObserver.disconnect();
|
|
}
|
|
}
|
|
});
|
|
|
|
resizeObserver.observe(wrapperRef.current);
|
|
|
|
if (wrapperRef.current && wrapperRef.current.clientWidth > 0) {
|
|
renderGraph();
|
|
resizeObserver.disconnect();
|
|
}
|
|
|
|
return () => resizeObserver.disconnect();
|
|
}, [isVisible, id, sanitizedGraph, fontSize, nodeFontSize, labelFontSize, actorFontSize, messageFontSize, noteFontSize, titleFontSize, sectionFontSize, legendFontSize]);
|
|
|
|
if (!id) return null;
|
|
|
|
return (
|
|
<Reveal direction="up" delay={0.1}>
|
|
<figure ref={wrapperRef} className="mermaid-wrapper not-prose my-16 w-full max-w-full group relative transition-all duration-500 ease-out z-10">
|
|
|
|
<div className="absolute -inset-1 bg-gradient-to-r from-slate-200 to-slate-100/50 rounded-[2rem] blur opacity-20 group-hover:opacity-40 transition duration-1000 -z-10" />
|
|
|
|
<div className="glass bg-white/80 backdrop-blur-xl border border-slate-100 rounded-2xl shadow-sm group-hover:shadow-md group-hover:border-slate-200 transition-all duration-500 overflow-hidden relative">
|
|
|
|
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-white to-transparent opacity-80" />
|
|
|
|
<div className="w-full flex flex-col items-center justify-center p-6 md:p-8 lg:p-10 relative z-10">
|
|
|
|
{showShare && (
|
|
<div className="absolute top-6 right-6 md:opacity-0 group-hover:opacity-100 transition-opacity duration-500 z-50">
|
|
<ComponentShareButton targetId={id} title={title || 'System Architecture'} />
|
|
</div>
|
|
)}
|
|
|
|
{title && (
|
|
<header className="w-full mb-8 flex flex-col md:flex-row md:justify-between md:items-end gap-2 border-b border-slate-100 pb-4">
|
|
<div>
|
|
<h4 className="text-xl md:text-2xl font-bold text-slate-900 tracking-tight m-0 flex items-center gap-3">
|
|
<span className="w-2 h-2 rounded-full bg-slate-400 shadow-[0_0_8px_rgba(148,163,184,0.6)] hidden md:block" />
|
|
{title}
|
|
</h4>
|
|
</div>
|
|
</header>
|
|
)}
|
|
|
|
<div className="flex justify-center w-full overflow-x-auto relative z-20">
|
|
<div
|
|
className={`mermaid
|
|
w-full flex justify-center
|
|
[&>div]:!w-full [&>div]:!flex [&>div]:!justify-center
|
|
[&_svg]:!max-w-full [&_svg]:max-w-4xl [&_svg]:!h-auto [&_svg]:!max-h-[60vh] md:[&_svg]:!max-h-[600px]
|
|
[&_.node_rect]:!rx-[8px] [&_.node_rect]:!ry-[8px]
|
|
[&_.node_rect]:!fill-white
|
|
[&_.node_rect]:!stroke-slate-200 [&_.node_rect]:!stroke-[2px]
|
|
[&_.edgePath_path]:!stroke-slate-400 [&_.edgePath_path]:!stroke-[1.5px]
|
|
[&_.marker]:!fill-slate-400 [&_.marker]:!stroke-slate-400
|
|
[&_.nodeLabel]:!font-sans [&_.nodeLabel]:!font-bold [&_.nodeLabel]:!text-slate-700
|
|
`}
|
|
id={id}
|
|
style={{
|
|
maxWidth: "100%",
|
|
overflow: "visible"
|
|
}}
|
|
>
|
|
{error ? (
|
|
<div className="text-red-500 p-4 border border-red-200 bg-red-50 text-xs font-mono uppercase tracking-widest text-center w-full h-32 flex items-center justify-center rounded-xl">
|
|
{error}
|
|
</div>
|
|
) : svgContent ? (
|
|
<div dangerouslySetInnerHTML={{ __html: svgContent }} />
|
|
) : (
|
|
<div style={{ display: 'none' }}>{sanitizedGraph}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</figure>
|
|
</Reveal>
|
|
);
|
|
};
|
|
|
|
export const Mermaid = (props: MermaidProps) => (
|
|
<MermaidErrorBoundary>
|
|
<MermaidInternal {...props} />
|
|
</MermaidErrorBoundary>
|
|
);
|