feat: Add Inline Mermaid Diagram Component with Error Handling and Zoom Features

This commit is contained in:
Danny Avila 2025-07-12 11:20:43 -04:00
parent 170cc340d8
commit c53bdc1fef
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
3 changed files with 477 additions and 1 deletions

3
.gitignore vendored
View file

@ -56,6 +56,7 @@ bower_components/
.clineignore
.cursor
.aider*
CLAUDE.md
# Floobits
.floo
@ -124,4 +125,4 @@ helm/**/.values.yaml
!/client/src/@types/i18next.d.ts
# SAML Idp cert
*.cert
*.cert

View file

@ -0,0 +1,455 @@
import React, {
useLayoutEffect,
useState,
memo,
useContext,
useMemo,
useCallback,
useRef,
} from 'react';
import { useTranslation } from 'react-i18next';
import DOMPurify from 'dompurify';
import { TransformWrapper, TransformComponent, ReactZoomPanPinchRef } from 'react-zoom-pan-pinch';
import { cn } from '~/utils';
import { ThemeContext, isDark } from '~/hooks/ThemeContext';
import { ClipboardIcon, CheckIcon, ZoomIn, ZoomOut, RotateCcw } from 'lucide-react';
interface InlineMermaidProps {
content: string;
className?: string;
}
const InlineMermaidDiagram = memo(({ content, className }: InlineMermaidProps) => {
const { t } = useTranslation();
const [svgContent, setSvgContent] = useState<string>('');
const [isRendered, setIsRendered] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isCopied, setIsCopied] = useState(false);
const [wasAutoCorrected, setWasAutoCorrected] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const { theme } = useContext(ThemeContext);
const isDarkMode = isDark(theme);
const transformRef = useRef<ReactZoomPanPinchRef>(null);
const diagramKey = useMemo(
() => `${content.trim()}-${isDarkMode ? 'dark' : 'light'}`,
[content, isDarkMode],
);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(content);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
} catch (err) {
console.error('Failed to copy diagram content:', err);
}
}, [content]);
const handleZoomIn = useCallback(() => {
if (transformRef.current) {
transformRef.current.zoomIn(0.2);
}
}, []);
const handleZoomOut = useCallback(() => {
if (transformRef.current) {
transformRef.current.zoomOut(0.2);
}
}, []);
const handleResetZoom = useCallback(() => {
if (transformRef.current) {
transformRef.current.resetTransform();
transformRef.current.centerView(1, 0);
}
}, []);
// Memoized to prevent re-renders when content/theme changes
const fixCommonSyntaxIssues = useMemo(() => {
return (text: string) => {
let fixed = text;
fixed = fixed.replace(/--\s+>/g, '-->');
fixed = fixed.replace(/--\s+\|/g, '--|');
fixed = fixed.replace(/\|\s+-->/g, '|-->');
fixed = fixed.replace(/\[([^[\]]*)"([^[\]]*)"([^[\]]*)\]/g, '[$1$2$3]');
fixed = fixed.replace(/subgraph([A-Za-z])/g, 'subgraph $1');
return fixed;
};
}, []);
const handleTryFix = useCallback(() => {
const fixedContent = fixCommonSyntaxIssues(content);
if (fixedContent !== content) {
// Currently just copies the fixed version to clipboard
navigator.clipboard.writeText(fixedContent).then(() => {
setError(t('com_mermaid_fix_copied'));
});
}
}, [content, fixCommonSyntaxIssues, t]);
// Use ref to track timeout to prevent stale closures
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
useLayoutEffect(() => {
let isCancelled = false;
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
// Clear previous SVG content
setSvgContent('');
const cleanContent = content.trim();
setError(null);
setWasAutoCorrected(false);
setIsRendered(false);
setIsLoading(false);
if (!cleanContent) {
setError(t('com_mermaid_error_no_content'));
return;
}
// Debounce rendering to avoid flickering during rapid content changes
timeoutRef.current = setTimeout(() => {
if (!isCancelled) {
renderDiagram();
}
}, 300);
async function renderDiagram() {
if (isCancelled) return;
try {
if (
!cleanContent.match(
/^(graph|flowchart|sequenceDiagram|classDiagram|stateDiagram|erDiagram|journey|gantt|pie|gitgraph|mindmap|timeline|quadrant|block-beta|sankey|xychart|gitgraph)/i,
)
) {
if (!isCancelled) {
setError(t('com_mermaid_error_invalid_type'));
setWasAutoCorrected(false);
}
return;
}
// Dynamic import to reduce bundle size
setIsLoading(true);
const mermaid = await import('mermaid').then((m) => m.default);
if (isCancelled) {
return;
}
// Initialize with error suppression to avoid console spam
mermaid.initialize({
startOnLoad: false,
theme: isDarkMode ? 'dark' : 'default',
securityLevel: 'loose',
logLevel: 'fatal',
flowchart: {
useMaxWidth: true,
htmlLabels: true,
},
suppressErrorRendering: true,
});
let result;
let contentToRender = cleanContent;
try {
const id = `mermaid-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
result = await mermaid.render(id, contentToRender);
} catch (_renderError) {
const fixedContent = fixCommonSyntaxIssues(cleanContent);
if (fixedContent !== cleanContent) {
try {
const fixedId = `mermaid-fixed-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
result = await mermaid.render(fixedId, fixedContent);
contentToRender = fixedContent;
setWasAutoCorrected(true);
} catch (_fixedRenderError) {
if (!isCancelled) {
setError(t('com_mermaid_error_invalid_syntax_auto_correct'));
setWasAutoCorrected(false);
setIsLoading(false);
}
return;
}
} else {
if (!isCancelled) {
setError(t('com_mermaid_error_invalid_syntax'));
setWasAutoCorrected(false);
setIsLoading(false);
}
return;
}
}
// Check if component was unmounted during async render
if (isCancelled) {
return;
}
if (result && result.svg) {
let processedSvg = result.svg;
// Enhance SVG for better zoom/pan interaction
processedSvg = processedSvg.replace(
'<svg',
'<svg style="width: 100%; height: auto;" preserveAspectRatio="xMidYMid meet"',
);
// Sanitize SVG content to prevent XSS attacks
const sanitizedSvg = DOMPurify.sanitize(processedSvg, {
USE_PROFILES: { svg: true, svgFilters: true },
ADD_TAGS: ['foreignObject'],
ADD_ATTR: ['preserveAspectRatio'],
FORBID_TAGS: ['script', 'object', 'embed', 'iframe'],
FORBID_ATTR: ['onerror', 'onload', 'onclick'],
});
if (!isCancelled) {
setSvgContent(sanitizedSvg);
setIsRendered(true);
setIsLoading(false);
}
} else {
if (!isCancelled) {
setError(t('com_mermaid_error_no_svg'));
setWasAutoCorrected(false);
setIsLoading(false);
}
}
} catch (err) {
console.error('Mermaid rendering error:', err);
if (!isCancelled) {
const errorMessage =
err instanceof Error
? err.message
: t('com_mermaid_error_rendering_failed', 'Failed to render diagram');
setError(t('com_mermaid_error_rendering_failed', { '0': errorMessage }));
setWasAutoCorrected(false);
setIsLoading(false);
}
}
}
return () => {
isCancelled = true;
};
}, [diagramKey, content, isDarkMode, fixCommonSyntaxIssues, t]);
useLayoutEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
}, []);
if (error) {
const fixedContent = fixCommonSyntaxIssues(content);
const canTryFix = fixedContent !== content;
return (
<div
className={cn(
'my-4 overflow-auto rounded-lg border border-red-300 bg-red-50',
'dark:border-red-700 dark:bg-red-900/20',
className,
)}
>
<div className="p-4 text-red-600 dark:text-red-400">
<div className="flex items-start justify-between">
<div className="flex-1">
<strong>{t('com_mermaid_error')}</strong> {error}
{canTryFix && (
<div className={cn('mt-2 text-sm text-red-500 dark:text-red-300')}>
💡 {t('com_mermaid_error_fixes_detected')}
</div>
)}
</div>
<div className="ml-4 flex gap-2">
{canTryFix && (
<button
onClick={handleTryFix}
className={cn(
'rounded border px-3 py-1 text-xs transition-colors',
'border-blue-300 bg-blue-100 text-blue-700 hover:bg-blue-200',
'dark:border-blue-700 dark:bg-blue-900 dark:text-blue-300 dark:hover:bg-blue-800',
)}
title={t('com_mermaid_copy_potential_fix')}
>
{t('com_mermaid_try_fix')}
</button>
)}
<button
onClick={handleCopy}
className={cn(
'rounded border px-3 py-1 text-xs transition-colors',
'border-gray-300 bg-gray-100 text-gray-700 hover:bg-gray-200',
'dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700',
)}
title={t('com_mermaid_copy_code')}
>
{isCopied ? `${t('com_mermaid_copied')}` : t('com_mermaid_copy')}
</button>
</div>
</div>
</div>
<div className="p-4 pt-0">
<pre className="overflow-x-auto rounded bg-gray-100 p-2 text-sm dark:bg-gray-800">
<code className="language-mermaid">{content}</code>
</pre>
{canTryFix && (
<div className="mt-3 rounded border border-blue-200 bg-blue-50 p-3 dark:border-blue-800 dark:bg-blue-950">
<div className={cn('mb-2 text-sm font-medium text-blue-800 dark:text-blue-200')}>
{t('com_mermaid_suggested_fix')}
</div>
<pre className="overflow-x-auto rounded border bg-white p-2 text-sm dark:bg-gray-800">
<code className="language-mermaid">{fixedContent}</code>
</pre>
</div>
)}
</div>
</div>
);
}
return (
<div
key={diagramKey}
className={cn(
'relative my-4 overflow-auto rounded-lg border border-border-light bg-surface-primary',
'dark:border-border-heavy dark:bg-surface-primary-alt',
className,
)}
>
{isRendered && wasAutoCorrected && (
<div
className={cn(
'absolute left-2 top-2 z-10 rounded-md px-2 py-1 text-xs',
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
'border border-yellow-300 dark:border-yellow-700',
'shadow-sm',
)}
>
{t('com_mermaid_auto_fixed')}
</div>
)}
{isRendered && svgContent && (
<div className="absolute right-2 top-2 z-10 flex gap-1">
<button
onClick={handleZoomIn}
className={cn(
'rounded-md p-2 transition-all duration-200',
'hover:bg-surface-hover active:bg-surface-active',
'text-text-secondary hover:text-text-primary',
'border border-border-light dark:border-border-heavy',
'bg-surface-primary dark:bg-surface-primary-alt',
'shadow-sm hover:shadow-md',
)}
title={t('com_mermaid_zoom_in')}
>
<ZoomIn className="h-4 w-4" />
</button>
<button
onClick={handleZoomOut}
className={cn(
'rounded-md p-2 transition-all duration-200',
'hover:bg-surface-hover active:bg-surface-active',
'text-text-secondary hover:text-text-primary',
'border border-border-light dark:border-border-heavy',
'bg-surface-primary dark:bg-surface-primary-alt',
'shadow-sm hover:shadow-md',
)}
title={t('com_mermaid_zoom_out')}
>
<ZoomOut className="h-4 w-4" />
</button>
<button
onClick={handleResetZoom}
className={cn(
'rounded-md p-2 transition-all duration-200',
'hover:bg-surface-hover active:bg-surface-active',
'text-text-secondary hover:text-text-primary',
'border border-border-light dark:border-border-heavy',
'bg-surface-primary dark:bg-surface-primary-alt',
'shadow-sm hover:shadow-md',
)}
title={t('com_mermaid_reset_zoom')}
>
<RotateCcw className="h-4 w-4" />
</button>
<button
onClick={handleCopy}
className={cn(
'rounded-md p-2 transition-all duration-200',
'hover:bg-surface-hover active:bg-surface-active',
'text-text-secondary hover:text-text-primary',
'border border-border-light dark:border-border-heavy',
'bg-surface-primary dark:bg-surface-primary-alt',
'shadow-sm hover:shadow-md',
)}
title={t('com_mermaid_copy_code')}
>
{isCopied ? (
<CheckIcon className="h-4 w-4 text-green-500" />
) : (
<ClipboardIcon className="h-4 w-4" />
)}
</button>
</div>
)}
<div className="p-4">
{(isLoading || !isRendered) && (
<div className="animate-pulse text-center text-text-secondary">
{t('com_mermaid_rendering')}
</div>
)}
{isRendered && svgContent && (
<TransformWrapper
ref={transformRef}
initialScale={1}
minScale={0.1}
maxScale={4}
limitToBounds={false}
centerOnInit={true}
wheel={{ step: 0.1 }}
panning={{ velocityDisabled: true }}
alignmentAnimation={{ disabled: true }}
>
<TransformComponent
wrapperStyle={{
width: '100%',
height: 'auto',
minHeight: '200px',
maxHeight: '600px',
overflow: 'hidden',
}}
>
<div
className="mermaid-container flex min-h-[200px] items-center justify-center"
dangerouslySetInnerHTML={{ __html: svgContent }}
/>
</TransformComponent>
</TransformWrapper>
)}
</div>
</div>
);
});
InlineMermaidDiagram.displayName = 'InlineMermaidDiagram';
export default InlineMermaidDiagram;

