feat: add interactive zoom, pan, and fullscreen support for Mermaid diagrams using svg-pan-zoom

This commit is contained in:
Ümit Tunç
2026-05-25 07:37:27 +03:00
parent 5fc0462ab1
commit 98355a5286
+304 -5
View File
@@ -174,13 +174,11 @@
.mermaid-container {
background: #ffffff;
border-radius: 16px;
padding: 1.5rem;
margin: 2rem 0;
border: 1px solid rgba(0, 0, 0, 0.05);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.05);
display: flex;
justify-content: center;
overflow-x: auto;
overflow: hidden;
position: relative;
}
.dark .mermaid-container {
background: #0f172a;
@@ -188,6 +186,131 @@
box-shadow: none;
}
/* Mermaid zoom toolbar */
.mermaid-toolbar {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 12px;
background: rgba(248, 250, 252, 0.9);
backdrop-filter: blur(8px);
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 16px 16px 0 0;
}
.dark .mermaid-toolbar {
background: rgba(15, 23, 42, 0.9);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.mermaid-toolbar-label {
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #94a3b8;
margin-right: 4px;
flex: 1;
}
.mermaid-zoom-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.08);
background: #fff;
color: #475569;
cursor: pointer;
font-size: 15px;
transition: all 0.15s ease;
user-select: none;
flex-shrink: 0;
}
.dark .mermaid-zoom-btn {
background: #1e293b;
border-color: rgba(255, 255, 255, 0.08);
color: #94a3b8;
}
.mermaid-zoom-btn:hover {
background: var(--accent-color, #4f46e5);
border-color: var(--accent-color, #4f46e5);
color: #fff;
transform: scale(1.05);
box-shadow: 0 2px 8px rgba(79, 70, 229, 0.3);
}
.mermaid-zoom-btn:active {
transform: scale(0.95);
}
.mermaid-zoom-pct {
font-size: 0.7rem;
font-weight: 700;
color: #64748b;
min-width: 38px;
text-align: center;
font-family: 'Inter', monospace;
}
.dark .mermaid-zoom-pct { color: #64748b; }
/* Mermaid svg canvas area */
.mermaid-canvas {
width: 100%;
min-height: 200px;
cursor: grab;
padding: 1.5rem;
box-sizing: border-box;
}
.mermaid-canvas:active { cursor: grabbing; }
.mermaid-canvas svg {
display: block;
margin: 0 auto;
max-width: 100%;
height: auto;
}
/* Fullscreen overlay */
.mermaid-fullscreen-overlay {
display: none;
position: fixed;
inset: 0;
z-index: 9999;
background: rgba(2, 6, 23, 0.92);
backdrop-filter: blur(12px);
flex-direction: column;
align-items: center;
justify-content: center;
}
.mermaid-fullscreen-overlay.active {
display: flex;
}
.mermaid-fullscreen-inner {
background: #fff;
border-radius: 20px;
overflow: hidden;
width: 92vw;
max-width: 1200px;
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: 0 40px 80px rgba(0,0,0,0.5);
}
.dark .mermaid-fullscreen-inner {
background: #0f172a;
}
.mermaid-fullscreen-canvas {
flex: 1;
overflow: auto;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
cursor: grab;
}
.mermaid-fullscreen-canvas:active { cursor: grabbing; }
.mermaid-fullscreen-canvas svg {
width: 100%;
height: auto;
}
/* Bullet lists */
.proposal-content ul, .proposal-content ol {
padding-left: 1.5rem;
@@ -682,6 +805,8 @@
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<!-- Mermaid.js for drawing diagrams -->
<script src="https://cdn.jsdelivr.net/npm/mermaid@10.8.0/dist/mermaid.min.js"></script>
<!-- SVG Pan Zoom for interactive Mermaid diagrams -->
<script src="https://cdn.jsdelivr.net/npm/svg-pan-zoom@3.6.1/dist/svg-pan-zoom.min.js"></script>
<!-- Confetti for approval celebration -->
<script src="https://cdn.jsdelivr.net/npm/canvas-confetti@1.6.0/dist/confetti.browser.min.js"></script>
<!-- Lucide Premium Icons -->
@@ -819,6 +944,9 @@
}
});
// Mermaid zoom state store
const mermaidZoomInstances = {};
// Scan code blocks for mermaid and convert them
const codeElements = contentDiv.querySelectorAll('pre code.language-mermaid');
codeElements.forEach((codeEl, index) => {
@@ -830,25 +958,196 @@
wrapper.className = 'mermaid-container no-print transition-all duration-300';
wrapper.id = 'mermaid-wrapper-' + index;
// Zoom Toolbar
const toolbar = document.createElement('div');
toolbar.className = 'mermaid-toolbar';
toolbar.innerHTML = `
<span class="mermaid-toolbar-label">Diyagram Görünümü</span>
<button class="mermaid-zoom-btn" title="Yakınlaştır (Zoom In)" onclick="mermaidZoomIn(${index})">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>
</button>
<span class="mermaid-zoom-pct" id="mermaid-pct-${index}">100%</span>
<button class="mermaid-zoom-btn" title="Uzaklaştır (Zoom Out)" onclick="mermaidZoomOut(${index})">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="8" y1="11" x2="14" y2="11"/></svg>
</button>
<button class="mermaid-zoom-btn" title="Tam Boyut (1:1)" onclick="mermaidZoomReset(${index})">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/></svg>
</button>
<button class="mermaid-zoom-btn" title="Tam Ekran" onclick="mermaidFullscreen(${index})">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg>
</button>
`;
// Canvas wrapper
const canvas = document.createElement('div');
canvas.className = 'mermaid-canvas';
canvas.id = 'mermaid-canvas-' + index;
const graphDiv = document.createElement('div');
graphDiv.className = 'mermaid w-full';
graphDiv.id = 'mermaid-graph-' + index;
graphDiv.textContent = rawMermaid;
wrapper.appendChild(graphDiv);
canvas.appendChild(graphDiv);
wrapper.appendChild(toolbar);
wrapper.appendChild(canvas);
preContainer.parentNode.replaceChild(wrapper, preContainer);
});
// Fullscreen overlay (singleton)
const fsOverlay = document.createElement('div');
fsOverlay.className = 'mermaid-fullscreen-overlay';
fsOverlay.id = 'mermaid-fs-overlay';
fsOverlay.innerHTML = `
<div class="mermaid-fullscreen-inner" id="mermaid-fs-inner">
<div class="mermaid-toolbar" style="border-radius:20px 20px 0 0;">
<span class="mermaid-toolbar-label">Tam Ekran Görünüm</span>
<button class="mermaid-zoom-btn" title="Yakınlaştır" onclick="mermaidFsZoom('in')">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>
</button>
<span class="mermaid-zoom-pct" id="mermaid-fs-pct">100%</span>
<button class="mermaid-zoom-btn" title="Uzaklaştır" onclick="mermaidFsZoom('out')">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="8" y1="11" x2="14" y2="11"/></svg>
</button>
<button class="mermaid-zoom-btn" title="1:1 Sıfırla" onclick="mermaidFsZoom('reset')">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/></svg>
</button>
<button class="mermaid-zoom-btn" title="Kapat" onclick="closeMermaidFullscreen()" style="background:#ef4444;border-color:#ef4444;color:#fff;">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="mermaid-fullscreen-canvas" id="mermaid-fs-canvas"></div>
</div>
`;
document.body.appendChild(fsOverlay);
fsOverlay.addEventListener('click', function(e) { if (e.target === fsOverlay) closeMermaidFullscreen(); });
// Run mermaid parser
setTimeout(() => {
mermaid.run().then(() => {
// Re-init lucide icons inside alerts after marked rendering completes
lucide.createIcons();
// Initialize svg-pan-zoom for each mermaid diagram
codeElements.forEach((_, index) => {
initMermaidZoom(index);
});
}).catch(err => {
console.error("Mermaid drawing error:", err);
});
}, 100);
// Init svg-pan-zoom for a given mermaid index
function initMermaidZoom(index) {
const canvas = document.getElementById('mermaid-canvas-' + index);
if (!canvas) return;
const svg = canvas.querySelector('svg');
if (!svg) return;
// Ensure SVG has an ID for svg-pan-zoom
if (!svg.id) svg.id = 'mermaid-svg-' + index;
// Make SVG fill canvas
svg.style.width = '100%';
svg.style.height = 'auto';
svg.removeAttribute('height');
try {
const panZoom = svgPanZoom('#' + svg.id, {
zoomEnabled: true,
controlIconsEnabled: false,
fit: true,
center: true,
minZoom: 0.3,
maxZoom: 8,
zoomScaleSensitivity: 0.3,
onZoom: (zoom) => {
const pctEl = document.getElementById('mermaid-pct-' + index);
if (pctEl) pctEl.textContent = Math.round(zoom * 100) + '%';
}
});
mermaidZoomInstances[index] = panZoom;
} catch(e) {
console.warn('svg-pan-zoom init failed for mermaid-' + index, e);
}
}
// Zoom controls
function mermaidZoomIn(index) {
const pz = mermaidZoomInstances[index];
if (pz) { pz.zoomIn(); updatePct(index, pz.getZoom()); }
}
function mermaidZoomOut(index) {
const pz = mermaidZoomInstances[index];
if (pz) { pz.zoomOut(); updatePct(index, pz.getZoom()); }
}
function mermaidZoomReset(index) {
const pz = mermaidZoomInstances[index];
if (pz) { pz.resetZoom(); pz.center(); updatePct(index, pz.getZoom()); }
}
function updatePct(index, zoom) {
const pctEl = document.getElementById('mermaid-pct-' + index);
if (pctEl) pctEl.textContent = Math.round(zoom * 100) + '%';
}
// Fullscreen
let fsActivePanZoom = null;
function mermaidFullscreen(index) {
const canvas = document.getElementById('mermaid-canvas-' + index);
if (!canvas) return;
const svg = canvas.querySelector('svg');
if (!svg) return;
const fsCanvas = document.getElementById('mermaid-fs-canvas');
const svgClone = svg.cloneNode(true);
svgClone.id = 'mermaid-fs-svg-' + index;
svgClone.style.width = '100%';
svgClone.style.height = 'auto';
svgClone.removeAttribute('height');
fsCanvas.innerHTML = '';
fsCanvas.appendChild(svgClone);
document.getElementById('mermaid-fs-overlay').classList.add('active');
document.body.style.overflow = 'hidden';
// Init pan-zoom on cloned SVG
setTimeout(() => {
try {
fsActivePanZoom = svgPanZoom('#mermaid-fs-svg-' + index, {
zoomEnabled: true,
controlIconsEnabled: false,
fit: true,
center: true,
minZoom: 0.2,
maxZoom: 10,
zoomScaleSensitivity: 0.3,
onZoom: (zoom) => {
const pctEl = document.getElementById('mermaid-fs-pct');
if (pctEl) pctEl.textContent = Math.round(zoom * 100) + '%';
}
});
document.getElementById('mermaid-fs-pct').textContent = Math.round(fsActivePanZoom.getZoom() * 100) + '%';
} catch(e) {}
}, 100);
}
function closeMermaidFullscreen() {
document.getElementById('mermaid-fs-overlay').classList.remove('active');
document.body.style.overflow = '';
if (fsActivePanZoom) { try { fsActivePanZoom.destroy(); } catch(e) {} fsActivePanZoom = null; }
}
function mermaidFsZoom(action) {
if (!fsActivePanZoom) return;
if (action === 'in') fsActivePanZoom.zoomIn();
else if (action === 'out') fsActivePanZoom.zoomOut();
else if (action === 'reset') { fsActivePanZoom.resetZoom(); fsActivePanZoom.center(); }
const pctEl = document.getElementById('mermaid-fs-pct');
if (pctEl) pctEl.textContent = Math.round(fsActivePanZoom.getZoom() * 100) + '%';
}
// Keyboard ESC closes fullscreen
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeMermaidFullscreen();
});
// 3. Dynamic Sidebar TOC Generator
const tocNav = document.getElementById('toc-nav');
const headings = contentDiv.querySelectorAll('h2, h3');