// ========================================== // Mermaid Diagram Renderer — Browser Extension // ========================================== // Renders ```mermaid code blocks as interactive SVG diagrams. // Features: viewBox-based zoom/pan, fullscreen expand, // SVG/PNG export, source copy. // Loads mermaid.js dynamically on first use. // // Registers with sw.renderers via the sw:ready event. // ========================================== (function () { 'use strict'; let _mermaidReady = false; let _mermaidLoading = null; function _isDark() { return document.documentElement.dataset.theme === 'dark' || document.documentElement.classList.contains('dark'); } function _escapeHtml(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } // ── ViewBox Zoom/Pan State ────────────── function _getState(id) { const vp = document.querySelector(`[data-viewport="${id}"]`); if (!vp) return null; if (!vp._mmdState) { vp._mmdState = { natX: 0, natY: 0, natW: 0, natH: 0, vbX: 0, vbY: 0, vbW: 0, vbH: 0, dragging: false, startX: 0, startY: 0, startVbX: 0, startVbY: 0, ready: false, }; } return vp._mmdState; } function _initState(id) { const state = _getState(id); if (!state) return; const svg = document.querySelector(`[data-diagram="${id}"] svg`); if (!svg) return; const vb = svg.getAttribute('viewBox'); if (!vb) { const bbox = svg.getBBox(); state.natX = bbox.x; state.natY = bbox.y; state.natW = bbox.width; state.natH = bbox.height; } else { const parts = vb.trim().split(/[\s,]+/).map(Number); state.natX = parts[0] || 0; state.natY = parts[1] || 0; state.natW = parts[2] || 0; state.natH = parts[3] || 0; } state.vbX = state.natX; state.vbY = state.natY; state.vbW = state.natW; state.vbH = state.natH; state.ready = true; _applyViewBox(id); } function _applyViewBox(id) { const state = _getState(id); if (!state || !state.ready) return; const svg = document.querySelector(`[data-diagram="${id}"] svg`); if (!svg) return; svg.setAttribute('viewBox', `${state.vbX} ${state.vbY} ${state.vbW} ${state.vbH}`); const zoomPct = Math.round((state.natW / state.vbW) * 100); const label = document.querySelector(`[data-zoom-label="${id}"]`); if (label) label.textContent = zoomPct + '%'; } function _zoom(id, factor) { const state = _getState(id); if (!state || !state.ready) return; const scale = 1 / (1 + factor); const newW = state.vbW * scale; const newH = state.vbH * scale; if (newW < state.natW / 20 || newW > state.natW * 2) return; const cx = state.vbX + state.vbW / 2; const cy = state.vbY + state.vbH / 2; state.vbW = newW; state.vbH = newH; state.vbX = cx - newW / 2; state.vbY = cy - newH / 2; _applyViewBox(id); } function _zoomAt(id, factor, clientX, clientY) { const state = _getState(id); if (!state || !state.ready) return; const svg = document.querySelector(`[data-diagram="${id}"] svg`); if (!svg) return; const svgRect = svg.getBoundingClientRect(); if (svgRect.width === 0 || svgRect.height === 0) return; const fx = (clientX - svgRect.left) / svgRect.width; const fy = (clientY - svgRect.top) / svgRect.height; const pointX = state.vbX + fx * state.vbW; const pointY = state.vbY + fy * state.vbH; const scale = 1 / (1 + factor); const newW = state.vbW * scale; const newH = state.vbH * scale; if (newW < state.natW / 20 || newW > state.natW * 2) return; state.vbW = newW; state.vbH = newH; state.vbX = pointX - fx * newW; state.vbY = pointY - fy * newH; _applyViewBox(id); } function _zoomReset(id) { const state = _getState(id); if (!state || !state.ready) return; state.vbX = state.natX; state.vbY = state.natY; state.vbW = state.natW; state.vbH = state.natH; _applyViewBox(id); } function _zoomFit(id) { const state = _getState(id); if (!state || !state.ready) return; const vp = document.querySelector(`[data-viewport="${id}"]`); if (!vp) return; const vpRect = vp.getBoundingClientRect(); if (vpRect.width === 0 || vpRect.height === 0) return; const vpAspect = vpRect.width / vpRect.height; const natAspect = state.natW / state.natH; if (vpAspect > natAspect) { const newW = state.natH * vpAspect; state.vbX = state.natX - (newW - state.natW) / 2; state.vbY = state.natY; state.vbW = newW; state.vbH = state.natH; } else { const newH = state.natW / vpAspect; state.vbX = state.natX; state.vbY = state.natY - (newH - state.natH) / 2; state.vbW = state.natW; state.vbH = newH; } _applyViewBox(id); } // ── Fullscreen ────────────────────────── function _toggleFullscreen(id) { const block = document.querySelector(`[data-mermaid-id="${id}"]`); if (!block) return; const isFS = block.classList.toggle('mermaid-fullscreen'); if (isFS) { document.body.style.overflow = 'hidden'; const closeBtn = document.createElement('button'); closeBtn.className = 'mmd-fullscreen-close'; closeBtn.innerHTML = '\u2715'; closeBtn.title = 'Close fullscreen'; closeBtn.addEventListener('click', () => _toggleFullscreen(id)); block.appendChild(closeBtn); block._mmdEscHandler = (e) => { if (e.key === 'Escape') _toggleFullscreen(id); }; document.addEventListener('keydown', block._mmdEscHandler); requestAnimationFrame(() => _zoomFit(id)); } else { document.body.style.overflow = ''; const closeBtn = block.querySelector('.mmd-fullscreen-close'); if (closeBtn) closeBtn.remove(); if (block._mmdEscHandler) { document.removeEventListener('keydown', block._mmdEscHandler); delete block._mmdEscHandler; } } } // ── Toolbar Wiring ────────────────────── function _wireToolbar(container) { if (container._mmdWired) return; container._mmdWired = true; container.addEventListener('click', (e) => { const btn = e.target.closest('[data-action]'); if (!btn) return; const action = btn.dataset.action; const id = btn.dataset.target; if (!id) return; switch (action) { case 'zoom-in': _zoom(id, 0.25); break; case 'zoom-out': _zoom(id, -0.2); break; case 'zoom-reset': _zoomReset(id); break; case 'zoom-fit': _zoomFit(id); break; case 'expand': _toggleFullscreen(id); break; case 'export-svg': _exportSVG(id); break; case 'export-png': _exportPNG(id); break; case 'copy-src': _copySource(id); break; } }); // Wire each viewport for mouse/touch interaction container.querySelectorAll('.mermaid-viewport').forEach(vp => { if (vp._mmdWired) return; vp._mmdWired = true; const id = vp.dataset.viewport; vp.addEventListener('wheel', (e) => { e.preventDefault(); const delta = e.deltaY > 0 ? -0.1 : 0.1; _zoomAt(id, delta, e.clientX, e.clientY); }, { passive: false }); vp.addEventListener('mousedown', (e) => { if (e.button !== 0) return; const state = _getState(id); if (!state || !state.ready) return; state.dragging = true; state.startX = e.clientX; state.startY = e.clientY; state.startVbX = state.vbX; state.startVbY = state.vbY; vp.classList.add('mmd-grabbing'); e.preventDefault(); }); vp.addEventListener('mousemove', (e) => { const state = _getState(id); if (!state?.dragging) return; const svg = document.querySelector(`[data-diagram="${id}"] svg`); if (!svg) return; const svgRect = svg.getBoundingClientRect(); if (svgRect.width === 0) return; const pxToVb = state.vbW / svgRect.width; state.vbX = state.startVbX - (e.clientX - state.startX) * pxToVb; state.vbY = state.startVbY - (e.clientY - state.startY) * pxToVb; _applyViewBox(id); }); const endDrag = () => { const state = _getState(id); if (!state) return; state.dragging = false; vp.classList.remove('mmd-grabbing'); }; vp.addEventListener('mouseup', endDrag); vp.addEventListener('mouseleave', endDrag); let lastTouchDist = 0; vp.addEventListener('touchstart', (e) => { const state = _getState(id); if (!state || !state.ready) return; if (e.touches.length === 1) { state.dragging = true; state.startX = e.touches[0].clientX; state.startY = e.touches[0].clientY; state.startVbX = state.vbX; state.startVbY = state.vbY; } else if (e.touches.length === 2) { lastTouchDist = Math.hypot( e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY ); } }, { passive: true }); vp.addEventListener('touchmove', (e) => { const state = _getState(id); if (!state || !state.ready) return; if (e.touches.length === 1 && state.dragging) { const svg = document.querySelector(`[data-diagram="${id}"] svg`); if (!svg) return; const svgRect = svg.getBoundingClientRect(); if (svgRect.width === 0) return; const pxToVb = state.vbW / svgRect.width; state.vbX = state.startVbX - (e.touches[0].clientX - state.startX) * pxToVb; state.vbY = state.startVbY - (e.touches[0].clientY - state.startY) * pxToVb; _applyViewBox(id); e.preventDefault(); } else if (e.touches.length === 2) { const dist = Math.hypot( e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY ); const center = { x: (e.touches[0].clientX + e.touches[1].clientX) / 2, y: (e.touches[0].clientY + e.touches[1].clientY) / 2, }; if (lastTouchDist > 0) { const factor = (dist - lastTouchDist) / lastTouchDist; _zoomAt(id, factor, center.x, center.y); } lastTouchDist = dist; e.preventDefault(); } }, { passive: false }); vp.addEventListener('touchend', () => { const state = _getState(id); if (state) state.dragging = false; lastTouchDist = 0; }, { passive: true }); }); } // ── Export ─────────────────────────────── function _exportSVG(id) { const diagram = document.querySelector(`[data-diagram="${id}"]`); const svg = diagram?.querySelector('svg'); if (!svg) return; const state = _getState(id); const clone = svg.cloneNode(true); clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); if (state?.ready) { clone.setAttribute('viewBox', `${state.natX} ${state.natY} ${state.natW} ${state.natH}`); } const blob = new Blob([clone.outerHTML], { type: 'image/svg+xml' }); _download(blob, `diagram-${id}.svg`); } function _exportPNG(id) { const diagram = document.querySelector(`[data-diagram="${id}"]`); const svg = diagram?.querySelector('svg'); if (!svg) return; const state = _getState(id); const clone = svg.cloneNode(true); clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); if (state?.ready) { clone.setAttribute('viewBox', `${state.natX} ${state.natY} ${state.natW} ${state.natH}`); clone.setAttribute('width', state.natW); clone.setAttribute('height', state.natH); } const svgData = new XMLSerializer().serializeToString(clone); const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' }); const url = URL.createObjectURL(svgBlob); const img = new Image(); img.onload = () => { const scale = 2; const canvas = document.createElement('canvas'); canvas.width = img.naturalWidth * scale; canvas.height = img.naturalHeight * scale; const ctx = canvas.getContext('2d'); ctx.scale(scale, scale); ctx.drawImage(img, 0, 0); URL.revokeObjectURL(url); canvas.toBlob((blob) => { if (blob) _download(blob, `diagram-${id}.png`); }, 'image/png'); }; img.onerror = () => { URL.revokeObjectURL(url); console.error('[Mermaid] PNG export failed'); }; img.src = url; } function _download(blob, filename) { const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(a.href), 1000); } function _copySource(id) { const code = document.querySelector(`[data-source="${id}"]`); if (!code) return; navigator.clipboard.writeText(code.textContent).then(() => { if (window.sw?.toast) sw.toast('Source copied', 'success'); }); } // ── Diagram Rendering ─────────────────── async function _renderDiagram(el) { const code = decodeURIComponent(el.dataset.mermaidSrc); const id = el.dataset.diagram; try { await _loadMermaid(); const svgId = 'mmd-svg-' + Math.random().toString(36).slice(2, 9); const { svg } = await mermaid.render(svgId, code); el.innerHTML = svg; el.dataset.rendered = 'true'; const svgEl = el.querySelector('svg'); if (svgEl) { svgEl.removeAttribute('height'); svgEl.setAttribute('width', '100%'); svgEl.style.maxWidth = 'none'; svgEl.setAttribute('preserveAspectRatio', 'xMidYMid meet'); } _initState(id); } catch (e) { el.innerHTML = `
Diagram error: ${_escapeHtml(e.message || String(e))}
`; el.dataset.rendered = 'error'; } } // ── Mermaid Library Loading ────────────── function _loadMermaid() { if (_mermaidReady) return Promise.resolve(); if (_mermaidLoading) return _mermaidLoading; _mermaidLoading = new Promise((resolve, reject) => { if (typeof mermaid !== 'undefined') { _initMermaid(); _mermaidReady = true; resolve(); return; } const base = (window.__BASE__ || ''); const localSrc = `${base}/vendor/mermaid/mermaid.min.js`; const cdnSrc = 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js'; const script = document.createElement('script'); script.src = localSrc; script.onload = () => { _initMermaid(); _mermaidReady = true; resolve(); }; script.onerror = () => { console.warn('[Mermaid] Local vendor not found, trying CDN'); const cdn = document.createElement('script'); cdn.src = cdnSrc; cdn.onload = () => { _initMermaid(); _mermaidReady = true; resolve(); }; cdn.onerror = () => { console.error('[Mermaid] Failed to load from both local and CDN'); reject(new Error('Failed to load mermaid.js')); }; document.head.appendChild(cdn); }; document.head.appendChild(script); }); return _mermaidLoading; } function _initMermaid() { if (typeof mermaid === 'undefined') return; mermaid.initialize({ startOnLoad: false, theme: _isDark() ? 'dark' : 'default', securityLevel: 'strict', fontFamily: 'inherit', logLevel: 'error', }); } // ── Styles ────────────────────────────── function _injectStyles() { if (document.getElementById('ext-style-mermaid-renderer')) return; const style = document.createElement('style'); style.id = 'ext-style-mermaid-renderer'; style.textContent = ` .mermaid-block { background: var(--bg-2); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; margin: 12px 0; } .mermaid-block.mermaid-fullscreen { position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 10000; border-radius: 0; margin: 0; display: flex; flex-direction: column; background: var(--bg-1, var(--bg-2, #1a1a2e)); } .mermaid-block.mermaid-fullscreen .mermaid-viewport { background: var(--bg-2, #1a1a2e); flex: 1; max-height: none; } .mermaid-block.mermaid-fullscreen .mermaid-toolbar { border-bottom: 1px solid var(--border); padding: 6px 14px; } .mmd-fullscreen-close { position: absolute; top: 12px; right: 12px; z-index: 10001; width: 40px; height: 40px; border-radius: 50%; background: var(--bg-raised, rgba(0,0,0,0.6)); border: 1px solid var(--border, rgba(255,255,255,0.2)); color: var(--text, #fff); font-size: 20px; line-height: 1; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: background 0.15s, transform 0.15s; box-shadow: 0 2px 8px rgba(0,0,0,0.3); } .mmd-fullscreen-close:hover { background: var(--bg-hover, rgba(255,255,255,0.15)); transform: scale(1.1); } .mermaid-toolbar { display: flex; align-items: center; gap: 6px; padding: 4px 10px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-3); flex-wrap: wrap; } .mermaid-title { font-weight: 600; color: var(--text-2); margin-right: auto; } .mermaid-zoom-label { font-family: var(--mono); font-size: 11px; min-width: 36px; text-align: center; color: var(--text-3); } .mermaid-toolbar-btns { display: flex; gap: 2px; align-items: center; } .mmd-btn { background: none; border: 1px solid var(--border); border-radius: 4px; padding: 1px 7px; font-size: 11px; cursor: pointer; color: var(--text-3); font-family: var(--mono); line-height: 1.6; } .mmd-btn:hover { color: var(--text-1); border-color: var(--text-3); background: var(--bg-3); } .mmd-sep { width: 1px; height: 16px; background: var(--border); margin: 0 4px; } .mermaid-viewport { overflow: hidden; position: relative; min-height: 80px; max-height: 600px; cursor: grab; user-select: none; } .mermaid-viewport.mmd-grabbing { cursor: grabbing; } .mermaid-diagram { width: 100%; height: 100%; min-height: 60px; } .mermaid-diagram svg { display: block; width: 100%; height: 100%; } .mermaid-loading { color: var(--text-3); font-size: 13px; padding: 20px; display: flex; align-items: center; justify-content: center; gap: 8px; } .mermaid-spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: mmd-spin 0.8s linear infinite; } @keyframes mmd-spin { to { transform: rotate(360deg); } } .mermaid-error { color: var(--error, #e74c3c); background: var(--bg-3, rgba(231,76,60,0.1)); padding: 12px 16px; border-radius: 4px; font-size: 13px; font-family: var(--mono); white-space: pre-wrap; } .mermaid-source { border-top: 1px solid var(--border); font-size: 12px; } .mermaid-source summary { padding: 6px 12px; cursor: pointer; color: var(--text-3); user-select: none; display: flex; align-items: center; gap: 8px; } .mermaid-source summary:hover { color: var(--text-2); } .mermaid-source summary span { flex: 1; } .mmd-copy-src { font-size: 11px; } .mermaid-source pre { margin: 0; border-radius: 0; border: none; max-height: 200px; overflow: auto; } .mermaid-block.mermaid-fullscreen .mermaid-source { display: none; } @media (max-width: 768px) { .mermaid-toolbar { padding: 6px 10px; gap: 4px; } .mmd-btn { padding: 4px 10px; font-size: 12px; } .mmd-sep { margin: 0 2px; } .mmd-fullscreen-close { width: 48px; height: 48px; font-size: 24px; top: 16px; right: 16px; } }`; document.head.appendChild(style); } // ── Registration ──────────────────────── function register() { if (!window.sw?.renderers) return; _injectStyles(); // Block renderer: match ```mermaid sw.renderers.register('mermaid', { type: 'block', pattern: 'mermaid', priority: 10, render(lang, code, container) { const id = 'mmd-' + Math.random().toString(36).slice(2, 9); container.innerHTML = `
\u{1f4ca} Diagram 100%
Rendering diagram\u2026
\u{1f4cb} View source
${_escapeHtml(code.trim())}
`; } }); // Post renderer: render diagrams + wire interactivity sw.renderers.register('mermaid-post', { type: 'post', priority: 10, render(container) { const diagrams = container.querySelectorAll('.mermaid-diagram[data-mermaid-src]'); if (diagrams.length === 0) return; diagrams.forEach(el => { if (el.dataset.rendered) return; el.dataset.rendered = 'pending'; _renderDiagram(el); }); _wireToolbar(container); } }); _loadMermaid(); } // Boot: register immediately if SDK ready, otherwise listen if (window.sw?._sdk) { register(); } else { document.addEventListener('sw:ready', register, { once: true }); } })();