mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-18 00:15:30 +01:00
feat: Add Inline Mermaid Diagram Component with Error Handling and Zoom Features
This commit is contained in:
parent
170cc340d8
commit
c53bdc1fef
3 changed files with 477 additions and 1 deletions
455
client/src/components/Chat/Messages/Content/MermaidDiagram.tsx
Normal file
455
client/src/components/Chat/Messages/Content/MermaidDiagram.tsx
Normal 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue