import React, { useEffect, useMemo, useState, useRef, useCallback, memo } from 'react'; import copy from 'copy-to-clipboard'; import { X, ZoomIn, Expand, ZoomOut, ChevronUp, RefreshCw, RotateCcw, ChevronDown, } from 'lucide-react'; import { Button, Spinner, OGDialog, Clipboard, CheckMark, OGDialogClose, OGDialogTitle, OGDialogContent, } from '@librechat/client'; import { useLocalize, useDebouncedMermaid } from '~/hooks'; import cn from '~/utils/cn'; interface MermaidProps { /** Mermaid diagram content */ children: string; /** Unique identifier */ id?: string; /** Custom theme */ theme?: string; } const MIN_ZOOM = 0.25; const MAX_ZOOM = 3; const ZOOM_STEP = 0.25; const Mermaid: React.FC = memo(({ children, id, theme }) => { const localize = useLocalize(); const [blobUrl, setBlobUrl] = useState(''); const [isCopied, setIsCopied] = useState(false); const [showCode, setShowCode] = useState(false); const [retryCount, setRetryCount] = useState(0); const [isDialogOpen, setIsDialogOpen] = useState(false); // Separate showCode state for dialog to avoid re-renders const [dialogShowCode, setDialogShowCode] = useState(false); const lastValidSvgRef = useRef(null); const expandButtonRef = useRef(null); const showCodeButtonRef = useRef(null); const copyButtonRef = useRef(null); const dialogShowCodeButtonRef = useRef(null); const dialogCopyButtonRef = useRef(null); const zoomCopyButtonRef = useRef(null); const dialogZoomCopyButtonRef = useRef(null); // Zoom and pan state const [zoom, setZoom] = useState(1); // Dialog zoom and pan state (separate from inline view) const [dialogZoom, setDialogZoom] = useState(1); const [dialogPan, setDialogPan] = useState({ x: 0, y: 0 }); const [isDialogPanning, setIsDialogPanning] = useState(false); const dialogPanStartRef = useRef({ x: 0, y: 0 }); const [pan, setPan] = useState({ x: 0, y: 0 }); const [isPanning, setIsPanning] = useState(false); const panStartRef = useRef({ x: 0, y: 0 }); const containerRef = useRef(null); const streamingCodeRef = useRef(null); // Get SVG from debounced hook (handles streaming gracefully) const { svg, isLoading, error } = useDebouncedMermaid({ content: children, id, theme, key: retryCount, }); // Auto-scroll streaming code to bottom useEffect(() => { if (isLoading && streamingCodeRef.current) { streamingCodeRef.current.scrollTop = streamingCodeRef.current.scrollHeight; } }, [children, isLoading]); // Store last valid SVG for showing during updates useEffect(() => { if (svg) { lastValidSvgRef.current = svg; } }, [svg]); // Process SVG and create blob URL const processedSvg = useMemo(() => { if (!svg) { return null; } let finalSvg = svg; // Firefox fix: Ensure viewBox is set correctly if (!svg.includes('viewBox') && svg.includes('height=') && svg.includes('width=')) { const widthMatch = svg.match(/width="(\d+)"/); const heightMatch = svg.match(/height="(\d+)"/); if (widthMatch && heightMatch) { const width = widthMatch[1]; const height = heightMatch[1]; finalSvg = svg.replace(' { if (!processedSvg) { return; } const blob = new Blob([processedSvg], { type: 'image/svg+xml' }); const url = URL.createObjectURL(blob); setBlobUrl(url); return () => { if (url) { URL.revokeObjectURL(url); } }; }, [processedSvg]); const handleCopy = useCallback(() => { copy(children.trim(), { format: 'text/plain' }); setIsCopied(true); requestAnimationFrame(() => { copyButtonRef.current?.focus(); }); setTimeout(() => { // Save currently focused element before state update causes re-render const focusedElement = document.activeElement as HTMLElement | null; setIsCopied(false); // Restore focus to whatever was focused (React re-render may have disrupted it) requestAnimationFrame(() => { focusedElement?.focus(); }); }, 3000); }, [children]); const [isDialogCopied, setIsDialogCopied] = useState(false); const handleDialogCopy = useCallback(() => { copy(children.trim(), { format: 'text/plain' }); setIsDialogCopied(true); requestAnimationFrame(() => { dialogCopyButtonRef.current?.focus(); }); setTimeout(() => { setIsDialogCopied(false); requestAnimationFrame(() => { dialogCopyButtonRef.current?.focus(); }); }, 3000); }, [children]); // Zoom controls copy with focus restoration const [isZoomCopied, setIsZoomCopied] = useState(false); const handleZoomCopy = useCallback(() => { copy(children.trim(), { format: 'text/plain' }); setIsZoomCopied(true); requestAnimationFrame(() => { zoomCopyButtonRef.current?.focus(); }); setTimeout(() => { setIsZoomCopied(false); requestAnimationFrame(() => { zoomCopyButtonRef.current?.focus(); }); }, 3000); }, [children]); // Dialog zoom controls copy const handleDialogZoomCopy = useCallback(() => { copy(children.trim(), { format: 'text/plain' }); requestAnimationFrame(() => { dialogZoomCopyButtonRef.current?.focus(); }); }, [children]); const handleRetry = () => { setRetryCount((prev) => prev + 1); }; // Toggle code with focus restoration const handleToggleCode = useCallback(() => { setShowCode((prev) => !prev); requestAnimationFrame(() => { showCodeButtonRef.current?.focus(); }); }, []); // Toggle dialog code with focus restoration const handleToggleDialogCode = useCallback(() => { setDialogShowCode((prev) => !prev); requestAnimationFrame(() => { dialogShowCodeButtonRef.current?.focus(); }); }, []); // Zoom handlers const handleZoomIn = useCallback(() => { setZoom((prev) => Math.min(prev + ZOOM_STEP, MAX_ZOOM)); }, []); const handleZoomOut = useCallback(() => { setZoom((prev) => Math.max(prev - ZOOM_STEP, MIN_ZOOM)); }, []); const handleResetZoom = useCallback(() => { setZoom(1); setPan({ x: 0, y: 0 }); }, []); // Dialog zoom handlers const handleDialogZoomIn = useCallback(() => { setDialogZoom((prev) => Math.min(prev + ZOOM_STEP, MAX_ZOOM)); }, []); const handleDialogZoomOut = useCallback(() => { setDialogZoom((prev) => Math.max(prev - ZOOM_STEP, MIN_ZOOM)); }, []); const handleDialogResetZoom = useCallback(() => { setDialogZoom(1); setDialogPan({ x: 0, y: 0 }); }, []); const handleDialogWheel = useCallback((e: React.WheelEvent) => { if (e.ctrlKey || e.metaKey) { e.preventDefault(); const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP; setDialogZoom((prev) => Math.min(Math.max(prev + delta, MIN_ZOOM), MAX_ZOOM)); } }, []); const handleDialogMouseDown = useCallback( (e: React.MouseEvent) => { const target = e.target as HTMLElement; const isButton = target.tagName === 'BUTTON' || target.closest('button'); if (e.button === 0 && !isButton) { setIsDialogPanning(true); dialogPanStartRef.current = { x: e.clientX - dialogPan.x, y: e.clientY - dialogPan.y }; } }, [dialogPan], ); const handleDialogMouseMove = useCallback( (e: React.MouseEvent) => { if (isDialogPanning) { setDialogPan({ x: e.clientX - dialogPanStartRef.current.x, y: e.clientY - dialogPanStartRef.current.y, }); } }, [isDialogPanning], ); const handleDialogMouseUp = useCallback(() => { setIsDialogPanning(false); }, []); const handleDialogMouseLeave = useCallback(() => { setIsDialogPanning(false); }, []); // Mouse wheel zoom const handleWheel = useCallback((e: React.WheelEvent) => { if (e.ctrlKey || e.metaKey) { e.preventDefault(); const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP; setZoom((prev) => Math.min(Math.max(prev + delta, MIN_ZOOM), MAX_ZOOM)); } }, []); // Pan handlers const handleMouseDown = useCallback( (e: React.MouseEvent) => { // Only start panning on left click and not on buttons/icons inside buttons const target = e.target as HTMLElement; const isButton = target.tagName === 'BUTTON' || target.closest('button'); if (e.button === 0 && !isButton) { setIsPanning(true); panStartRef.current = { x: e.clientX - pan.x, y: e.clientY - pan.y }; } }, [pan], ); const handleMouseMove = useCallback( (e: React.MouseEvent) => { if (isPanning) { setPan({ x: e.clientX - panStartRef.current.x, y: e.clientY - panStartRef.current.y, }); } }, [isPanning], ); const handleMouseUp = useCallback(() => { setIsPanning(false); }, []); const handleMouseLeave = useCallback(() => { setIsPanning(false); }, []); // Header component (shared across states) const Header = ({ showActions = false, showExpandButton = false, }: { showActions?: boolean; showExpandButton?: boolean; }) => (
{localize('com_ui_mermaid')} {showActions && (
{showExpandButton && ( )}
)}
); // Zoom controls - inline JSX to avoid stale closure issues const zoomControls = (
{Math.round(zoom * 100)}%
); // Dialog zoom controls const dialogZoomControls = (
{Math.round(dialogZoom * 100)}%
); // Full-screen dialog - rendered inline, not as function component to avoid recreation const expandedDialog = ( {localize('com_ui_mermaid')}
{localize('com_ui_close')}
{dialogShowCode && (
              {children}
            
)}
Mermaid diagram
{dialogZoomControls}
); // Loading state - show last valid diagram with loading indicator, or spinner if (isLoading) { // If we have a previous valid render, show it with a subtle loading indicator if (lastValidSvgRef.current && blobUrl) { return (
Mermaid diagram
{zoomControls}
); } // No previous render, show streaming code return (
{localize('com_ui_mermaid')}
          {children}
        
); } // Error state if (error) { return (
{localize('com_ui_mermaid_failed')}
            {error.message}
          
{showCode && (
{localize('com_ui_mermaid_source')}
                {children}
              
)}
); } // Success state if (!blobUrl) { return null; } return ( <> {expandedDialog}
{showCode && (
              {children}
            
)}
Mermaid diagram
{zoomControls}
); }); Mermaid.displayName = 'Mermaid'; export default Mermaid;