2026-04-19 23:48:33 +08:00
import { isDarkTheme } from '../utils.ts' ;
2024-07-07 17:32:30 +02:00
import { displayError } from './common.ts' ;
2026-02-11 11:22:33 +08:00
import { createElementFromAttrs , createElementFromHTML , queryElems } from '../utils/dom.ts' ;
import { html , htmlRaw } from '../utils/html.ts' ;
2026-02-07 03:22:57 +01:00
import { load as loadYaml } from 'js-yaml' ;
import type { MermaidConfig } from 'mermaid' ;
2026-02-11 11:22:33 +08:00
import { svg } from '../svg.ts' ;
2022-12-25 18:17:48 +01:00
2021-10-21 15:37:43 +08:00
const { mermaidMaxSourceCharacters } = window . config ;
2020-08-04 21:56:37 +02:00
2026-02-10 14:36:31 +08:00
function getIframeCss ( ) : string {
return `
html, body { height: 100%; }
body { margin: 0; padding: 0; overflow: hidden; }
#mermaid { display: block; margin: 0 auto; }
` ;
}
2020-07-27 08:24:09 +02:00
2026-02-07 03:22:57 +01:00
function isSourceTooLarge ( source : string ) {
return mermaidMaxSourceCharacters >= 0 && source . length > mermaidMaxSourceCharacters ;
}
function parseYamlInitConfig ( source : string ) : MermaidConfig | null {
// ref: https://github.com/mermaid-js/mermaid/blob/develop/packages/mermaid/src/diagram-api/regexes.ts
const yamlFrontMatterRegex = / ^ - - - \ s * [ \ n \ r ] ( . * ? ) [ \ n \ r ] - - - \ s * [ \ n \ r ] + / s ;
const frontmatter = ( yamlFrontMatterRegex . exec ( source ) || [ ] ) [ 1 ] ;
if ( ! frontmatter ) return null ;
try {
return ( loadYaml ( frontmatter ) as { config : MermaidConfig } ) ? . config ;
} catch {
console . error ( 'invalid or unsupported mermaid init YAML config' , frontmatter ) ;
}
return null ;
}
function parseJsonInitConfig ( source : string ) : MermaidConfig | null {
// https://mermaid.js.org/config/directives.html#declaring-directives
// Do as dirty as mermaid does: https://github.com/mermaid-js/mermaid/blob/develop/packages/mermaid/src/utils.ts
// It can even accept invalid JSON string like:
// %%{initialize: { 'logLevel': 'fatal', "theme":'dark', 'startOnLoad': true } }%%
const jsonInitConfigRegex = / % % \ { \ s * ( i n i t | i n i t i a l i z e ) \ s * : \ s * ( . * ? ) \ } % % / s ;
const jsonInitText = ( jsonInitConfigRegex . exec ( source ) || [ ] ) [ 2 ] ;
if ( ! jsonInitText ) return null ;
try {
const processed = jsonInitText . trim ( ) . replace ( /'/g , '"' ) ;
return JSON . parse ( processed ) ;
} catch {
console . error ( 'invalid or unsupported mermaid init JSON config' , jsonInitText ) ;
}
return null ;
}
function configValueIsElk ( layoutOrRenderer : string | undefined ) {
if ( typeof layoutOrRenderer !== 'string' ) return false ;
return layoutOrRenderer === 'elk' || layoutOrRenderer . startsWith ( 'elk.' ) ;
}
function configContainsElk ( config : MermaidConfig | null ) {
if ( ! config ) return false ;
// Check the layout from the following properties:
// * config.layout
// * config.{any-diagram-config}.defaultRenderer
// Although only a few diagram types like "flowchart" support "defaultRenderer",
// as long as there is no side effect, here do a general check for all properties of "config", for ease of maintenance
2026-02-08 12:21:11 +08:00
return configValueIsElk ( config . layout ) || Object . values ( config ) . some ( ( diagCfg ) = > configValueIsElk ( diagCfg ? . defaultRenderer ) ) ;
2026-02-07 03:22:57 +01:00
}
2026-02-08 12:21:11 +08:00
export function sourceNeedsElk ( source : string ) {
if ( isSourceTooLarge ( source ) ) return false ;
const configYaml = parseYamlInitConfig ( source ) , configJson = parseJsonInitConfig ( source ) ;
return configContainsElk ( configYaml ) || configContainsElk ( configJson ) ;
2026-02-07 03:22:57 +01:00
}
2026-02-08 12:21:11 +08:00
async function loadMermaid ( needElkRender : boolean ) {
2026-03-29 12:24:30 +02:00
const mermaidPromise = import ( 'mermaid' ) ;
const elkPromise = needElkRender ? import ( '@mermaid-js/layout-elk' ) : null ;
2026-02-07 03:22:57 +01:00
const results = await Promise . all ( [ mermaidPromise , elkPromise ] ) ;
return {
mermaid : results [ 0 ] . default ,
elkLayouts : results [ 1 ] ? . default ,
} ;
}
2026-04-19 23:48:33 +08:00
function initMermaidViewController ( viewController : Element , dragElement : SVGSVGElement ) {
2026-02-10 14:36:31 +08:00
let inited = false , isDragging = false ;
let currentScale = 1 , initLeft = 0 , lastLeft = 0 , lastTop = 0 , lastPageX = 0 , lastPageY = 0 ;
const resetView = ( ) = > {
currentScale = 1 ;
lastLeft = initLeft ;
lastTop = 0 ;
dragElement . style . left = ` ${ lastLeft } px ` ;
dragElement . style . top = ` ${ lastTop } px ` ;
dragElement . style . position = 'absolute' ;
dragElement . style . margin = '0' ;
} ;
const initAbsolutePosition = ( ) = > {
if ( inited ) return ;
// if we need to drag or zoom, use absolute position and get the current "left" from the "margin: auto" layout.
inited = true ;
2026-02-11 11:22:33 +08:00
const container = dragElement . parentElement ! ;
2026-02-10 14:36:31 +08:00
initLeft = container . getBoundingClientRect ( ) . width / 2 - dragElement . getBoundingClientRect ( ) . width / 2 ;
resetView ( ) ;
} ;
2026-02-11 11:22:33 +08:00
for ( const el of viewController . querySelectorAll ( '[data-control-action]' ) ) {
2026-02-10 14:36:31 +08:00
el . addEventListener ( 'click' , ( ) = > {
initAbsolutePosition ( ) ;
switch ( el . getAttribute ( 'data-control-action' ) ) {
case 'zoom-in' :
currentScale *= 1.2 ;
break ;
case 'zoom-out' :
currentScale /= 1.2 ;
break ;
case 'reset' :
resetView ( ) ;
break ;
}
dragElement . style . transform = ` scale( ${ currentScale } ) ` ;
} ) ;
}
dragElement . addEventListener ( 'mousedown' , ( e ) = > {
if ( e . button !== 0 || e . altKey || e . ctrlKey || e . metaKey || e . shiftKey ) return ; // only left mouse button can drag
const target = e . target as Element ;
2026-02-11 11:22:33 +08:00
// don't start the drag if the click is on an interactive element (e.g.: link, button) or text element
if ( target . closest ( 'div, p, a, span, button, input, text' ) ) return ;
2026-02-10 14:36:31 +08:00
initAbsolutePosition ( ) ;
isDragging = true ;
lastPageX = e . pageX ;
lastPageY = e . pageY ;
dragElement . style . cursor = 'grabbing' ;
} ) ;
dragElement . ownerDocument . addEventListener ( 'mousemove' , ( e ) = > {
if ( ! isDragging ) return ;
lastLeft = e . pageX - lastPageX + lastLeft ;
lastTop = e . pageY - lastPageY + lastTop ;
dragElement . style . left = ` ${ lastLeft } px ` ;
dragElement . style . top = ` ${ lastTop } px ` ;
lastPageX = e . pageX ;
lastPageY = e . pageY ;
} ) ;
dragElement . ownerDocument . addEventListener ( 'mouseup' , ( ) = > {
if ( ! isDragging ) return ;
isDragging = false ;
dragElement . style . removeProperty ( 'cursor' ) ;
} ) ;
}
2026-02-07 03:22:57 +01:00
let elkLayoutsRegistered = false ;
2025-03-04 03:49:15 +08:00
export async function initMarkupCodeMermaid ( elMarkup : HTMLElement ) : Promise < void > {
2025-04-05 11:56:48 +08:00
// .markup code.language-mermaid
2026-02-08 12:21:11 +08:00
const mermaidBlocks : Array < { source : string , parentContainer : HTMLElement } > = [ ] ;
const attrMermaidRendered = 'data-markup-mermaid-rendered' ;
let needElkRender = false ;
for ( const elCodeBlock of queryElems ( elMarkup , 'code.language-mermaid' ) ) {
const parentContainer = elCodeBlock . closest ( 'pre' ) ! ; // it must exist, if no, there must be a bug
if ( parentContainer . hasAttribute ( attrMermaidRendered ) ) continue ;
parentContainer . setAttribute ( attrMermaidRendered , 'true' ) ;
const source = elCodeBlock . textContent ? ? '' ;
needElkRender = needElkRender || sourceNeedsElk ( source ) ;
mermaidBlocks . push ( { source , parentContainer } ) ;
}
if ( ! mermaidBlocks . length ) return ;
2025-04-05 11:56:48 +08:00
2026-02-08 12:21:11 +08:00
const { mermaid , elkLayouts } = await loadMermaid ( needElkRender ) ;
2026-02-07 03:22:57 +01:00
if ( elkLayouts && ! elkLayoutsRegistered ) {
mermaid . registerLayoutLoaders ( elkLayouts ) ;
elkLayoutsRegistered = true ;
}
mermaid . initialize ( {
startOnLoad : false ,
2026-02-08 12:21:11 +08:00
theme : isDarkTheme ( ) ? 'dark' : 'neutral' , // TODO: maybe it should use "darkMode" to adopt more user-specified theme instead of just "dark" or "neutral"
2026-02-07 03:22:57 +01:00
securityLevel : 'strict' ,
suppressErrorRendering : true ,
} ) ;
2025-03-04 03:49:15 +08:00
2026-02-10 14:36:31 +08:00
const iframeStyleText = getIframeCss ( ) ;
const applyMermaidIframeHeight = ( iframe : HTMLIFrameElement , height : number ) = > {
if ( ! height ) return ;
// use a min-height to make sure the buttons won't overlap.
iframe . style . height = ` ${ Math . max ( height , 85 ) } px ` ;
} ;
2026-02-08 12:21:11 +08:00
// mermaid is a globally shared instance, its document also says "Multiple calls to this function will be enqueued to run serially."
// so here we just simply render the mermaid blocks one by one, no need to do "Promise.all" concurrently
for ( const block of mermaidBlocks ) {
const { source , parentContainer } = block ;
2026-02-07 03:22:57 +01:00
if ( isSourceTooLarge ( source ) ) {
2026-02-08 12:21:11 +08:00
displayError ( parentContainer , new Error ( ` Mermaid source of ${ source . length } characters exceeds the maximum allowed length of ${ mermaidMaxSourceCharacters } . ` ) ) ;
continue ;
2025-04-05 11:56:48 +08:00
}
try {
2026-02-08 12:21:11 +08:00
// render the mermaid diagram to svg text, and parse it to a DOM node
const { svg : svgText , bindFunctions } = await mermaid . render ( 'mermaid' , source , parentContainer ) ;
2026-04-19 23:48:33 +08:00
const svgNode = createElementFromHTML < SVGSVGElement > ( svgText ) ;
2025-04-05 11:56:48 +08:00
2026-02-11 11:22:33 +08:00
const viewControllerHtml = html `
<div class="view-controller auto-hide-control flex-text-block">
<button type="button" class="ui tiny compact icon button" data-control-action="zoom-in"> ${ htmlRaw ( svg ( 'octicon-zoom-in' , 12 ) ) } </button>
<button type="button" class="ui tiny compact icon button" data-control-action="reset"> ${ htmlRaw ( svg ( 'octicon-sync' , 12 ) ) } </button>
<button type="button" class="ui tiny compact icon button" data-control-action="zoom-out"> ${ htmlRaw ( svg ( 'octicon-zoom-out' , 12 ) ) } </button>
</div>
` ;
const viewController = createElementFromHTML ( viewControllerHtml ) ;
2026-02-10 14:36:31 +08:00
2026-02-08 12:21:11 +08:00
// create an iframe to sandbox the svg with styles, and set correct height by reading svg's viewBox height
2025-04-05 11:56:48 +08:00
const iframe = document . createElement ( 'iframe' ) ;
2026-02-08 12:21:11 +08:00
iframe . classList . add ( 'markup-content-iframe' , 'is-loading' ) ;
2026-02-10 14:36:31 +08:00
// the styles are not ready, so don't really render anything before the "load" event, to avoid flicker of unstyled content
iframe . srcdoc = html ` <html><head></head><body></body></html> ` ;
2025-04-05 11:56:48 +08:00
2026-02-08 12:21:11 +08:00
// although the "viewBox" is optional, mermaid's output should always have a correct viewBox with width and height
const iframeHeightFromViewBox = Math . ceil ( svgNode . viewBox ? . baseVal ? . height ? ? 0 ) ;
2026-02-10 14:36:31 +08:00
applyMermaidIframeHeight ( iframe , iframeHeightFromViewBox ) ;
2025-04-05 11:56:48 +08:00
2026-02-08 12:21:11 +08:00
// the iframe will be fully reloaded if its DOM context is changed (e.g.: moved in the DOM tree).
// to avoid unnecessary reloading, we should insert the iframe to its final position only once.
2025-04-05 11:56:48 +08:00
iframe . addEventListener ( 'load' , ( ) = > {
2026-02-10 14:36:31 +08:00
// same origin, so we can operate "iframe head/body" and all elements directly
const style = document . createElement ( 'style' ) ;
style . textContent = iframeStyleText ;
iframe . contentDocument ! . head . append ( style ) ;
2026-02-08 12:21:11 +08:00
const iframeBody = iframe . contentDocument ! . body ;
iframeBody . append ( svgNode ) ;
bindFunctions ? . ( iframeBody ) ; // follow "mermaid.render" doc, attach event handlers to the svg's container
// according to mermaid, the viewBox height should always exist, here just a fallback for unknown cases.
// and keep in mind: clientHeight can be 0 if the element is hidden (display: none).
2026-02-10 14:36:31 +08:00
if ( ! iframeHeightFromViewBox ) applyMermaidIframeHeight ( iframe , iframeBody . clientHeight ) ;
2026-02-08 12:21:11 +08:00
iframe . classList . remove ( 'is-loading' ) ;
2026-02-11 11:22:33 +08:00
initMermaidViewController ( viewController , svgNode ) ;
2025-04-05 11:56:48 +08:00
} ) ;
2026-02-11 11:22:33 +08:00
const container = createElementFromAttrs ( 'div' , { class : 'mermaid-block' } , iframe , viewController ) ;
2026-02-08 12:21:11 +08:00
parentContainer . replaceWith ( container ) ;
2025-04-05 11:56:48 +08:00
} catch ( err ) {
2026-02-08 12:21:11 +08:00
displayError ( parentContainer , err ) ;
2025-04-05 11:56:48 +08:00
}
2026-02-08 12:21:11 +08:00
}
2020-07-27 08:24:09 +02:00
}