pediatric-ai-scribe-v3/public/js/diagrams.js
2026-05-08 00:23:00 +02:00

397 lines
14 KiB
JavaScript

// ============================================================
// 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 = '<div class="diagrams-empty">Failed to load.</div>';
});
}
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 = '<div class="diagrams-empty">' + (library.length ? 'No matches.' : 'No diagrams yet.') + '</div>';
return;
}
listEl.innerHTML = rows.map(function (d) {
return '<div class="diagram-row' + (d.id === currentId ? ' active' : '') + '" data-id="' + d.id + '">'
+ '<strong>' + escapeHtml(d.title || 'Untitled') + '</strong>'
+ '<span>' + formatDate(d.updated_at) + '</span>'
+ '</div>';
}).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 = '<?xml version="1.0" encoding="UTF-8"?>\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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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();
});
})();