blog/themes/stack/assets/ts/mermaid.ts
2026-07-31 15:38:57 +08:00

236 lines
8.5 KiB
TypeScript

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); });
});
}