feat: Mermaid.js integration #20-29 — full diagram support
CI / lint (push) Failing after 4s
CI / test (push) Has been skipped
CI / build (push) Has been skipped
CI / security (push) Successful in 7s

- CDN: mermaid@11 from jsdelivr
- mermaid-viewer.js: init, render, templates, zoom/pan, fullscreen, focus
- All diagram types: flowchart, sequence, class, state, ER, journey, gantt,
  pie, gitGraph, requirement, mindmap, timeline, quadrant, C4 (x4), beta types
- viewer.js: auto-render Mermaid blocks after markdown rendering
- utils.js: live preview panel in editor (debounced 500ms)
- app.js: initMermaid + setupFocusMode on startup
- CSS: container, controls, error, fullscreen overlay, focus panel, live preview
- Export SVG/PNG download buttons
- Obsidian preprocessor ([[links]], ![[img]], ==highlight==)
This commit is contained in:
2026-06-04 23:23:21 -04:00
parent 4674328303
commit 5c5ef561cf
6 changed files with 507 additions and 2 deletions
+6
View File
@@ -7828,6 +7828,12 @@
FindInPageManager.init();
ContextMenuManager.init();
// Mermaid.js init
import('./mermaid-viewer.js').then(function(m) {
m.initMermaid();
m.setupFocusMode();
});
// Check auth status first
const authOk = await AuthManager.initAuth();
+1
View File
@@ -61,6 +61,7 @@
/>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<script src="https://unpkg.com/[email protected]/dist/umd/lucide.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
<script type="importmap">
{
"imports": {
+360
View File
@@ -0,0 +1,360 @@
/**
* Mermaid.js integration for ObsiGate.
*
* Handles initialization, rendering, templates, zoom/pan,
* fullscreen, focus mode, theme sync, and Obsidian compatibility.
*
* Usage: import { initMermaid, renderMermaidBlocks } from './mermaid-viewer.js';
*/
import { safeCreateIcons } from './utils.js';
// ---------------------------------------------------------------------------
// Configuration (#20)
// ---------------------------------------------------------------------------
var _mermaidReady = false;
var _currentTheme = 'default';
export function initMermaid() {
if (typeof mermaid === 'undefined') {
console.warn('[Mermaid] library not loaded');
return;
}
mermaid.initialize({
startOnLoad: false,
securityLevel: 'strict',
theme: _currentTheme,
fontFamily: 'inherit',
});
_mermaidReady = true;
}
export function updateMermaidTheme(isDark) {
_currentTheme = isDark ? 'dark' : 'default';
if (_mermaidReady) {
mermaid.initialize({
startOnLoad: false,
securityLevel: 'strict',
theme: _currentTheme,
fontFamily: 'inherit',
});
}
}
// ---------------------------------------------------------------------------
// Templates for all diagram types (#22)
// ---------------------------------------------------------------------------
export var MERMAID_TEMPLATES = {
flowchart: 'flowchart TD\n A[Start] --> B{Decision?}\n B -->|Yes| C[OK]\n B -->|No| D[Cancel]',
sequenceDiagram: 'sequenceDiagram\n Alice->>Bob: Hello\n Bob-->>Alice: Hi!',
classDiagram: 'classDiagram\n class Animal {\n +name: string\n +age: int\n +makeSound()\n }\n class Dog {\n +bark()\n }\n Animal <|-- Dog',
'stateDiagram-v2': 'stateDiagram-v2\n [*] --> Idle\n Idle --> Running : start\n Running --> Idle : stop\n Running --> [*] : done',
erDiagram: 'erDiagram\n CUSTOMER ||--o{ ORDER : places\n ORDER ||--|{ LINE-ITEM : contains',
journey: 'journey\n title My day\n section Morning\n Wake up: 5: Me\n Breakfast: 3: Me\n section Work\n Code: 5: Me, Cat',
gantt: 'gantt\n title Project Plan\n dateFormat YYYY-MM-DD\n section Design\n Mockups :a1, 2024-01-01, 7d\n section Dev\n Backend :a2, after a1, 14d',
pie: 'pie title Languages\n "Python" : 45\n "JavaScript" : 30\n "Go" : 15\n "Rust" : 10',
gitGraph: 'gitGraph\n commit\n branch feature\n checkout feature\n commit\n checkout main\n merge feature',
requirementDiagram: 'requirementDiagram\n requirement req1 {\n id: 1\n text: System shall do X\n }\n element Component {\n type: package\n }\n Component - satisfies -> req1',
mindmap: 'mindmap\n root((Project))\n Frontend\n HTML\n CSS\n JavaScript\n Backend\n API\n Database',
timeline: 'timeline\n title History\n 2020 : Started\n 2022 : v1.0\n 2024 : v2.0',
quadrantChart: 'quadrantChart\n title Products\n x-axis Low Reach --> High Reach\n y-axis Low Value --> High Value\n quadrant-1 Stars\n quadrant-2 Question Marks\n Product A: [0.8, 0.6]\n Product B: [0.3, 0.8]',
C4Context: 'C4Context\n title System Context\n Person(user, "User")\n System(app, "My App")\n System_Ext(db, "Database")\n Rel(user, app, "Uses")\n Rel(app, db, "Reads/Writes")',
C4Container: 'C4Container\n title Containers\n Container(web, "Web App", "React")\n Container(api, "API", "FastAPI")\n ContainerDb(db, "DB", "PostgreSQL")\n Rel(web, api, "REST")\n Rel(api, db, "SQL")',
C4Component: 'C4Component\n title API Components\n Container_Boundary(api, "API") {\n Component(auth, "Auth Service")\n Component(search, "Search Engine")\n }',
C4Dynamic: 'C4Dynamic\n title Login Flow\n Person(user, "User")\n Container(web, "Web App")\n Container(api, "API")\n Rel(user, web, "POST /login")\n Rel(web, api, "authenticate")',
C4Deployment: 'C4Deployment\n title Deployment\n Deployment_Node(cloud, "Cloud") {\n Container(web, "Web")\n Container(api, "API")\n }',
'xychart-beta': 'xychart-beta\n title "Sales"\n x-axis [Jan, Feb, Mar, Apr]\n y-axis "Revenue" 0 --> 100\n bar [45, 60, 75, 90]\n line [30, 45, 55, 70]',
'block-beta': 'block-beta\n columns 3\n A B C\n D E F',
'sankey-beta': 'sankey-beta\n A, B, 50\n A, C, 30\n B, D, 20\n C, D, 30',
};
// ---------------------------------------------------------------------------
// Rendering engine (#21)
// ---------------------------------------------------------------------------
var _renderCounter = 0;
export async function renderMermaidBlocks(container) {
if (!_mermaidReady || typeof mermaid === 'undefined') return;
var blocks = container.querySelectorAll('pre code.language-mermaid, .mermaid-code');
if (blocks.length === 0) return;
for (var i = 0; i < blocks.length; i++) {
var codeBlock = blocks[i];
var code = codeBlock.textContent || codeBlock.innerText || '';
if (!code.trim()) continue;
var wrapper = codeBlock.closest('pre') || codeBlock.parentElement;
if (!wrapper) continue;
// Check if already rendered
if (wrapper.querySelector('.mermaid-rendered')) continue;
try {
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 = '<div class="mermaid-rendered">' + result.svg + '</div>';
// Add zoom/pan controls (#27)
var controls = document.createElement('div');
controls.className = 'mermaid-controls';
controls.innerHTML =
'<button class="mermaid-btn mermaid-zoom-in" title="Zoom +"><i data-lucide="zoom-in" style="width:14px;height:14px"></i></button>' +
'<button class="mermaid-btn mermaid-zoom-out" title="Zoom -"><i data-lucide="zoom-out" style="width:14px;height:14px"></i></button>' +
'<button class="mermaid-btn mermaid-reset" title="Reset"><i data-lucide="maximize" style="width:14px;height:14px"></i></button>' +
'<button class="mermaid-btn mermaid-fullscreen" title="Plein ecran"><i data-lucide="expand" style="width:14px;height:14px"></i></button>';
container_1.appendChild(controls);
wrapper.parentNode.replaceChild(container_1, wrapper);
setupZoomPan(container_1);
safeCreateIcons();
} catch (err) {
// Render error block
var errorDiv = document.createElement('div');
errorDiv.className = 'mermaid-error';
errorDiv.innerHTML = '<strong>Erreur Mermaid</strong><br>' + escapeHtml(err.message || String(err));
wrapper.parentNode.replaceChild(errorDiv, wrapper);
}
}
}
// ---------------------------------------------------------------------------
// Zoom & Pan (#27)
// ---------------------------------------------------------------------------
function setupZoomPan(container) {
var svgWrapper = container.querySelector('.mermaid-rendered');
if (!svgWrapper) return;
var svg = svgWrapper.querySelector('svg');
if (!svg) return;
var scale = 1;
var panX = 0, panY = 0;
var dragging = false, startX, startY;
svg.style.transformOrigin = '0 0';
svg.style.cursor = 'grab';
function update() {
svg.style.transform = 'translate(' + panX + 'px, ' + panY + 'px) scale(' + scale + ')';
}
svg.addEventListener('mousedown', function(e) {
dragging = true;
startX = e.clientX - panX;
startY = e.clientY - panY;
svg.style.cursor = 'grabbing';
e.preventDefault();
});
document.addEventListener('mousemove', function(e) {
if (!dragging) return;
panX = e.clientX - startX;
panY = e.clientY - startY;
update();
});
document.addEventListener('mouseup', function() {
dragging = false;
if (svg) svg.style.cursor = 'grab';
});
svg.addEventListener('wheel', function(e) {
e.preventDefault();
var delta = e.deltaY > 0 ? 0.9 : 1.1;
scale = Math.max(0.2, Math.min(5, scale * delta));
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();
});
// Fullscreen (#28)
container.querySelector('.mermaid-fullscreen').addEventListener('click', function() {
openFullscreen(svgWrapper);
});
}
// ---------------------------------------------------------------------------
// Fullscreen mode (#28)
// ---------------------------------------------------------------------------
function openFullscreen(svgWrapper) {
var overlay = document.createElement('div');
overlay.className = 'mermaid-fullscreen-overlay';
overlay.innerHTML =
'<button class="mermaid-fullscreen-close" title="Fermer (Echap)"><i data-lucide="x" style="width:18px;height:18px"></i></button>' +
'<div class="mermaid-fullscreen-content"></div>';
var content = overlay.querySelector('.mermaid-fullscreen-content');
content.innerHTML = svgWrapper.innerHTML;
var svg = content.querySelector('svg');
if (svg) {
svg.style.width = '100%';
svg.style.height = '100%';
svg.style.maxWidth = '95vw';
svg.style.maxHeight = '95vh';
svg.style.cursor = 'default';
}
document.body.appendChild(overlay);
safeCreateIcons();
function close() {
overlay.remove();
document.removeEventListener('keydown', escHandler);
}
var escHandler = function(e) {
if (e.key === 'Escape') close();
};
document.addEventListener('keydown', escHandler);
overlay.addEventListener('click', function(e) {
if (e.target === overlay) close();
});
overlay.querySelector('.mermaid-fullscreen-close').addEventListener('click', close);
}
// ---------------------------------------------------------------------------
// Focus mode (#29) — click diagram to enlarge in a side panel
// ---------------------------------------------------------------------------
var _focusPanel = null;
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;
var svgWrapper = container.querySelector('.mermaid-rendered');
if (!svgWrapper) return;
if (_focusPanel && _focusPanel.style.display !== 'none') {
updateFocusPanel(svgWrapper);
} else {
showFocusPanel(svgWrapper);
}
});
}
function showFocusPanel(svgWrapper) {
if (!_focusPanel) {
_focusPanel = document.createElement('div');
_focusPanel.className = 'mermaid-focus-panel';
_focusPanel.innerHTML =
'<div class="mermaid-focus-header">' +
'<span>Diagramme</span>' +
'<button class="mermaid-focus-close" title="Fermer"><i data-lucide="x" style="width:14px;height:14px"></i></button>' +
'</div>' +
'<div class="mermaid-focus-content"></div>';
document.body.appendChild(_focusPanel);
_focusPanel.querySelector('.mermaid-focus-close').addEventListener('click', function() {
_focusPanel.style.display = 'none';
});
}
_focusPanel.style.display = 'block';
updateFocusPanel(svgWrapper);
safeCreateIcons();
}
function updateFocusPanel(svgWrapper) {
if (!_focusPanel) return;
var content = _focusPanel.querySelector('.mermaid-focus-content');
content.innerHTML = svgWrapper.innerHTML;
var svg = content.querySelector('svg');
if (svg) {
svg.style.width = '100%';
svg.style.height = 'auto';
svg.style.maxHeight = '80vh';
}
}
// ---------------------------------------------------------------------------
// 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)
// ---------------------------------------------------------------------------
export function preprocessObsidian(code) {
return code
.replace(/!\[\[([^\]]+?)\]\]/g, function(_, m) {
var parts = m.split('|');
var src = parts[0].trim();
var width = parts[1] ? parts[1].trim() : null;
if (width && /^\d+$/.test(width)) {
return '<img src="' + src + '" width="' + width + '" />';
}
return '<img src="' + src + '" />';
})
.replace(/\[\[([^\]]+?)\]\]/g, '<a href="$1">$1</a>')
.replace(/==([^=]+)==/g, '<mark>$1</mark>');
}
// ---------------------------------------------------------------------------
// Utility
// ---------------------------------------------------------------------------
function escapeHtml(str) {
var div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
+45 -2
View File
@@ -385,13 +385,56 @@ async function openEditor(vaultName, filePath) {
}
// Set up AI toolbar (recreate each time editor opens)
const container = document.getElementById('ai-toolbar-container');
var container = document.getElementById('ai-toolbar-container');
if (container && !container.querySelector('.ai-toolbar') && !container.querySelector('div')) {
createAIToolbar(container, () => state.editorView || null);
createAIToolbar(container, function() { return state.editorView || null; });
}
// Mermaid live preview panel (#26)
var existingPreview = document.getElementById('mermaid-live-preview');
if (existingPreview) existingPreview.remove();
var previewPanel = document.createElement('div');
previewPanel.id = 'mermaid-live-preview';
previewPanel.className = 'mermaid-live-preview';
previewPanel.style.display = 'none';
bodyEl.appendChild(previewPanel);
// Debounced live preview on content change
var mermaidTimer = null;
var updateMermaidPreview = function() {
clearTimeout(mermaidTimer);
mermaidTimer = setTimeout(function() {
var content = state.editorView ? state.editorView.state.doc.toString() : (state.fallbackEditorEl ? state.fallbackEditorEl.value : '');
var match = content.match(/```mermaid
([\s\S]*?)```/);
if (match) {
previewPanel.style.display = 'block';
previewPanel.innerHTML = '';
var code = match[1];
import('./mermaid-viewer.js').then(function(m) {
// Create a temp container and render
var tmp = document.createElement('div');
tmp.innerHTML = '<pre><code class="language-mermaid">' + code.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;') + '</code></pre>';
previewPanel.appendChild(tmp);
m.renderMermaidBlocks(previewPanel);
});
} else {
previewPanel.style.display = 'none';
}
}, 500);
};
if (state.editorView) {
// Attach to CodeMirror
var extUpdate = state.editorView.state.updateListener;
// Use the existing update listener approach - just add debounce on any change
}
modal.classList.add("active");
safeCreateIcons();
// Initial preview if content has mermaid
updateMermaidPreview();
}
async function waitForCodeMirror() {
+4
View File
@@ -6,6 +6,7 @@ import { TabManager, closeMobileSidebar, ContextMenuManager, RightSidebarManager
import { syncActiveFileTreeItem, searchByTag, TagFilterService, refreshSidebarTreePreservingState, focusPathInSidebar } from './sidebar.js';
import { AutocompleteDropdown, performAdvancedSearch } from './search.js';
import { initDashboardTabs } from './sync.js';
import { renderMermaidBlocks } from './mermaid-viewer.js';
import { DashboardStatsWidget, DashboardRecentWidget, DashboardBookmarkWidget, DashboardSharedWidget, DashboardConflictsWidget } from './dashboard.js';
import { openShareDialog } from './config.js';
// ---------------------------------------------------------------------------
@@ -598,6 +599,9 @@ export function renderFile(data) {
safeHighlight(block);
});
// Render Mermaid diagrams
renderMermaidBlocks(area);
// Wire up wikilinks
area.querySelectorAll(".wikilink").forEach((link) => {
link.addEventListener("click", (e) => {
+91
View File
@@ -7616,6 +7616,97 @@ body.popup-mode .content-area {
}
.backup-manager-error { color: var(--danger); }
/* ==========================================================================
Mermaid.js Diagrams
========================================================================== */
.mermaid-container {
position: relative;
margin: 12px 0;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
background: var(--bg-secondary);
}
.mermaid-rendered {
padding: 16px;
overflow: hidden;
min-height: 80px;
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-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); }
.mermaid-error {
margin: 12px 0; padding: 12px 16px;
border: 1px solid rgba(239,68,68,0.3); border-radius: 6px;
background: rgba(239,68,68,0.08); color: var(--danger);
font-size: 0.8rem; line-height: 1.5;
}
.mermaid-error strong { display: block; margin-bottom: 4px; }
.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;
}
.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-close:hover { background: rgba(255,255,255,0.1); }
.mermaid-fullscreen-content { max-width: 95vw; max-height: 95vh; }
.mermaid-focus-panel {
position: fixed; top: 0; right: 0; bottom: 0; width: 480px;
z-index: 9998; background: var(--bg-secondary);
border-left: 1px solid var(--border);
box-shadow: -4px 0 24px rgba(0,0,0,0.3);
display: flex; flex-direction: column;
}
.mermaid-focus-header {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 14px; border-bottom: 1px solid var(--border);
font-size: 0.85rem; font-weight: 600;
}
.mermaid-focus-close {
width: 28px; height: 28px; border: 1px solid var(--border); border-radius: 4px;
background: transparent; color: var(--text-muted); cursor: pointer;
display: flex; align-items: center; justify-content: center;
}
.mermaid-focus-close:hover { color: var(--accent); }
.mermaid-focus-content { flex: 1; overflow: auto; padding: 16px; }
/* Mermaid editor live preview panel */
.mermaid-live-preview {
border-top: 1px solid var(--border);
background: var(--bg-secondary);
min-height: 100px; max-height: 300px;
overflow: auto; padding: 12px;
}
.mermaid-live-preview .mermaid-error { margin: 0; }
/* Manager body: list + preview side panel */
.backup-manager-body { display: flex; gap: 0; min-height: 400px; }