📊 feat: Enhance Inline Mermaid UX (#11170)

This commit is contained in:
Joel Hirzel 2026-01-02 16:44:16 +01:00 committed by Danny Avila
parent f3aec0576d
commit 1e74dc231f
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
2 changed files with 360 additions and 173 deletions

View file

@ -1,15 +1,6 @@
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 { X, ZoomIn, ZoomOut, ChevronUp, RefreshCw, RotateCcw, ChevronDown } from 'lucide-react';
import {
Button,
Spinner,
@ -21,6 +12,7 @@ import {
OGDialogContent,
} from '@librechat/client';
import { useLocalize, useDebouncedMermaid } from '~/hooks';
import MermaidHeader from './MermaidHeader';
import cn from '~/utils/cn';
interface MermaidProps {
@ -33,13 +25,14 @@ interface MermaidProps {
}
const MIN_ZOOM = 0.25;
const MAX_ZOOM = 3;
const MAX_ZOOM = 4;
const ZOOM_STEP = 0.25;
const MIN_CONTAINER_HEIGHT = 100;
const MAX_CONTAINER_HEIGHT = 500;
const Mermaid: React.FC<MermaidProps> = memo(({ children, id, theme }) => {
const localize = useLocalize();
const [blobUrl, setBlobUrl] = useState<string>('');
const [isCopied, setIsCopied] = useState(false);
const [showCode, setShowCode] = useState(false);
const [retryCount, setRetryCount] = useState(0);
const [isDialogOpen, setIsDialogOpen] = useState(false);
@ -47,14 +40,23 @@ const Mermaid: React.FC<MermaidProps> = memo(({ children, id, theme }) => {
const [dialogShowCode, setDialogShowCode] = useState(false);
const lastValidSvgRef = useRef<string | null>(null);
const expandButtonRef = useRef<HTMLButtonElement>(null);
const showCodeButtonRef = useRef<HTMLButtonElement>(null);
const copyButtonRef = useRef<HTMLButtonElement>(null);
const dialogShowCodeButtonRef = useRef<HTMLButtonElement>(null);
const dialogCopyButtonRef = useRef<HTMLButtonElement>(null);
const zoomCopyButtonRef = useRef<HTMLButtonElement>(null);
const dialogZoomCopyButtonRef = useRef<HTMLButtonElement>(null);
// Zoom and pan state
const [svgDimensions, setSvgDimensions] = useState<{ width: number; height: number } | null>(
null,
);
const [containerWidth, setContainerWidth] = useState(700);
const [isHovered, setIsHovered] = useState(false);
const [isTouchDevice, setIsTouchDevice] = useState(false);
const [showMobileControls, setShowMobileControls] = useState(false);
useEffect(() => {
setIsTouchDevice('ontouchstart' in window || navigator.maxTouchPoints > 0);
}, []);
const [zoom, setZoom] = useState(1);
// Dialog zoom and pan state (separate from inline view)
const [dialogZoom, setDialogZoom] = useState(1);
@ -89,35 +91,112 @@ const Mermaid: React.FC<MermaidProps> = memo(({ children, id, theme }) => {
}
}, [svg]);
// Process SVG and create blob URL
const processedSvg = useMemo(() => {
if (!svg) {
return null;
}
useEffect(() => {
if (!containerRef.current) return;
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('<svg', `<svg viewBox="0 0 ${width} ${height}"`);
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
setContainerWidth(entry.contentRect.width);
}
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);
// Process SVG and extract dimensions
const { processedSvg, parsedDimensions } = useMemo(() => {
if (!svg) {
return { processedSvg: null, parsedDimensions: null };
}
// Ensure SVG has proper XML namespace
if (!finalSvg.includes('xmlns')) {
finalSvg = finalSvg.replace('<svg', '<svg xmlns="http://www.w3.org/2000/svg"');
// Regex-based fallback for malformed or unparseable SVG
const applyFallbackFixes = (svgString: string): string => {
let finalSvg = svgString;
// Firefox fix: Ensure viewBox is set correctly
if (
!svgString.includes('viewBox') &&
svgString.includes('height=') &&
svgString.includes('width=')
) {
const widthMatch = svgString.match(/width="(\d+)"/);
const heightMatch = svgString.match(/height="(\d+)"/);
if (widthMatch && heightMatch) {
const width = widthMatch[1];
const height = heightMatch[1];
finalSvg = finalSvg.replace('<svg', `<svg viewBox="0 0 ${width} ${height}"`);
}
}
// Ensure SVG has proper XML namespace
if (!finalSvg.includes('xmlns')) {
finalSvg = finalSvg.replace('<svg', '<svg xmlns="http://www.w3.org/2000/svg"');
}
return finalSvg;
};
const parser = new DOMParser();
const doc = parser.parseFromString(svg, 'image/svg+xml');
const parseError = doc.querySelector('parsererror');
if (parseError) {
return { processedSvg: applyFallbackFixes(svg), parsedDimensions: null };
}
return finalSvg;
const svgElement = doc.querySelector('svg');
if (svgElement) {
let width = parseFloat(svgElement.getAttribute('width') || '0');
let height = parseFloat(svgElement.getAttribute('height') || '0');
if (!width || !height) {
const viewBox = svgElement.getAttribute('viewBox');
if (viewBox) {
const parts = viewBox.split(/[\s,]+/).map(Number);
if (parts.length === 4) {
width = parts[2];
height = parts[3];
}
}
}
let dimensions: { width: number; height: number } | null = null;
if (width > 0 && height > 0) {
dimensions = { width, height };
if (!svgElement.getAttribute('viewBox')) {
svgElement.setAttribute('viewBox', `0 0 ${width} ${height}`);
}
svgElement.removeAttribute('width');
svgElement.removeAttribute('height');
svgElement.removeAttribute('style');
}
if (!svgElement.getAttribute('xmlns')) {
svgElement.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
}
return {
processedSvg: new XMLSerializer().serializeToString(doc),
parsedDimensions: dimensions,
};
}
// Fallback: if svgElement is null
return { processedSvg: applyFallbackFixes(svg), parsedDimensions: null };
}, [svg]);
// Create blob URL for the SVG
// The svg dimension update needs to be in useEffect instead of useMemo to avoid re-render problems
useEffect(() => {
if (parsedDimensions) {
setSvgDimensions(parsedDimensions);
}
}, [parsedDimensions]);
useEffect(() => {
if (!processedSvg) {
return;
@ -134,22 +213,23 @@ const Mermaid: React.FC<MermaidProps> = memo(({ children, id, theme }) => {
};
}, [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 { initialScale, calculatedHeight } = useMemo(() => {
if (!svgDimensions) {
return { initialScale: 1, calculatedHeight: MAX_CONTAINER_HEIGHT };
}
const padding = 32;
const availableWidth = containerWidth - padding;
const scaleX = availableWidth / svgDimensions.width;
const scaleY = MAX_CONTAINER_HEIGHT / svgDimensions.height;
const scale = Math.min(scaleX, scaleY, 1); // Cap at 1 to prevent small diagrams from being scaled up
const height = Math.max(
MIN_CONTAINER_HEIGHT,
Math.min(MAX_CONTAINER_HEIGHT, svgDimensions.height * scale + padding),
);
return { initialScale: scale, calculatedHeight: height };
}, [svgDimensions, containerWidth]);
const [isDialogCopied, setIsDialogCopied] = useState(false);
const handleDialogCopy = useCallback(() => {
@ -194,12 +274,8 @@ const Mermaid: React.FC<MermaidProps> = memo(({ children, id, theme }) => {
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
@ -239,11 +315,10 @@ const Mermaid: React.FC<MermaidProps> = memo(({ children, id, theme }) => {
}, []);
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));
}
// In the expanded dialog, allow zooming without holding modifier key
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(
@ -279,15 +354,23 @@ const Mermaid: React.FC<MermaidProps> = memo(({ children, id, theme }) => {
}, []);
// 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));
}
}, []);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const handleWheelNative = (e: 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));
}
};
// use native event listener with passive: false to prevent scroll
container.addEventListener('wheel', handleWheelNative, { passive: false });
return () => container.removeEventListener('wheel', handleWheelNative);
}, [blobUrl]); // blobUrl dep (unused in callback) ensures listener re-attaches when container mounts
// Pan handlers
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
// Only start panning on left click and not on buttons/icons inside buttons
@ -301,84 +384,58 @@ const Mermaid: React.FC<MermaidProps> = memo(({ children, id, theme }) => {
[pan],
);
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
if (isPanning) {
setPan({
x: e.clientX - panStartRef.current.x,
y: e.clientY - panStartRef.current.y,
});
// Attach document-level listeners when panning starts
useEffect(() => {
if (!isPanning) return;
const handleDocumentMouseMove = (e: MouseEvent) => {
setPan({
x: e.clientX - panStartRef.current.x,
y: e.clientY - panStartRef.current.y,
});
};
const handleDocumentMouseUp = () => {
setIsPanning(false);
};
document.addEventListener('mousemove', handleDocumentMouseMove);
document.addEventListener('mouseup', handleDocumentMouseUp);
return () => {
document.removeEventListener('mousemove', handleDocumentMouseMove);
document.removeEventListener('mouseup', handleDocumentMouseUp);
};
}, [isPanning]);
const showControls = isTouchDevice ? showMobileControls || showCode : isHovered || showCode;
const handleContainerClick = useCallback(
(e: React.MouseEvent | React.TouchEvent) => {
if (!isTouchDevice) return;
const target = e.target as HTMLElement;
const isInteractive = target.closest('button, a, [role="button"]');
if (!isInteractive) {
setShowMobileControls((prev) => !prev);
}
},
[isPanning],
[isTouchDevice],
);
const handleMouseUp = useCallback(() => {
setIsPanning(false);
const handleExpand = useCallback(() => {
setDialogShowCode(false);
setDialogZoom(1);
setDialogPan({ x: 0, y: 0 });
setIsDialogOpen(true);
}, []);
const handleMouseLeave = useCallback(() => {
setIsPanning(false);
}, []);
// Header component (shared across states)
const Header = ({
showActions = false,
showExpandButton = false,
}: {
showActions?: boolean;
showExpandButton?: boolean;
}) => (
<div className="relative flex items-center justify-between rounded-tl-md rounded-tr-md bg-gray-700 px-4 py-2 font-sans text-xs text-gray-200">
<span>{localize('com_ui_mermaid')}</span>
{showActions && (
<div className="ml-auto flex gap-2">
{showExpandButton && (
<Button
ref={expandButtonRef}
variant="ghost"
size="sm"
className="h-auto gap-1 rounded-sm px-1 py-0 text-xs text-gray-200 hover:bg-gray-600 hover:text-white focus-visible:ring-white focus-visible:ring-offset-0"
onClick={() => {
setDialogShowCode(false);
setDialogZoom(1);
setDialogPan({ x: 0, y: 0 });
setIsDialogOpen(true);
}}
title={localize('com_ui_expand')}
>
<Expand className="h-4 w-4" />
{localize('com_ui_expand')}
</Button>
)}
<Button
ref={showCodeButtonRef}
variant="ghost"
size="sm"
className="h-auto min-w-[6rem] gap-1 rounded-sm px-1 py-0 text-xs text-gray-200 hover:bg-gray-600 hover:text-white focus-visible:ring-white focus-visible:ring-offset-0"
onClick={handleToggleCode}
>
{showCode ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
{showCode ? localize('com_ui_hide_code') : localize('com_ui_show_code')}
</Button>
<Button
ref={copyButtonRef}
variant="ghost"
size="sm"
className="h-auto gap-1 rounded-sm px-1 py-0 text-xs text-gray-200 hover:bg-gray-600 hover:text-white focus-visible:ring-white focus-visible:ring-offset-0"
onClick={handleCopy}
>
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard />}
{localize('com_ui_copy_code')}
</Button>
</div>
)}
</div>
);
// Zoom controls - inline JSX to avoid stale closure issues
const zoomControls = (
<div className="absolute bottom-2 right-2 z-10 flex items-center gap-1 rounded-md border border-border-light bg-surface-secondary p-1 shadow-lg">
<div
className={cn(
'absolute bottom-2 right-2 z-10 flex items-center gap-1 rounded-md border border-border-light bg-surface-secondary p-1 shadow-lg transition-opacity duration-200',
showControls ? 'opacity-100' : 'pointer-events-none opacity-0',
)}
>
<button
type="button"
onClick={(e) => {
@ -583,40 +640,52 @@ const Mermaid: React.FC<MermaidProps> = memo(({ children, id, theme }) => {
// If we have a previous valid render, show it with a subtle loading indicator
if (lastValidSvgRef.current && blobUrl) {
return (
<div className="w-full overflow-hidden rounded-md border border-border-light">
<Header showActions />
<div
className={cn(
'relative w-full overflow-hidden rounded-md border transition-all duration-200',
showControls ? 'border-border-light' : 'border-transparent',
)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onClick={handleContainerClick}
>
<MermaidHeader
className={cn(
'absolute left-0 right-0 top-0 z-20',
showControls ? 'opacity-100' : 'pointer-events-none opacity-0',
)}
codeContent={children}
showCode={showCode}
onToggleCode={handleToggleCode}
/>
<div
ref={containerRef}
className={cn(
'relative overflow-hidden p-4',
'rounded-b-md bg-surface-primary-alt',
'relative overflow-hidden p-4 transition-colors duration-200',
'rounded-md',
showControls ? 'bg-surface-primary-alt' : 'bg-transparent',
isPanning ? 'cursor-grabbing' : 'cursor-grab',
)}
style={{ minHeight: '250px', maxHeight: '600px' }}
onWheel={handleWheel}
style={{ height: `${calculatedHeight}px` }}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave}
>
<div className="absolute left-2 top-2 z-10 flex items-center gap-1 rounded border border-border-light bg-surface-secondary px-2 py-1 text-xs text-text-secondary">
<Spinner className="h-3 w-3" />
</div>
<div
className="flex items-center justify-center"
className="absolute inset-0 flex items-center justify-center"
style={{
transform: `translate(${pan.x}px, ${pan.y}px)`,
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
transition: isPanning ? 'none' : 'transform 0.1s ease-out',
}}
>
<img
src={blobUrl}
alt="Mermaid diagram"
className="max-w-full select-none opacity-70"
className="select-none opacity-70"
style={{
maxHeight: '500px',
transform: `scale(${zoom})`,
transformOrigin: 'center center',
width: svgDimensions ? `${svgDimensions.width * initialScale}px` : 'auto',
height: svgDimensions ? `${svgDimensions.height * initialScale}px` : 'auto',
}}
draggable={false}
/>
@ -648,7 +717,7 @@ const Mermaid: React.FC<MermaidProps> = memo(({ children, id, theme }) => {
if (error) {
return (
<div className="w-full overflow-hidden rounded-md border border-border-light">
<Header showActions />
<MermaidHeader codeContent={children} showCode={showCode} onToggleCode={handleToggleCode} />
<div className="rounded-b-md border-t border-red-500/30 bg-red-500/10 p-4">
<div className="mb-2 flex items-center justify-between">
<span className="font-semibold text-red-500 dark:text-red-400">
@ -689,10 +758,34 @@ const Mermaid: React.FC<MermaidProps> = memo(({ children, id, theme }) => {
return (
<>
{expandedDialog}
<div className="w-full overflow-hidden rounded-md border border-border-light">
<Header showActions showExpandButton />
<div
className={cn(
'relative w-full overflow-hidden rounded-md border transition-all duration-200',
showControls ? 'border-border-light' : 'border-transparent',
)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onClick={handleContainerClick}
>
<MermaidHeader
className={cn(
'absolute left-0 right-0 top-0 z-20',
showControls ? 'opacity-100' : 'pointer-events-none opacity-0',
)}
codeContent={children}
showCode={showCode}
showExpandButton
expandButtonRef={expandButtonRef}
onExpand={handleExpand}
onToggleCode={handleToggleCode}
/>
{showCode && (
<div className="border-b border-border-medium bg-surface-secondary p-4">
<div
className={cn(
'border-b border-border-medium bg-surface-secondary p-4 pt-12 transition-opacity duration-200',
showControls ? 'opacity-100' : 'pointer-events-none opacity-0',
)}
>
<pre className="overflow-auto whitespace-pre-wrap text-xs text-text-secondary">
{children}
</pre>
@ -701,33 +794,28 @@ const Mermaid: React.FC<MermaidProps> = memo(({ children, id, theme }) => {
<div
ref={containerRef}
className={cn(
'relative overflow-hidden p-4',
'bg-surface-primary-alt',
!showCode && 'rounded-b-md',
'relative overflow-hidden p-4 transition-colors duration-200',
'rounded-md',
showControls ? 'bg-surface-primary-alt' : 'bg-transparent',
isPanning ? 'cursor-grabbing' : 'cursor-grab',
)}
style={{ minHeight: '250px', maxHeight: '600px' }}
onWheel={handleWheel}
style={{ height: `${calculatedHeight}px` }}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave}
>
<div
className="flex min-h-[200px] items-center justify-center"
className="absolute inset-0 flex items-center justify-center"
style={{
transform: `translate(${pan.x}px, ${pan.y}px)`,
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
transition: isPanning ? 'none' : 'transform 0.1s ease-out',
}}
>
<img
src={blobUrl}
alt="Mermaid diagram"
className="max-w-full select-none"
className="select-none"
style={{
maxHeight: '500px',
transform: `scale(${zoom})`,
transformOrigin: 'center center',
width: svgDimensions ? `${svgDimensions.width * initialScale}px` : 'auto',
height: svgDimensions ? `${svgDimensions.height * initialScale}px` : 'auto',
}}
draggable={false}
/>

View file

@ -0,0 +1,99 @@
import React, { memo, useState, useCallback, useRef } from 'react';
import copy from 'copy-to-clipboard';
import { Expand, ChevronUp, ChevronDown } from 'lucide-react';
import { Button, Clipboard, CheckMark } from '@librechat/client';
import { useLocalize } from '~/hooks';
import cn from '~/utils/cn';
interface MermaidHeaderProps {
className?: string;
codeContent: string;
showCode: boolean;
showExpandButton?: boolean;
expandButtonRef?: React.RefObject<HTMLButtonElement>;
onExpand?: () => void;
onToggleCode: () => void;
}
const buttonClasses =
'h-auto gap-1 rounded-sm px-1 py-0 text-xs text-gray-200 hover:bg-gray-600 hover:text-white focus-visible:ring-white focus-visible:ring-offset-0';
const MermaidHeader: React.FC<MermaidHeaderProps> = memo(
({
className,
codeContent,
showCode,
showExpandButton = false,
expandButtonRef,
onExpand,
onToggleCode,
}) => {
const localize = useLocalize();
const [isCopied, setIsCopied] = useState(false);
const copyButtonRef = useRef<HTMLButtonElement>(null);
const showCodeButtonRef = useRef<HTMLButtonElement>(null);
const handleCopy = useCallback(() => {
copy(codeContent.trim(), { format: 'text/plain' });
setIsCopied(true);
setTimeout(() => setIsCopied(false), 3000);
}, [codeContent]);
const handleToggleCode = useCallback(() => {
onToggleCode();
requestAnimationFrame(() => {
showCodeButtonRef.current?.focus();
});
}, [onToggleCode]);
return (
<div
className={cn(
'flex items-center justify-between rounded-tl-md rounded-tr-md bg-gray-700/80 px-4 py-2 font-sans text-xs text-gray-200 backdrop-blur-sm transition-opacity duration-200',
className,
)}
>
<span>{localize('com_ui_mermaid')}</span>
<div className="ml-auto flex gap-2">
{showExpandButton && onExpand && (
<Button
ref={expandButtonRef}
variant="ghost"
size="sm"
className={buttonClasses}
onClick={onExpand}
title={localize('com_ui_expand')}
>
<Expand className="h-4 w-4" />
{localize('com_ui_expand')}
</Button>
)}
<Button
ref={showCodeButtonRef}
variant="ghost"
size="sm"
className={cn(buttonClasses, 'min-w-[6rem]')}
onClick={handleToggleCode}
>
{showCode ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
{showCode ? localize('com_ui_hide_code') : localize('com_ui_show_code')}
</Button>
<Button
ref={copyButtonRef}
variant="ghost"
size="sm"
className={buttonClasses}
onClick={handleCopy}
>
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard />}
{localize('com_ui_copy_code')}
</Button>
</div>
</div>
);
},
);
MermaidHeader.displayName = 'MermaidHeader';
export default MermaidHeader;