This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/extensions/mermaid-renderer/script.js
2026-02-25 13:29:15 +00:00

508 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==========================================
// 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 = `
<div class="mermaid-block" data-mermaid-id="${id}">
<div class="mermaid-toolbar">
<span class="mermaid-title">📊 Diagram</span>
<span class="mermaid-zoom-label" data-zoom-label="${id}">100%</span>
<div class="mermaid-toolbar-btns">
<button class="mmd-btn" data-action="zoom-in" data-target="${id}" title="Zoom in">+</button>
<button class="mmd-btn" data-action="zoom-out" data-target="${id}" title="Zoom out"></button>
<button class="mmd-btn" data-action="zoom-fit" data-target="${id}" title="Fit to view">⊡</button>
<button class="mmd-btn" data-action="zoom-reset" data-target="${id}" title="Reset zoom">1:1</button>
<span class="mmd-sep"></span>
<button class="mmd-btn" data-action="export-svg" data-target="${id}" title="Download SVG">SVG</button>
<button class="mmd-btn" data-action="export-png" data-target="${id}" title="Download PNG">PNG</button>
</div>
</div>
<div class="mermaid-viewport" data-viewport="${id}">
<div class="mermaid-diagram" data-mermaid-src="${encodeURIComponent(code.trim())}" data-diagram="${id}">
<div class="mermaid-loading">
<span class="mermaid-spinner"></span> Rendering diagram…
</div>
</div>
</div>
<details class="mermaid-source">
<summary>
<span>📋 View source</span>
<button class="mmd-btn mmd-copy-src" data-action="copy-src" data-target="${id}" title="Copy source" onclick="event.stopPropagation()">Copy</button>
</summary>
<pre><code class="language-mermaid" data-source="${id}">${self._escapeHtml(code.trim())}</code></pre>
</details>
</div>
`;
}
});
// ── 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 = `
<div class="mermaid-error">
<strong>Diagram error:</strong> ${this._escapeHtml(e.message || String(e))}
</div>
`;
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();
}
});