diff --git a/package.json b/package.json index 7d4c3d5cb..f06321dfe 100644 --- a/package.json +++ b/package.json @@ -153,6 +153,7 @@ "jsonc-parser": "^3.3.1", "katex": "^0.16.45", "lucide-react": "^0.564.0", + "mermaid": "^11.12.2", "minimatch": "^10.1.1", "mobx": "^6.15.0", "mobx-react-lite": "^4.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1a941bdaa..ef3b5ef4b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -197,6 +197,9 @@ importers: lucide-react: specifier: ^0.564.0 version: 0.564.0(react@19.2.4) + mermaid: + specifier: ^11.12.2 + version: 11.12.2 minimatch: specifier: ^10.1.1 version: 10.1.2 diff --git a/src/renderer/lib/ui/markdown-renderer.tsx b/src/renderer/lib/ui/markdown-renderer.tsx index 5595a1dc8..8936a252f 100644 --- a/src/renderer/lib/ui/markdown-renderer.tsx +++ b/src/renderer/lib/ui/markdown-renderer.tsx @@ -12,6 +12,7 @@ import { useTheme } from '@renderer/lib/hooks/useTheme'; import { rpc } from '@renderer/lib/ipc'; import { cn } from '@renderer/utils/utils'; import { normalizeLatexDelimiters } from './markdown-latex'; +import { MermaidDiagram } from './mermaid-diagram'; type Variant = 'full' | 'compact'; @@ -106,6 +107,29 @@ type WithChildrenAndClass = { children?: React.ReactNode; className?: string }; type AnchorProps = { href?: string; children?: React.ReactNode }; type ImgProps = { src?: string; alt?: string }; +function getCodeBlock(children: React.ReactNode, className?: string) { + const language = /language-(\w+)/.exec(className || '')?.[1] ?? ''; + const isBlock = className?.includes('language-') ?? false; + const code = String(children).replace(/\n$/, ''); + return { code, isBlock, language }; +} + +function renderMermaidCodeBlock( + children: React.ReactNode, + className: string | undefined, + isDark: boolean, + compact?: boolean +) { + const { code, isBlock, language } = getCodeBlock(children, className); + if (!isBlock || language !== 'mermaid') return null; + return ; +} + +function isOnlyMermaidDiagramChild(children: React.ReactNode): boolean { + const child = Array.isArray(children) ? children[0] : children; + return React.isValidElement(child) && child.type === MermaidDiagram; +} + function useFullComponents( isDark: boolean, resolveImage?: (src: string) => Promise @@ -145,10 +169,10 @@ function useFullComponents( ), li: ({ children }: WithChildren) =>
  • {children}
  • , code: ({ children, className }: WithChildrenAndClass) => { - const match = /language-(\w+)/.exec(className || ''); - const language = match ? match[1] : ''; - const isBlock = className?.includes('language-'); + const mermaidBlock = renderMermaidCodeBlock(children, className, isDark); + if (mermaidBlock) return mermaidBlock; + const { code, isBlock, language } = getCodeBlock(children, className); if (isBlock) { return ( - {String(children).replace(/\n$/, '')} + {code} ); } return {children}; }, - pre: ({ children }: WithChildren) => ( -
    {children}
    - ), + pre: ({ children }: WithChildren) => + isOnlyMermaidDiagramChild(children) ? ( + <>{children} + ) : ( +
    {children}
    + ), a: ({ href, children }: AnchorProps) => { const isHttp = typeof href === 'string' && /^https?:\/\//i.test(href); const handleClick = (e: React.MouseEvent) => { @@ -231,7 +258,7 @@ function useFullComponents( ); } -function useCompactComponents() { +function useCompactComponents(isDark: boolean) { return useMemo( () => ({ h1: ({ children }: WithChildren) => ( @@ -252,16 +279,25 @@ function useCompactComponents() { ), li: ({ children }: WithChildren) =>
  • {children}
  • , code: ({ children, className }: WithChildrenAndClass) => { - const isBlock = className?.includes('language-'); - return isBlock ? ( - - {children} - - ) : ( - {children} - ); + const mermaidBlock = renderMermaidCodeBlock(children, className, isDark, true); + if (mermaidBlock) return mermaidBlock; + + const { isBlock } = getCodeBlock(children, className); + if (isBlock) { + return ( + + {children} + + ); + } + return {children}; }, - pre: ({ children }: WithChildren) =>
    {children}
    , + pre: ({ children }: WithChildren) => + isOnlyMermaidDiagramChild(children) ? ( + <>{children} + ) : ( +
    {children}
    + ), strong: ({ children }: WithChildren) => ( {children} ), @@ -286,7 +322,7 @@ function useCompactComponents() { ); }, }), - [] + [isDark] ); } @@ -300,7 +336,7 @@ export const MarkdownRenderer: React.FC = ({ const isDark = effectiveTheme === 'emdark'; const fullComponents = useFullComponents(isDark, resolveImage); - const compactComponents = useCompactComponents(); + const compactComponents = useCompactComponents(isDark); const components = variant === 'full' ? fullComponents : compactComponents; const rehypePlugins = variant === 'full' ? FULL_REHYPE_PLUGINS : COMPACT_REHYPE_PLUGINS; diff --git a/src/renderer/lib/ui/mermaid-diagram.tsx b/src/renderer/lib/ui/mermaid-diagram.tsx new file mode 100644 index 000000000..19d73f980 --- /dev/null +++ b/src/renderer/lib/ui/mermaid-diagram.tsx @@ -0,0 +1,97 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { cn } from '@renderer/utils/utils'; +import { createMermaidRenderId, renderMermaidDiagram } from './mermaid-renderer'; + +interface MermaidDiagramProps { + chart: string; + isDark: boolean; + compact?: boolean; +} + +type RenderState = + | { kind: 'rendered'; key: string; svg: string } + | { kind: 'error'; key: string; message: string | null }; + +const GENERIC_RENDER_ERROR = 'Unable to render Mermaid diagram.'; + +function errorMessage(error: unknown): string | null { + if (error instanceof Error && error.message && error.message !== GENERIC_RENDER_ERROR) { + return error.message; + } + if (typeof error === 'string' && error && error !== GENERIC_RENDER_ERROR) { + return error; + } + return null; +} + +export const MermaidDiagram: React.FC = ({ chart, isDark, compact }) => { + const id = useMemo(() => createMermaidRenderId(), []); + const theme = isDark ? 'dark' : 'default'; + const renderKey = `${theme}:${chart}`; + const [state, setState] = useState(null); + + useEffect(() => { + let cancelled = false; + + renderMermaidDiagram({ id, chart, theme }) + .then((svg) => { + if (!cancelled) setState({ kind: 'rendered', key: renderKey, svg }); + }) + .catch((error: unknown) => { + if (!cancelled) setState({ kind: 'error', key: renderKey, message: errorMessage(error) }); + }); + + return () => { + cancelled = true; + }; + }, [chart, id, renderKey, theme]); + + const visibleState = state?.key === renderKey ? state : null; + + if (visibleState?.kind === 'error') { + return ( +
    +
    {GENERIC_RENDER_ERROR}
    + {visibleState.message && ( +
    {visibleState.message}
    + )} +
    +          {chart}
    +        
    +
    + ); + } + + if (!visibleState) { + return ( +
    + Rendering diagram... +
    + ); + } + + return ( +
    +
    +
    + ); +}; diff --git a/src/renderer/lib/ui/mermaid-renderer.ts b/src/renderer/lib/ui/mermaid-renderer.ts new file mode 100644 index 000000000..fc7cdd061 --- /dev/null +++ b/src/renderer/lib/ui/mermaid-renderer.ts @@ -0,0 +1,54 @@ +import type { MermaidConfig } from 'mermaid'; + +let idCounter = 0; +let renderQueue: Promise = Promise.resolve(); +let lastInitializedConfigKey: string | null = null; + +type MermaidTheme = NonNullable; + +interface MermaidRenderRequest { + id: string; + chart: string; + theme: MermaidTheme; +} + +export function createMermaidRenderId(): string { + idCounter += 1; + return `emdash-mermaid-${idCounter}`; +} + +export async function renderMermaidDiagram({ + id, + chart, + theme, +}: MermaidRenderRequest): Promise { + const render = async () => { + const mermaid = (await import('mermaid')).default; + const config: MermaidConfig = { + startOnLoad: false, + htmlLabels: false, + securityLevel: 'strict', + suppressErrorRendering: true, + theme, + flowchart: { + htmlLabels: false, + }, + }; + const configKey = JSON.stringify(config); + + if (lastInitializedConfigKey !== configKey) { + mermaid.initialize(config); + lastInitializedConfigKey = configKey; + } + + const { svg } = await mermaid.render(id, chart); + return svg; + }; + + const result = renderQueue.then(render, render); + renderQueue = result.then( + () => undefined, + () => undefined + ); + return result; +}