fix: improve Mermaid diagram implementation (#1214)
* fix: improve Mermaid diagram implementation (#1213) This commit addresses multiple issues with Mermaid diagram rendering and adds significant improvements to the user experience. - Fix `<br/>` tags rendering as literal text instead of line breaks - Fix flash of raw mermaid code before diagram renders - Fix Gantt chart rendering issues in dark mode - Fix diagrams briefly showing during theme switch re-render - Fix expand modal showing diagram left-aligned before centering - Add fullscreen expand modal with pan/zoom functionality - Add toolbar with zoom controls (zoom in/out, reset, fit to screen) - Add keyboard support (Escape to close modal) - Add per-diagram transparent background via `%%transparent%%` directive - Add comprehensive configuration options: - `look`: classic or handDrawn (sketch style) - `lightTheme`/`darkTheme`: theme selection per mode - `lightThemeVariables`/`darkThemeVariables`: custom theme colors - `securityLevel`, `htmlLabels`, `maxTextSize`, `maxEdges` - `fontSize`, `fontFamily`, `curve`, `logLevel` - Switch from ESM to UMD bundle for faster initial load (~1s vs 2s+) - Lazy-load panzoom library only when expand modal is opened - Pre-render alternate theme during idle time for instant theme switching - Cache rendered diagrams to avoid re-rendering on theme toggle - Move all inline CSS to article.scss using SCSS nesting - Extract helper functions for better maintainability - Use data attributes and event delegation for cleaner handlers - Reduce mermaid.html from 411 to 177 lines (57% reduction) - Add comprehensive mermaid-diagrams example post with all diagram types - Document all configuration options in hugo.yaml * refactor: extract mermaid inline JS to TypeScript module Move ~155 lines of inline JavaScript from mermaid.html partial into assets/ts/mermaid.ts. The partial shrinks from 179 to 36 lines and now only contains modal HTML markup and Hugo Pipes build/import logic. - Deduplicate initWithTheme() and renderOffscreen() helpers - Enable minification via Hugo Pipes js.Build in production - Remove user-configurable mermaid version from params.toml --------- Co-authored-by: delize <4028612+delize@users.noreply.github.com>
This commit is contained in:
@@ -266,3 +266,167 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mermaid:not([data-processed]) {
|
||||
background: var(--card-background);
|
||||
min-height: 150px;
|
||||
border-radius: 8px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
font-size: 0 !important;
|
||||
line-height: 0;
|
||||
color: transparent;
|
||||
|
||||
&::after {
|
||||
visibility: visible;
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin: -14px 0 0 -14px;
|
||||
border: 3px solid var(--card-background);
|
||||
border-top-color: var(--accent-color, #3273dc);
|
||||
border-radius: 50%;
|
||||
animation: mermaid-spinner 0.8s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mermaid-spinner {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
// Mermaid diagram wrapper with expand toolbar
|
||||
.mermaid-wrapper {
|
||||
position: relative;
|
||||
margin: 1rem 0;
|
||||
|
||||
.mermaid {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mermaid-toolbar {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
right: 0.75rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
z-index: 10;
|
||||
|
||||
button {
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 2px solid #333;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #333;
|
||||
color: #fff;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mermaid fullscreen modal
|
||||
.mermaid-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.95);
|
||||
z-index: 9999;
|
||||
|
||||
&.active {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.mermaid-modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.mermaid-modal-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.mermaid-modal-controls button,
|
||||
.mermaid-modal-close {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
|
||||
&:hover {
|
||||
background: #ddd;
|
||||
}
|
||||
}
|
||||
|
||||
.mermaid-modal-body {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mermaid-modal-content {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
|
||||
[data-scheme="dark"] & {
|
||||
background: #1e1e1e;
|
||||
}
|
||||
}
|
||||
|
||||
.mermaid-panzoom-container {
|
||||
display: inline-block;
|
||||
transform-origin: 0 0;
|
||||
visibility: hidden;
|
||||
|
||||
&.ready {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
// Off-screen container for pre-rendering alternate theme
|
||||
.mermaid-offscreen {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
visibility: hidden;
|
||||
width: 800px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
declare const mermaid: {
|
||||
initialize(config: Record<string, any>): void;
|
||||
run(options: { nodes: HTMLElement[] }): Promise<void>;
|
||||
};
|
||||
|
||||
interface MermaidConfig {
|
||||
transparentBackground?: boolean;
|
||||
lightTheme?: string;
|
||||
darkTheme?: string;
|
||||
lightThemeVariables?: Record<string, any>;
|
||||
darkThemeVariables?: Record<string, any>;
|
||||
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<string, any> {
|
||||
const base: Record<string, any> = {
|
||||
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<Scheme, ReturnType<typeof buildThemeConfig>>,
|
||||
baseConfig: Record<string, any>,
|
||||
) {
|
||||
const { theme, themeVariables } = themes[scheme];
|
||||
mermaid.initialize({
|
||||
...baseConfig,
|
||||
theme,
|
||||
...(Object.keys(themeVariables).length && { themeVariables }),
|
||||
});
|
||||
}
|
||||
|
||||
async function renderOffscreen(sources: string[]): Promise<string[]> {
|
||||
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<HTMLElement>) {
|
||||
elements.forEach((el, idx) => {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'mermaid-wrapper';
|
||||
el.parentNode!.insertBefore(wrapper, el);
|
||||
wrapper.appendChild(el);
|
||||
wrapper.insertAdjacentHTML(
|
||||
'beforeend',
|
||||
`<div class="mermaid-toolbar"><button data-idx="${idx}" title="Open fullscreen with pan/zoom">⛶ Expand</button></div>`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function setupModal(elements: NodeListOf<HTMLElement>) {
|
||||
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<HTMLElement>;
|
||||
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<Scheme, string[]> = { 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); });
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user