From f1eabdbdb7eafe6c5a9a95b73066b0c491365f1b Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 4 Mar 2026 08:25:57 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=8C=97=20refactor:=20Consistent=20Mermaid?= =?UTF-8?q?=20Theming=20for=20Inline=20and=20Artifact=20Renderers=20(#1205?= =?UTF-8?q?5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: consistent theming between inline and Artifacts Mermaid Diagram * refactor: Enhance Mermaid component with improved theming and security features - Updated Mermaid component to utilize useCallback for performance optimization. - Increased maximum zoom level from 4 to 10 for better diagram visibility. - Added security level configuration to Mermaid initialization for enhanced security. - Refactored theme handling to ensure consistent theming between inline and artifact diagrams. - Introduced unit tests for Mermaid configuration to validate flowchart settings and theme behavior. * refactor: Improve theme handling in useMermaid hook - Enhanced theme variable management by merging custom theme variables with default values for dark mode. - Ensured consistent theming across Mermaid diagrams by preserving existing theme configurations while applying new defaults. * refactor: Consolidate imports in mermaid test file - Combined multiple imports from the mermaid utility into a single statement for improved readability and organization in the test file. * feat: Add subgraph title contrast adjustment for Mermaid diagrams - Introduced a utility function to enhance text visibility on subgraph titles by adjusting the fill color based on background luminance. - Updated the Mermaid component to utilize this function, ensuring better contrast in rendered SVGs. - Added comprehensive unit tests to validate the contrast adjustment logic across various scenarios. * refactor: Update MermaidHeader component for improved button accessibility and styling - Replaced Button components with TooltipAnchor for better accessibility and user experience. - Consolidated button styles into a single class for consistency. - Enhanced the layout and spacing of the header for a cleaner appearance. * fix: hex color handling and improve contrast adjustment in Mermaid diagrams - Updated hexLuminance function to support 3-character hex shorthand by expanding it to 6 characters. - Refined the fixSubgraphTitleContrast function to avoid double semicolons in style attributes and ensure proper fill color adjustments based on background luminance. - Added unit tests to validate the handling of 3-character hex fills and the prevention of double semicolons in text styles. * chore: Simplify Virtual Scrolling Performance tests by removing performance timing checks - Removed performance timing checks and associated console logs from tests handling 1000 and 5000 agents. - Focused tests on verifying the correct rendering of virtual list items without measuring render time. --- .../VirtualScrollingPerformance.test.tsx | 18 - client/src/components/Artifacts/Mermaid.tsx | 182 +++---- .../components/Messages/Content/Mermaid.tsx | 7 +- .../Messages/Content/MermaidHeader.tsx | 90 ++-- .../src/hooks/Artifacts/useArtifactProps.ts | 12 +- client/src/hooks/Mermaid/useMermaid.ts | 13 +- client/src/utils/__tests__/mermaid.test.ts | 172 +++++++ client/src/utils/mermaid.ts | 486 +++++++++++++----- 8 files changed, 671 insertions(+), 309 deletions(-) create mode 100644 client/src/utils/__tests__/mermaid.test.ts diff --git a/client/src/components/Agents/tests/VirtualScrollingPerformance.test.tsx b/client/src/components/Agents/tests/VirtualScrollingPerformance.test.tsx index 1e1b7d1e4b..293bd8878e 100644 --- a/client/src/components/Agents/tests/VirtualScrollingPerformance.test.tsx +++ b/client/src/components/Agents/tests/VirtualScrollingPerformance.test.tsx @@ -179,9 +179,7 @@ describe('Virtual Scrolling Performance', () => { }; it('efficiently handles 1000 agents without rendering all DOM nodes', () => { - const startTime = performance.now(); renderComponent(1000); - const endTime = performance.now(); const virtualList = screen.getByTestId('virtual-list'); expect(virtualList).toBeInTheDocument(); @@ -191,19 +189,10 @@ describe('Virtual Scrolling Performance', () => { const renderedCards = screen.getAllByTestId(/agent-card-/); expect(renderedCards.length).toBeLessThan(50); // Much less than 1000 expect(renderedCards.length).toBeGreaterThan(0); - - // Performance check: rendering should be fast - const renderTime = endTime - startTime; - expect(renderTime).toBeLessThan(740); - - console.log(`Rendered 1000 agents in ${renderTime.toFixed(2)}ms`); - console.log(`Only ${renderedCards.length} DOM nodes created for 1000 agents`); }); it('efficiently handles 5000 agents (stress test)', () => { - const startTime = performance.now(); renderComponent(5000); - const endTime = performance.now(); const virtualList = screen.getByTestId('virtual-list'); expect(virtualList).toBeInTheDocument(); @@ -213,13 +202,6 @@ describe('Virtual Scrolling Performance', () => { const renderedCards = screen.getAllByTestId(/agent-card-/); expect(renderedCards.length).toBeLessThan(50); expect(renderedCards.length).toBeGreaterThan(0); - - // Performance should still be reasonable - const renderTime = endTime - startTime; - expect(renderTime).toBeLessThan(200); // Should render in less than 200ms - - console.log(`Rendered 5000 agents in ${renderTime.toFixed(2)}ms`); - console.log(`Only ${renderedCards.length} DOM nodes created for 5000 agents`); }); it('calculates correct number of virtual rows for different screen sizes', () => { diff --git a/client/src/components/Artifacts/Mermaid.tsx b/client/src/components/Artifacts/Mermaid.tsx index f7291998a4..5eb55be3ae 100644 --- a/client/src/components/Artifacts/Mermaid.tsx +++ b/client/src/components/Artifacts/Mermaid.tsx @@ -1,153 +1,123 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useRef, useState, useCallback } from 'react'; import mermaid from 'mermaid'; import { Button } from '@librechat/client'; -import { TransformWrapper, TransformComponent, ReactZoomPanPinchRef } from 'react-zoom-pan-pinch'; -import { ZoomIn, ZoomOut, RefreshCw } from 'lucide-react'; +import { ZoomIn, ZoomOut, RotateCcw } from 'lucide-react'; +import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch'; +import type { ReactZoomPanPinchRef } from 'react-zoom-pan-pinch'; +import { artifactFlowchartConfig } from '~/utils/mermaid'; interface MermaidDiagramProps { content: string; + isDarkMode?: boolean; } -/** Note: this is just for testing purposes, don't actually use this component */ -const MermaidDiagram: React.FC = ({ content }) => { +const MermaidDiagram: React.FC = ({ content, isDarkMode = true }) => { const mermaidRef = useRef(null); const transformRef = useRef(null); const [isRendered, setIsRendered] = useState(false); + const theme = isDarkMode ? 'dark' : 'neutral'; + const bgColor = isDarkMode ? '#212121' : '#FFFFFF'; useEffect(() => { mermaid.initialize({ startOnLoad: false, - theme: 'base', + theme, securityLevel: 'sandbox', - themeVariables: { - background: '#282C34', - primaryColor: '#333842', - secondaryColor: '#333842', - tertiaryColor: '#333842', - primaryTextColor: '#ABB2BF', - secondaryTextColor: '#ABB2BF', - lineColor: '#636D83', - fontSize: '16px', - nodeBorder: '#636D83', - mainBkg: '#282C34', - altBackground: '#282C34', - textColor: '#ABB2BF', - edgeLabelBackground: '#282C34', - clusterBkg: '#282C34', - clusterBorder: '#636D83', - labelBoxBkgColor: '#333842', - labelBoxBorderColor: '#636D83', - labelTextColor: '#ABB2BF', - }, - flowchart: { - curve: 'basis', - nodeSpacing: 50, - rankSpacing: 50, - diagramPadding: 8, - htmlLabels: true, - useMaxWidth: true, - padding: 15, - wrappingWidth: 200, - }, + flowchart: artifactFlowchartConfig, }); const renderDiagram = async () => { - if (mermaidRef.current) { - try { - const { svg } = await mermaid.render('mermaid-diagram', content); - mermaidRef.current.innerHTML = svg; + if (!mermaidRef.current) { + return; + } - const svgElement = mermaidRef.current.querySelector('svg'); - if (svgElement) { - svgElement.style.width = '100%'; - svgElement.style.height = '100%'; + try { + const { svg } = await mermaid.render('mermaid-diagram', content); + mermaidRef.current.innerHTML = svg; - const pathElements = svgElement.querySelectorAll('path'); - pathElements.forEach((path) => { - path.style.strokeWidth = '1.5px'; - }); - - const rectElements = svgElement.querySelectorAll('rect'); - rectElements.forEach((rect) => { - const parent = rect.parentElement; - if (parent && parent.classList.contains('node')) { - rect.style.stroke = '#636D83'; - rect.style.strokeWidth = '1px'; - } else { - rect.style.stroke = 'none'; - } - }); - } - setIsRendered(true); - } catch (error) { - console.error('Mermaid rendering error:', error); + const svgElement = mermaidRef.current.querySelector('svg'); + if (svgElement) { + svgElement.style.width = '100%'; + svgElement.style.height = '100%'; + } + setIsRendered(true); + } catch (error) { + console.error('Mermaid rendering error:', error); + if (mermaidRef.current) { mermaidRef.current.innerHTML = 'Error rendering diagram'; } } }; renderDiagram(); - }, [content]); + }, [content, theme]); - const centerAndFitDiagram = () => { + const centerAndFitDiagram = useCallback(() => { if (transformRef.current && mermaidRef.current) { const { centerView, zoomToElement } = transformRef.current; zoomToElement(mermaidRef.current as HTMLElement); centerView(1, 0); } - }; + }, []); useEffect(() => { if (isRendered) { centerAndFitDiagram(); } - }, [isRendered]); + }, [isRendered, centerAndFitDiagram]); - const handlePanning = () => { - if (transformRef.current) { - const { state, instance } = (transformRef.current as ReactZoomPanPinchRef | undefined) ?? {}; - if (!state || !instance) { - return; - } - const { scale, positionX, positionY } = state; - const { wrapperComponent, contentComponent } = instance; - - if (wrapperComponent && contentComponent) { - const wrapperRect = wrapperComponent.getBoundingClientRect(); - const contentRect = contentComponent.getBoundingClientRect(); - const maxX = wrapperRect.width - contentRect.width * scale; - const maxY = wrapperRect.height - contentRect.height * scale; - - let newX = positionX; - let newY = positionY; - - if (newX > 0) { - newX = 0; - } - if (newY > 0) { - newY = 0; - } - if (newX < maxX) { - newX = maxX; - } - if (newY < maxY) { - newY = maxY; - } - - if (newX !== positionX || newY !== positionY) { - instance.setTransformState(scale, newX, newY); - } - } + const handlePanning = useCallback(() => { + if (!transformRef.current) { + return; } - }; + + const { state, instance } = transformRef.current; + if (!state || !instance) { + return; + } + const { scale, positionX, positionY } = state; + const { wrapperComponent, contentComponent } = instance; + + if (!wrapperComponent || !contentComponent) { + return; + } + + const wrapperRect = wrapperComponent.getBoundingClientRect(); + const contentRect = contentComponent.getBoundingClientRect(); + const maxX = wrapperRect.width - contentRect.width * scale; + const maxY = wrapperRect.height - contentRect.height * scale; + + let newX = positionX; + let newY = positionY; + + if (newX > 0) { + newX = 0; + } + if (newY > 0) { + newY = 0; + } + if (newX < maxX) { + newX = maxX; + } + if (newY < maxY) { + newY = maxY; + } + + if (newX !== positionX || newY !== positionY) { + instance.setTransformState(scale, newX, newY); + } + }, []); return ( -
+
= ({ content }) => {
diff --git a/client/src/components/Messages/Content/Mermaid.tsx b/client/src/components/Messages/Content/Mermaid.tsx index 9d830b3fdc..03037f4427 100644 --- a/client/src/components/Messages/Content/Mermaid.tsx +++ b/client/src/components/Messages/Content/Mermaid.tsx @@ -12,6 +12,7 @@ import { OGDialogContent, } from '@librechat/client'; import { useLocalize, useDebouncedMermaid } from '~/hooks'; +import { fixSubgraphTitleContrast } from '~/utils/mermaid'; import MermaidHeader from './MermaidHeader'; import cn from '~/utils/cn'; @@ -181,6 +182,8 @@ const Mermaid: React.FC = memo(({ children, id, theme }) => { svgElement.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); } + fixSubgraphTitleContrast(svgElement); + return { processedSvg: new XMLSerializer().serializeToString(doc), parsedDimensions: dimensions, @@ -672,7 +675,7 @@ const Mermaid: React.FC = memo(({ children, id, theme }) => { className={cn( 'relative overflow-hidden p-4 transition-colors duration-200', 'rounded-md', - showControls ? 'bg-surface-primary-alt' : 'bg-transparent', + showControls ? 'bg-surface-primary-alt dark:bg-white/[0.03]' : 'bg-transparent', isPanning ? 'cursor-grabbing' : 'cursor-grab', )} style={{ height: `${calculatedHeight}px` }} @@ -811,7 +814,7 @@ const Mermaid: React.FC = memo(({ children, id, theme }) => { className={cn( 'relative overflow-hidden p-4 transition-colors duration-200', 'rounded-md', - showControls ? 'bg-surface-primary-alt' : 'bg-transparent', + showControls ? 'bg-surface-primary-alt dark:bg-white/[0.03]' : 'bg-transparent', isPanning ? 'cursor-grabbing' : 'cursor-grab', )} style={{ height: `${calculatedHeight}px` }} diff --git a/client/src/components/Messages/Content/MermaidHeader.tsx b/client/src/components/Messages/Content/MermaidHeader.tsx index 03b49b6558..2d3a416a5a 100644 --- a/client/src/components/Messages/Content/MermaidHeader.tsx +++ b/client/src/components/Messages/Content/MermaidHeader.tsx @@ -1,7 +1,7 @@ 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 { Clipboard, CheckMark, TooltipAnchor } from '@librechat/client'; import { useLocalize } from '~/hooks'; import cn from '~/utils/cn'; @@ -15,8 +15,8 @@ interface MermaidHeaderProps { 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 iconBtnClass = + 'flex items-center justify-center rounded p-1.5 text-text-secondary hover:bg-surface-hover focus-visible:outline focus-visible:outline-white'; const MermaidHeader: React.FC = memo( ({ @@ -49,46 +49,58 @@ const MermaidHeader: React.FC = memo( return (
- {localize('com_ui_mermaid')} -
- {showExpandButton && onExpand && ( - + } + /> + )} + - - {localize('com_ui_expand')} - - )} - - -
+ {showCode ? : } + + } + /> + + {isCopied ? ( + + ) : ( + + )} + + } + />
); }, diff --git a/client/src/hooks/Artifacts/useArtifactProps.ts b/client/src/hooks/Artifacts/useArtifactProps.ts index 2b898934c4..ce5e30cf5a 100644 --- a/client/src/hooks/Artifacts/useArtifactProps.ts +++ b/client/src/hooks/Artifacts/useArtifactProps.ts @@ -1,17 +1,21 @@ -import { useMemo } from 'react'; +import { useContext, useMemo } from 'react'; +import { ThemeContext, isDark } from '@librechat/client'; import { removeNullishValues } from 'librechat-data-provider'; import type { Artifact } from '~/common'; import { getKey, getProps, getTemplate, getArtifactFilename } from '~/utils/artifacts'; -import { getMermaidFiles } from '~/utils/mermaid'; import { getMarkdownFiles } from '~/utils/markdown'; +import { getMermaidFiles } from '~/utils/mermaid'; export default function useArtifactProps({ artifact }: { artifact: Artifact }) { + const { theme } = useContext(ThemeContext); + const isDarkMode = isDark(theme); + const [fileKey, files] = useMemo(() => { const key = getKey(artifact.type ?? '', artifact.language); const type = artifact.type ?? ''; if (key.includes('mermaid')) { - return ['diagram.mmd', getMermaidFiles(artifact.content ?? '')]; + return ['diagram.mmd', getMermaidFiles(artifact.content ?? '', isDarkMode)]; } if (type === 'text/markdown' || type === 'text/md' || type === 'text/plain') { @@ -23,7 +27,7 @@ export default function useArtifactProps({ artifact }: { artifact: Artifact }) { [fileKey]: artifact.content, }); return [fileKey, files]; - }, [artifact.type, artifact.content, artifact.language]); + }, [artifact.type, artifact.content, artifact.language, isDarkMode]); const template = useMemo( () => getTemplate(artifact.type ?? '', artifact.language), diff --git a/client/src/hooks/Mermaid/useMermaid.ts b/client/src/hooks/Mermaid/useMermaid.ts index 26e195e401..31957dce36 100644 --- a/client/src/hooks/Mermaid/useMermaid.ts +++ b/client/src/hooks/Mermaid/useMermaid.ts @@ -1,9 +1,10 @@ import { useContext, useMemo, useState } from 'react'; -import DOMPurify from 'dompurify'; import useSWR from 'swr'; import { Md5 } from 'ts-md5'; +import DOMPurify from 'dompurify'; import { ThemeContext, isDark } from '@librechat/client'; import type { MermaidConfig } from 'mermaid'; +import { inlineFlowchartConfig } from '~/utils/mermaid'; // Constants const MD5_LENGTH_THRESHOLD = 10_000; @@ -85,12 +86,12 @@ export const useMermaid = ({ return { startOnLoad: false, theme: (customTheme as MermaidConfig['theme']) || defaultTheme, - // Spread custom config but override security settings after ...config, - // Security hardening - these MUST come last to prevent override - securityLevel: 'strict', // Highest security: disables click, sanitizes text - maxTextSize: config?.maxTextSize ?? 50000, // Limit text size to prevent DoS - maxEdges: config?.maxEdges ?? 500, // Limit edges to prevent DoS + flowchart: { ...inlineFlowchartConfig, ...config?.flowchart, htmlLabels: false }, + // Security hardening: MUST come after ...config spread to prevent override + securityLevel: 'strict', + maxTextSize: config?.maxTextSize ?? 50000, + maxEdges: config?.maxEdges ?? 500, }; }, [customTheme, isDarkMode, config]); diff --git a/client/src/utils/__tests__/mermaid.test.ts b/client/src/utils/__tests__/mermaid.test.ts new file mode 100644 index 0000000000..eb34c2c0f8 --- /dev/null +++ b/client/src/utils/__tests__/mermaid.test.ts @@ -0,0 +1,172 @@ +import { + fixSubgraphTitleContrast, + artifactFlowchartConfig, + inlineFlowchartConfig, + getMermaidFiles, +} from '~/utils/mermaid'; + +const makeSvg = (clusters: string): Element => { + const parser = new DOMParser(); + const doc = parser.parseFromString( + `${clusters}`, + 'image/svg+xml', + ); + return doc.querySelector('svg')!; +}; + +describe('mermaid config', () => { + describe('flowchart config invariants', () => { + it('inlineFlowchartConfig must have htmlLabels: false for blob URL rendering', () => { + expect(inlineFlowchartConfig.htmlLabels).toBe(false); + }); + + it('artifactFlowchartConfig must have htmlLabels: true for direct DOM injection', () => { + expect(artifactFlowchartConfig.htmlLabels).toBe(true); + }); + + it('both configs share the same base layout settings', () => { + expect(inlineFlowchartConfig.curve).toBe(artifactFlowchartConfig.curve); + expect(inlineFlowchartConfig.nodeSpacing).toBe(artifactFlowchartConfig.nodeSpacing); + expect(inlineFlowchartConfig.rankSpacing).toBe(artifactFlowchartConfig.rankSpacing); + expect(inlineFlowchartConfig.padding).toBe(artifactFlowchartConfig.padding); + }); + }); + + describe('getMermaidFiles', () => { + const content = 'graph TD\n A-->B'; + + it('produces dark theme files when isDarkMode is true', () => { + const files = getMermaidFiles(content, true); + expect(files['/components/ui/MermaidDiagram.tsx']).toContain('theme: "dark"'); + expect(files['mermaid.css']).toContain('#212121'); + }); + + it('produces neutral theme files when isDarkMode is false', () => { + const files = getMermaidFiles(content, false); + expect(files['/components/ui/MermaidDiagram.tsx']).toContain('theme: "neutral"'); + expect(files['mermaid.css']).toContain('#FFFFFF'); + }); + + it('defaults to dark mode when isDarkMode is omitted', () => { + const files = getMermaidFiles(content); + expect(files['/components/ui/MermaidDiagram.tsx']).toContain('theme: "dark"'); + }); + + it('includes securityLevel in generated component', () => { + const files = getMermaidFiles(content, true); + expect(files['/components/ui/MermaidDiagram.tsx']).toContain('securityLevel: "strict"'); + }); + + it('includes all required file keys', () => { + const files = getMermaidFiles(content, true); + expect(files['diagram.mmd']).toBe(content); + expect(files['App.tsx']).toBeDefined(); + expect(files['index.tsx']).toBeDefined(); + expect(files['/components/ui/MermaidDiagram.tsx']).toBeDefined(); + expect(files['mermaid.css']).toBeDefined(); + }); + + it('uses artifact flowchart config with htmlLabels: true', () => { + const files = getMermaidFiles(content, true); + expect(files['/components/ui/MermaidDiagram.tsx']).toContain('"htmlLabels": true'); + }); + + it('does not inject custom themeVariables into generated component', () => { + const darkFiles = getMermaidFiles(content, true); + const lightFiles = getMermaidFiles(content, false); + expect(darkFiles['/components/ui/MermaidDiagram.tsx']).not.toContain('themeVariables'); + expect(lightFiles['/components/ui/MermaidDiagram.tsx']).not.toContain('themeVariables'); + }); + + it('handles empty content', () => { + const files = getMermaidFiles('', true); + expect(files['diagram.mmd']).toBe('# No mermaid diagram content provided'); + }); + }); + + describe('fixSubgraphTitleContrast', () => { + it('darkens title text on light subgraph backgrounds (fill attribute)', () => { + const svg = makeSvg( + 'Title', + ); + fixSubgraphTitleContrast(svg); + expect(svg.querySelector('text')!.getAttribute('style')).toContain('fill: #1a1a1a'); + }); + + it('darkens title text on light subgraph backgrounds (inline style fill)', () => { + const svg = makeSvg( + 'Title', + ); + fixSubgraphTitleContrast(svg); + expect(svg.querySelector('text')!.getAttribute('style')).toContain('fill: #1a1a1a'); + }); + + it('lightens title text on dark subgraph backgrounds', () => { + const svg = makeSvg( + 'Title', + ); + fixSubgraphTitleContrast(svg); + expect(svg.querySelector('text')!.getAttribute('style')).toContain('fill: #f0f0f0'); + }); + + it('leaves title text alone when contrast is already good', () => { + const svg = makeSvg( + 'Title', + ); + fixSubgraphTitleContrast(svg); + expect(svg.querySelector('text')!.getAttribute('style')).toBeNull(); + }); + + it('skips clusters without a rect', () => { + const svg = makeSvg( + 'Title', + ); + fixSubgraphTitleContrast(svg); + expect(svg.querySelector('text')!.getAttribute('style')).toBeNull(); + }); + + it('skips clusters with non-hex fills', () => { + const svg = makeSvg( + 'Title', + ); + fixSubgraphTitleContrast(svg); + expect(svg.querySelector('text')!.getAttribute('style')).toBeNull(); + }); + + it('sets dark fill when text has no explicit fill on light backgrounds', () => { + const svg = makeSvg( + 'Title', + ); + fixSubgraphTitleContrast(svg); + expect(svg.querySelector('text')!.getAttribute('style')).toContain('fill: #1a1a1a'); + }); + + it('preserves existing text style when appending fill override', () => { + const svg = makeSvg( + 'Title', + ); + fixSubgraphTitleContrast(svg); + const style = svg.querySelector('text')!.getAttribute('style')!; + expect(style).toContain('font-size: 14px'); + expect(style).toContain('fill: #1a1a1a'); + }); + + it('handles 3-char hex shorthand fills', () => { + const svg = makeSvg( + 'Title', + ); + fixSubgraphTitleContrast(svg); + expect(svg.querySelector('text')!.getAttribute('style')).toContain('fill: #1a1a1a'); + }); + + it('avoids double semicolons when existing style has trailing semicolon', () => { + const svg = makeSvg( + 'Title', + ); + fixSubgraphTitleContrast(svg); + const style = svg.querySelector('text')!.getAttribute('style')!; + expect(style).not.toContain(';;'); + expect(style).toContain('fill: #1a1a1a'); + }); + }); +}); diff --git a/client/src/utils/mermaid.ts b/client/src/utils/mermaid.ts index 7930d9ab1e..60ea96ee55 100644 --- a/client/src/utils/mermaid.ts +++ b/client/src/utils/mermaid.ts @@ -1,15 +1,157 @@ import dedent from 'dedent'; -const mermaid = dedent(`import React, { useEffect, useRef, useState } from "react"; +interface MermaidButtonStyles { + bg: string; + bgHover: string; + border: string; + text: string; + textSecondary: string; + shadow: string; + divider: string; +} + +const darkButtonStyles: MermaidButtonStyles = { + bg: 'rgba(40, 40, 40, 0.95)', + bgHover: 'rgba(60, 60, 60, 0.95)', + border: '1px solid rgba(255, 255, 255, 0.1)', + text: '#D1D5DB', + textSecondary: '#9CA3AF', + shadow: '0 2px 8px rgba(0, 0, 0, 0.4)', + divider: 'rgba(255, 255, 255, 0.1)', +}; + +const lightButtonStyles: MermaidButtonStyles = { + bg: 'rgba(255, 255, 255, 0.95)', + bgHover: 'rgba(243, 244, 246, 0.95)', + border: '1px solid rgba(0, 0, 0, 0.1)', + text: '#374151', + textSecondary: '#6B7280', + shadow: '0 2px 8px rgba(0, 0, 0, 0.1)', + divider: 'rgba(0, 0, 0, 0.1)', +}; + +const getButtonStyles = (isDarkMode: boolean): MermaidButtonStyles => + isDarkMode ? darkButtonStyles : lightButtonStyles; + +const baseFlowchartConfig = { + curve: 'basis' as const, + nodeSpacing: 50, + rankSpacing: 50, + diagramPadding: 8, + useMaxWidth: true, + padding: 15, + wrappingWidth: 200, +}; + +/** Artifact renderer injects SVG directly into the DOM where foreignObject works */ +const artifactFlowchartConfig = { + ...baseFlowchartConfig, + htmlLabels: true, +}; + +/** Inline renderer converts SVG to a blob URL ; browsers block foreignObject in that context */ +const inlineFlowchartConfig = { + ...baseFlowchartConfig, + htmlLabels: false, +}; + +export { inlineFlowchartConfig, artifactFlowchartConfig }; + +/** Perceived luminance (0 = black, 1 = white) via BT.601 luma coefficients */ +const hexLuminance = (hex: string): number => { + let h = hex.replace('#', ''); + if (h.length === 3) { + h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2]; + } + if (h.length < 6) { + return -1; + } + const r = parseInt(h.slice(0, 2), 16) / 255; + const g = parseInt(h.slice(2, 4), 16) / 255; + const b = parseInt(h.slice(4, 6), 16) / 255; + return 0.299 * r + 0.587 * g + 0.114 * b; +}; + +/** + * Fixes subgraph title text contrast in mermaid SVGs rendered with htmlLabels: false. + * When a subgraph has an explicit light fill via `style` directives, the title + * gets its fill from a CSS rule (.cluster-label text / .cluster text) set to titleColor. + * In dark mode, titleColor is light, producing invisible text on light backgrounds. + * This walks cluster groups and overrides the text fill attribute when contrast is poor. + */ +export const fixSubgraphTitleContrast = (svgElement: Element): void => { + const clusters = svgElement.querySelectorAll('g.cluster'); + for (const cluster of clusters) { + const rect = cluster.querySelector(':scope > rect, :scope > polygon'); + if (!rect) { + continue; + } + + const inlineStyle = rect.getAttribute('style') || ''; + const attrFill = rect.getAttribute('fill') || ''; + const styleFillMatch = inlineStyle.match(/fill\s*:\s*(#[0-9a-fA-F]{3,8})/); + const hex = styleFillMatch?.[1] ?? (attrFill.startsWith('#') ? attrFill : ''); + if (!hex) { + continue; + } + + const bgLum = hexLuminance(hex); + if (bgLum < 0) { + continue; + } + + const textElements = cluster.querySelectorAll( + ':scope > g.cluster-label text, :scope > text, :scope > g > text', + ); + for (const textEl of textElements) { + const textFill = textEl.getAttribute('fill') || ''; + const textStyle = textEl.getAttribute('style') || ''; + const textStyleFill = textStyle.match(/fill\s*:\s*(#[0-9a-fA-F]{3,8})/); + const currentHex = textStyleFill?.[1] ?? (textFill.startsWith('#') ? textFill : ''); + const isLightBg = bgLum > 0.5; + + let newFill = ''; + if (!currentHex) { + if (isLightBg) { + newFill = '#1a1a1a'; + } + } else { + const textLum = hexLuminance(currentHex); + if (textLum < 0) { + continue; + } + if (isLightBg && textLum > 0.5) { + newFill = '#1a1a1a'; + } else if (!isLightBg && textLum < 0.4) { + newFill = '#f0f0f0'; + } + } + + if (newFill) { + let sep = ''; + if (textStyle) { + sep = textStyle.trimEnd().endsWith(';') ? ' ' : '; '; + } + textEl.setAttribute('style', `${textStyle}${sep}fill: ${newFill}`); + } + } + } +}; + +const buildMermaidComponent = ( + mermaidTheme: string, + bgColor: string, + btnStyles: MermaidButtonStyles, +) => + dedent(`import React, { useEffect, useRef, useState, useCallback } from "react"; import { TransformWrapper, TransformComponent, ReactZoomPanPinchRef, } from "react-zoom-pan-pinch"; import mermaid from "mermaid"; -import { Button } from "/components/ui/button"; -const ZoomIn = () => ( +const ZoomInIcon = () => ( @@ -18,7 +160,7 @@ const ZoomIn = () => ( ); -const ZoomOut = () => ( +const ZoomOutIcon = () => ( @@ -26,90 +168,147 @@ const ZoomOut = () => ( ); -const RefreshCw = () => ( +const ResetIcon = () => ( - - - - + + ); +const CopyIcon = () => ( + + + + +); + +const CheckIcon = () => ( + + + +); + +const btnBase = { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + width: "28px", + height: "28px", + borderRadius: "4px", + background: "transparent", + border: "none", + color: "${btnStyles.text}", + cursor: "pointer", + padding: "6px", + transition: "background 0.15s ease", +}; + +const btnHover = { + ...btnBase, + background: "${btnStyles.bgHover}", +}; + +const toolbarStyle = { + position: "absolute", + bottom: "12px", + right: "12px", + display: "flex", + alignItems: "center", + gap: "2px", + padding: "4px", + borderRadius: "8px", + background: "${btnStyles.bg}", + boxShadow: "${btnStyles.shadow}", + backdropFilter: "blur(8px)", + border: "${btnStyles.border}", + zIndex: 10, +}; + +const dividerStyle = { + width: "1px", + height: "16px", + background: "${btnStyles.divider}", + margin: "0 4px", +}; + +const zoomTextStyle = { + minWidth: "3rem", + textAlign: "center", + fontSize: "12px", + color: "${btnStyles.textSecondary}", + userSelect: "none", + fontFamily: "system-ui, -apple-system, sans-serif", +}; + interface MermaidDiagramProps { content: string; } +interface IconButtonProps { + onClick: () => void; + children: React.ReactNode; + title: string; + disabled?: boolean; +} + +const IconButton = ({ onClick, children, title, disabled = false }: IconButtonProps) => { + const [hovered, setHovered] = useState(false); + return ( + + ); +}; + +const ZOOM_STEP = 0.1; +const MIN_SCALE = 0.1; +const MAX_SCALE = 10; + const MermaidDiagram: React.FC = ({ content }) => { const mermaidRef = useRef(null); const transformRef = useRef(null); const [isRendered, setIsRendered] = useState(false); + const [zoomLevel, setZoomLevel] = useState(100); + const [copied, setCopied] = useState(false); useEffect(() => { mermaid.initialize({ startOnLoad: false, - theme: "base", - themeVariables: { - background: "#282C34", - primaryColor: "#333842", - secondaryColor: "#333842", - tertiaryColor: "#333842", - primaryTextColor: "#ABB2BF", - secondaryTextColor: "#ABB2BF", - lineColor: "#636D83", - fontSize: "16px", - nodeBorder: "#636D83", - mainBkg: '#282C34', - altBackground: '#282C34', - textColor: '#ABB2BF', - edgeLabelBackground: '#282C34', - clusterBkg: '#282C34', - clusterBorder: "#636D83", - labelBoxBkgColor: "#333842", - labelBoxBorderColor: "#636D83", - labelTextColor: "#ABB2BF", - }, - flowchart: { - curve: "basis", - nodeSpacing: 50, - rankSpacing: 50, - diagramPadding: 8, - htmlLabels: true, - useMaxWidth: true, - padding: 15, - wrappingWidth: 200, - }, + theme: "${mermaidTheme}", + securityLevel: "strict", + flowchart: ${JSON.stringify(artifactFlowchartConfig, null, 8)}, }); const renderDiagram = async () => { - if (mermaidRef.current) { - try { - const { svg } = await mermaid.render("mermaid-diagram", content); - mermaidRef.current.innerHTML = svg; + if (!mermaidRef.current) { + return; + } + try { + const { svg } = await mermaid.render("mermaid-diagram", content); + mermaidRef.current.innerHTML = svg; - const svgElement = mermaidRef.current.querySelector("svg"); - if (svgElement) { - svgElement.style.width = "100%"; - svgElement.style.height = "100%"; - - const pathElements = svgElement.querySelectorAll("path"); - pathElements.forEach((path) => { - path.style.strokeWidth = "1.5px"; - }); - - const rectElements = svgElement.querySelectorAll("rect"); - rectElements.forEach((rect) => { - const parent = rect.parentElement; - if (parent && parent.classList.contains("node")) { - rect.style.stroke = "#636D83"; - rect.style.strokeWidth = "1px"; - } else { - rect.style.stroke = "none"; - } - }); - } - setIsRendered(true); - } catch (error) { - console.error("Mermaid rendering error:", error); + const svgElement = mermaidRef.current.querySelector("svg"); + if (svgElement) { + svgElement.style.width = "100%"; + svgElement.style.height = "100%"; + } + setIsRendered(true); + } catch (error) { + console.error("Mermaid rendering error:", error); + if (mermaidRef.current) { mermaidRef.current.innerHTML = "Error rendering diagram"; } } @@ -118,72 +317,90 @@ const MermaidDiagram: React.FC = ({ content }) => { renderDiagram(); }, [content]); - const centerAndFitDiagram = () => { + const centerAndFitDiagram = useCallback(() => { if (transformRef.current && mermaidRef.current) { const { centerView, zoomToElement } = transformRef.current; - zoomToElement(mermaidRef.current as HTMLElement); + zoomToElement(mermaidRef.current); centerView(1, 0); + setZoomLevel(100); } - }; + }, []); useEffect(() => { if (isRendered) { centerAndFitDiagram(); } - }, [isRendered]); + }, [isRendered, centerAndFitDiagram]); - const handlePanning = () => { - if (transformRef.current) { - const { state, instance } = transformRef.current; - if (!state) { - return; - } - const { scale, positionX, positionY } = state; - const { wrapperComponent, contentComponent } = instance; - - if (wrapperComponent && contentComponent) { - const wrapperRect = wrapperComponent.getBoundingClientRect(); - const contentRect = contentComponent.getBoundingClientRect(); - const maxX = wrapperRect.width - contentRect.width * scale; - const maxY = wrapperRect.height - contentRect.height * scale; - - let newX = positionX; - let newY = positionY; - - if (newX > 0) { - newX = 0; - } - if (newY > 0) { - newY = 0; - } - if (newX < maxX) { - newX = maxX; - } - if (newY < maxY) { - newY = maxY; - } - - if (newX !== positionX || newY !== positionY) { - instance.setTransformState(scale, newX, newY); - } - } + const handleTransform = useCallback((ref) => { + if (ref && ref.state) { + setZoomLevel(Math.round(ref.state.scale * 100)); } - }; + }, []); + + const handleCopy = useCallback(() => { + navigator.clipboard.writeText(content).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }).catch(() => {}); + }, [content]); + + const handlePanning = useCallback(() => { + if (!transformRef.current) { + return; + } + const { state, instance } = transformRef.current; + if (!state) { + return; + } + const { scale, positionX, positionY } = state; + const { wrapperComponent, contentComponent } = instance; + + if (!wrapperComponent || !contentComponent) { + return; + } + + const wrapperRect = wrapperComponent.getBoundingClientRect(); + const contentRect = contentComponent.getBoundingClientRect(); + const maxX = wrapperRect.width - contentRect.width * scale; + const maxY = wrapperRect.height - contentRect.height * scale; + + let newX = positionX; + let newY = positionY; + + if (newX > 0) { + newX = 0; + } + if (newY > 0) { + newY = 0; + } + if (newX < maxX) { + newX = maxX; + } + if (newY < maxY) { + newY = maxY; + } + + if (newX !== positionX || newY !== positionY) { + instance.setTransformState(scale, newX, newY); + } + }, []); return ( -
+
{({ zoomIn, zoomOut }) => ( <> @@ -204,24 +421,22 @@ const MermaidDiagram: React.FC = ({ content }) => { }} /> -
- - - +
+ zoomOut(ZOOM_STEP)} title="Zoom out"> + + + {zoomLevel}% + zoomIn(ZOOM_STEP)} title="Zoom in"> + + +
+ + + +
+ + {copied ? : } +
)} @@ -242,13 +457,16 @@ export default App = () => ( `); }; -const mermaidCSS = ` +export const getMermaidFiles = (content: string, isDarkMode = true) => { + const mermaidTheme = isDarkMode ? 'dark' : 'neutral'; + const btnStyles = getButtonStyles(isDarkMode); + const bgColor = isDarkMode ? '#212121' : '#FFFFFF'; + const mermaidCSS = ` body { - background-color: #282C34; + background-color: ${bgColor}; } `; -export const getMermaidFiles = (content: string) => { return { 'diagram.mmd': content || '# No mermaid diagram content provided', 'App.tsx': wrapMermaidDiagram(content), @@ -262,7 +480,7 @@ import App from "./App"; const root = createRoot(document.getElementById("root")); root.render(); ;`), - '/components/ui/MermaidDiagram.tsx': mermaid, + '/components/ui/MermaidDiagram.tsx': buildMermaidComponent(mermaidTheme, bgColor, btnStyles), 'mermaid.css': mermaidCSS, }; };