feat(MarkdownEditor): integrate Mermaid support for diagram rendering in Markdown

- Added MermaidRenderer component to handle Mermaid diagrams within Markdown content.
- Enhanced preformatted code block handling to detect and render Mermaid syntax.
- Updated package.json and package-lock.json to include Mermaid dependency for diagram support.
This commit is contained in:
Julien Froidefond
2025-10-10 12:00:56 +02:00
parent 8cb0dcf3af
commit 75f27c69ee
4 changed files with 1418 additions and 11 deletions

1086
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -37,6 +37,7 @@
"emoji-mart": "^5.6.0",
"emoji-regex": "^10.5.0",
"lucide-react": "^0.544.0",
"mermaid": "^11.12.0",
"next": "15.5.3",
"next-auth": "^4.24.11",
"prisma": "^6.16.1",

View File

@@ -1,6 +1,7 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import React from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeHighlight from 'rehype-highlight';
@@ -9,6 +10,7 @@ import { Eye, EyeOff, Edit3, X, CheckSquare2 } from 'lucide-react';
import { TagInput } from '@/components/ui/TagInput';
import { TagDisplay } from '@/components/ui/TagDisplay';
import { TaskSelectorWithData } from '@/components/shared/TaskSelectorWithData';
import { MermaidRenderer } from '@/components/ui/MermaidRenderer';
import { Tag, Task } from '@/lib/types';
interface MarkdownEditorProps {
@@ -514,11 +516,50 @@ export function MarkdownEditor({
}
return <code className={className}>{children}</code>;
},
pre: ({ children }) => (
<pre className="bg-[var(--card)]/60 border border-[var(--border)]/60 rounded-lg p-4 overflow-x-auto backdrop-blur-sm">
{children}
</pre>
),
pre: ({ children }) => {
// Check if this is a mermaid code block
let codeElement: string | null = null;
let className = '';
if (React.isValidElement(children)) {
const props = children.props as {
children?: string;
className?: string;
};
codeElement = props?.children || null;
className = props?.className || '';
}
const isMermaid =
className.includes('language-mermaid') ||
(typeof codeElement === 'string' &&
(codeElement.trim().startsWith('graph') ||
codeElement.trim().startsWith('flowchart') ||
codeElement.trim().startsWith('sequenceDiagram') ||
codeElement.trim().startsWith('gantt') ||
codeElement.trim().startsWith('pie') ||
codeElement.trim().startsWith('gitgraph') ||
codeElement.trim().startsWith('journey') ||
codeElement.trim().startsWith('stateDiagram') ||
codeElement.trim().startsWith('classDiagram') ||
codeElement.trim().startsWith('erDiagram') ||
codeElement.trim().startsWith('mindmap') ||
codeElement.trim().startsWith('timeline')));
if (isMermaid && typeof codeElement === 'string') {
return (
<div className="my-6">
<MermaidRenderer chart={codeElement} />
</div>
);
}
return (
<pre className="bg-[var(--card)]/60 border border-[var(--border)]/60 rounded-lg p-4 overflow-x-auto backdrop-blur-sm">
{children}
</pre>
);
},
a: ({ children, href }) => (
<a
href={href}
@@ -701,11 +742,52 @@ export function MarkdownEditor({
}
return <code className={className}>{children}</code>;
},
pre: ({ children }) => (
<pre className="bg-[var(--card)]/60 border border-[var(--border)]/60 rounded-lg p-4 overflow-x-auto backdrop-blur-sm">
{children}
</pre>
),
pre: ({ children }) => {
// Check if this is a mermaid code block
let codeElement: string | null = null;
let className = '';
if (React.isValidElement(children)) {
const props = children.props as {
children?: string;
className?: string;
};
codeElement = props?.children || null;
className = props?.className || '';
}
const isMermaid =
className.includes('language-mermaid') ||
(typeof codeElement === 'string' &&
(codeElement.trim().startsWith('graph') ||
codeElement.trim().startsWith('flowchart') ||
codeElement
.trim()
.startsWith('sequenceDiagram') ||
codeElement.trim().startsWith('gantt') ||
codeElement.trim().startsWith('pie') ||
codeElement.trim().startsWith('gitgraph') ||
codeElement.trim().startsWith('journey') ||
codeElement.trim().startsWith('stateDiagram') ||
codeElement.trim().startsWith('classDiagram') ||
codeElement.trim().startsWith('erDiagram') ||
codeElement.trim().startsWith('mindmap') ||
codeElement.trim().startsWith('timeline')));
if (isMermaid && typeof codeElement === 'string') {
return (
<div className="my-6">
<MermaidRenderer chart={codeElement} />
</div>
);
}
return (
<pre className="bg-[var(--card)]/60 border border-[var(--border)]/60 rounded-lg p-4 overflow-x-auto backdrop-blur-sm">
{children}
</pre>
);
},
a: ({ children, href }) => (
<a
href={href}

View File

@@ -0,0 +1,240 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import mermaid from 'mermaid';
interface MermaidRendererProps {
chart: string;
className?: string;
}
export function MermaidRenderer({
chart,
className = '',
}: MermaidRendererProps) {
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const chartRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let isMounted = true;
const renderChart = async () => {
if (!chartRef.current || !chart) return;
try {
setIsLoading(true);
setError(null);
// Clear previous content
chartRef.current.innerHTML = '';
// Generate unique ID for this chart
const id = `mermaid-${Math.random().toString(36).substr(2, 9)}`;
// Always reinitialize to pick up theme changes
// Get computed CSS variable values
const getCSSVariable = (variable: string): string => {
return (
getComputedStyle(document.documentElement)
.getPropertyValue(variable)
.trim() || '#000000'
);
};
mermaid.initialize({
startOnLoad: false,
theme: 'base',
themeVariables: {
// Use computed CSS variable values
primaryColor: getCSSVariable('--primary'),
primaryTextColor: getCSSVariable('--foreground'),
primaryBorderColor: getCSSVariable('--border'),
lineColor: getCSSVariable('--foreground'),
sectionBkgColor: getCSSVariable('--card'),
altSectionBkgColor: getCSSVariable('--card-hover'),
gridColor: getCSSVariable('--border'),
secondaryColor: getCSSVariable('--accent'),
tertiaryColor: getCSSVariable('--muted'),
background: getCSSVariable('--background'),
mainBkg: getCSSVariable('--card'),
secondBkg: getCSSVariable('--card-hover'),
tertiaryBkg: getCSSVariable('--muted'),
// Additional theme variables for better integration
nodeBkg: getCSSVariable('--card'),
nodeBorder: getCSSVariable('--border'),
clusterBkg: getCSSVariable('--card-hover'),
clusterBorder: getCSSVariable('--border'),
defaultLinkColor: getCSSVariable('--primary'),
titleColor: getCSSVariable('--foreground'),
edgeLabelBackground: getCSSVariable('--card'),
edgeLabelColor: getCSSVariable('--foreground'),
// Sequence diagram specific
actorBkg: getCSSVariable('--card'),
actorBorder: getCSSVariable('--border'),
actorTextColor: getCSSVariable('--foreground'),
actorLineColor: getCSSVariable('--foreground'),
signalColor: getCSSVariable('--primary'),
signalTextColor: getCSSVariable('--foreground'),
labelBoxBkgColor: getCSSVariable('--card'),
labelBoxBorderColor: getCSSVariable('--border'),
labelTextColor: getCSSVariable('--foreground'),
loopTextColor: getCSSVariable('--foreground'),
activationBkgColor: getCSSVariable('--primary'),
activationBorderColor: getCSSVariable('--primary'),
// Gantt specific
section0: getCSSVariable('--primary'),
section1: getCSSVariable('--accent'),
section2: getCSSVariable('--success'),
section3: getCSSVariable('--purple'),
altSection: getCSSVariable('--muted'),
gridColor: getCSSVariable('--border'),
todayLineColor: getCSSVariable('--destructive'),
// Pie chart specific
pie1: getCSSVariable('--primary'),
pie2: getCSSVariable('--accent'),
pie3: getCSSVariable('--success'),
pie4: getCSSVariable('--purple'),
pie5: getCSSVariable('--yellow'),
pie6: getCSSVariable('--blue'),
pie7: getCSSVariable('--green'),
pieTitleTextSize: '16px',
pieTitleTextColor: getCSSVariable('--foreground'),
pieSectionTextSize: '14px',
pieSectionTextColor: getCSSVariable('--foreground'),
pieLegendTextSize: '12px',
pieLegendTextColor: getCSSVariable('--muted-foreground'),
pieStrokeColor: getCSSVariable('--border'),
pieStrokeWidth: '2px',
pieOuterStrokeWidth: '2px',
pieOuterStrokeColor: getCSSVariable('--border'),
pieOpacity: '0.8',
},
fontFamily: 'inherit',
fontSize: 14,
flowchart: {
useMaxWidth: true,
htmlLabels: true,
curve: 'basis',
},
sequence: {
useMaxWidth: true,
diagramMarginX: 50,
diagramMarginY: 10,
actorMargin: 50,
width: 150,
height: 65,
boxMargin: 10,
boxTextMargin: 5,
noteMargin: 10,
messageMargin: 35,
messageAlign: 'center',
mirrorActors: true,
bottomMarginAdj: 1,
useMaxWidth: true,
rightAngles: false,
showSequenceNumbers: false,
},
gantt: {
useMaxWidth: true,
leftPadding: 75,
gridLineStartPadding: 35,
fontSize: 11,
fontFamily: 'inherit',
sectionFontSize: 24,
numberSectionStyles: 4,
},
pie: {
useMaxWidth: true,
},
journey: {
useMaxWidth: true,
},
gitgraph: {
useMaxWidth: true,
},
state: {
useMaxWidth: true,
},
class: {
useMaxWidth: true,
},
er: {
useMaxWidth: true,
},
mindmap: {
useMaxWidth: true,
},
timeline: {
useMaxWidth: true,
},
});
// Render the chart
const { svg } = await mermaid.render(id, chart);
if (isMounted && chartRef.current) {
chartRef.current.innerHTML = svg;
setIsLoading(false);
}
} catch (err) {
if (isMounted) {
setError(
err instanceof Error
? err.message
: 'Erreur lors du rendu du diagramme'
);
setIsLoading(false);
}
}
};
renderChart();
return () => {
isMounted = false;
};
}, [chart]);
if (error) {
return (
<div
className={`p-4 border border-[var(--destructive)]/20 bg-[var(--destructive)]/5 rounded-lg ${className}`}
>
<div className="text-[var(--destructive)] text-sm font-medium mb-2">
Erreur de rendu Mermaid
</div>
<div className="text-[var(--muted-foreground)] text-xs font-mono bg-[var(--card)] p-2 rounded border">
{error}
</div>
<details className="mt-2">
<summary className="text-xs text-[var(--muted-foreground)] cursor-pointer">
Code source
</summary>
<pre className="text-xs text-[var(--muted-foreground)] mt-1 p-2 bg-[var(--card)] rounded border overflow-x-auto">
{chart}
</pre>
</details>
</div>
);
}
return (
<div className={`relative ${className}`}>
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-[var(--background)]/80 backdrop-blur-sm rounded-lg">
<div className="text-[var(--muted-foreground)] text-sm">
Rendu du diagramme...
</div>
</div>
)}
<div
ref={chartRef}
className="mermaid-chart rounded-lg border border-[var(--border)]/60 bg-[var(--card)]/30 p-4 backdrop-blur-sm"
style={{
minHeight: isLoading ? '100px' : 'auto',
}}
/>
</div>
);
}