@@ -29,8 +29,10 @@ import {
2929 syncMarkdownCodeLineNumbers ,
3030 type DecorateContext ,
3131 type DecorateLabels ,
32+ type MermaidControlOptions ,
3233 type MermaidRender ,
3334} from './markdown/decorate' ;
35+ import { createMermaidViewerRegistry , MERMAID_BLOCK_SELECTOR , shouldRefreshMermaidViewers } from './markdown/mermaidViewer' ;
3436import {
3537 BLOCK_PATH_TOKEN_RE ,
3638 isAbsoluteReferencePath ,
@@ -103,27 +105,12 @@ const useExternalLinkInteractions = ({
103105 } , [ containerRef , enabled ] ) ;
104106} ;
105107
106- type MermaidControlOptions = {
107- download : boolean ;
108- copy : boolean ;
109- fullscreen : boolean ;
110- panZoom : boolean ;
111- } ;
112-
113- const extractMermaidBlocks = ( markdown : string ) : string [ ] => {
114- if ( ! markdown . includes ( 'mermaid' ) ) return [ ] ;
115- const blocks : string [ ] = [ ] ;
116- const regex = / (?: ^ | \r ? \n ) ( ` { 3 , } | ~ { 3 , } ) m e r m a i d [ ^ \n \r ] * \r ? \n ( [ \s \S ] * ?) \r ? \n \1(? = \r ? \n | $ ) / gi;
117- let match : RegExpExecArray | null = regex . exec ( markdown ) ;
118-
119- while ( match ) {
120- const block = ( match [ 2 ] ?? '' ) . replace ( / \s + $ / , '' ) ;
121- blocks . push ( block ) ;
122- match = regex . exec ( markdown ) ;
123- }
124-
125- return blocks ;
108+ const DEFAULT_MERMAID_CONTROLS : MermaidControlOptions = {
109+ download : true ,
110+ copy : true ,
111+ showPanZoomControls : true ,
126112} ;
113+ const DEFAULT_MERMAID_FULLSCREEN_ENABLED = true ;
127114
128115const stripLeadingFrontmatter = ( markdown : string ) : string => {
129116 const frontmatterMatch = markdown . match (
@@ -153,7 +140,6 @@ interface MarkdownRendererProps {
153140 enableFileReferences ?: boolean ;
154141}
155142
156- const MERMAID_BLOCK_SELECTOR = '[data-markdown="mermaid-block"]' ;
157143const FILE_LINK_SELECTOR = '[data-openchamber-file-link="true"]' ;
158144const BLOCK_PATH_TOKEN_ATTR = 'data-openchamber-block-path-token' ;
159145const BLOCK_PATH_TOKEN_SELECTOR = `[${ BLOCK_PATH_TOKEN_ATTR } ]` ;
@@ -666,14 +652,16 @@ const useFileReferenceInteractions = ({
666652
667653const useMermaidInlineInteractions = ( {
668654 containerRef,
669- mermaidBlocks,
670655 onShowPopup,
671- allowWheelZoom,
656+ enableFullscreen,
657+ enablePanZoom,
658+ allowMermaidWheelEvents,
672659} : {
673660 containerRef : React . RefObject < HTMLDivElement | null > ;
674- mermaidBlocks : string [ ] ;
675661 onShowPopup ?: ( content : ToolPopupContent ) => void ;
676- allowWheelZoom ?: boolean ;
662+ enableFullscreen ?: boolean ;
663+ enablePanZoom ?: boolean ;
664+ allowMermaidWheelEvents ?: boolean ;
677665} ) => {
678666 React . useEffect ( ( ) => {
679667 const container = containerRef . current ;
@@ -682,7 +670,7 @@ const useMermaidInlineInteractions = ({
682670 }
683671
684672 const handleMermaidClick = ( event : MouseEvent ) => {
685- if ( ! onShowPopup ) {
673+ if ( ! enableFullscreen || ! onShowPopup ) {
686674 return ;
687675 }
688676
@@ -700,13 +688,18 @@ const useMermaidInlineInteractions = ({
700688 return ;
701689 }
702690
703- const renderedBlocks = Array . from ( container . querySelectorAll ( MERMAID_BLOCK_SELECTOR ) ) ;
704- const blockIndex = renderedBlocks . indexOf ( block ) ;
691+ if ( block instanceof HTMLElement && block . hasAttribute ( 'data-mermaid-suppress-click' ) ) {
692+ block . removeAttribute ( 'data-mermaid-suppress-click' ) ;
693+ return ;
694+ }
695+
696+ const renderedBlocks = Array . from ( container . querySelectorAll < HTMLElement > ( MERMAID_BLOCK_SELECTOR ) ) ;
697+ const blockIndex = renderedBlocks . indexOf ( block as HTMLElement ) ;
705698 if ( blockIndex < 0 ) {
706699 return ;
707700 }
708701
709- const source = mermaidBlocks [ blockIndex ] ;
702+ const source = block instanceof HTMLElement ? block . getAttribute ( 'data-md-source' ) : null ;
710703 if ( ! source || source . trim ( ) . length === 0 ) {
711704 return ;
712705 }
@@ -729,7 +722,7 @@ const useMermaidInlineInteractions = ({
729722 } ;
730723
731724 const handleInlineWheel = ( event : WheelEvent ) => {
732- if ( allowWheelZoom ) {
725+ if ( allowMermaidWheelEvents || ( ( event . ctrlKey || event . metaKey ) && enablePanZoom ) ) {
733726 return ;
734727 }
735728
@@ -754,7 +747,7 @@ const useMermaidInlineInteractions = ({
754747 container . removeEventListener ( 'click' , handleMermaidClick ) ;
755748 container . removeEventListener ( 'wheel' , handleInlineWheel , true ) ;
756749 } ;
757- } , [ allowWheelZoom , containerRef , mermaidBlocks , onShowPopup ] ) ;
750+ } , [ allowMermaidWheelEvents , containerRef , enableFullscreen , enablePanZoom , onShowPopup ] ) ;
758751} ;
759752
760753// ---------------------------------------------------------------------------
@@ -862,17 +855,21 @@ const useDecorateContext = (
862855 currentTheme : Theme ,
863856 deferCodeLineNumberSync : boolean ,
864857 onPreviewLoopback ?: ( url : string ) => void ,
858+ mermaidControls : MermaidControlOptions = DEFAULT_MERMAID_CONTROLS ,
865859) : DecorateContext => {
866860 const { t } = useI18n ( ) ;
867861 const labels : DecorateLabels = React . useMemo ( ( ) => ( {
868- copy : 'Copy code' ,
869- copied : 'Copied' ,
862+ copy : t ( 'markdownRenderer. code.actions.copyTitle' ) ,
863+ copied : t ( 'markdownRenderer.code.actions.copiedTitle' ) ,
870864 enableCodeWrap : t ( 'markdownRenderer.code.actions.enableWrapTitle' ) ,
871865 disableCodeWrap : t ( 'markdownRenderer.code.actions.disableWrapTitle' ) ,
872866 copyTable : t ( 'markdownRenderer.table.actions.copyTitle' ) ,
873867 downloadTable : t ( 'markdownRenderer.table.actions.downloadTitle' ) ,
874868 copyDiagram : t ( 'markdownRenderer.mermaid.actions.copySourceTitle' ) ,
875869 downloadDiagram : t ( 'markdownRenderer.mermaid.actions.downloadSvgTitle' ) ,
870+ zoomInDiagram : t ( 'markdownRenderer.mermaid.actions.zoomInTitle' ) ,
871+ zoomOutDiagram : t ( 'markdownRenderer.mermaid.actions.zoomOutTitle' ) ,
872+ resetDiagramView : t ( 'markdownRenderer.mermaid.actions.resetViewTitle' ) ,
876873 previewLabel : t ( 'terminalView.preview.open' ) ,
877874 previewTitle : t ( 'terminalView.preview.openTitle' ) ,
878875 } ) , [ t ] ) ;
@@ -896,8 +893,8 @@ const useDecorateContext = (
896893 return { } ;
897894 }
898895 } ) ;
899- return { labels, codeBlockLineWrap, deferCodeLineNumberSync, onToggleCodeBlockLineWrap : toggleCodeBlockLineWrap , renderMermaid, onPreviewLoopback } ;
900- } , [ currentTheme , labels , codeBlockLineWrap , deferCodeLineNumberSync , toggleCodeBlockLineWrap , onPreviewLoopback ] ) ;
896+ return { labels, mermaidControls , codeBlockLineWrap, deferCodeLineNumberSync, onToggleCodeBlockLineWrap : toggleCodeBlockLineWrap , renderMermaid, onPreviewLoopback } ;
897+ } , [ currentTheme , labels , mermaidControls , codeBlockLineWrap , deferCodeLineNumberSync , toggleCodeBlockLineWrap , onPreviewLoopback ] ) ;
901898} ;
902899
903900// Runs the async render pipeline into the container and keeps a stable
@@ -921,6 +918,22 @@ const useMorphdomMarkdown = ({
921918 ensureMarkdownShikiTheme ( ) ;
922919 } , [ ] ) ;
923920
921+ const mermaidViewerRef = React . useRef < ReturnType < typeof createMermaidViewerRegistry > | null > ( null ) ;
922+ const refreshMermaidViewers = React . useCallback ( ( ) => {
923+ const container = containerRef . current ;
924+ if ( ! container ) {
925+ return ;
926+ }
927+ if ( ! mermaidViewerRef . current ) {
928+ if ( ! shouldRefreshMermaidViewers ( container ) ) {
929+ return ;
930+ }
931+ mermaidViewerRef . current = createMermaidViewerRegistry ( container ) ;
932+ return ;
933+ }
934+ mermaidViewerRef . current . refresh ( ) ;
935+ } , [ containerRef ] ) ;
936+
924937 // Synchronous first paint: while the async parse is in-flight, show escaped
925938 // plain text immediately so there is no blank frame on initial mount. Only
926939 // runs when the target is empty — subsequent updates keep the prior rich DOM
@@ -944,8 +957,16 @@ const useMorphdomMarkdown = ({
944957 // the structure here keeps the async morph to syntax colors only.
945958 decorateMarkdown ( block , ctx ) ;
946959 target . appendChild ( block ) ;
960+ if ( shouldRefreshMermaidViewers ( block ) ) {
961+ refreshMermaidViewers ( ) ;
962+ }
947963 }
948- } , [ containerRef , text , ctx ] ) ;
964+ } , [ containerRef , text , ctx , refreshMermaidViewers ] ) ;
965+
966+ React . useEffect ( ( ) => ( ) => {
967+ mermaidViewerRef . current ?. cleanup ( ) ;
968+ mermaidViewerRef . current = null ;
969+ } , [ ] ) ;
949970
950971 React . useEffect ( ( ) => {
951972 const container = containerRef . current ;
@@ -973,16 +994,30 @@ const useMorphdomMarkdown = ({
973994 const temp = document . createElement ( 'div' ) ;
974995 temp . innerHTML = block . html ;
975996 decorateMarkdown ( temp , ctx ) ;
997+ const hadMermaidBlock = shouldRefreshMermaidViewers ( el ) ;
998+ const tempHasMermaidBlock = shouldRefreshMermaidViewers ( temp ) ;
976999 morphdom ( el , temp , {
9771000 childrenOnly : true ,
9781001 onBeforeElUpdated : ( fromEl , toEl ) => ! fromEl . isEqualNode ( toEl ) ,
9791002 } ) ;
9801003 el . setAttribute ( 'data-md-id' , block . id ) ;
1004+ if ( hadMermaidBlock || tempHasMermaidBlock || shouldRefreshMermaidViewers ( el ) ) {
1005+ refreshMermaidViewers ( ) ;
1006+ }
9811007 } ) ;
9821008
9831009 // Remove any trailing block elements no longer present.
1010+ const hadMermaidBeforeTrailingCleanup = shouldRefreshMermaidViewers ( target ) ;
1011+ let removedMermaidBlock = false ;
9841012 for ( let i = existing . length - 1 ; i >= blocks . length ; i -= 1 ) {
985- existing [ i ] ?. remove ( ) ;
1013+ const removed = existing [ i ] ;
1014+ if ( removed && shouldRefreshMermaidViewers ( removed ) ) {
1015+ removedMermaidBlock = true ;
1016+ }
1017+ removed ?. remove ( ) ;
1018+ }
1019+ if ( removedMermaidBlock || ( existing . length > blocks . length && hadMermaidBeforeTrailingCleanup ) ) {
1020+ refreshMermaidViewers ( ) ;
9861021 }
9871022
9881023 if ( ! ctx . deferCodeLineNumberSync ) {
@@ -993,7 +1028,7 @@ const useMorphdomMarkdown = ({
9931028 return ( ) => {
9941029 active = false ;
9951030 } ;
996- } , [ containerRef , text , streaming , cacheKey , ctx ] ) ;
1031+ } , [ containerRef , text , streaming , cacheKey , ctx , refreshMermaidViewers ] ) ;
9971032
9981033 React . useEffect ( ( ) => {
9991034 const container = containerRef . current ;
@@ -1073,8 +1108,12 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
10731108 const live = isStreaming && ! disableStreamAnimation ;
10741109 const pacedText = usePacedText ( content , live ) ;
10751110
1076- const mermaidBlocks = React . useMemo ( ( ) => extractMermaidBlocks ( content ) , [ content ] ) ;
1077- useMermaidInlineInteractions ( { containerRef, mermaidBlocks, onShowPopup } ) ;
1111+ useMermaidInlineInteractions ( {
1112+ containerRef,
1113+ onShowPopup,
1114+ enableFullscreen : DEFAULT_MERMAID_FULLSCREEN_ENABLED ,
1115+ enablePanZoom : DEFAULT_MERMAID_CONTROLS . showPanZoomControls ,
1116+ } ) ;
10781117 useFileReferenceInteractions ( {
10791118 containerRef,
10801119 effectiveDirectory,
@@ -1085,7 +1124,7 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
10851124 useExternalLinkInteractions ( { containerRef } ) ;
10861125
10871126 const syntaxVars = React . useMemo ( ( ) => getMarkdownSyntaxVars ( currentTheme ) , [ currentTheme ] ) ;
1088- const ctx = useDecorateContext ( currentTheme , live , effectiveDirectory ? handlePreviewLoopback : undefined ) ;
1127+ const ctx = useDecorateContext ( currentTheme , live , effectiveDirectory ? handlePreviewLoopback : undefined , DEFAULT_MERMAID_CONTROLS ) ;
10891128 const cacheKey = `markdown-${ part ?. id ? `part-${ part . id } ` : `message-${ messageId } ` } ` ;
10901129
10911130 useMorphdomMarkdown ( { containerRef, text : pacedText , streaming : live , cacheKey, syntaxVars, ctx } ) ;
@@ -1129,7 +1168,7 @@ const SimpleMarkdownRendererImpl: React.FC<{
11291168 stripFrontmatter ?: boolean ;
11301169 onShowPopup ?: ( content : ToolPopupContent ) => void ;
11311170 mermaidControls ?: MermaidControlOptions ;
1132- allowMermaidWheelZoom ?: boolean ;
1171+ allowMermaidWheelEvents ?: boolean ;
11331172 enableFileReferences ?: boolean ;
11341173} > = ( {
11351174 content,
@@ -1138,7 +1177,8 @@ const SimpleMarkdownRendererImpl: React.FC<{
11381177 disableLinkSafety,
11391178 stripFrontmatter = false ,
11401179 onShowPopup,
1141- allowMermaidWheelZoom = false ,
1180+ mermaidControls = DEFAULT_MERMAID_CONTROLS ,
1181+ allowMermaidWheelEvents = false ,
11421182 enableFileReferences = true ,
11431183} ) => {
11441184 const { editor, runtime } = useRuntimeAPIs ( ) ;
@@ -1151,12 +1191,12 @@ const SimpleMarkdownRendererImpl: React.FC<{
11511191 [ content , stripFrontmatter ] ,
11521192 ) ;
11531193
1154- const mermaidBlocks = React . useMemo ( ( ) => extractMermaidBlocks ( renderedContent ) , [ renderedContent ] ) ;
11551194 useMermaidInlineInteractions ( {
11561195 containerRef,
1157- mermaidBlocks,
11581196 onShowPopup,
1159- allowWheelZoom : allowMermaidWheelZoom ,
1197+ enableFullscreen : DEFAULT_MERMAID_FULLSCREEN_ENABLED ,
1198+ enablePanZoom : mermaidControls . showPanZoomControls ,
1199+ allowMermaidWheelEvents,
11601200 } ) ;
11611201 useFileReferenceInteractions ( {
11621202 containerRef,
@@ -1168,7 +1208,7 @@ const SimpleMarkdownRendererImpl: React.FC<{
11681208 useExternalLinkInteractions ( { containerRef, enabled : ! disableLinkSafety } ) ;
11691209
11701210 const syntaxVars = React . useMemo ( ( ) => getMarkdownSyntaxVars ( currentTheme ) , [ currentTheme ] ) ;
1171- const ctx = useDecorateContext ( currentTheme , false ) ;
1211+ const ctx = useDecorateContext ( currentTheme , false , undefined , mermaidControls ) ;
11721212
11731213 useMorphdomMarkdown ( {
11741214 containerRef,
@@ -1187,12 +1227,18 @@ const SimpleMarkdownRendererImpl: React.FC<{
11871227} ;
11881228
11891229export const SimpleMarkdownRenderer = React . memo ( SimpleMarkdownRendererImpl , ( prev , next ) => {
1230+ const prevMermaidControls = prev . mermaidControls ?? DEFAULT_MERMAID_CONTROLS ;
1231+ const nextMermaidControls = next . mermaidControls ?? DEFAULT_MERMAID_CONTROLS ;
1232+
11901233 return prev . content === next . content
11911234 && prev . variant === next . variant
11921235 && prev . className === next . className
11931236 && prev . disableLinkSafety === next . disableLinkSafety
11941237 && prev . stripFrontmatter === next . stripFrontmatter
11951238 && prev . onShowPopup === next . onShowPopup
1196- && prev . allowMermaidWheelZoom === next . allowMermaidWheelZoom
1239+ && prevMermaidControls . download === nextMermaidControls . download
1240+ && prevMermaidControls . copy === nextMermaidControls . copy
1241+ && prevMermaidControls . showPanZoomControls === nextMermaidControls . showPanZoomControls
1242+ && prev . allowMermaidWheelEvents === next . allowMermaidWheelEvents
11971243 && prev . enableFileReferences === next . enableFileReferences ;
11981244} ) ;
0 commit comments