Changeset 0.17.1 (#76)

This commit is contained in:
2026-02-28 01:40:31 +00:00
parent c9141a6896
commit 856dc9b0ac
64 changed files with 8037 additions and 1657 deletions

View File

@@ -2,7 +2,7 @@
// Mermaid Diagram Renderer — Browser Extension
// ==========================================
// Renders ```mermaid code blocks as interactive SVG diagrams.
// Features: zoom/pan viewport, SVG/PNG export, source copy.
// Features: viewBox-based zoom/pan, fullscreen mode, SVG/PNG export, source copy.
// Loads mermaid.js dynamically on first use.
// ==========================================
@@ -36,6 +36,8 @@ Extensions.register({
<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="fullscreen" data-target="${id}" title="Toggle fullscreen">⛶</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>
@@ -82,63 +84,213 @@ Extensions.register({
this._loadMermaid();
},
// ── Zoom/Pan State ──────────────────────
// ── 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 = { scale: 1, panX: 0, panY: 0, dragging: false, startX: 0, startY: 0 };
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;
},
_applyTransform(id) {
// Call after SVG is rendered to capture the natural viewBox
_initState(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) + '%';
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;
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;
}
// Start with the full diagram visible
state.vbX = state.natX;
state.vbY = state.natY;
state.vbW = state.natW;
state.vbH = state.natH;
state.ready = true;
this._applyViewBox(id);
},
_zoom(id, delta) {
_applyViewBox(id) {
const state = this._getState(id);
if (!state) return;
state.scale = Math.max(0.1, Math.min(5, state.scale * (1 + delta)));
this._applyTransform(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}`);
// 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;
state.vbW = newW;
state.vbH = newH;
state.vbX = cx - newW / 2;
state.vbY = cy - newH / 2;
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;
const svg = document.querySelector(`[data-diagram="${id}"] svg`);
if (!svg) return;
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;
const pointY = state.vbY + fy * state.vbH;
const scale = 1 / (1 + factor);
const newW = state.vbW * scale;
const newH = state.vbH * scale;
const minW = state.natW / 20;
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;
state.vbY = pointY - fy * newH;
this._applyViewBox(id);
},
_zoomReset(id) {
const state = this._getState(id);
if (!state) return;
state.scale = 1; state.panX = 0; state.panY = 0;
this._applyTransform(id);
if (!state || !state.ready) return;
state.vbX = state.natX;
state.vbY = state.natY;
state.vbW = state.natW;
state.vbH = state.natH;
this._applyViewBox(id);
},
_zoomFit(id) {
// Fit = show the full diagram, same as reset
// but adjust aspect ratio to match viewport
const state = this._getState(id);
if (!state) return;
if (!state || !state.ready) return;
const vp = document.querySelector(`[data-viewport="${id}"]`);
const diagram = document.querySelector(`[data-diagram="${id}"]`);
const svg = diagram?.querySelector('svg');
if (!vp || !svg) return;
if (!vp) return;
// Reset to measure natural size
state.scale = 1; state.panX = 0; state.panY = 0;
diagram.style.transform = '';
const vpRect = vp.getBoundingClientRect();
if (vpRect.width === 0 || vpRect.height === 0) return;
requestAnimationFrame(() => {
const vpRect = vp.getBoundingClientRect();
const svgRect = svg.getBoundingClientRect();
if (svgRect.width === 0 || svgRect.height === 0) return;
const vpAspect = vpRect.width / vpRect.height;
const natAspect = state.natW / state.natH;
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);
});
// 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;
state.vbW = state.natW;
state.vbH = newH;
}
this._applyViewBox(id);
},
// ── Fullscreen ──────────────────────────
_toggleFullscreen(id) {
const block = document.querySelector(`[data-mermaid-id="${id}"]`);
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
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 = '';
if (block._mmdEscHandler) {
document.removeEventListener('keydown', block._mmdEscHandler);
delete block._mmdEscHandler;
}
}
},
// ── Toolbar Wiring ──────────────────────
@@ -163,6 +315,7 @@ 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 'export-svg': self._exportSVG(id); break;
case 'export-png': self._exportPNG(id); break;
case 'copy-src': self._copySource(id, btn); break;
@@ -175,21 +328,23 @@ Extensions.register({
vp._mmdWired = true;
const id = vp.dataset.viewport;
// Mouse wheel zoom
// Mouse wheel zoom — centered on cursor position
vp.addEventListener('wheel', (e) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
self._zoom(id, delta);
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);
if (!state) return;
if (!state || !state.ready) return;
state.dragging = true;
state.startX = e.clientX - state.panX;
state.startY = e.clientY - state.panY;
state.startX = e.clientX;
state.startY = e.clientY;
state.startVbX = state.vbX;
state.startVbY = state.vbY;
vp.classList.add('mmd-grabbing');
e.preventDefault();
});
@@ -197,9 +352,20 @@ Extensions.register({
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 svg = document.querySelector(`[data-diagram="${id}"] svg`);
if (!svg) return;
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;
state.vbX = state.startVbX - dx;
state.vbY = state.startVbY - dy;
self._applyViewBox(id);
});
const endDrag = () => {
@@ -213,38 +379,59 @@ Extensions.register({
// Touch: single-finger pan, two-finger pinch zoom
let lastTouchDist = 0;
let lastTouchCenter = null;
vp.addEventListener('touchstart', (e) => {
const state = self._getState(id);
if (!state) return;
if (!state || !state.ready) 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;
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
);
lastTouchCenter = {
x: (e.touches[0].clientX + e.touches[1].clientX) / 2,
y: (e.touches[0].clientY + e.touches[1].clientY) / 2,
};
}
}, { passive: true });
vp.addEventListener('touchmove', (e) => {
const state = self._getState(id);
if (!state) return;
if (!state || !state.ready) 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);
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;
const dx = (e.touches[0].clientX - state.startX) * pxToVb;
const dy = (e.touches[0].clientY - state.startY) * pxToVb;
state.vbX = state.startVbX - dx;
state.vbY = state.startVbY - dy;
self._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) {
self._zoom(id, (dist - lastTouchDist) / lastTouchDist);
const factor = (dist - lastTouchDist) / lastTouchDist;
self._zoomAt(id, factor, center.x, center.y);
}
lastTouchDist = dist;
lastTouchCenter = center;
e.preventDefault();
}
}, { passive: false });
@@ -253,6 +440,7 @@ Extensions.register({
const state = self._getState(id);
if (state) state.dragging = false;
lastTouchDist = 0;
lastTouchCenter = null;
}, { passive: true });
});
},
@@ -264,8 +452,13 @@ 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');
if (state?.ready) {
clone.setAttribute('viewBox', `${state.natX} ${state.natY} ${state.natW} ${state.natH}`);
}
const blob = new Blob([clone.outerHTML], { type: 'image/svg+xml' });
this._download(blob, `diagram-${id}.svg`);
},
@@ -275,8 +468,15 @@ Extensions.register({
const svg = diagram?.querySelector('svg');
if (!svg) return;
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);
clone.setAttribute('height', state.natH);
}
const svgData = new XMLSerializer().serializeToString(clone);
const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
@@ -329,6 +529,7 @@ Extensions.register({
async _renderDiagram(el) {
const code = decodeURIComponent(el.dataset.mermaidSrc);
const id = el.dataset.diagram;
try {
await this._loadMermaid();
@@ -341,8 +542,14 @@ Extensions.register({
const svgEl = el.querySelector('svg');
if (svgEl) {
svgEl.removeAttribute('height');
svgEl.style.maxWidth = 'none'; // Viewport handles sizing
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 = `
<div class="mermaid-error">
@@ -426,6 +633,24 @@ Extensions.register({
border-radius: 8px; overflow: hidden; margin: 12px 0;
}
/* ── Fullscreen mode ── */
.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);
}
.mermaid-block.mermaid-fullscreen .mermaid-viewport {
flex: 1; max-height: none;
}
.mermaid-block.mermaid-fullscreen .mermaid-toolbar {
border-bottom: 1px solid var(--border);
padding: 6px 14px;
}
/* Toolbar */
.mermaid-toolbar {
display: flex; align-items: center; gap: 6px;
@@ -446,7 +671,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: zoom/pan container */
/* Viewport: contains the SVG, no overflow hidden needed — viewBox handles clipping */
.mermaid-viewport {
overflow: hidden; position: relative;
min-height: 80px; max-height: 600px;
@@ -454,13 +679,13 @@ Extensions.register({
}
.mermaid-viewport.mmd-grabbing { cursor: grabbing; }
/* Diagram: the transform target */
/* Diagram wrapper */
.mermaid-diagram {
padding: 16px; text-align: center; min-height: 60px;
transform-origin: center center;
will-change: transform;
width: 100%; height: 100%; min-height: 60px;
}
.mermaid-diagram svg {
display: block; width: 100%; height: 100%;
}
.mermaid-diagram svg { height: auto; }
/* Loading / Error */
.mermaid-loading {
@@ -491,7 +716,11 @@ Extensions.register({
.mermaid-source pre {
margin: 0; border-radius: 0; border: none;
max-height: 200px; overflow: auto;
}`;
}
/* Hide source panel in fullscreen — it's in the way */
.mermaid-block.mermaid-fullscreen .mermaid-source { display: none; }
`;
document.head.appendChild(style);
},
@@ -503,5 +732,10 @@ 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');
});
document.body.style.overflow = '';
}
});
});