From 1e74dc231f7644dd45429cfcbac44283f1db9063 Mon Sep 17 00:00:00 2001 From: Joel Hirzel Date: Fri, 2 Jan 2026 16:44:16 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=8A=20feat:=20Enhance=20Inline=20Merma?= =?UTF-8?q?id=20UX=20(#11170)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/Messages/Content/Mermaid.tsx | 434 +++++++++++------- .../Messages/Content/MermaidHeader.tsx | 99 ++++ 2 files changed, 360 insertions(+), 173 deletions(-) create mode 100644 client/src/components/Messages/Content/MermaidHeader.tsx diff --git a/client/src/components/Messages/Content/Mermaid.tsx b/client/src/components/Messages/Content/Mermaid.tsx index 2e81f6c1d4..f19b3cbe94 100644 --- a/client/src/components/Messages/Content/Mermaid.tsx +++ b/client/src/components/Messages/Content/Mermaid.tsx @@ -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 = 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); @@ -47,14 +40,23 @@ const Mermaid: React.FC = memo(({ children, id, theme }) => { 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 [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 = 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(' { + 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(' { + 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(' 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 = 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 = 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 = 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 = 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 = 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; - }) => ( -
- {localize('com_ui_mermaid')} - {showActions && ( -
- {showExpandButton && ( - - )} - - -
- )} -
- ); - - // Zoom controls - inline JSX to avoid stale closure issues const zoomControls = ( -
+
+ )} + + +
+
+ ); + }, +); + +MermaidHeader.displayName = 'MermaidHeader'; + +export default MermaidHeader;