add mermaid rendering

This commit is contained in:
Ramnique Singh 2026-04-10 17:59:23 +05:30
parent 220e15f642
commit 610616e5a0
7 changed files with 298 additions and 77 deletions

View file

@ -40,6 +40,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"lucide-react": "^0.562.0",
"mermaid": "^11.14.0",
"motion": "^12.23.26",
"nanoid": "^5.1.6",
"posthog-js": "^1.332.0",

View file

@ -1,5 +1,6 @@
import { isValidElement, type JSX } from 'react'
import { FilePathCard } from './file-path-card'
import { MermaidRenderer } from '@/components/mermaid-renderer'
export function MarkdownPreOverride(props: JSX.IntrinsicElements['pre']) {
const { children, ...rest } = props
@ -19,6 +20,17 @@ export function MarkdownPreOverride(props: JSX.IntrinsicElements['pre']) {
return <FilePathCard filePath={text} />
}
}
if (
typeof childProps.className === 'string' &&
childProps.className.includes('language-mermaid')
) {
const text = typeof childProps.children === 'string'
? childProps.children.trim()
: ''
if (text) {
return <MermaidRenderer source={text} />
}
}
}
// Passthrough for all other code blocks - return children directly

View file

@ -16,6 +16,7 @@ import { TableBlockExtension } from '@/extensions/table-block'
import { CalendarBlockExtension } from '@/extensions/calendar-block'
import { EmailBlockExtension } from '@/extensions/email-block'
import { TranscriptBlockExtension } from '@/extensions/transcript-block'
import { MermaidBlockExtension } from '@/extensions/mermaid-block'
import { Markdown } from 'tiptap-markdown'
import { useEffect, useCallback, useMemo, useRef, useState } from 'react'
import { Calendar, ChevronDown, ExternalLink } from 'lucide-react'
@ -163,6 +164,8 @@ function getMarkdownWithBlankLines(editor: Editor): string {
blocks.push('```email\n' + (node.attrs?.data as string || '{}') + '\n```')
} else if (node.type === 'transcriptBlock') {
blocks.push('```transcript\n' + (node.attrs?.data as string || '{}') + '\n```')
} else if (node.type === 'mermaidBlock') {
blocks.push('```mermaid\n' + (node.attrs?.data as string || '') + '\n```')
} else if (node.type === 'codeBlock') {
const lang = (node.attrs?.language as string) || ''
blocks.push('```' + lang + '\n' + nodeToText(node) + '\n```')
@ -576,6 +579,7 @@ export function MarkdownEditor({
CalendarBlockExtension,
EmailBlockExtension,
TranscriptBlockExtension,
MermaidBlockExtension,
WikiLink.configure({
onCreate: wikiLinks?.onCreate
? (path) => {

View file

@ -0,0 +1,89 @@
import { useEffect, useId, useRef, useState } from 'react'
import mermaid from 'mermaid'
import { useTheme } from '@/contexts/theme-context'
let lastTheme: string | null = null
function ensureInit(theme: 'default' | 'dark') {
if (lastTheme === theme) return
mermaid.initialize({
startOnLoad: false,
theme,
securityLevel: 'strict',
})
lastTheme = theme
}
interface MermaidRendererProps {
source: string
className?: string
}
export function MermaidRenderer({ source, className }: MermaidRendererProps) {
const { resolvedTheme } = useTheme()
const id = useId().replace(/:/g, '-')
const containerRef = useRef<HTMLDivElement>(null)
const [svg, setSvg] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!source.trim()) {
setSvg(null)
setError(null)
return
}
let cancelled = false
const mermaidTheme = resolvedTheme === 'dark' ? 'dark' : 'default'
ensureInit(mermaidTheme)
mermaid
.render(`mermaid-${id}`, source.trim())
.then(({ svg: renderedSvg }) => {
if (!cancelled) {
setSvg(renderedSvg)
setError(null)
}
})
.catch((err: unknown) => {
if (!cancelled) {
setSvg(null)
setError(err instanceof Error ? err.message : 'Failed to render diagram')
}
})
return () => {
cancelled = true
}
}, [source, resolvedTheme, id])
if (error) {
return (
<div className={className}>
<div style={{ color: 'var(--destructive, #ef4444)', fontSize: 12, marginBottom: 4 }}>
Invalid mermaid syntax
</div>
<pre style={{ fontSize: 12, opacity: 0.7, whiteSpace: 'pre-wrap', margin: 0 }}>
<code>{source}</code>
</pre>
</div>
)
}
if (!svg) {
return (
<div className={className} style={{ fontSize: 13, opacity: 0.5 }}>
Rendering diagram...
</div>
)
}
return (
<div
ref={containerRef}
className={className}
dangerouslySetInnerHTML={{ __html: svg }}
style={{ lineHeight: 0 }}
/>
)
}

View file

@ -0,0 +1,86 @@
import { mergeAttributes, Node } from '@tiptap/react'
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
import { X, GitBranch } from 'lucide-react'
import { MermaidRenderer } from '@/components/mermaid-renderer'
function MermaidBlockView({ node, deleteNode }: { node: { attrs: Record<string, unknown> }; deleteNode: () => void }) {
const source = (node.attrs.data as string) || ''
return (
<NodeViewWrapper className="mermaid-block-wrapper" data-type="mermaid-block">
<div className="mermaid-block-card">
<button
className="mermaid-block-delete"
onClick={deleteNode}
aria-label="Delete mermaid block"
>
<X size={14} />
</button>
{source ? (
<MermaidRenderer source={source} />
) : (
<div className="mermaid-block-empty">
<GitBranch size={16} />
<span>Empty mermaid block</span>
</div>
)}
</div>
</NodeViewWrapper>
)
}
export const MermaidBlockExtension = Node.create({
name: 'mermaidBlock',
group: 'block',
atom: true,
selectable: true,
draggable: false,
addAttributes() {
return {
data: {
default: '',
},
}
},
parseHTML() {
return [
{
tag: 'pre',
priority: 60,
getAttrs(element) {
const code = element.querySelector('code')
if (!code) return false
const cls = code.className || ''
if (cls.includes('language-mermaid')) {
return { data: code.textContent || '' }
}
return false
},
},
]
},
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'mermaid-block' })]
},
addNodeView() {
return ReactNodeViewRenderer(MermaidBlockView)
},
addStorage() {
return {
markdown: {
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
state.write('```mermaid\n' + node.attrs.data + '\n```')
state.closeBlock(node)
},
parse: {
// handled by parseHTML
},
},
}
},
})

