// ============================================================ // MERMAID DIAGRAMS — per-user clinical pathway editor. // Source on the left, live SVG preview on the right. CRUD against // /api/diagrams. Autosaves on quiet typing like Personal Notes. // ============================================================ (function () { 'use strict'; var initialized = false; var mermaidReady = false; var library = []; var currentId = null; var currentTitle = ''; var currentSource = ''; var currentNotes = ''; var saveTimer = null; var renderTimer = null; var renderToken = 0; document.addEventListener('tabChanged', function (e) { if (e.detail && e.detail.tab === 'diagrams') initIfNeeded(); }); function initIfNeeded() { if (initialized) { reloadLibrary(); return; } var tabRoot = document.getElementById('diagrams-tab'); if (!tabRoot || !tabRoot.querySelector('#diagrams-list')) { // Component HTML not yet injected — try again on the next tab activation. return; } initialized = true; bindEvents(); ensureMermaid().then(reloadLibrary); } function ensureMermaid() { if (window.mermaid && mermaidReady) return Promise.resolve(); return new Promise(function (resolve) { if (window.mermaid) { configureMermaid(); resolve(); return; } var script = document.createElement('script'); script.src = '/vendor/mermaid.min.js'; script.onload = function () { configureMermaid(); resolve(); }; script.onerror = function () { showError('Failed to load Mermaid. Refresh and try again.'); resolve(); }; document.head.appendChild(script); }); } function configureMermaid() { if (!window.mermaid) return; window.mermaid.initialize({ startOnLoad: false, securityLevel: 'strict', theme: 'default', flowchart: { useMaxWidth: true, htmlLabels: true }, }); mermaidReady = true; } function bindEvents() { var newBtn = document.getElementById('btn-diagram-new'); var search = document.getElementById('diagram-search'); var titleEl = document.getElementById('diagram-title'); var sourceEl = document.getElementById('diagram-source'); var deleteBtn = document.getElementById('btn-diagram-delete'); var svgBtn = document.getElementById('btn-diagram-export-svg'); var pngBtn = document.getElementById('btn-diagram-export-png'); if (newBtn) newBtn.addEventListener('click', onNew); if (search) search.addEventListener('input', renderList); if (titleEl) titleEl.addEventListener('input', onTitleInput); if (sourceEl) sourceEl.addEventListener('input', onSourceInput); if (deleteBtn) deleteBtn.addEventListener('click', onDelete); if (svgBtn) svgBtn.addEventListener('click', exportSvg); if (pngBtn) pngBtn.addEventListener('click', exportPng); } // ── Library ──────────────────────────────────────────────── function reloadLibrary() { return fetch('/api/diagrams', { credentials: 'include', headers: window.getAuthHeaders ? window.getAuthHeaders() : {} }) .then(function (r) { return r.json(); }) .then(function (data) { library = (data && data.diagrams) || []; renderList(); if (currentId == null && library.length) { loadDiagram(library[0].id); } else if (!library.length) { showEmptyEditor(); } }) .catch(function () { var listEl = document.getElementById('diagrams-list'); if (listEl) listEl.innerHTML = '
Failed to load.
'; }); } function renderList() { var listEl = document.getElementById('diagrams-list'); if (!listEl) return; var searchEl = document.getElementById('diagram-search'); var q = searchEl ? String(searchEl.value || '').toLowerCase() : ''; var rows = library.filter(function (d) { if (!q) return true; return String(d.title || '').toLowerCase().indexOf(q) !== -1; }); if (!rows.length) { listEl.innerHTML = '
' + (library.length ? 'No matches.' : 'No diagrams yet.') + '
'; return; } listEl.innerHTML = rows.map(function (d) { return '
' + '' + escapeHtml(d.title || 'Untitled') + '' + '' + formatDate(d.updated_at) + '' + '
'; }).join(''); Array.prototype.forEach.call(listEl.querySelectorAll('.diagram-row'), function (row) { row.addEventListener('click', function () { loadDiagram(parseInt(row.getAttribute('data-id'), 10)); }); }); } function showEmptyEditor() { currentId = null; setFields('', '', ''); setStatus(''); var preview = document.getElementById('diagram-preview'); if (preview) preview.innerHTML = ''; hideError(); } // ── Diagram load / save ──────────────────────────────────── function loadDiagram(id) { flushSaveIfPending(); fetch('/api/diagrams/' + id, { credentials: 'include', headers: window.getAuthHeaders ? window.getAuthHeaders() : {} }) .then(function (r) { return r.json(); }) .then(function (data) { if (!data || !data.diagram) return; currentId = data.diagram.id; setFields(data.diagram.title || '', data.diagram.source || '', data.diagram.notes || ''); setStatus(''); renderList(); scheduleRender(0); }) .catch(function () { setStatus('Failed to load', 'error'); }); } function setFields(title, source, notes) { currentTitle = title; currentSource = source; currentNotes = notes; var titleEl = document.getElementById('diagram-title'); var sourceEl = document.getElementById('diagram-source'); if (titleEl) titleEl.value = title; if (sourceEl) sourceEl.value = source; } function onNew() { flushSaveIfPending(); var title = 'New diagram'; var starter = 'graph TD\n A[Start] --> B{Decision?}\n B -->|Yes| C[Action]\n B -->|No| D[End]'; fetch('/api/diagrams', { method: 'POST', credentials: 'include', headers: window.getAuthHeaders ? window.getAuthHeaders() : { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: title, source: starter, notes: '' }), }) .then(function (r) { return r.json(); }) .then(function (data) { if (data && data.id) { return reloadLibrary().then(function () { loadDiagram(data.id); }); } }) .catch(function () { if (window.showToast) window.showToast('Could not create diagram', 'error'); }); } function onTitleInput(e) { currentTitle = e.target.value; scheduleSave(); var row = document.querySelector('.diagram-row.active strong'); if (row) row.textContent = currentTitle || 'Untitled'; } function onSourceInput(e) { currentSource = e.target.value; scheduleSave(); scheduleRender(220); } function scheduleSave() { if (currentId == null) return; setStatus('Saving…', 'saving'); if (saveTimer) clearTimeout(saveTimer); saveTimer = setTimeout(saveNow, 700); } function flushSaveIfPending() { if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; saveNow(); } } function saveNow() { saveTimer = null; if (currentId == null) return; var id = currentId; var payload = JSON.stringify({ title: currentTitle, source: currentSource, notes: currentNotes }); fetch('/api/diagrams/' + id, { method: 'PUT', credentials: 'include', headers: window.getAuthHeaders ? window.getAuthHeaders() : { 'Content-Type': 'application/json' }, body: payload, }) .then(function (r) { if (!r.ok) throw new Error('save failed'); setStatus('Saved', 'saved'); var item = library.find(function (d) { return d.id === id; }); if (item) { item.title = currentTitle; item.source = currentSource; item.updated_at = new Date().toISOString(); } }) .catch(function () { setStatus('Save failed', 'error'); }); } function onDelete() { if (currentId == null) return; var id = currentId; var confirmFn = window.showConfirm || function (msg) { return Promise.resolve(window.confirm(msg)); }; Promise.resolve(confirmFn('Delete this diagram?')).then(function (ok) { if (!ok) return; fetch('/api/diagrams/' + id, { method: 'DELETE', credentials: 'include', headers: window.getAuthHeaders ? window.getAuthHeaders() : {}, }).then(function () { currentId = null; return reloadLibrary(); }); }); } // ── Render ───────────────────────────────────────────────── function scheduleRender(delay) { if (renderTimer) clearTimeout(renderTimer); renderTimer = setTimeout(renderNow, delay || 0); } function renderNow() { renderTimer = null; var preview = document.getElementById('diagram-preview'); if (!preview) return; var src = String(currentSource || '').trim(); if (!src) { preview.innerHTML = ''; hideError(); return; } if (!window.mermaid || !mermaidReady) { ensureMermaid().then(renderNow); return; } var token = ++renderToken; var id = 'mermaid-render-' + token; Promise.resolve() .then(function () { return window.mermaid.render(id, src); }) .then(function (result) { if (token !== renderToken) return; preview.innerHTML = result.svg; hideError(); }) .catch(function (err) { if (token !== renderToken) return; var msg = err && err.message ? err.message : String(err); showError(msg); }); } function showError(msg) { var el = document.getElementById('diagram-error'); if (!el) return; el.textContent = msg; el.classList.remove('hidden'); } function hideError() { var el = document.getElementById('diagram-error'); if (el) el.classList.add('hidden'); } // ── Export ───────────────────────────────────────────────── function exportSvg() { var preview = document.getElementById('diagram-preview'); var svg = preview && preview.querySelector('svg'); if (!svg) { if (window.showToast) window.showToast('Render a diagram first', 'info'); return; } var serializer = new XMLSerializer(); var raw = serializer.serializeToString(svg); if (!/^<\?xml/.test(raw)) raw = '\n' + raw; var blob = new Blob([raw], { type: 'image/svg+xml' }); triggerDownload(blob, fileBase() + '.svg'); } function exportPng() { var preview = document.getElementById('diagram-preview'); var svg = preview && preview.querySelector('svg'); if (!svg) { if (window.showToast) window.showToast('Render a diagram first', 'info'); return; } var serializer = new XMLSerializer(); var raw = serializer.serializeToString(svg); var encoded = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(raw); var img = new Image(); img.onload = function () { var rect = svg.getBoundingClientRect(); var scale = 2; var w = Math.max(1, Math.round(rect.width * scale)); var h = Math.max(1, Math.round(rect.height * scale)); var canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; var ctx = canvas.getContext('2d'); ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, w, h); ctx.drawImage(img, 0, 0, w, h); canvas.toBlob(function (blob) { if (blob) triggerDownload(blob, fileBase() + '.png'); }, 'image/png'); }; img.onerror = function () { if (window.showToast) window.showToast('PNG export failed', 'error'); }; img.src = encoded; } function triggerDownload(blob, name) { var url = URL.createObjectURL(blob); var a = document.createElement('a'); a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(function () { URL.revokeObjectURL(url); }, 1000); } function fileBase() { var t = String(currentTitle || 'diagram').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); return t || 'diagram'; } // ── Helpers ──────────────────────────────────────────────── function setStatus(text, kind) { var el = document.getElementById('diagram-status'); if (!el) return; el.textContent = text || ''; el.className = 'diagram-status' + (kind ? ' ' + kind : ''); } function escapeHtml(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; }); } function formatDate(iso) { if (!iso) return ''; try { var d = new Date(iso); return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); } catch (e) { return ''; } } // Save in-flight on tab switch / page hide so the user never loses keystrokes. window.addEventListener('beforeunload', flushSaveIfPending); document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'hidden') flushSaveIfPending(); }); })();