import { codeBlockConfig } from '@milkdown/kit/component/code-block' import mermaid from 'mermaid' let mermaidReadyTheme = '' let diagramCounter = 0 let renderCounter = 0 type MermaidImagePayload = { previewUrl: string downloadUrl: string | null width: number height: number sourceWidth: number sourceHeight: number filename: string } function getMermaidTheme() { const rootTheme = document.documentElement.getAttribute('data-theme') return rootTheme === 'dark' ? 'dark' : 'default' } function ensureMermaid() { const theme = getMermaidTheme() if (mermaidReadyTheme === theme) return const dark = theme === 'dark' mermaid.initialize({ startOnLoad: false, theme: dark ? 'dark' : 'base', securityLevel: 'loose', fontFamily: 'inherit', flowchart: { htmlLabels: false }, themeVariables: dark ? { primaryColor: '#1e2d45', primaryTextColor: '#c9d6e8', primaryBorderColor: '#3b5278', lineColor: '#5a7aa8', secondaryColor: '#162236', tertiaryColor: '#0f1926', edgeLabelBackground: '#1a2a40', clusterBkg: '#111e2e', titleColor: '#c9d6e8', nodeBorder: '#3b5278', mainBkg: '#1e2d45', } : { primaryColor: '#e8f0fe', primaryTextColor: '#1e3a5f', primaryBorderColor: '#93b4d9', lineColor: '#4a7cb5', secondaryColor: '#dbeafe', tertiaryColor: '#f0f7ff', edgeLabelBackground: '#f0f7ff', clusterBkg: '#f5f8ff', titleColor: '#1e3a5f', nodeBorder: '#93b4d9', mainBkg: '#e8f0fe', }, }) mermaidReadyTheme = theme } function encodeMermaidCode(code: string) { return encodeURIComponent(code) } function decodeMermaidCode(code: string) { try { return decodeURIComponent(code) } catch { return code } } function escapeHtml(value: string) { return value .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') } function nowFileStamp() { const now = new Date() const pad = (n: number) => String(n).padStart(2, '0') return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}` } function makeMermaidFilename() { return `mermaid-${nowFileStamp()}.png` } function buildMermaidPreviewMarkup(code: string, token: number) { // 剥离首尾的 ```mermaid 或 ``` 标识符,防止其被误认为图表节点 const cleanCode = code .replace(/^```[a-z]*\s*\n?/i, '') .replace(/\n?```\s*$/i, '') .trim() const encoded = encodeMermaidCode(cleanCode) const filename = makeMermaidFilename() const zoomSvg = `` const dlSvg = `` return `
`.trim() } function setMermaidActionsState(block: HTMLElement, payload: MermaidImagePayload | null) { const actionNodes = block.querySelectorAll('[data-mermaid-action]') actionNodes.forEach((node) => { const action = node.getAttribute('data-mermaid-action') if (action === 'zoom') { if (payload) { node.removeAttribute('disabled') node.removeAttribute('title') node.setAttribute('data-mermaid-url', payload.previewUrl) } else { node.setAttribute('disabled', 'true') node.removeAttribute('title') node.removeAttribute('data-mermaid-url') } return } if (action === 'download') { if (payload) { node.removeAttribute('disabled') node.setAttribute('data-mermaid-url', payload.downloadUrl || payload.previewUrl) node.setAttribute('data-mermaid-filename', payload.filename) if (payload.downloadUrl) { node.removeAttribute('title') } else { node.setAttribute('title', 'PNG will be generated on download') } } else { node.setAttribute('disabled', 'true') node.removeAttribute('data-mermaid-url') node.removeAttribute('title') } return } if (payload) { node.removeAttribute('disabled') } else { node.setAttribute('disabled', 'true') node.removeAttribute('data-mermaid-url') } }) } function getSvgSize(svg: string) { // 优先读取 viewBox (取第三、四个值作为宽高) const viewBox = svg.match(/viewBox\s*=\s*["']\s*[-\d.]+\s+[-\d.]+\s+([-\d.]+)\s+([-\d.]+)\s*["']/i) if (viewBox) { const width = Number(viewBox[1]) const height = Number(viewBox[2]) if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) { return { width, height } } } // 次选读取数字像素属性 const widthAttr = svg.match(/\bwidth\s*=\s*["']([\d.]+)(?:px)?["']/i) const heightAttr = svg.match(/\bheight\s*=\s*["']([\d.]+)(?:px)?["']/i) const attrW = widthAttr ? Number(widthAttr[1]) : 0 const attrH = heightAttr ? Number(heightAttr[1]) : 0 if (attrW > 0 && attrH > 0) { return { width: attrW, height: attrH } } return { width: 960, height: 540 } } function svgToDataUrl(svg: string) { return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}` } function stripExternalSvgResources(svg: string) { return svg .replace(/]*(?:href|xlink:href)\s*=\s*["']https?:\/\/[^"']*["'][^>]*>/gi, '') .replace(/url\(\s*["']?https?:\/\/[^"')]*["']?\s*\)/gi, 'none') } /** 给 SVG viewBox 四周加 padding,同步更新 width/height 并注入字体样式防止截断 */ function padSvgViewBox(svg: string, pad = 48): string { let result = svg // 注入显式字体样式,确保测量与渲染一致 const styleInject = ` ` if (result.includes('')) { result = result.replace('', ` svg { font-family: 'Inter', system-ui, sans-serif !important; }\n .node text, .edgeLabel text { font-family: 'Inter', system-ui, sans-serif !important; }\n`) } else { result = result.replace(/>/, `>${styleInject}`) } // 匹配 viewBox const vbMatch = result.match(/viewBox\s*=\s*["']\s*([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s*["']/i) if (vbMatch) { const x = parseFloat(vbMatch[1]) - pad const y = parseFloat(vbMatch[2]) - pad const w = parseFloat(vbMatch[3]) + pad * 2 const h = parseFloat(vbMatch[4]) + pad * 2 result = result.replace(vbMatch[0], `viewBox="${x} ${y} ${w} ${h}"`) // 覆盖外层 width/height 为实际像素值 result = result.replace(/\bwidth\s*=\s*["'][^"']*["']/i, `width="${w}"`) result = result.replace(/\bheight\s*=\s*["'][^"']*["']/i, `height="${h}"`) } return result } function getRasterDpr(width: number, height: number) { const rawDpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1 const baseDpr = Math.min(3, Math.max(1, rawDpr)) const maxCanvasEdge = 4096 const edgeLimitedDpr = maxCanvasEdge / Math.max(width, height, 1) return Math.max(1, Math.min(baseDpr, edgeLimitedDpr)) } async function rasterizeSvgToPngDataUrl(svg: string, width: number, height: number): Promise { const image = new Image() image.decoding = 'async' image.crossOrigin = 'anonymous' await new Promise((resolve, reject) => { image.onload = () => resolve() image.onerror = () => reject(new Error('Failed to load rendered SVG')) image.src = svgToDataUrl(svg) }) const dpr = getRasterDpr(width, height) const canvas = document.createElement('canvas') canvas.width = Math.max(1, Math.round(width * dpr)) canvas.height = Math.max(1, Math.round(height * dpr)) const ctx = canvas.getContext('2d') if (!ctx) { throw new Error('Canvas context unavailable') } ctx.setTransform(dpr, 0, 0, dpr, 0, 0) ctx.imageSmoothingEnabled = true ctx.imageSmoothingQuality = 'high' ctx.clearRect(0, 0, width, height) ctx.drawImage(image, 0, 0, width, height) return canvas.toDataURL('image/png') } async function svgToImageDataUrl(svg: string): Promise { const paddedSvg = padSvgViewBox(svg) const fallback = getSvgSize(paddedSvg) const sourceWidth = fallback.width const sourceHeight = fallback.height let width = sourceWidth let height = sourceHeight const maxEdge = 2400 const scale = Math.min(1, maxEdge / Math.max(width, height)) width = Math.max(1, Math.round(width * scale)) height = Math.max(1, Math.round(height * scale)) const normalizedSvg = stripExternalSvgResources(paddedSvg) const candidates = normalizedSvg === paddedSvg ? [paddedSvg] : [paddedSvg, normalizedSvg] let lastError: unknown = null for (const candidate of candidates) { try { const pngUrl = await rasterizeSvgToPngDataUrl(candidate, width, height) return { previewUrl: pngUrl, downloadUrl: pngUrl, width, height, sourceWidth, sourceHeight, filename: makeMermaidFilename(), } } catch (err) { lastError = err } } const message = lastError instanceof Error ? lastError.message.toLowerCase() : String(lastError).toLowerCase() if (message.includes('tainted') || message.includes('security')) { return { previewUrl: svgToDataUrl(svg), downloadUrl: null, width, height, sourceWidth, sourceHeight, filename: makeMermaidFilename(), } } throw lastError instanceof Error ? lastError : new Error('Failed to convert Mermaid diagram to PNG') } function getViewportWidth() { const docWidth = document.documentElement?.clientWidth ?? 0 const bodyWidth = document.body?.clientWidth ?? 0 const winWidth = window.innerWidth ?? 0 return Math.max(docWidth, bodyWidth, winWidth, 1) } function getDisplayWidthPx(payload: MermaidImagePayload) { const threshold = Math.max(1, Math.floor(getViewportWidth() * 0.8)) if (payload.sourceWidth > threshold) { return Math.min(payload.width, threshold) } return Math.min(payload.width, payload.sourceWidth) } async function renderMermaidBlock(block: HTMLElement, token: number): Promise { const tokenOnBlock = Number(block.getAttribute('data-mermaid-token') || '0') if (tokenOnBlock !== token) return const inner = block.querySelector('.mermaid-inner') if (!(inner instanceof HTMLElement)) return const encodedCode = block.getAttribute('data-mermaid-code') || '' const code = decodeMermaidCode(encodedCode).trim() || 'graph TD\nA-->B' inner.innerHTML = '
' setMermaidActionsState(block, null) try { ensureMermaid() const id = `mermaid-render-${++diagramCounter}` const { svg } = await mermaid.render(id, code) const imagePayload = await svgToImageDataUrl(svg) const latestToken = Number(block.getAttribute('data-mermaid-token') || '0') if (latestToken !== token) return const displayWidth = Math.max(1, Math.round(getDisplayWidthPx(imagePayload))) const displayHeight = Math.max(1, Math.round((imagePayload.height / Math.max(1, imagePayload.width)) * displayWidth)) inner.innerHTML = `Mermaid diagram` block.setAttribute('data-mermaid-width', String(imagePayload.width)) block.setAttribute('data-mermaid-height', String(imagePayload.height)) block.setAttribute('data-mermaid-source-width', String(imagePayload.sourceWidth)) block.setAttribute('data-mermaid-source-height', String(imagePayload.sourceHeight)) block.setAttribute('data-mermaid-display-width', String(displayWidth)) block.setAttribute('data-mermaid-url', imagePayload.previewUrl) block.style.removeProperty('width') block.style.removeProperty('max-width') setMermaidActionsState(block, imagePayload) } catch (err) { const latestToken = Number(block.getAttribute('data-mermaid-token') || '0') if (latestToken !== token) return const message = err instanceof Error ? err.message : String(err) inner.innerHTML = `
Mermaid error:\n${escapeHtml(message)}
` setMermaidActionsState(block, null) } } function scheduleMermaidRender(token: number) { const maxAttempts = 24 const targetSelector = `.mermaid-block[data-mermaid-token="${token}"]` const run = (attempt: number) => { const block = document.querySelector(targetSelector) if (block instanceof HTMLElement) { void renderMermaidBlock(block, token) return } if (attempt >= maxAttempts) return if (typeof window.requestAnimationFrame === 'function') { window.requestAnimationFrame(() => run(attempt + 1)) } else { window.setTimeout(() => run(attempt + 1), 16) } } run(0) } export function mermaidRenderPreview( language: string, content: string, applyPreview: (value: null | string | HTMLElement) => void, ): void | null { if (language !== 'mermaid') { return null } const code = content.trim() || 'graph TD\nA-->B' const token = ++renderCounter applyPreview(buildMermaidPreviewMarkup(code, token)) scheduleMermaidRender(token) } export function refreshMermaidPreviews() { const blocks = document.querySelectorAll('.mermaid-block[data-mermaid-code]') blocks.forEach((block) => { const token = ++renderCounter block.setAttribute('data-mermaid-token', String(token)) void renderMermaidBlock(block, token) }) } export { codeBlockConfig }