From 28ced406bcd60e837da081d192bc1864161e62e9 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Fri, 5 Jun 2026 08:38:21 -0400 Subject: [PATCH] feat: Mermaid header bar, Code/Preview toggle, Copy SVG, Download PNG, fullscreen header --- frontend/js/mermaid-viewer.js | 371 ++++++++++++++++++++++++++-------- frontend/style.css | 103 +++++++--- 2 files changed, 369 insertions(+), 105 deletions(-) diff --git a/frontend/js/mermaid-viewer.js b/frontend/js/mermaid-viewer.js index 942079d..a705257 100644 --- a/frontend/js/mermaid-viewer.js +++ b/frontend/js/mermaid-viewer.js @@ -83,8 +83,8 @@ export async function renderMermaidBlocks(container) { for (var i = 0; i < blocks.length; i++) { var codeBlock = blocks[i]; - var code = codeBlock.textContent || codeBlock.innerText || ''; - if (!code.trim()) continue; + var originalCode = codeBlock.textContent || codeBlock.innerText || ''; + if (!originalCode.trim()) continue; var wrapper = codeBlock.closest('pre') || codeBlock.parentElement; if (!wrapper) continue; @@ -94,30 +94,53 @@ export async function renderMermaidBlocks(container) { try { // Preprocess Obsidian syntax before Mermaid parsing (#24) - code = preprocessObsidian(code); + var code = preprocessObsidian(originalCode); await mermaid.parse(code); var id = 'mermaid-' + (++_renderCounter) + '-' + Date.now(); var result = await mermaid.render(id, code); - var container_1 = document.createElement('div'); - container_1.className = 'mermaid-container'; - container_1.innerHTML = '
' + result.svg + '
'; + // Extract diagram type from first non-comment, non-empty line + var diagramType = extractDiagramType(code); - // Add zoom/pan controls (#27) - var controls = document.createElement('div'); - controls.className = 'mermaid-controls'; - controls.innerHTML = - '' + - '' + - '' + - ''; - container_1.appendChild(controls); + // Build container with header + body + var containerDiv = document.createElement('div'); + containerDiv.className = 'mermaid-container'; - wrapper.parentNode.replaceChild(container_1, wrapper); - setupZoomPan(container_1); + // Header bar + var header = document.createElement('div'); + header.className = 'mermaid-header'; + header.innerHTML = + '' + escapeHtml(diagramType) + '' + + '
' + + '' + + '' + + '' + + '' + + '' + + '
' + + '' + + '' + + '
' + + '
'; + + // Body + var body = document.createElement('div'); + body.className = 'mermaid-body'; + body.innerHTML = + '
' + result.svg + '
' + + ''; + + containerDiv.appendChild(header); + containerDiv.appendChild(body); + + wrapper.parentNode.replaceChild(containerDiv, wrapper); + + // Wire up header buttons + wireHeaderButtons(containerDiv, originalCode); + setupZoomPan(containerDiv); + setupCodePreviewToggle(containerDiv); safeCreateIcons(); } catch (err) { - // Render error block var errorDiv = document.createElement('div'); errorDiv.className = 'mermaid-error'; errorDiv.innerHTML = 'Erreur Mermaid
' + escapeHtml(err.message || String(err)); @@ -126,6 +149,86 @@ export async function renderMermaidBlocks(container) { } } +// --------------------------------------------------------------------------- +// Header button wiring +// --------------------------------------------------------------------------- + +function wireHeaderButtons(container, originalCode) { + // Copy SVG + var copyBtn = container.querySelector('.mermaid-copy'); + if (copyBtn) { + copyBtn.addEventListener('click', function() { + copySvgToClipboard(container); + }); + } + + // Download PNG + var downloadBtn = container.querySelector('.mermaid-download'); + if (downloadBtn) { + downloadBtn.addEventListener('click', function() { + exportMermaidPNG(container); + }); + } +} + +// --------------------------------------------------------------------------- +// Code / Preview toggle +// --------------------------------------------------------------------------- + +function setupCodePreviewToggle(container) { + var toggleBtns = container.querySelectorAll('.mermaid-toggle-btn'); + var rendered = container.querySelector('.mermaid-rendered'); + var source = container.querySelector('.mermaid-source'); + var zoomBtns = container.querySelectorAll('.mermaid-zoom-in, .mermaid-zoom-out'); + var fullscreenBtn = container.querySelector('.mermaid-fullscreen'); + + for (var i = 0; i < toggleBtns.length; i++) { + toggleBtns[i].addEventListener('click', function() { + var mode = this.getAttribute('data-mode'); + + // Update active state + for (var j = 0; j < toggleBtns.length; j++) { + toggleBtns[j].classList.remove('active'); + } + this.classList.add('active'); + + if (mode === 'preview') { + rendered.style.display = ''; + source.style.display = 'none'; + // Show zoom/fullscreen buttons in preview mode + for (var k = 0; k < zoomBtns.length; k++) zoomBtns[k].style.display = ''; + if (fullscreenBtn) fullscreenBtn.style.display = ''; + } else { + rendered.style.display = 'none'; + source.style.display = ''; + // Hide zoom/fullscreen in code mode + for (var k = 0; k < zoomBtns.length; k++) zoomBtns[k].style.display = 'none'; + if (fullscreenBtn) fullscreenBtn.style.display = 'none'; + } + }); + } +} + +// --------------------------------------------------------------------------- +// Diagram type extraction +// --------------------------------------------------------------------------- + +function extractDiagramType(code) { + var lines = code.trim().split('\n'); + for (var i = 0; i < lines.length; i++) { + var line = lines[i].trim(); + // Skip comments and empty lines + if (!line || line.indexOf('%%') === 0) continue; + // Return the first meaningful word(s) + var words = line.split(/\s+/); + if (words.length >= 2) { + return words[0] + ' ' + words[1]; + } + return words[0] || 'mermaid'; + } + return 'mermaid'; +} + // --------------------------------------------------------------------------- // Zoom & Pan (#27) // --------------------------------------------------------------------------- @@ -174,31 +277,44 @@ function setupZoomPan(container) { update(); }, { passive: false }); - container.querySelector('.mermaid-zoom-in').addEventListener('click', function() { - scale = Math.min(5, scale * 1.3); update(); - }); - container.querySelector('.mermaid-zoom-out').addEventListener('click', function() { - scale = Math.max(0.2, scale * 0.7); update(); - }); - container.querySelector('.mermaid-reset').addEventListener('click', function() { - scale = 1; panX = 0; panY = 0; update(); - }); + var zoomIn = container.querySelector('.mermaid-zoom-in'); + var zoomOut = container.querySelector('.mermaid-zoom-out'); + if (zoomIn) zoomIn.addEventListener('click', function() { scale = Math.min(5, scale * 1.3); update(); }); + if (zoomOut) zoomOut.addEventListener('click', function() { scale = Math.max(0.2, scale * 0.7); update(); }); // Fullscreen (#28) - container.querySelector('.mermaid-fullscreen').addEventListener('click', function() { - openFullscreen(svgWrapper); - }); + var fsBtn = container.querySelector('.mermaid-fullscreen'); + if (fsBtn) { + fsBtn.addEventListener('click', function() { + openFullscreen(container); + }); + } } // --------------------------------------------------------------------------- -// Fullscreen mode (#28) +// Fullscreen mode (#28) — with header bar + full-size diagram // --------------------------------------------------------------------------- -function openFullscreen(svgWrapper) { +function openFullscreen(container) { + var svgWrapper = container.querySelector('.mermaid-rendered'); + var diagramType = container.querySelector('.mermaid-diagram-type'); + var typeLabel = diagramType ? diagramType.textContent : 'mermaid'; + var overlay = document.createElement('div'); overlay.className = 'mermaid-fullscreen-overlay'; + + // Fullscreen header bar overlay.innerHTML = - '' + + '
' + + '' + escapeHtml(typeLabel) + '' + + '
' + + '' + + '' + + '' + + '' + + '' + + '
' + + '
' + '
'; var content = overlay.querySelector('.mermaid-fullscreen-content'); @@ -209,6 +325,64 @@ function openFullscreen(svgWrapper) { } document.body.appendChild(overlay); + + // Fullscreen zoom + var fsScale = 1, fsPanX = 0, fsPanY = 0, fsDragging = false, fsStartX, fsStartY; + if (svg) { + svg.style.transformOrigin = '0 0'; + function fsUpdate() { + svg.style.transform = 'translate(' + fsPanX + 'px, ' + fsPanY + 'px) scale(' + fsScale + ')'; + } + + svg.addEventListener('mousedown', function(e) { + fsDragging = true; + fsStartX = e.clientX - fsPanX; + fsStartY = e.clientY - fsPanY; + svg.style.cursor = 'grabbing'; + e.preventDefault(); + }); + + document.addEventListener('mousemove', function(e) { + if (!fsDragging) return; + fsPanX = e.clientX - fsStartX; + fsPanY = e.clientY - fsStartY; + fsUpdate(); + }); + + document.addEventListener('mouseup', function() { + fsDragging = false; + if (svg) svg.style.cursor = 'grab'; + }); + + svg.addEventListener('wheel', function(e) { + e.preventDefault(); + var delta = e.deltaY > 0 ? 0.9 : 1.1; + fsScale = Math.max(0.2, Math.min(5, fsScale * delta)); + fsUpdate(); + }, { passive: false }); + + var fsZoomIn = overlay.querySelector('.mermaid-fs-zoom-in'); + var fsZoomOut = overlay.querySelector('.mermaid-fs-zoom-out'); + if (fsZoomIn) fsZoomIn.addEventListener('click', function() { fsScale = Math.min(5, fsScale * 1.3); fsUpdate(); }); + if (fsZoomOut) fsZoomOut.addEventListener('click', function() { fsScale = Math.max(0.2, fsScale * 0.7); fsUpdate(); }); + } + + // Copy SVG in fullscreen + var fsCopy = overlay.querySelector('.mermaid-fs-copy'); + if (fsCopy) { + fsCopy.addEventListener('click', function() { + copySvgFromElement(content); + }); + } + + // Download PNG in fullscreen + var fsDownload = overlay.querySelector('.mermaid-fs-download'); + if (fsDownload) { + fsDownload.addEventListener('click', function() { + exportMermaidPNGFromElement(svg); + }); + } + safeCreateIcons(); function close() { @@ -224,7 +398,89 @@ function openFullscreen(svgWrapper) { overlay.addEventListener('click', function(e) { if (e.target === overlay) close(); }); - overlay.querySelector('.mermaid-fullscreen-close').addEventListener('click', close); + overlay.querySelector('.mermaid-fs-close').addEventListener('click', close); +} + +// --------------------------------------------------------------------------- +// Copy SVG to clipboard +// --------------------------------------------------------------------------- + +function copySvgToClipboard(container) { + copySvgFromElement(container.querySelector('.mermaid-rendered')); +} + +function copySvgFromElement(el) { + if (!el) return; + var svg = el.querySelector('svg'); + if (!svg) return; + var data = new XMLSerializer().serializeToString(svg); + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(data).then(function() { + /* copied */ + }).catch(function() { + fallbackCopy(data); + }); + } else { + fallbackCopy(data); + } +} + +function fallbackCopy(text) { + var textarea = document.createElement('textarea'); + textarea.value = text; + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + try { document.execCommand('copy'); } catch (e) {} + document.body.removeChild(textarea); +} + +// --------------------------------------------------------------------------- +// Export SVG/PNG (#24) +// --------------------------------------------------------------------------- + +export function exportMermaidSVG(container) { + var svg = container.querySelector('svg'); + if (!svg) return; + var data = new XMLSerializer().serializeToString(svg); + var blob = new Blob([data], { type: 'image/svg+xml' }); + downloadBlob(blob, 'diagram.svg'); +} + +export function exportMermaidPNG(container) { + var svg = container.querySelector('svg'); + exportMermaidPNGFromElement(svg); +} + +function exportMermaidPNGFromElement(svg) { + if (!svg) return; + var data = new XMLSerializer().serializeToString(svg); + var canvas = document.createElement('canvas'); + var ctx = canvas.getContext('2d'); + var img = new Image(); + var blob = new Blob([data], { type: 'image/svg+xml' }); + var url = URL.createObjectURL(blob); + + img.onload = function() { + canvas.width = img.width * 2; + canvas.height = img.height * 2; + ctx.scale(2, 2); + ctx.drawImage(img, 0, 0); + URL.revokeObjectURL(url); + canvas.toBlob(function(pngBlob) { + downloadBlob(pngBlob, 'diagram.png'); + }, 'image/png'); + }; + img.src = url; +} + +function downloadBlob(blob, filename) { + var a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = filename; + a.click(); + URL.revokeObjectURL(a.href); } // --------------------------------------------------------------------------- @@ -237,8 +493,8 @@ export function setupFocusMode() { document.addEventListener('click', function(e) { var container = e.target.closest('.mermaid-container'); if (!container) return; - // Don't trigger focus if clicking controls - if (e.target.closest('.mermaid-controls') || e.target.closest('button')) return; + // Don't trigger focus if clicking header or controls + if (e.target.closest('.mermaid-header') || e.target.closest('button')) return; var svgWrapper = container.querySelector('.mermaid-rendered'); if (!svgWrapper) return; @@ -285,49 +541,6 @@ function updateFocusPanel(svgWrapper) { } } -// --------------------------------------------------------------------------- -// Export SVG/PNG (#24) -// --------------------------------------------------------------------------- - -export function exportMermaidSVG(container) { - var svg = container.querySelector('svg'); - if (!svg) return; - var data = new XMLSerializer().serializeToString(svg); - var blob = new Blob([data], { type: 'image/svg+xml' }); - downloadBlob(blob, 'diagram.svg'); -} - -export function exportMermaidPNG(container) { - var svg = container.querySelector('svg'); - if (!svg) return; - var data = new XMLSerializer().serializeToString(svg); - var canvas = document.createElement('canvas'); - var ctx = canvas.getContext('2d'); - var img = new Image(); - var blob = new Blob([data], { type: 'image/svg+xml' }); - var url = URL.createObjectURL(blob); - - img.onload = function() { - canvas.width = img.width * 2; - canvas.height = img.height * 2; - ctx.scale(2, 2); - ctx.drawImage(img, 0, 0); - URL.revokeObjectURL(url); - canvas.toBlob(function(pngBlob) { - downloadBlob(pngBlob, 'diagram.png'); - }, 'image/png'); - }; - img.src = url; -} - -function downloadBlob(blob, filename) { - var a = document.createElement('a'); - a.href = URL.createObjectURL(blob); - a.download = filename; - a.click(); - URL.revokeObjectURL(a.href); -} - // --------------------------------------------------------------------------- // Obsidian compatibility pre-processor (#24) // --------------------------------------------------------------------------- diff --git a/frontend/style.css b/frontend/style.css index 4c585c2..3e0517b 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -7621,7 +7621,6 @@ body.popup-mode .content-area { ========================================================================== */ .mermaid-container { - position: relative; margin: 12px 0; border: 1px solid var(--border); border-radius: 8px; @@ -7629,6 +7628,55 @@ body.popup-mode .content-area { background: var(--bg-secondary); } +/* ── Header bar ── */ +.mermaid-header { + display: flex; align-items: center; justify-content: space-between; + padding: 6px 10px; + background: var(--bg-tertiary, #f0f0f5); + border-bottom: 1px solid var(--border); + min-height: 34px; +} +.mermaid-diagram-type { + font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace; + font-size: 0.75rem; font-weight: 600; + color: var(--text-secondary); +} +.mermaid-header-actions { + display: flex; align-items: center; gap: 2px; +} + +/* ── Buttons ── */ +.mermaid-btn { + width: 28px; height: 28px; + border: 1px solid transparent; border-radius: 4px; + background: transparent; color: var(--text-secondary); + cursor: pointer; display: flex; align-items: center; justify-content: center; + transition: background 0.12s, color 0.12s; +} +.mermaid-btn:hover { background: var(--bg-primary); color: var(--accent); border-color: var(--border); } + +/* ── Code / Preview toggle ── */ +.mermaid-toggle { + display: flex; margin-left: 8px; + border: 1px solid var(--border); border-radius: 5px; + overflow: hidden; +} +.mermaid-toggle-btn { + padding: 2px 10px; font-size: 0.7rem; font-weight: 500; + border: none; background: transparent; + color: var(--text-muted, #999); cursor: pointer; + transition: background 0.12s, color 0.12s; +} +.mermaid-toggle-btn.active { + background: var(--bg-primary); color: var(--text-primary); + box-shadow: 0 0 0 1px var(--border); +} +.mermaid-toggle-btn:first-child { border-right: 1px solid var(--border); } + +/* ── Body ── */ +.mermaid-body { + position: relative; +} .mermaid-rendered { padding: 16px; overflow: hidden; @@ -7636,25 +7684,17 @@ body.popup-mode .content-area { display: flex; justify-content: center; } - .mermaid-rendered svg { max-width: 100%; height: auto; } -.mermaid-controls { - position: absolute; - top: 6px; right: 6px; - display: flex; gap: 2px; - opacity: 0; transition: opacity 0.15s; +.mermaid-source { padding: 16px; overflow: auto; } +.mermaid-source pre { + margin: 0; padding: 12px; + background: var(--bg-tertiary, #f5f5f5); + border-radius: 4px; + font-size: 0.78rem; line-height: 1.5; } -.mermaid-container:hover .mermaid-controls { opacity: 1; } - -.mermaid-btn { - width: 28px; height: 28px; - border: 1px solid var(--border); border-radius: 4px; - background: var(--bg-primary); color: var(--text-secondary); - cursor: pointer; display: flex; align-items: center; justify-content: center; -} -.mermaid-btn:hover { color: var(--accent); border-color: var(--accent); } +/* ── Error ── */ .mermaid-error { margin: 12px 0; padding: 12px 16px; border: 1px solid rgba(239,68,68,0.3); border-radius: 6px; @@ -7663,22 +7703,33 @@ body.popup-mode .content-area { } .mermaid-error strong { display: block; margin-bottom: 4px; } +/* ── Fullscreen ── */ .mermaid-fullscreen-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; - z-index: 10000; background: rgba(0,0,0,0.85); - display: flex; align-items: center; justify-content: center; + z-index: 10000; background: rgba(0,0,0,0.92); + display: flex; flex-direction: column; } -.mermaid-fullscreen-close { - position: absolute; top: 16px; right: 16px; - width: 40px; height: 40px; border: 1px solid rgba(255,255,255,0.2); - border-radius: 8px; background: rgba(0,0,0,0.5); color: #fff; - cursor: pointer; display: flex; align-items: center; justify-content: center; - z-index: 1; +.mermaid-fullscreen-header { + display: flex; align-items: center; justify-content: space-between; + padding: 10px 16px; + background: rgba(255,255,255,0.06); + border-bottom: 1px solid rgba(255,255,255,0.08); + min-height: 46px; + flex-shrink: 0; +} +.mermaid-fullscreen-header .mermaid-diagram-type { color: rgba(255,255,255,0.7); } +.mermaid-fullscreen-header .mermaid-btn { + color: rgba(255,255,255,0.6); +} +.mermaid-fullscreen-header .mermaid-btn:hover { + background: rgba(255,255,255,0.1); + color: #fff; } -.mermaid-fullscreen-close:hover { background: rgba(255,255,255,0.1); } .mermaid-fullscreen-content { - width: 95vw; height: 95vh; + flex: 1; display: flex; align-items: center; justify-content: center; + padding: 16px; + overflow: hidden; } .mermaid-fullscreen-content svg { width: 100%; height: 100%;