Changeset 0.18.1 (#80)
This commit is contained in:
@@ -2,8 +2,12 @@
|
||||
// Mermaid Diagram Renderer — Browser Extension
|
||||
// ==========================================
|
||||
// Renders ```mermaid code blocks as interactive SVG diagrams.
|
||||
// Features: viewBox-based zoom/pan, fullscreen mode, SVG/PNG export, source copy.
|
||||
// Features: viewBox-based zoom/pan, context-aware expand (fullscreen
|
||||
// or side-panel pop-out), SVG/PNG export, source copy.
|
||||
// Loads mermaid.js dynamically on first use.
|
||||
//
|
||||
// Uses ctx.ui primitives from the host app for toast notifications,
|
||||
// side-panel preview, theme detection, and mobile awareness.
|
||||
// ==========================================
|
||||
|
||||
Extensions.register({
|
||||
@@ -11,14 +15,15 @@ Extensions.register({
|
||||
|
||||
_mermaidReady: false,
|
||||
_mermaidLoading: null,
|
||||
_ctx: null,
|
||||
|
||||
async init(ctx) {
|
||||
const self = this;
|
||||
this._ctx = ctx;
|
||||
|
||||
// ── Inject styles ──
|
||||
this._injectStyles();
|
||||
|
||||
// ── Block renderer: match ```mermaid, output placeholder ──
|
||||
// ── Block renderer: match ```mermaid ──
|
||||
ctx.renderers.register('mermaid', {
|
||||
type: 'block',
|
||||
pattern: 'mermaid',
|
||||
@@ -28,15 +33,15 @@ Extensions.register({
|
||||
container.innerHTML = `
|
||||
<div class="mermaid-block" data-mermaid-id="${id}">
|
||||
<div class="mermaid-toolbar">
|
||||
<span class="mermaid-title">📊 Diagram</span>
|
||||
<span class="mermaid-title">\u{1f4ca} 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-out" data-target="${id}" title="Zoom out">\u2212</button>
|
||||
<button class="mmd-btn" data-action="zoom-fit" data-target="${id}" title="Fit to view">\u22a1</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="fullscreen" data-target="${id}" title="Toggle fullscreen">⛶</button>
|
||||
<button class="mmd-btn" data-action="expand" data-target="${id}" title="Expand">\u26f6</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>
|
||||
@@ -45,13 +50,13 @@ Extensions.register({
|
||||
<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…
|
||||
<span class="mermaid-spinner"></span> Rendering diagram\u2026
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<details class="mermaid-source">
|
||||
<summary>
|
||||
<span>📋 View source</span>
|
||||
<span>\u{1f4cb} 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>
|
||||
@@ -75,49 +80,38 @@ Extensions.register({
|
||||
self._renderDiagram(el);
|
||||
});
|
||||
|
||||
// Wire toolbar buttons (event delegation on the container)
|
||||
self._wireToolbar(container);
|
||||
}
|
||||
});
|
||||
|
||||
// Pre-load mermaid library
|
||||
this._loadMermaid();
|
||||
},
|
||||
|
||||
// ── ViewBox Zoom/Pan State ──────────────
|
||||
|
||||
// State stores the current viewBox and the original (natural) viewBox
|
||||
// for computing zoom level relative to the full diagram.
|
||||
_getState(id) {
|
||||
const vp = document.querySelector(`[data-viewport="${id}"]`);
|
||||
if (!vp) return null;
|
||||
if (!vp._mmdState) {
|
||||
vp._mmdState = {
|
||||
// Natural (full) viewBox from the SVG — set after render
|
||||
natX: 0, natY: 0, natW: 0, natH: 0,
|
||||
// Current viewBox
|
||||
vbX: 0, vbY: 0, vbW: 0, vbH: 0,
|
||||
// Drag state
|
||||
dragging: false, startX: 0, startY: 0,
|
||||
startVbX: 0, startVbY: 0,
|
||||
// Track initialization
|
||||
ready: false,
|
||||
};
|
||||
}
|
||||
return vp._mmdState;
|
||||
},
|
||||
|
||||
// Call after SVG is rendered to capture the natural viewBox
|
||||
_initState(id) {
|
||||
const state = this._getState(id);
|
||||
if (!state) return;
|
||||
const svg = document.querySelector(`[data-diagram="${id}"] svg`);
|
||||
if (!svg) return;
|
||||
|
||||
// Parse the SVG's viewBox
|
||||
const vb = svg.getAttribute('viewBox');
|
||||
if (!vb) {
|
||||
// No viewBox — create one from the SVG's rendered size
|
||||
const bbox = svg.getBBox();
|
||||
state.natX = bbox.x;
|
||||
state.natY = bbox.y;
|
||||
@@ -131,7 +125,6 @@ Extensions.register({
|
||||
state.natH = parts[3] || 0;
|
||||
}
|
||||
|
||||
// Start with the full diagram visible
|
||||
state.vbX = state.natX;
|
||||
state.vbY = state.natY;
|
||||
state.vbW = state.natW;
|
||||
@@ -149,29 +142,23 @@ Extensions.register({
|
||||
|
||||
svg.setAttribute('viewBox', `${state.vbX} ${state.vbY} ${state.vbW} ${state.vbH}`);
|
||||
|
||||
// Update zoom label — zoom = natural width / current width
|
||||
const zoomPct = Math.round((state.natW / state.vbW) * 100);
|
||||
const label = document.querySelector(`[data-zoom-label="${id}"]`);
|
||||
if (label) label.textContent = zoomPct + '%';
|
||||
},
|
||||
|
||||
// Zoom toward/away from center of current view
|
||||
_zoom(id, factor) {
|
||||
const state = this._getState(id);
|
||||
if (!state || !state.ready) return;
|
||||
|
||||
// factor > 0 = zoom in (shrink viewBox), factor < 0 = zoom out
|
||||
const scale = 1 / (1 + factor);
|
||||
|
||||
const newW = state.vbW * scale;
|
||||
const newH = state.vbH * scale;
|
||||
|
||||
// Clamp: don't zoom out beyond 0.5x natural, don't zoom in beyond 20x
|
||||
const minW = state.natW / 20;
|
||||
const maxW = state.natW * 2;
|
||||
if (newW < minW || newW > maxW) return;
|
||||
|
||||
// Keep the center point stable
|
||||
const cx = state.vbX + state.vbW / 2;
|
||||
const cy = state.vbY + state.vbH / 2;
|
||||
|
||||
@@ -183,7 +170,6 @@ Extensions.register({
|
||||
this._applyViewBox(id);
|
||||
},
|
||||
|
||||
// Zoom centered on a specific viewport pixel coordinate
|
||||
_zoomAt(id, factor, clientX, clientY) {
|
||||
const state = this._getState(id);
|
||||
if (!state || !state.ready) return;
|
||||
@@ -193,7 +179,6 @@ Extensions.register({
|
||||
const svgRect = svg.getBoundingClientRect();
|
||||
if (svgRect.width === 0 || svgRect.height === 0) return;
|
||||
|
||||
// Map client coords to viewBox coords (the point we want to keep stable)
|
||||
const fx = (clientX - svgRect.left) / svgRect.width;
|
||||
const fy = (clientY - svgRect.top) / svgRect.height;
|
||||
const pointX = state.vbX + fx * state.vbW;
|
||||
@@ -207,7 +192,6 @@ Extensions.register({
|
||||
const maxW = state.natW * 2;
|
||||
if (newW < minW || newW > maxW) return;
|
||||
|
||||
// Keep the mouse point at the same fractional position
|
||||
state.vbW = newW;
|
||||
state.vbH = newH;
|
||||
state.vbX = pointX - fx * newW;
|
||||
@@ -227,8 +211,6 @@ Extensions.register({
|
||||
},
|
||||
|
||||
_zoomFit(id) {
|
||||
// Fit = show the full diagram, same as reset
|
||||
// but adjust aspect ratio to match viewport
|
||||
const state = this._getState(id);
|
||||
if (!state || !state.ready) return;
|
||||
const vp = document.querySelector(`[data-viewport="${id}"]`);
|
||||
@@ -240,16 +222,13 @@ Extensions.register({
|
||||
const vpAspect = vpRect.width / vpRect.height;
|
||||
const natAspect = state.natW / state.natH;
|
||||
|
||||
// Reset to natural, then expand the smaller dimension to fill viewport
|
||||
if (vpAspect > natAspect) {
|
||||
// Viewport is wider — expand width to match
|
||||
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 {
|
||||
// Viewport is taller — expand height to match
|
||||
const newH = state.natW / vpAspect;
|
||||
state.vbX = state.natX;
|
||||
state.vbY = state.natY - (newH - state.natH) / 2;
|
||||
@@ -260,6 +239,18 @@ Extensions.register({
|
||||
this._applyViewBox(id);
|
||||
},
|
||||
|
||||
// ── Expand (context-aware) ──────────────
|
||||
// Side panel open → pop out into it (replaces current content).
|
||||
// Side panel closed → fullscreen overlay.
|
||||
|
||||
_expand(id) {
|
||||
if (this._ctx.ui.isPanelOpen()) {
|
||||
this._popOutToPanel(id);
|
||||
} else {
|
||||
this._toggleFullscreen(id);
|
||||
}
|
||||
},
|
||||
|
||||
// ── Fullscreen ──────────────────────────
|
||||
|
||||
_toggleFullscreen(id) {
|
||||
@@ -267,25 +258,31 @@ Extensions.register({
|
||||
if (!block) return;
|
||||
|
||||
const isFS = block.classList.toggle('mermaid-fullscreen');
|
||||
const btn = block.querySelector('[data-action="fullscreen"]');
|
||||
|
||||
if (isFS) {
|
||||
// Entering fullscreen
|
||||
if (btn) btn.textContent = '✕';
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// Escape key listener
|
||||
// Close button overlay (always visible — critical for mobile)
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'mmd-fullscreen-close';
|
||||
closeBtn.innerHTML = '\u2715';
|
||||
closeBtn.title = 'Close fullscreen';
|
||||
closeBtn.addEventListener('click', () => this._toggleFullscreen(id));
|
||||
block.appendChild(closeBtn);
|
||||
|
||||
// Escape listener (desktop convenience, not sole exit)
|
||||
block._mmdEscHandler = (e) => {
|
||||
if (e.key === 'Escape') this._toggleFullscreen(id);
|
||||
};
|
||||
document.addEventListener('keydown', block._mmdEscHandler);
|
||||
|
||||
// Re-fit after layout change
|
||||
requestAnimationFrame(() => this._zoomFit(id));
|
||||
} else {
|
||||
// Exiting fullscreen
|
||||
if (btn) btn.textContent = '⛶';
|
||||
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;
|
||||
@@ -293,16 +290,47 @@ Extensions.register({
|
||||
}
|
||||
},
|
||||
|
||||
// ── Side Panel Pop-out ──────────────────
|
||||
|
||||
_popOutToPanel(id) {
|
||||
const diagram = document.querySelector(`[data-diagram="${id}"]`);
|
||||
const svg = diagram?.querySelector('svg');
|
||||
if (!svg) return;
|
||||
|
||||
const state = this._getState(id);
|
||||
const clone = svg.cloneNode(true);
|
||||
if (state?.ready) {
|
||||
clone.setAttribute('viewBox',
|
||||
`${state.natX} ${state.natY} ${state.natW} ${state.natH}`);
|
||||
}
|
||||
clone.setAttribute('width', '100%');
|
||||
clone.removeAttribute('height');
|
||||
clone.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
||||
|
||||
const isDark = this._ctx.ui.isDark();
|
||||
const html = `<!DOCTYPE html>
|
||||
<html><head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { margin: 0; padding: 16px; display: flex; justify-content: center;
|
||||
align-items: flex-start; min-height: 100vh;
|
||||
background: ${isDark ? '#1a1a2e' : '#fff'};
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
|
||||
svg { max-width: 100%; height: auto; }
|
||||
</style>
|
||||
</head><body>${clone.outerHTML}</body></html>`;
|
||||
|
||||
this._ctx.ui.openPreview(html);
|
||||
},
|
||||
|
||||
// ── 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;
|
||||
@@ -315,10 +343,10 @@ Extensions.register({
|
||||
case 'zoom-out': self._zoom(id, -0.2); break;
|
||||
case 'zoom-reset': self._zoomReset(id); break;
|
||||
case 'zoom-fit': self._zoomFit(id); break;
|
||||
case 'fullscreen': self._toggleFullscreen(id); break;
|
||||
case 'expand': self._expand(id); break;
|
||||
case 'export-svg': self._exportSVG(id); break;
|
||||
case 'export-png': self._exportPNG(id); break;
|
||||
case 'copy-src': self._copySource(id, btn); break;
|
||||
case 'copy-src': self._copySource(id); break;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -328,14 +356,12 @@ Extensions.register({
|
||||
vp._mmdWired = true;
|
||||
const id = vp.dataset.viewport;
|
||||
|
||||
// Mouse wheel zoom — centered on cursor position
|
||||
vp.addEventListener('wheel', (e) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -0.1 : 0.1;
|
||||
self._zoomAt(id, delta, e.clientX, e.clientY);
|
||||
}, { passive: false });
|
||||
|
||||
// Pan: mousedown → mousemove → mouseup
|
||||
vp.addEventListener('mousedown', (e) => {
|
||||
if (e.button !== 0) return;
|
||||
const state = self._getState(id);
|
||||
@@ -358,7 +384,6 @@ Extensions.register({
|
||||
const svgRect = svg.getBoundingClientRect();
|
||||
if (svgRect.width === 0) return;
|
||||
|
||||
// Convert pixel delta to viewBox delta
|
||||
const pxToVb = state.vbW / svgRect.width;
|
||||
const dx = (e.clientX - state.startX) * pxToVb;
|
||||
const dy = (e.clientY - state.startY) * pxToVb;
|
||||
@@ -377,7 +402,6 @@ Extensions.register({
|
||||
vp.addEventListener('mouseup', endDrag);
|
||||
vp.addEventListener('mouseleave', endDrag);
|
||||
|
||||
// Touch: single-finger pan, two-finger pinch zoom
|
||||
let lastTouchDist = 0;
|
||||
let lastTouchCenter = null;
|
||||
vp.addEventListener('touchstart', (e) => {
|
||||
@@ -452,7 +476,6 @@ Extensions.register({
|
||||
const svg = diagram?.querySelector('svg');
|
||||
if (!svg) return;
|
||||
|
||||
// Export with the natural viewBox (full diagram)
|
||||
const state = this._getState(id);
|
||||
const clone = svg.cloneNode(true);
|
||||
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||
@@ -471,7 +494,6 @@ Extensions.register({
|
||||
const state = this._getState(id);
|
||||
const clone = svg.cloneNode(true);
|
||||
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||
// Export full diagram at natural size
|
||||
if (state?.ready) {
|
||||
clone.setAttribute('viewBox', `${state.natX} ${state.natY} ${state.natW} ${state.natH}`);
|
||||
clone.setAttribute('width', state.natW);
|
||||
@@ -485,7 +507,7 @@ Extensions.register({
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const scale = 2; // 2x for retina
|
||||
const scale = 2;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.naturalWidth * scale;
|
||||
canvas.height = img.naturalHeight * scale;
|
||||
@@ -515,13 +537,11 @@ Extensions.register({
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||
},
|
||||
|
||||
_copySource(id, btn) {
|
||||
_copySource(id) {
|
||||
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);
|
||||
this._ctx.ui.toast('Source copied', 'success');
|
||||
});
|
||||
},
|
||||
|
||||
@@ -544,11 +564,9 @@ Extensions.register({
|
||||
svgEl.removeAttribute('height');
|
||||
svgEl.setAttribute('width', '100%');
|
||||
svgEl.style.maxWidth = 'none';
|
||||
// preserveAspectRatio ensures the viewBox maps cleanly
|
||||
svgEl.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
||||
}
|
||||
|
||||
// Initialize viewBox-based zoom state
|
||||
this._initState(id);
|
||||
} catch (e) {
|
||||
el.innerHTML = `
|
||||
@@ -609,12 +627,9 @@ Extensions.register({
|
||||
_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',
|
||||
theme: this._ctx.ui.isDark() ? 'dark' : 'default',
|
||||
securityLevel: 'strict',
|
||||
fontFamily: 'inherit',
|
||||
logLevel: 'error',
|
||||
@@ -642,8 +657,6 @@ Extensions.register({
|
||||
}
|
||||
.mermaid-block.mermaid-fullscreen .mermaid-viewport {
|
||||
background: var(--bg-2, #1a1a2e);
|
||||
}
|
||||
.mermaid-block.mermaid-fullscreen .mermaid-viewport {
|
||||
flex: 1; max-height: none;
|
||||
}
|
||||
.mermaid-block.mermaid-fullscreen .mermaid-toolbar {
|
||||
@@ -651,6 +664,22 @@ Extensions.register({
|
||||
padding: 6px 14px;
|
||||
}
|
||||
|
||||
/* Fullscreen close button — always visible, critical for mobile */
|
||||
.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);
|
||||
}
|
||||
|
||||
/* Toolbar */
|
||||
.mermaid-toolbar {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
@@ -671,7 +700,7 @@ Extensions.register({
|
||||
.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: contains the SVG, no overflow hidden needed — viewBox handles clipping */
|
||||
/* Viewport */
|
||||
.mermaid-viewport {
|
||||
overflow: hidden; position: relative;
|
||||
min-height: 80px; max-height: 600px;
|
||||
@@ -718,8 +747,16 @@ Extensions.register({
|
||||
max-height: 200px; overflow: auto;
|
||||
}
|
||||
|
||||
/* Hide source panel in fullscreen — it's in the way */
|
||||
/* Hide source panel in fullscreen */
|
||||
.mermaid-block.mermaid-fullscreen .mermaid-source { display: none; }
|
||||
|
||||
/* ── Mobile adjustments ── */
|
||||
@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);
|
||||
},
|
||||
@@ -732,10 +769,11 @@ Extensions.register({
|
||||
|
||||
destroy() {
|
||||
document.getElementById('ext-style-mermaid-renderer')?.remove();
|
||||
// Clean up any fullscreen state
|
||||
document.querySelectorAll('.mermaid-fullscreen').forEach(el => {
|
||||
el.classList.remove('mermaid-fullscreen');
|
||||
const closeBtn = el.querySelector('.mmd-fullscreen-close');
|
||||
if (closeBtn) closeBtn.remove();
|
||||
});
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user