View file

@ -619,7 +619,8 @@
.tiptap-editor .ProseMirror .table-block-wrapper,
.tiptap-editor .ProseMirror .calendar-block-wrapper,
.tiptap-editor .ProseMirror .email-block-wrapper,
.tiptap-editor .ProseMirror .transcript-block-wrapper {
.tiptap-editor .ProseMirror .transcript-block-wrapper,
.tiptap-editor .ProseMirror .mermaid-block-wrapper {
margin: 8px 0;
}
@ -630,7 +631,8 @@
.tiptap-editor .ProseMirror .calendar-block-card,
.tiptap-editor .ProseMirror .email-block-card,
.tiptap-editor .ProseMirror .email-draft-block-card,
.tiptap-editor .ProseMirror .transcript-block-card {
.tiptap-editor .ProseMirror .transcript-block-card,
.tiptap-editor .ProseMirror .mermaid-block-card {
position: relative;
padding: 12px 14px;
border: 1px solid var(--border);
@ -647,7 +649,8 @@
.tiptap-editor .ProseMirror .calendar-block-card:hover,
.tiptap-editor .ProseMirror .email-block-card:hover,
.tiptap-editor .ProseMirror .email-draft-block-card:hover,
.tiptap-editor .ProseMirror .transcript-block-card:hover {
.tiptap-editor .ProseMirror .transcript-block-card:hover,
.tiptap-editor .ProseMirror .mermaid-block-card:hover {
background-color: color-mix(in srgb, var(--muted) 70%, transparent);
}
@ -657,7 +660,8 @@
.tiptap-editor .ProseMirror .table-block-wrapper.ProseMirror-selectednode .table-block-card,
.tiptap-editor .ProseMirror .calendar-block-wrapper.ProseMirror-selectednode .calendar-block-card,
.tiptap-editor .ProseMirror .email-block-wrapper.ProseMirror-selectednode .email-block-card,
.tiptap-editor .ProseMirror .email-draft-block-wrapper.ProseMirror-selectednode .email-draft-block-card {
.tiptap-editor .ProseMirror .email-draft-block-wrapper.ProseMirror-selectednode .email-draft-block-card,
.tiptap-editor .ProseMirror .mermaid-block-wrapper.ProseMirror-selectednode .mermaid-block-card {
outline: 2px solid var(--primary);
outline-offset: 1px;
}
@ -668,7 +672,8 @@
.tiptap-editor .ProseMirror .table-block-delete,
.tiptap-editor .ProseMirror .calendar-block-delete,
.tiptap-editor .ProseMirror .email-block-delete,
.tiptap-editor .ProseMirror .email-draft-block-delete {
.tiptap-editor .ProseMirror .email-draft-block-delete,
.tiptap-editor .ProseMirror .mermaid-block-delete {
position: absolute;
top: 6px;
right: 6px;
@ -693,7 +698,8 @@
.tiptap-editor .ProseMirror .table-block-card:hover .table-block-delete,
.tiptap-editor .ProseMirror .calendar-block-card:hover .calendar-block-delete,
.tiptap-editor .ProseMirror .email-block-card:hover .email-block-delete,
.tiptap-editor .ProseMirror .email-draft-block-card:hover .email-draft-block-delete {
.tiptap-editor .ProseMirror .email-draft-block-card:hover .email-draft-block-delete,
.tiptap-editor .ProseMirror .mermaid-block-card:hover .mermaid-block-delete {
opacity: 1;
}
@ -703,11 +709,26 @@
.tiptap-editor .ProseMirror .table-block-delete:hover,
.tiptap-editor .ProseMirror .calendar-block-delete:hover,
.tiptap-editor .ProseMirror .email-block-delete:hover,
.tiptap-editor .ProseMirror .email-draft-block-delete:hover {
.tiptap-editor .ProseMirror .email-draft-block-delete:hover,
.tiptap-editor .ProseMirror .mermaid-block-delete:hover {
background-color: color-mix(in srgb, var(--foreground) 8%, transparent);
color: var(--foreground);
}
/* Mermaid block */
.tiptap-editor .ProseMirror .mermaid-block-card svg {
max-width: 100%;
height: auto;
}
.tiptap-editor .ProseMirror .mermaid-block-empty {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: color-mix(in srgb, var(--foreground) 50%, transparent);
}
/* Image block */
.tiptap-editor .ProseMirror .image-block-img {
max-width: 100%;