mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-06 13:52:44 +02:00
add mermaid rendering
This commit is contained in:
parent
220e15f642
commit
610616e5a0
7 changed files with 298 additions and 77 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
89
apps/x/apps/renderer/src/components/mermaid-renderer.tsx
Normal file
89
apps/x/apps/renderer/src/components/mermaid-renderer.tsx
Normal 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 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
86
apps/x/apps/renderer/src/extensions/mermaid-block.tsx
Normal file
86
apps/x/apps/renderer/src/extensions/mermaid-block.tsx
Normal 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
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
@ -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%;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue