Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@
"ignore": "^5.3.1",
"jsonc-parser": "^3.3.1",
"lucide-react": "^0.564.0",
"mermaid": "^11.12.2",
"minimatch": "^10.1.1",
"mobx": "^6.15.0",
"mobx-react-lite": "^4.1.1",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

74 changes: 55 additions & 19 deletions src/renderer/lib/ui/markdown-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { PluggableList } from 'unified';
import { useTheme } from '@renderer/lib/hooks/useTheme';
import { rpc } from '@renderer/lib/ipc';
import { cn } from '@renderer/utils/utils';
import { MermaidDiagram } from './mermaid-diagram';

type Variant = 'full' | 'compact';

Expand Down Expand Up @@ -81,6 +82,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 <MermaidDiagram chart={code} isDark={isDark} compact={compact} />;
}

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<string | null>
Expand Down Expand Up @@ -120,10 +144,10 @@ function useFullComponents(
),
li: ({ children }: WithChildren) => <li className="leading-relaxed">{children}</li>,
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 (
<SyntaxHighlighter
Expand All @@ -132,16 +156,19 @@ function useFullComponents(
PreTag="div"
className="!my-0 !rounded-md !text-xs"
>
{String(children).replace(/\n$/, '')}
{code}
</SyntaxHighlighter>
);
}

return <code className="rounded bg-muted px-1.5 py-0.5 text-xs">{children}</code>;
},
pre: ({ children }: WithChildren) => (
<pre className="mb-3 overflow-x-auto rounded-md border border-border">{children}</pre>
),
pre: ({ children }: WithChildren) =>
isOnlyMermaidDiagramChild(children) ? (
<>{children}</>
) : (
<pre className="mb-3 overflow-x-auto rounded-md border border-border">{children}</pre>
),
a: ({ href, children }: AnchorProps) => {
const isHttp = typeof href === 'string' && /^https?:\/\//i.test(href);
const handleClick = (e: React.MouseEvent) => {
Expand Down Expand Up @@ -206,7 +233,7 @@ function useFullComponents(
);
}

function useCompactComponents() {
function useCompactComponents(isDark: boolean) {
return useMemo(
() => ({
h1: ({ children }: WithChildren) => (
Expand All @@ -227,16 +254,25 @@ function useCompactComponents() {
),
li: ({ children }: WithChildren) => <li className="leading-relaxed">{children}</li>,
code: ({ children, className }: WithChildrenAndClass) => {
const isBlock = className?.includes('language-');
return isBlock ? (
<code className="block overflow-x-auto rounded bg-muted/60 p-2 text-[11px]">
{children}
</code>
) : (
<code className="rounded bg-muted/60 px-1 py-0.5 text-[11px]">{children}</code>
);
const mermaidBlock = renderMermaidCodeBlock(children, className, isDark, true);
if (mermaidBlock) return mermaidBlock;

const { isBlock } = getCodeBlock(children, className);
if (isBlock) {
return (
<code className="block overflow-x-auto rounded bg-muted/60 p-2 text-[11px]">
{children}
</code>
);
}
return <code className="rounded bg-muted/60 px-1 py-0.5 text-[11px]">{children}</code>;
},
pre: ({ children }: WithChildren) => <pre className="mb-2 overflow-x-auto">{children}</pre>,
pre: ({ children }: WithChildren) =>
isOnlyMermaidDiagramChild(children) ? (
<>{children}</>
) : (
<pre className="mb-2 overflow-x-auto">{children}</pre>
),
strong: ({ children }: WithChildren) => (
<strong className="font-semibold text-foreground">{children}</strong>
),
Expand All @@ -261,7 +297,7 @@ function useCompactComponents() {
);
},
}),
[]
[isDark]
);
}

Expand All @@ -275,7 +311,7 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
const isDark = effectiveTheme === 'emdark';

const fullComponents = useFullComponents(isDark, resolveImage);
const compactComponents = useCompactComponents();
const compactComponents = useCompactComponents(isDark);

const components = variant === 'full' ? fullComponents : compactComponents;
const rehypePlugins: PluggableList =
Expand Down
88 changes: 88 additions & 0 deletions src/renderer/lib/ui/mermaid-diagram.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
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 };

function errorMessage(error: unknown): string {
if (error instanceof Error && error.message) return error.message;
return 'Unable to render Mermaid diagram.';
}

export const MermaidDiagram: React.FC<MermaidDiagramProps> = ({ chart, isDark, compact }) => {
const id = useMemo(() => createMermaidRenderId(), []);
const theme = isDark ? 'dark' : 'default';
const renderKey = `${theme}:${chart}`;
const [state, setState] = useState<RenderState | null>(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 (
<div
className={cn(
'my-3 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive',
compact && 'my-2 p-2 text-[11px]'
)}
role="alert"
>
<div className="font-medium">Unable to render Mermaid diagram.</div>
<div className="mt-1 text-muted-foreground">{visibleState.message}</div>
<pre className="mt-2 overflow-x-auto rounded bg-muted/60 p-2 text-muted-foreground">
<code>{chart}</code>
</pre>
</div>
Comment thread
jschwxrz marked this conversation as resolved.
);
}

if (!visibleState) {
return (
<div
className={cn(
'my-3 rounded-md border border-border bg-muted/20 p-3 text-xs text-muted-foreground',
compact && 'my-2 p-2 text-[11px]'
)}
>
Rendering diagram...
</div>
);
}

return (
<div
className={cn(
'my-3 overflow-x-auto rounded-md border border-border bg-background p-3',
compact && 'my-2 p-2'
)}
>
<div
className="min-w-fit text-foreground [&_svg]:h-auto [&_svg]:max-w-full"
dangerouslySetInnerHTML={{ __html: visibleState.svg }}
/>
Comment thread
jschwxrz marked this conversation as resolved.
</div>
);
};
44 changes: 44 additions & 0 deletions src/renderer/lib/ui/mermaid-renderer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { MermaidConfig } from 'mermaid';

let idCounter = 0;
let renderQueue: Promise<void> = Promise.resolve();

type MermaidTheme = NonNullable<MermaidConfig['theme']>;

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<string> {
const render = async () => {
const mermaid = (await import('mermaid')).default;
const config: MermaidConfig = {
startOnLoad: false,
securityLevel: 'strict',
suppressErrorRendering: true,
theme,
};

mermaid.initialize(config);
const { svg } = await mermaid.render(id, chart);
return svg;
Comment thread
jschwxrz marked this conversation as resolved.
};

const result = renderQueue.then(render, render);
renderQueue = result.then(
() => undefined,
() => undefined
);
return result;
}
Loading