1- import { useEffect , useState } from 'react'
1+ import { lazy , Suspense , useContext , useEffect , useState } from 'react'
22import {
33 chipVariants ,
44 cn ,
@@ -11,11 +11,18 @@ import {
1111import { Check , ChevronDown , Code , Duplicate , Eye , Wrap } from '@sim/emcn/icons'
1212import type { ReactNodeViewProps } from '@tiptap/react'
1313import { NodeViewContent , NodeViewWrapper , ReactNodeViewRenderer } from '@tiptap/react'
14+ import { DASHBOARD_EMBED_LANGUAGE } from '@/lib/dashboards/embed-language'
15+ import { MarkdownStreamingContext } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-streaming-context'
1416import { looksLikeMermaid , MermaidDiagram } from '../mermaid-diagram'
1517import { MarkdownCodeBlock } from './code-block-schema'
1618import { detectLanguage } from './detect-language'
1719import { useEditorEditable } from './use-editor-editable'
1820
21+ /** Kept out of every rich-markdown surface's graph until a document actually holds a dashboard. */
22+ const DashboardEmbed = lazy ( ( ) =>
23+ import ( '@/components/dashboards/dashboard-embed' ) . then ( ( m ) => ( { default : m . DashboardEmbed } ) )
24+ )
25+
1926const PLAIN = 'plain'
2027const MERMAID = 'mermaid'
2128
@@ -51,7 +58,8 @@ const CONTROL_CLASS =
5158 * whenever the cursor is outside it (and always in read-only), and as editable source while the
5259 * cursor is inside, re-rendering on blur (the Linear/GitHub model). The source `<pre>` stays mounted
5360 * (hidden behind the diagram) so ProseMirror keeps managing its contentDOM, and the node remains an
54- * ordinary code block, so markdown round-trips unchanged.
61+ * ordinary code block, so markdown round-trips unchanged. A ```dashboard fence renders live
62+ * dashboard panels the same way.
5563 */
5664function CodeBlockView ( { node, updateAttributes, editor, getPos } : ReactNodeViewProps ) {
5765 const [ wrap , setWrap ] = useState ( false )
@@ -60,16 +68,19 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView
6068 const [ peekSource , setPeekSource ] = useState ( false )
6169 const { copied, copy } = useCopyToClipboard ( { resetMs : 1500 } )
6270 const editable = useEditorEditable ( editor )
71+ const isStreaming = useContext ( MarkdownStreamingContext )
6372
6473 const explicitLanguage = node . attrs . language as string | null
6574 const text = node . textContent
6675 const isMermaid = explicitLanguage === MERMAID || ( ! explicitLanguage && looksLikeMermaid ( text ) )
76+ const isDashboard = explicitLanguage === DASHBOARD_EMBED_LANGUAGE
77+ const isRendered = isMermaid || isDashboard
6778
6879 // Editable Mermaid shows source while the caret is focused inside the block and re-renders the
6980 // diagram on blur (the Linear/GitHub model). The Show source / Show diagram control drives this by
7081 // focusing into / blurring the block; read-only uses {@link peekSource} since there is no caret.
7182 useEffect ( ( ) => {
72- if ( ! isMermaid || ! editable ) {
83+ if ( ! isRendered || ! editable ) {
7384 setEditingInline ( false )
7485 return
7586 }
@@ -92,13 +103,13 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView
92103 editor . off ( 'focus' , sync )
93104 editor . off ( 'blur' , sync )
94105 }
95- } , [ editor , getPos , isMermaid , editable ] )
106+ } , [ editor , getPos , isRendered , editable ] )
96107
97108 const showSource = editable ? editingInline : peekSource
98- const showDiagram = isMermaid && text . trim ( ) . length > 0 && ! showSource
109+ const showRendered = isRendered && text . trim ( ) . length > 0 && ! showSource
99110
100- // Skip language detection on the mermaid path — the picker/label never render there.
101- const language = explicitLanguage ?? ( isMermaid ? null : detectLanguage ( text ) ) ?? PLAIN
111+ // Skip language detection on rendered blocks — the picker/label never render there.
112+ const language = explicitLanguage ?? ( isRendered ? null : detectLanguage ( text ) ) ?? PLAIN
102113 const label =
103114 LANGUAGE_OPTIONS . find ( ( option ) => option . value === language ) ?. label ??
104115 explicitLanguage ??
@@ -133,18 +144,20 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView
133144 ) }
134145 contentEditable = { false }
135146 >
136- { isMermaid && (
147+ { isRendered && (
137148 < button
138149 type = 'button'
139- aria-label = { showSource ? 'Show diagram' : 'Show source' }
150+ aria-label = {
151+ showSource ? ( isDashboard ? 'Show dashboard' : 'Show diagram' ) : 'Show source'
152+ }
140153 onMouseDown = { ( event ) => event . preventDefault ( ) }
141154 onClick = { toggleSource }
142155 className = { CONTROL_CLASS }
143156 >
144157 { showSource ? < Eye /> : < Code /> }
145158 </ button >
146159 ) }
147- { ! isMermaid &&
160+ { ! isRendered &&
148161 ( editable ? (
149162 // Editable: a language picker. Read-only: a static label — selecting a language calls
150163 // updateAttributes, which would mutate a doc that must not change.
@@ -180,7 +193,7 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView
180193 { label }
181194 </ span >
182195 ) ) }
183- { ! isMermaid && editable && (
196+ { ! isRendered && editable && (
184197 < button
185198 type = 'button'
186199 aria-label = 'Toggle line wrap'
@@ -205,22 +218,30 @@ function CodeBlockView({ node, updateAttributes, editor, getPos }: ReactNodeView
205218 { copied ? < Check /> : < Duplicate /> }
206219 </ button >
207220 </ div >
208- < pre className = { cn ( 'code-editor-theme pr-20' , showDiagram && 'hidden' ) } data-wrap = { wrap } >
221+ < pre className = { cn ( 'code-editor-theme pr-20' , showRendered && 'hidden' ) } data-wrap = { wrap } >
209222 < NodeViewContent < 'code' > as = 'code' />
210223 </ pre >
211- { showDiagram && (
212- // Clicking the diagram selects the whole node (same selection ring as an image/code block)
213- // instead of dropping a caret inside — preventDefault stops ProseMirror placing the caret,
214- // which would otherwise flip to source. Editing is an explicit Show source / blur action.
224+ { showRendered && (
225+ // Select the whole node instead of placing a caret, which would flip the block to source.
215226 < div
216227 contentEditable = { false }
228+ className = { cn ( isDashboard && 'dashboard-embed' ) }
217229 onMouseDown = { ( event ) => {
230+ const target = event . target
231+ if ( ! ( target instanceof Element ) || ! event . currentTarget . contains ( target ) ) return
232+ if ( target . closest ( 'button, input' ) ) return
218233 event . preventDefault ( )
219234 const pos = typeof getPos === 'function' ? getPos ( ) : null
220235 if ( typeof pos === 'number' ) editor . commands . setNodeSelection ( pos )
221236 } }
222237 >
223- < MermaidDiagram definition = { text } className = 'mermaid-diagram-frame' />
238+ { isDashboard ? (
239+ < Suspense fallback = { null } >
240+ < DashboardEmbed source = { text } isStreaming = { isStreaming } />
241+ </ Suspense >
242+ ) : (
243+ < MermaidDiagram definition = { text } className = 'mermaid-diagram-frame' />
244+ ) }
224245 </ div >
225246 ) }
226247 </ NodeViewWrapper >
0 commit comments