1- import { isDarkTheme } from '../utils.ts' ;
1+ import { isDarkTheme , parseDom } from '../utils.ts' ;
22import { makeCodeCopyButton } from './codecopy.ts' ;
33import { displayError } from './common.ts' ;
4- import { queryElems } from '../utils/dom.ts' ;
4+ import { createElementFromAttrs , queryElems } from '../utils/dom.ts' ;
55import { html , htmlRaw } from '../utils/html.ts' ;
66import { load as loadYaml } from 'js-yaml' ;
77import type { MermaidConfig } from 'mermaid' ;
@@ -58,29 +58,18 @@ function configContainsElk(config: MermaidConfig | null) {
5858 // * config.{any-diagram-config}.defaultRenderer
5959 // Although only a few diagram types like "flowchart" support "defaultRenderer",
6060 // as long as there is no side effect, here do a general check for all properties of "config", for ease of maintenance
61- return configValueIsElk ( config . layout ) || Object . values ( config ) . some ( ( value ) => configValueIsElk ( value ?. defaultRenderer ) ) ;
61+ return configValueIsElk ( config . layout ) || Object . values ( config ) . some ( ( diagCfg ) => configValueIsElk ( diagCfg ?. defaultRenderer ) ) ;
6262}
6363
64- /** detect whether mermaid sources contain elk layout configuration */
65- export function sourcesContainElk ( sources : Array < string > ) {
66- for ( const source of sources ) {
67- if ( isSourceTooLarge ( source ) ) continue ;
68-
69- const yamlConfig = parseYamlInitConfig ( source ) ;
70- if ( configContainsElk ( yamlConfig ) ) return true ;
71-
72- const jsonConfig = parseJsonInitConfig ( source ) ;
73- if ( configContainsElk ( jsonConfig ) ) return true ;
74- }
75-
76- return false ;
64+ export function sourceNeedsElk ( source : string ) {
65+ if ( isSourceTooLarge ( source ) ) return false ;
66+ const configYaml = parseYamlInitConfig ( source ) , configJson = parseJsonInitConfig ( source ) ;
67+ return configContainsElk ( configYaml ) || configContainsElk ( configJson ) ;
7768}
7869
79- async function loadMermaid ( sources : Array < string > ) {
70+ async function loadMermaid ( needElkRender : boolean ) {
8071 const mermaidPromise = import ( /* webpackChunkName: "mermaid" */ 'mermaid' ) ;
81- const elkPromise = sourcesContainElk ( sources ) ?
82- import ( /* webpackChunkName: "mermaid-layout-elk" */ '@mermaid-js/layout-elk' ) : null ;
83-
72+ const elkPromise = needElkRender ? import ( /* webpackChunkName: "mermaid-layout-elk" */ '@mermaid-js/layout-elk' ) : null ;
8473 const results = await Promise . all ( [ mermaidPromise , elkPromise ] ) ;
8574 return {
8675 mermaid : results [ 0 ] . default ,
@@ -92,86 +81,74 @@ let elkLayoutsRegistered = false;
9281
9382export async function initMarkupCodeMermaid ( elMarkup : HTMLElement ) : Promise < void > {
9483 // .markup code.language-mermaid
95- const els = Array . from ( queryElems ( elMarkup , 'code.language-mermaid' ) ) ;
96- if ( ! els . length ) return ;
97- const sources = Array . from ( els , ( el ) => el . textContent ?? '' ) ;
98- const { mermaid, elkLayouts} = await loadMermaid ( sources ) ;
84+ const mermaidBlocks : Array < { source : string , parentContainer : HTMLElement } > = [ ] ;
85+ const attrMermaidRendered = 'data-markup-mermaid-rendered' ;
86+ let needElkRender = false ;
87+ for ( const elCodeBlock of queryElems ( elMarkup , 'code.language-mermaid' ) ) {
88+ const parentContainer = elCodeBlock . closest ( 'pre' ) ! ; // it must exist, if no, there must be a bug
89+ if ( parentContainer . hasAttribute ( attrMermaidRendered ) ) continue ;
90+ parentContainer . setAttribute ( attrMermaidRendered , 'true' ) ;
91+
92+ const source = elCodeBlock . textContent ?? '' ;
93+ needElkRender = needElkRender || sourceNeedsElk ( source ) ;
94+ mermaidBlocks . push ( { source, parentContainer} ) ;
95+ }
96+ if ( ! mermaidBlocks . length ) return ;
9997
98+ const { mermaid, elkLayouts} = await loadMermaid ( needElkRender ) ;
10099 if ( elkLayouts && ! elkLayoutsRegistered ) {
101100 mermaid . registerLayoutLoaders ( elkLayouts ) ;
102101 elkLayoutsRegistered = true ;
103102 }
104103 mermaid . initialize ( {
105104 startOnLoad : false ,
106- theme : isDarkTheme ( ) ? 'dark' : 'neutral' ,
105+ theme : isDarkTheme ( ) ? 'dark' : 'neutral' , // TODO: maybe it should use "darkMode" to adopt more user-specified theme instead of just "dark" or "neutral"
107106 securityLevel : 'strict' ,
108107 suppressErrorRendering : true ,
109108 } ) ;
110109
111- await Promise . all ( els . map ( async ( el , index ) => {
112- const source = sources [ index ] ;
113- const pre = el . closest ( 'pre' ) ;
114-
115- if ( ! pre || pre . hasAttribute ( 'data-render-done' ) ) {
116- return ;
117- }
118-
110+ // mermaid is a globally shared instance, its document also says "Multiple calls to this function will be enqueued to run serially."
111+ // so here we just simply render the mermaid blocks one by one, no need to do "Promise.all" concurrently
112+ for ( const block of mermaidBlocks ) {
113+ const { source, parentContainer} = block ;
119114 if ( isSourceTooLarge ( source ) ) {
120- displayError ( pre , new Error ( `Mermaid source of ${ source . length } characters exceeds the maximum allowed length of ${ mermaidMaxSourceCharacters } .` ) ) ;
121- return ;
122- }
123-
124- try {
125- await mermaid . parse ( source ) ;
126- } catch ( err ) {
127- displayError ( pre , err ) ;
128- return ;
115+ displayError ( parentContainer , new Error ( `Mermaid source of ${ source . length } characters exceeds the maximum allowed length of ${ mermaidMaxSourceCharacters } .` ) ) ;
116+ continue ;
129117 }
130118
131119 try {
132- // can't use bindFunctions here because we can't cross the iframe boundary. This
133- // means js-based interactions won't work but they aren't intended to work either
134- const { svg} = await mermaid . render ( 'mermaid' , source ) ;
120+ // render the mermaid diagram to svg text, and parse it to a DOM node
121+ const { svg : svgText , bindFunctions} = await mermaid . render ( 'mermaid' , source , parentContainer ) ;
122+ const svgDoc = parseDom ( svgText , 'image/svg+xml' ) ;
123+ const svgNode = ( svgDoc . documentElement as unknown ) as SVGSVGElement ;
135124
125+ // create an iframe to sandbox the svg with styles, and set correct height by reading svg's viewBox height
136126 const iframe = document . createElement ( 'iframe' ) ;
137- iframe . classList . add ( 'markup-content-iframe' , 'tw-invisible' ) ;
138- iframe . srcdoc = html `< html > < head > < style > ${ htmlRaw ( iframeCss ) } </ style > </ head > < body > ${ htmlRaw ( svg ) } </ body > </ html > ` ;
139-
140- const mermaidBlock = document . createElement ( 'div' ) ;
141- mermaidBlock . classList . add ( 'mermaid-block' , 'is-loading' , 'tw-hidden' ) ;
142- mermaidBlock . append ( iframe ) ;
127+ iframe . classList . add ( 'markup-content-iframe' , 'is-loading' ) ;
128+ iframe . srcdoc = html `< html > < head > < style > ${ htmlRaw ( iframeCss ) } </ style > </ head > < body > </ body > </ html > ` ;
143129
144- const btn = makeCodeCopyButton ( ) ;
145- btn . setAttribute ( 'data-clipboard-text' , source ) ;
146- mermaidBlock . append ( btn ) ;
147-
148- const updateIframeHeight = ( ) => {
149- const body = iframe . contentWindow ?. document ?. body ;
150- if ( body ) {
151- iframe . style . height = `${ body . clientHeight } px` ;
152- }
153- } ;
130+ // although the "viewBox" is optional, mermaid's output should always have a correct viewBox with width and height
131+ const iframeHeightFromViewBox = Math . ceil ( svgNode . viewBox ?. baseVal ?. height ?? 0 ) ;
132+ if ( iframeHeightFromViewBox ) iframe . style . height = `${ iframeHeightFromViewBox } px` ;
154133
134+ // the iframe will be fully reloaded if its DOM context is changed (e.g.: moved in the DOM tree).
135+ // to avoid unnecessary reloading, we should insert the iframe to its final position only once.
155136 iframe . addEventListener ( 'load' , ( ) => {
156- pre . replaceWith ( mermaidBlock ) ;
157- mermaidBlock . classList . remove ( 'tw-hidden' ) ;
158- updateIframeHeight ( ) ;
159- setTimeout ( ( ) => { // avoid flash of iframe background
160- mermaidBlock . classList . remove ( 'is-loading' ) ;
161- iframe . classList . remove ( 'tw-invisible' ) ;
162- } , 0 ) ;
163-
164- // update height when element's visibility state changes, for example when the diagram is inside
165- // a <details> + <summary> block and the <details> block becomes visible upon user interaction, it
166- // would initially set a incorrect height and the correct height is set during this callback.
167- ( new IntersectionObserver ( ( ) => {
168- updateIframeHeight ( ) ;
169- } , { root : document . documentElement } ) ) . observe ( iframe ) ;
137+ // same origin, so we can operate "iframe body" and all elements directly
138+ const iframeBody = iframe . contentDocument ! . body ;
139+ iframeBody . append ( svgNode ) ;
140+ bindFunctions ?.( iframeBody ) ; // follow "mermaid.render" doc, attach event handlers to the svg's container
141+
142+ // according to mermaid, the viewBox height should always exist, here just a fallback for unknown cases.
143+ // and keep in mind: clientHeight can be 0 if the element is hidden (display: none).
144+ if ( ! iframeHeightFromViewBox && iframeBody . clientHeight ) iframe . style . height = `${ iframeBody . clientHeight } px` ;
145+ iframe . classList . remove ( 'is-loading' ) ;
170146 } ) ;
171147
172- document . body . append ( mermaidBlock ) ;
148+ const container = createElementFromAttrs ( 'div' , { class : 'mermaid-block' } , iframe , makeCodeCopyButton ( { 'data-clipboard-text' : source } ) ) ;
149+ parentContainer . replaceWith ( container ) ;
173150 } catch ( err ) {
174- displayError ( pre , err ) ;
151+ displayError ( parentContainer , err ) ;
175152 }
176- } ) ) ;
153+ }
177154}
0 commit comments