397 lines
14 KiB
JavaScript
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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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();
|
|
});
|
|
})();
|