View file

@ -300,6 +300,26 @@
"com_error_moderation": "It appears that the content submitted has been flagged by our moderation system for not aligning with our community guidelines. We're unable to proceed with this specific topic. If you have any other questions or topics you'd like to explore, please edit your message, or create a new conversation.",
"com_error_no_base_url": "No base URL found. Please provide one and try again.",
"com_error_no_user_key": "No key found. Please provide a key and try again.",
"com_mermaid_auto_fixed": "Auto-fixed",
"com_mermaid_copy": "Copy",
"com_mermaid_copy_potential_fix": "Copy potential fix to clipboard",
"com_mermaid_copy_code": "Copy mermaid code",
"com_mermaid_copied": "Copied",
"com_mermaid_error": "Mermaid Error:",
"com_mermaid_error_fixes_detected": "Potential fixes detected: spacing issues in arrows or labels",
"com_mermaid_error_invalid_syntax": "Invalid diagram syntax - check arrow formatting and node labels",
"com_mermaid_error_invalid_syntax_auto_correct": "Invalid diagram syntax - syntax errors found but unable to auto-correct",
"com_mermaid_error_invalid_type": "Invalid Mermaid syntax - diagram must start with a valid diagram type (flowchart, graph, sequenceDiagram, etc.)",
"com_mermaid_error_no_content": "No diagram content provided",
"com_mermaid_error_no_svg": "No SVG generated - rendering failed unexpectedly",
"com_mermaid_error_rendering_failed": "Rendering failed: {{0}}",
"com_mermaid_fix_copied": "Potential fix copied to clipboard. Common issues found and corrected.",
"com_mermaid_rendering": "Rendering diagram...",
"com_mermaid_suggested_fix": "Suggested Fix:",
"com_mermaid_try_fix": "Try Fix",
"com_mermaid_zoom_in": "Zoom in",
"com_mermaid_zoom_out": "Zoom out",
"com_mermaid_reset_zoom": "Reset zoom",
"com_files_filter": "Filter files...",
"com_files_no_results": "No results.",
"com_files_number_selected": "{{0}} of {{1}} items(s) selected",