mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
MAESTRO: Add Mermaid diagram rendering to Markdown viewers
Integrated the mermaid package to render diagrams in fenced code blocks marked as `mermaid`. Both FilePreview and Scratchpad components now support Mermaid diagrams with proper theme detection and DOMPurify sanitization for security.
This commit is contained in:
1296
package-lock.json
generated
1296
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -97,6 +97,7 @@
|
||||
"dompurify": "^3.3.0",
|
||||
"electron-store": "^8.1.0",
|
||||
"fastify": "^4.25.2",
|
||||
"mermaid": "^11.12.1",
|
||||
"node-pty": "^1.0.0",
|
||||
"react-diff-view": "^3.3.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
|
||||
@@ -7,6 +7,7 @@ import { FileCode, X, Copy, FileText, Eye, ChevronUp, ChevronDown } from 'lucide
|
||||
import { visit } from 'unist-util-visit';
|
||||
import { useLayerStack } from '../contexts/LayerStackContext';
|
||||
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
|
||||
import { MermaidRenderer } from './MermaidRenderer';
|
||||
|
||||
interface FilePreviewProps {
|
||||
file: { name: string; content: string; path: string } | null;
|
||||
@@ -645,6 +646,12 @@ export function FilePreview({ file, onClose, theme, markdownRawMode, setMarkdown
|
||||
code: ({ node, inline, className, children, ...props }) => {
|
||||
const match = (className || '').match(/language-(\w+)/);
|
||||
const language = match ? match[1] : 'text';
|
||||
const codeContent = String(children).replace(/\n$/, '');
|
||||
|
||||
// Handle mermaid code blocks
|
||||
if (!inline && language === 'mermaid') {
|
||||
return <MermaidRenderer chart={codeContent} theme={theme} />;
|
||||
}
|
||||
|
||||
return !inline && match ? (
|
||||
<SyntaxHighlighter
|
||||
@@ -659,7 +666,7 @@ export function FilePreview({ file, onClose, theme, markdownRawMode, setMarkdown
|
||||
}}
|
||||
PreTag="div"
|
||||
>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
{codeContent}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<code className={className} {...props}>
|
||||
|
||||
161
src/renderer/components/MermaidRenderer.tsx
Normal file
161
src/renderer/components/MermaidRenderer.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import mermaid from 'mermaid';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
interface MermaidRendererProps {
|
||||
chart: string;
|
||||
theme: any;
|
||||
}
|
||||
|
||||
// Initialize mermaid with custom theme settings
|
||||
const initMermaid = (isDarkTheme: boolean) => {
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: isDarkTheme ? 'dark' : 'default',
|
||||
securityLevel: 'strict',
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace',
|
||||
flowchart: {
|
||||
useMaxWidth: true,
|
||||
htmlLabels: true,
|
||||
curve: 'basis'
|
||||
},
|
||||
sequence: {
|
||||
useMaxWidth: true,
|
||||
diagramMarginX: 8,
|
||||
diagramMarginY: 8
|
||||
},
|
||||
gantt: {
|
||||
useMaxWidth: true
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Sanitize and parse SVG into safe DOM nodes
|
||||
const createSanitizedSvgElement = (svgString: string): Node | null => {
|
||||
// First sanitize with DOMPurify configured for SVG
|
||||
const sanitized = DOMPurify.sanitize(svgString, {
|
||||
USE_PROFILES: { svg: true, svgFilters: true },
|
||||
ADD_TAGS: ['foreignObject'],
|
||||
ADD_ATTR: ['xmlns', 'xmlns:xlink', 'xlink:href', 'dominant-baseline', 'text-anchor'],
|
||||
RETURN_DOM: true
|
||||
});
|
||||
|
||||
// Return the first child (the SVG element)
|
||||
return sanitized.firstChild;
|
||||
};
|
||||
|
||||
export function MermaidRenderer({ chart, theme }: MermaidRendererProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const renderChart = async () => {
|
||||
if (!containerRef.current || !chart.trim()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Determine if theme is dark by checking background color
|
||||
const isDarkTheme = theme.colors.bgMain.toLowerCase().includes('#1') ||
|
||||
theme.colors.bgMain.toLowerCase().includes('#2') ||
|
||||
theme.colors.bgMain.toLowerCase().includes('#0');
|
||||
|
||||
// Initialize mermaid with the current theme
|
||||
initMermaid(isDarkTheme);
|
||||
|
||||
try {
|
||||
// Generate a unique ID for this diagram
|
||||
const id = `mermaid-${Math.random().toString(36).substring(2, 11)}`;
|
||||
|
||||
// Render the diagram
|
||||
const { svg: renderedSvg } = await mermaid.render(id, chart.trim());
|
||||
|
||||
// Create sanitized DOM element from SVG string
|
||||
const svgElement = createSanitizedSvgElement(renderedSvg);
|
||||
|
||||
// Clear container and append sanitized SVG
|
||||
while (containerRef.current.firstChild) {
|
||||
containerRef.current.removeChild(containerRef.current.firstChild);
|
||||
}
|
||||
|
||||
if (svgElement) {
|
||||
containerRef.current.appendChild(svgElement);
|
||||
}
|
||||
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Mermaid rendering error:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to render diagram');
|
||||
|
||||
// Clear container on error
|
||||
if (containerRef.current) {
|
||||
while (containerRef.current.firstChild) {
|
||||
containerRef.current.removeChild(containerRef.current.firstChild);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
renderChart();
|
||||
}, [chart, theme.colors.bgMain]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
className="p-4 rounded-lg text-center text-sm"
|
||||
style={{
|
||||
backgroundColor: theme.colors.bgActivity,
|
||||
color: theme.colors.textDim
|
||||
}}
|
||||
>
|
||||
Rendering diagram...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className="p-4 rounded-lg border"
|
||||
style={{
|
||||
backgroundColor: theme.colors.bgActivity,
|
||||
borderColor: theme.colors.error,
|
||||
color: theme.colors.error
|
||||
}}
|
||||
>
|
||||
<div className="text-sm font-medium mb-2">Failed to render Mermaid diagram</div>
|
||||
<pre className="text-xs whitespace-pre-wrap opacity-75">{error}</pre>
|
||||
<details className="mt-3">
|
||||
<summary
|
||||
className="text-xs cursor-pointer"
|
||||
style={{ color: theme.colors.textDim }}
|
||||
>
|
||||
View source
|
||||
</summary>
|
||||
<pre
|
||||
className="mt-2 p-2 text-xs rounded overflow-x-auto"
|
||||
style={{
|
||||
backgroundColor: theme.colors.bgMain,
|
||||
color: theme.colors.textMain
|
||||
}}
|
||||
>
|
||||
{chart}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="mermaid-container p-4 rounded-lg overflow-x-auto"
|
||||
style={{
|
||||
backgroundColor: theme.colors.bgActivity
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import { Eye, Edit, Play, Square, Loader2, HelpCircle } from 'lucide-react';
|
||||
import type { BatchRunState } from '../types';
|
||||
import { AutoRunnerHelpModal } from './AutoRunnerHelpModal';
|
||||
import { MermaidRenderer } from './MermaidRenderer';
|
||||
|
||||
interface ScratchpadProps {
|
||||
content: string;
|
||||
@@ -390,7 +393,42 @@ export function Scratchpad({
|
||||
margin-left: -1.5em;
|
||||
}
|
||||
`}</style>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
code: ({ node, inline, className, children, ...props }: any) => {
|
||||
const match = (className || '').match(/language-(\w+)/);
|
||||
const language = match ? match[1] : 'text';
|
||||
const codeContent = String(children).replace(/\n$/, '');
|
||||
|
||||
// Handle mermaid code blocks
|
||||
if (!inline && language === 'mermaid') {
|
||||
return <MermaidRenderer chart={codeContent} theme={theme} />;
|
||||
}
|
||||
|
||||
return !inline && match ? (
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={vscDarkPlus}
|
||||
customStyle={{
|
||||
margin: '0.5em 0',
|
||||
padding: '1em',
|
||||
background: theme.colors.bgActivity,
|
||||
fontSize: '0.9em',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
PreTag="div"
|
||||
>
|
||||
{codeContent}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{content || '*No content yet. Switch to Edit mode to start writing.*'}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user