From 178e6110c9fefca33a8cb9bdc05a3cfd340e583b Mon Sep 17 00:00:00 2001 From: Daisuke Awaji Date: Fri, 5 Dec 2025 19:33:35 +0900 Subject: [PATCH 01/16] feat(markdown): add Mermaid diagram support with code toggle - Create MermaidWithToggle component for rendering Mermaid diagrams - Add toggle functionality to switch between diagram and source code view - Support SVG and PNG export/download options - Add PreRenderer to skip prose styling for mermaid blocks - Integrate mermaid rendering into Markdown CodeRenderer - Re-export MermaidWithToggle for backward compatibility --- .../web/src/components/DiagramRenderer.tsx | 254 --------------- packages/web/src/components/Markdown.tsx | 35 +- .../components/Mermaid/MermaidWithToggle.tsx | 299 ++++++++++++++++++ .../web/src/pages/GenerateDiagramPage.tsx | 29 +- packages/web/src/prompts/claude.ts | 23 +- 5 files changed, 362 insertions(+), 278 deletions(-) delete mode 100644 packages/web/src/components/DiagramRenderer.tsx create mode 100644 packages/web/src/components/Mermaid/MermaidWithToggle.tsx diff --git a/packages/web/src/components/DiagramRenderer.tsx b/packages/web/src/components/DiagramRenderer.tsx deleted file mode 100644 index c4ec1dd87..000000000 --- a/packages/web/src/components/DiagramRenderer.tsx +++ /dev/null @@ -1,254 +0,0 @@ -import React, { useEffect, useState, useCallback } from 'react'; -import { IoIosClose, IoMdDownload } from 'react-icons/io'; -import { VscCode } from 'react-icons/vsc'; -import { LuNetwork } from 'react-icons/lu'; -import EditableMarkdown from './EditableMarkdown'; -import Button from './Button'; -import mermaid, { MermaidConfig } from 'mermaid'; -import { TbSvg, TbPng } from 'react-icons/tb'; -import { useTranslation } from 'react-i18next'; - -const defaultConfig: MermaidConfig = { - // Prevent syntax error from being added to the dom node - // https://github.com/mermaid-js/mermaid/pull/4359 - suppressErrorRendering: true, - securityLevel: 'loose', // Allow SVG rendering - fontFamily: 'monospace', // Specify the font family - fontSize: 16, // Specify the font size - htmlLabels: true, // Allow HTML labels -}; -mermaid.initialize(defaultConfig); -interface MermaidProps { - code: string; - handler?: () => void; -} - -export const Mermaid: React.FC = (props) => { - const { t } = useTranslation(); - const { code } = props; - const [svgContent, setSvgContent] = useState(''); - - const render = useCallback(async () => { - if (code) { - try { - // It is necessary to specify a unique ID - const { svg } = await mermaid.render(`m${crypto.randomUUID()}`, code); - // Parse the SVG string to convert it to a DOM object - const parser = new DOMParser(); - const doc = parser.parseFromString(svg, 'image/svg+xml'); - const svgElement = doc.querySelector('svg'); - - if (svgElement) { - // Set the necessary attributes to the SVG element - svgElement.setAttribute('width', '100%'); - svgElement.setAttribute('height', '100%'); - setSvgContent(svgElement.outerHTML); - } - } catch (error) { - console.error(error); - setSvgContent(`
${t('diagram.invalid_syntax')}
`); - } - } - }, [code, t]); - - useEffect(() => { - render(); - }, [code, render]); - - return code ? ( -
-
-
- ) : null; -}; - -interface DiagramRendererProps { - code: string; - handleMarkdownChange: (markdown: string) => void; -} - -const DiagramRenderer: React.FC = ({ - code, - handleMarkdownChange, -}) => { - const { t } = useTranslation(); - const [zoom, setZoom] = useState(false); - const [viewMode, setViewMode] = useState<'diagram' | 'code'>('diagram'); - - useEffect(() => { - const handleEsc = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - setZoom(false); - } - }; - - window.addEventListener('keydown', handleEsc); - return () => { - window.removeEventListener('keydown', handleEsc); - }; - }, []); - - const downloadAsSVG = async () => { - try { - const { svg } = await mermaid.render('download-svg', code); - const blob = new Blob([svg], { type: 'image/svg+xml' }); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = `diagram_${new Date().getTime()}.svg`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); - } catch (error) { - console.error(t('diagram.svg_error'), error); - } - }; - - const downloadAsPNG = async () => { - try { - const { svg } = await mermaid.render('download-png', code); - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d'); - if (!ctx) return; - - const parser = new DOMParser(); - const svgDoc = parser.parseFromString(svg, 'image/svg+xml'); - const svgElement = svgDoc.querySelector('svg'); - if (!(svgElement instanceof SVGSVGElement)) return; - - const viewBox = svgElement - .getAttribute('viewBox') - ?.split(' ') - .map(Number) || [0, 0, 0, 0]; - const width = Math.max(svgElement.width.baseVal.value || 0, viewBox[2]); - const height = Math.max(svgElement.height.baseVal.value || 0, viewBox[3]); - - const scale = 2; - canvas.width = width * scale; - canvas.height = height * scale; - - const wrappedSvg = ` - - - ${svg} - - `; - - const svgBase64 = btoa(unescape(encodeURIComponent(wrappedSvg))); - const img = new Image(); - img.src = 'data:image/svg+xml;base64,' + svgBase64; - - await new Promise((resolve, reject) => { - img.onload = resolve; - img.onerror = reject; - }); - - ctx.fillStyle = 'white'; - ctx.fillRect(0, 0, canvas.width, canvas.height); - - ctx.scale(scale, scale); - ctx.drawImage(img, 0, 0, width, height); - - const link = document.createElement('a'); - link.download = `diagram_${new Date().getTime()}.png`; - link.href = canvas.toDataURL('image/png', 1.0); - link.click(); - } catch (error) { - console.error(t('diagram.png_error'), error); - } - }; - - const DownloadButton: React.FC<{ type: 'SVG' | 'PNG' }> = ({ type }) => { - return ( - - ); - }; - - return ( -
- {/* The header above the diagram */} -
-
- - -
-
-
setViewMode('diagram')}> - - {t('diagram.show_diagram')} -
-
setViewMode('code')}> - - {t('diagram.show_code')} -
-
-
- - {/* The drawing part of the diagram */} -
-
- setZoom(true)} /> -
-
- -
-
- - {/* When zooming */} - {zoom && ( - <> -
setZoom(false)} - /> -
e.stopPropagation()}> -
- -
-
- -
-
- - )} -
- ); -}; - -export default DiagramRenderer; diff --git a/packages/web/src/components/Markdown.tsx b/packages/web/src/components/Markdown.tsx index e99042205..b4bd63268 100644 --- a/packages/web/src/components/Markdown.tsx +++ b/packages/web/src/components/Markdown.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState, memo } from 'react'; +import React, { useEffect, useMemo, useState, memo } from 'react'; import { BaseProps } from '../@types/common'; import { default as ReactMarkdown } from 'react-markdown'; import remarkGfm from 'remark-gfm'; @@ -35,6 +35,8 @@ import xmlDoc from 'react-syntax-highlighter/dist/esm/languages/prism/xml-doc'; import yaml from 'react-syntax-highlighter/dist/esm/languages/prism/yaml'; import { useLocation } from 'react-router-dom'; +import { MermaidWithToggle } from './Mermaid/MermaidWithToggle'; + SyntaxHighlighter.registerLanguage('bash', bash); SyntaxHighlighter.registerLanguage('c', c); SyntaxHighlighter.registerLanguage('cpp', cpp); @@ -58,6 +60,9 @@ SyntaxHighlighter.registerLanguage('tsx', tsx); SyntaxHighlighter.registerLanguage('xml-doc', xmlDoc); SyntaxHighlighter.registerLanguage('yaml', yaml); +// Re-export MermaidWithToggle for backward compatibility +export { MermaidWithToggle }; + type Props = BaseProps & { children: string; prefix?: string; @@ -125,12 +130,39 @@ const ImageRenderer = (props: any) => { return ; }; +// PreRenderer to skip
 tag for mermaid code blocks
+// This prevents the dark prose background from appearing around mermaid diagrams
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const PreRenderer = (props: any) => {
+  const { children } = props;
+
+  // Check if children is a code element with 'language-mermaid' class
+  if (React.isValidElement(children)) {
+    const childProps = children.props as { className?: string };
+    const className = childProps?.className || '';
+    if (className.includes('language-mermaid')) {
+      // Skip 
 tag for mermaid - return children directly
+      return <>{children};
+    }
+  }
+
+  // For other code blocks, render normal 
 tag
+  return 
{children}
; +}; + const CodeRenderer = memo( // eslint-disable-next-line @typescript-eslint/no-explicit-any (props: any) => { const language = /language-(\w+)/.exec(props.className || '')?.[1]; const codeText = String(props.children).replace(/\n$/, ''); const isCodeBlock = codeText.includes('\n'); + + // Render Mermaid diagrams with toggle + // Use not-prose to prevent prose styles from affecting the diagram container + if (language === 'mermaid') { + return ; + } + return ( <> {language ? ( @@ -189,6 +221,7 @@ const Markdown = memo(({ className, prefix, children }: Props) => { sup: ({ children }) => ( {children} ), + pre: PreRenderer, code: CodeRenderer, }} /> diff --git a/packages/web/src/components/Mermaid/MermaidWithToggle.tsx b/packages/web/src/components/Mermaid/MermaidWithToggle.tsx new file mode 100644 index 000000000..c4362c248 --- /dev/null +++ b/packages/web/src/components/Mermaid/MermaidWithToggle.tsx @@ -0,0 +1,299 @@ +import React, { useEffect, useState, memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { VscCode } from 'react-icons/vsc'; +import { LuNetwork } from 'react-icons/lu'; +import { IoIosClose, IoMdDownload } from 'react-icons/io'; +import { TbSvg, TbPng } from 'react-icons/tb'; +import mermaid, { MermaidConfig } from 'mermaid'; + +import ButtonCopy from '../ButtonCopy'; +import Button from '../Button'; +import Textarea from '../Textarea'; + +import { PrismLight as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; + +// Initialize mermaid with default config +const defaultMermaidConfig: MermaidConfig = { + suppressErrorRendering: true, + securityLevel: 'loose', + fontFamily: 'monospace', + fontSize: 16, + htmlLabels: true, +}; +mermaid.initialize(defaultMermaidConfig); + +// Mermaid SVG renderer component +interface MermaidProps { + code: string; + handler?: () => void; +} + +const Mermaid: React.FC = (props) => { + const { t } = useTranslation(); + const { code } = props; + const [svgContent, setSvgContent] = useState(''); + + const render = useCallback(async () => { + if (code) { + try { + const { svg } = await mermaid.render(`m${crypto.randomUUID()}`, code); + const parser = new DOMParser(); + const doc = parser.parseFromString(svg, 'image/svg+xml'); + const svgElement = doc.querySelector('svg'); + + if (svgElement) { + svgElement.setAttribute('width', '100%'); + svgElement.setAttribute('height', '100%'); + setSvgContent(svgElement.outerHTML); + } + } catch (error) { + console.error(error); + setSvgContent(`
${t('diagram.invalid_syntax')}
`); + } + } + }, [code, t]); + + useEffect(() => { + render(); + }, [code, render]); + + return code ? ( +
+
+
+ ) : null; +}; + +// Mermaid with toggle component (diagram/code view with download, zoom, and optional edit) +interface MermaidWithToggleProps { + code: string; + editable?: boolean; + onCodeChange?: (code: string) => void; +} + +export const MermaidWithToggle = memo( + ({ code, editable = false, onCodeChange }: MermaidWithToggleProps) => { + const { t } = useTranslation(); + const [viewMode, setViewMode] = useState<'diagram' | 'code'>('diagram'); + const [zoom, setZoom] = useState(false); + const [editedCode, setEditedCode] = useState(code); + + // Sync editedCode when code prop changes + useEffect(() => { + setEditedCode(code); + }, [code]); + + // Handle escape key for zoom + useEffect(() => { + const handleEsc = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setZoom(false); + } + }; + window.addEventListener('keydown', handleEsc); + return () => window.removeEventListener('keydown', handleEsc); + }, []); + + // Download as SVG + const downloadAsSVG = useCallback(async () => { + try { + const { svg } = await mermaid.render('download-svg', editedCode); + const blob = new Blob([svg], { type: 'image/svg+xml' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `diagram_${new Date().getTime()}.svg`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } catch (error) { + console.error(t('diagram.svg_error'), error); + } + }, [editedCode, t]); + + // Download as PNG + const downloadAsPNG = useCallback(async () => { + try { + const { svg } = await mermaid.render('download-png', editedCode); + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const parser = new DOMParser(); + const svgDoc = parser.parseFromString(svg, 'image/svg+xml'); + const svgElement = svgDoc.querySelector('svg'); + if (!(svgElement instanceof SVGSVGElement)) return; + + const viewBox = svgElement + .getAttribute('viewBox') + ?.split(' ') + .map(Number) || [0, 0, 0, 0]; + const width = Math.max(svgElement.width.baseVal.value || 0, viewBox[2]); + const height = Math.max( + svgElement.height.baseVal.value || 0, + viewBox[3] + ); + + const scale = 2; + canvas.width = width * scale; + canvas.height = height * scale; + + const wrappedSvg = ` + + + ${svg} + + `; + + const svgBase64 = btoa(unescape(encodeURIComponent(wrappedSvg))); + const img = new Image(); + img.src = 'data:image/svg+xml;base64,' + svgBase64; + + await new Promise((resolve, reject) => { + img.onload = resolve; + img.onerror = reject; + }); + + ctx.fillStyle = 'white'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.scale(scale, scale); + ctx.drawImage(img, 0, 0, width, height); + + const link = document.createElement('a'); + link.download = `diagram_${new Date().getTime()}.png`; + link.href = canvas.toDataURL('image/png', 1.0); + link.click(); + } catch (error) { + console.error(t('diagram.png_error'), error); + } + }, [editedCode, t]); + + // Handle code edit + const handleCodeChange = useCallback( + (newCode: string) => { + setEditedCode(newCode); + onCodeChange?.(newCode); + }, + [onCodeChange] + ); + + return ( + <> +
+ {/* Toggle header */} +
+
+ {/* View mode toggle */} +
+
setViewMode('diagram')}> + + {t('diagram.show_diagram')} +
+
setViewMode('code')}> + + {t('diagram.show_code')} +
+
+ + {/* Download buttons */} +
+ + +
+
+ + +
+ + {/* Content area */} +
+
+ setZoom(true)} /> +
+
+ {editable ? ( +
+