declare const mermaid: { initialize(config: Record): void; run(options: { nodes: HTMLElement[] }): Promise; }; interface MermaidConfig { transparentBackground?: boolean; lightTheme?: string; darkTheme?: string; lightThemeVariables?: Record; darkThemeVariables?: Record; securityLevel?: string; look?: string; htmlLabels?: boolean; maxTextSize?: number; maxEdges?: number; fontSize?: number; fontFamily?: string; curve?: string; logLevel?: number; } type Scheme = 'light' | 'dark'; const PANZOOM_CDN = 'https://cdn.jsdelivr.net/npm/panzoom@9.4.3/+esm'; function getScheme(): Scheme { return document.documentElement.dataset.scheme === 'dark' ? 'dark' : 'light'; } function buildThemeConfig(cfg: MermaidConfig, scheme: Scheme) { const isLight = scheme === 'light'; const theme = isLight ? (cfg.lightTheme ?? 'default') : (cfg.darkTheme ?? 'dark'); const vars = isLight ? (cfg.lightThemeVariables ?? {}) : (cfg.darkThemeVariables ?? {}); return { theme, themeVariables: { ...vars, ...(cfg.transparentBackground ? { background: 'transparent' } : {}) }, }; } function buildBaseConfig(cfg: MermaidConfig): Record { const base: Record = { startOnLoad: false, securityLevel: cfg.securityLevel ?? 'strict', look: cfg.look ?? 'classic', flowchart: { htmlLabels: cfg.htmlLabels ?? true, useMaxWidth: true }, gantt: { useWidth: 800 }, }; const optional: (keyof MermaidConfig)[] = ['maxTextSize', 'maxEdges', 'fontSize', 'fontFamily', 'curve', 'logLevel']; for (const key of optional) { if (cfg[key] != null) base[key] = cfg[key]; } return base; } function initWithTheme( scheme: Scheme, themes: Record>, baseConfig: Record, ) { const { theme, themeVariables } = themes[scheme]; mermaid.initialize({ ...baseConfig, theme, ...(Object.keys(themeVariables).length && { themeVariables }), }); } async function renderOffscreen(sources: string[]): Promise { const container = document.createElement('div'); container.className = 'mermaid-offscreen'; document.body.appendChild(container); const nodes = sources.map(src => { const n = document.createElement('pre'); n.innerHTML = src; container.appendChild(n); return n; }); await mermaid.run({ nodes }); const results = nodes.map(n => n.innerHTML); container.remove(); return results; } function setupWrappers(elements: NodeListOf) { elements.forEach((el, idx) => { const wrapper = document.createElement('div'); wrapper.className = 'mermaid-wrapper'; el.parentNode!.insertBefore(wrapper, el); wrapper.appendChild(el); wrapper.insertAdjacentHTML( 'beforeend', `
`, ); }); } function setupModal(elements: NodeListOf) { const modal = document.getElementById('mermaid-modal')!; const modalBody = document.getElementById('mermaid-modal-body')!; const modalContent = document.getElementById('mermaid-modal-content')!; let pzInstance: any = null; let panzoom: any = null; const loadPanzoom = async () => { if (!panzoom) { const url = PANZOOM_CDN; panzoom = (await import(url)).default; } return panzoom; }; const fitToScreen = () => { const wrapper = modalContent.querySelector('.mermaid-panzoom-container') as HTMLElement | null; if (!pzInstance || !wrapper) return; const w = +(wrapper.dataset.nativeWidth ?? 0); const h = +(wrapper.dataset.nativeHeight ?? 0); const rect = modalContent.getBoundingClientRect(); const scale = Math.min((rect.width - 60) / w, (rect.height - 60) / h); pzInstance.zoomAbs(0, 0, scale); pzInstance.moveTo((rect.width - w * scale) / 2, (rect.height - h * scale) / 2); }; const closeModal = () => { modal.classList.remove('active'); document.body.style.overflow = ''; pzInstance?.dispose(); pzInstance = null; modalContent.innerHTML = ''; }; const openModal = async (idx: number) => { const svg = elements[idx].querySelector('svg'); if (!svg) return; const svgClone = svg.cloneNode(true) as SVGElement; const viewBox = svg.getAttribute('viewBox'); const [w, h] = viewBox ? viewBox.split(/[\s,]+/).slice(2).map(Number) : [svg.getBoundingClientRect().width || 800, svg.getBoundingClientRect().height || 600]; svgClone.setAttribute('width', String(w)); svgClone.setAttribute('height', String(h)); const wrapper = document.createElement('div'); wrapper.className = 'mermaid-panzoom-container'; wrapper.dataset.nativeWidth = String(w); wrapper.dataset.nativeHeight = String(h); wrapper.appendChild(svgClone); modalContent.innerHTML = ''; modalContent.appendChild(wrapper); modal.classList.add('active'); document.body.style.overflow = 'hidden'; const pz = await loadPanzoom(); setTimeout(() => { pzInstance = pz(wrapper, { maxZoom: 10, minZoom: 0.05, bounds: false }); fitToScreen(); wrapper.classList.add('ready'); }, 50); }; // Event delegation document.addEventListener('click', (e) => { const target = e.target as HTMLElement; const toolbarBtn = target.closest('.mermaid-toolbar button') as HTMLElement | null; if (toolbarBtn) return openModal(+(toolbarBtn.dataset.idx!)); const zoomBtn = target.closest('.mermaid-modal-controls button') as HTMLElement | null; if (zoomBtn && pzInstance) { const z = zoomBtn.dataset.zoom; const rect = modalBody.getBoundingClientRect(); if (z === 'fit') fitToScreen(); else if (z === '0') { pzInstance.moveTo(0, 0); pzInstance.zoomAbs(0, 0, 1); } else pzInstance.smoothZoom(rect.width / 2, rect.height / 2, z === '1' ? 1.5 : 0.67); } }); document.getElementById('mermaid-modal-close')!.addEventListener('click', closeModal); modalBody.addEventListener('click', (e) => { if (e.target === modalBody) closeModal(); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && modal.classList.contains('active')) closeModal(); }); } export async function initMermaidPage(config: MermaidConfig) { const elements = document.querySelectorAll('.mermaid') as NodeListOf; if (!elements.length) return; const sources = Array.from(elements).map(el => el.innerHTML); const perDiagramTransparent = sources.map(src => /%%\s*transparent\s*%%/i.test(src)); const cache: Record = { light: [], dark: [] }; const themes = { light: buildThemeConfig(config, 'light'), dark: buildThemeConfig(config, 'dark'), }; const baseConfig = buildBaseConfig(config); const applyTransparency = (el: HTMLElement, i: number) => { if (perDiagramTransparent[i]) el.querySelector('svg')?.style.setProperty('background', 'transparent'); }; setupWrappers(elements); setupModal(elements); // Initial render const scheme = getScheme(); initWithTheme(scheme, themes, baseConfig); await mermaid.run({ nodes: Array.from(elements) }); elements.forEach((el, i) => { el.style.visibility = ''; cache[scheme][i] = el.innerHTML; applyTransparency(el, i); }); // Pre-render alternate theme during idle time const alt: Scheme = scheme === 'dark' ? 'light' : 'dark'; const idle = window.requestIdleCallback ?? ((fn: IdleRequestCallback) => setTimeout(fn, 1000)); idle(() => { if (cache[alt].length) return; initWithTheme(alt, themes, baseConfig); renderOffscreen(sources).then(results => { cache[alt] = results; }); }); // Swap cached diagrams on theme change window.addEventListener('onColorSchemeChange', async () => { const newScheme = getScheme(); if (!cache[newScheme].length) { initWithTheme(newScheme, themes, baseConfig); cache[newScheme] = await renderOffscreen(sources); } elements.forEach((el, i) => { el.innerHTML = cache[newScheme][i]; applyTransparency(el, i); }); }); }