// ========================================== // Mermaid Diagram Renderer — Browser Extension // ========================================== // Renders ```mermaid code blocks as interactive SVG diagrams. // Features: zoom/pan viewport, SVG/PNG export, source copy. // Loads mermaid.js dynamically on first use. // ========================================== Extensions.register({ id: 'mermaid-renderer', _mermaidReady: false, _mermaidLoading: null, async init(ctx) { const self = this; // ── Inject styles ── this._injectStyles(); // ── Block renderer: match ```mermaid, output placeholder ── ctx.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 = `
📊 Diagram 100%
Rendering diagram…
📋 View source
${self._escapeHtml(code.trim())}
`; } }); // ── Post renderer: render diagrams + wire interactivity ── ctx.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'; self._renderDiagram(el); }); // Wire toolbar buttons (event delegation on the container) self._wireToolbar(container); } }); // Pre-load mermaid library this._loadMermaid(); }, // ── Zoom/Pan State ────────────────────── _getState(id) { const vp = document.querySelector(`[data-viewport="${id}"]`); if (!vp) return null; if (!vp._mmdState) { vp._mmdState = { scale: 1, panX: 0, panY: 0, dragging: false, startX: 0, startY: 0 }; } return vp._mmdState; }, _applyTransform(id) { const state = this._getState(id); if (!state) return; const diagram = document.querySelector(`[data-diagram="${id}"]`); if (!diagram) return; diagram.style.transform = `translate(${state.panX}px, ${state.panY}px) scale(${state.scale})`; const label = document.querySelector(`[data-zoom-label="${id}"]`); if (label) label.textContent = Math.round(state.scale * 100) + '%'; }, _zoom(id, delta) { const state = this._getState(id); if (!state) return; state.scale = Math.max(0.1, Math.min(5, state.scale * (1 + delta))); this._applyTransform(id); }, _zoomReset(id) { const state = this._getState(id); if (!state) return; state.scale = 1; state.panX = 0; state.panY = 0; this._applyTransform(id); }, _zoomFit(id) { const state = this._getState(id); if (!state) return; const vp = document.querySelector(`[data-viewport="${id}"]`); const diagram = document.querySelector(`[data-diagram="${id}"]`); const svg = diagram?.querySelector('svg'); if (!vp || !svg) return; // Reset to measure natural size state.scale = 1; state.panX = 0; state.panY = 0; diagram.style.transform = ''; requestAnimationFrame(() => { const vpRect = vp.getBoundingClientRect(); const svgRect = svg.getBoundingClientRect(); if (svgRect.width === 0 || svgRect.height === 0) return; const scaleX = (vpRect.width - 32) / svgRect.width; const scaleY = (vpRect.height - 32) / svgRect.height; state.scale = Math.min(scaleX, scaleY, 2); // Cap at 2x this._applyTransform(id); }); }, // ── Toolbar Wiring ────────────────────── _wireToolbar(container) { const self = this; // Skip if already wired if (container._mmdWired) return; container._mmdWired = true; // Button clicks (delegation) 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': self._zoom(id, 0.25); break; case 'zoom-out': self._zoom(id, -0.2); break; case 'zoom-reset': self._zoomReset(id); break; case 'zoom-fit': self._zoomFit(id); break; case 'export-svg': self._exportSVG(id); break; case 'export-png': self._exportPNG(id); break; case 'copy-src': self._copySource(id, btn); 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; // Mouse wheel zoom vp.addEventListener('wheel', (e) => { e.preventDefault(); const delta = e.deltaY > 0 ? -0.1 : 0.1; self._zoom(id, delta); }, { passive: false }); // Pan: mousedown → mousemove → mouseup vp.addEventListener('mousedown', (e) => { if (e.button !== 0) return; const state = self._getState(id); if (!state) return; state.dragging = true; state.startX = e.clientX - state.panX; state.startY = e.clientY - state.panY; vp.classList.add('mmd-grabbing'); e.preventDefault(); }); vp.addEventListener('mousemove', (e) => { const state = self._getState(id); if (!state?.dragging) return; state.panX = e.clientX - state.startX; state.panY = e.clientY - state.startY; self._applyTransform(id); }); const endDrag = () => { const state = self._getState(id); if (!state) return; state.dragging = false; vp.classList.remove('mmd-grabbing'); }; vp.addEventListener('mouseup', endDrag); vp.addEventListener('mouseleave', endDrag); // Touch: single-finger pan, two-finger pinch zoom let lastTouchDist = 0; vp.addEventListener('touchstart', (e) => { const state = self._getState(id); if (!state) return; if (e.touches.length === 1) { state.dragging = true; state.startX = e.touches[0].clientX - state.panX; state.startY = e.touches[0].clientY - state.panY; } 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 = self._getState(id); if (!state) return; if (e.touches.length === 1 && state.dragging) { state.panX = e.touches[0].clientX - state.startX; state.panY = e.touches[0].clientY - state.startY; self._applyTransform(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 ); if (lastTouchDist > 0) { self._zoom(id, (dist - lastTouchDist) / lastTouchDist); } lastTouchDist = dist; e.preventDefault(); } }, { passive: false }); vp.addEventListener('touchend', () => { const state = self._getState(id); if (state) state.dragging = false; lastTouchDist = 0; }, { passive: true }); }); }, // ── Export ─────────────────────────────── _exportSVG(id) { const diagram = document.querySelector(`[data-diagram="${id}"]`); const svg = diagram?.querySelector('svg'); if (!svg) return; const clone = svg.cloneNode(true); clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); const blob = new Blob([clone.outerHTML], { type: 'image/svg+xml' }); this._download(blob, `diagram-${id}.svg`); }, _exportPNG(id) { const diagram = document.querySelector(`[data-diagram="${id}"]`); const svg = diagram?.querySelector('svg'); if (!svg) return; const clone = svg.cloneNode(true); clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); const svgData = new XMLSerializer().serializeToString(clone); const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' }); const url = URL.createObjectURL(svgBlob); const self = this; const img = new Image(); img.onload = () => { const scale = 2; // 2x for retina 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) self._download(blob, `diagram-${id}.png`); }, 'image/png'); }; img.onerror = () => { URL.revokeObjectURL(url); console.error('[Mermaid] PNG export failed'); }; img.src = url; }, _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); }, _copySource(id, btn) { const code = document.querySelector(`[data-source="${id}"]`); if (!code) return; navigator.clipboard.writeText(code.textContent).then(() => { const orig = btn.textContent; btn.textContent = '✓'; setTimeout(() => { btn.textContent = orig; }, 1500); }); }, // ── Diagram Rendering ─────────────────── async _renderDiagram(el) { const code = decodeURIComponent(el.dataset.mermaidSrc); try { await this._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.style.maxWidth = 'none'; // Viewport handles sizing } } catch (e) { el.innerHTML = `
Diagram error: ${this._escapeHtml(e.message || String(e))}
`; el.dataset.rendered = 'error'; } }, // ── Mermaid Library Loading ────────────── _loadMermaid() { if (this._mermaidReady) return Promise.resolve(); if (this._mermaidLoading) return this._mermaidLoading; this._mermaidLoading = new Promise((resolve, reject) => { if (typeof mermaid !== 'undefined') { this._initMermaid(); this._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 = () => { this._initMermaid(); this._mermaidReady = true; resolve(); }; script.onerror = () => { console.warn('[Mermaid] Local vendor not found, trying CDN'); const cdn = document.createElement('script'); cdn.src = cdnSrc; cdn.onload = () => { this._initMermaid(); this._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 this._mermaidLoading; }, _initMermaid() { if (typeof mermaid === 'undefined') return; const isDark = document.body.classList.contains('dark-theme') || window.matchMedia('(prefers-color-scheme: dark)').matches; mermaid.initialize({ startOnLoad: false, theme: isDark ? 'dark' : 'default', securityLevel: 'strict', fontFamily: 'inherit', logLevel: 'error', }); }, // ── Styles ────────────────────────────── _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; } /* Toolbar */ .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; } /* Viewport: zoom/pan container */ .mermaid-viewport { overflow: hidden; position: relative; min-height: 80px; max-height: 600px; cursor: grab; user-select: none; } .mermaid-viewport.mmd-grabbing { cursor: grabbing; } /* Diagram: the transform target */ .mermaid-diagram { padding: 16px; text-align: center; min-height: 60px; transform-origin: center center; will-change: transform; } .mermaid-diagram svg { height: auto; } /* Loading / Error */ .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; } /* Source panel */ .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; }`; document.head.appendChild(style); }, _escapeHtml(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; }, destroy() { document.getElementById('ext-style-mermaid-renderer')?.remove(); } });