Changeset 0.11.0 (#62)
This commit is contained in:
507
extensions/mermaid-renderer/script.js
Normal file
507
extensions/mermaid-renderer/script.js
Normal file
@@ -0,0 +1,507 @@
|
||||
// ==========================================
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user