484 lines
31 KiB
JavaScript
484 lines
31 KiB
JavaScript
// Simple markup inserter module
|
|
(function(global){
|
|
console.log('markup_inserter.js loaded');
|
|
function insertAtCursor(el, text){
|
|
if(!el) return;
|
|
const start = el.selectionStart || 0;
|
|
const end = el.selectionEnd || 0;
|
|
const val = el.value || '';
|
|
el.value = val.slice(0, start) + text + val.slice(end);
|
|
el.selectionStart = el.selectionEnd = start + text.length;
|
|
el.focus();
|
|
}
|
|
|
|
function createToolbar(){
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'd-flex gap-2 mb-2 markup-inserter-toolbar';
|
|
const group = document.createElement('div');
|
|
group.className = 'btn-group';
|
|
const makeBtn = function(type, text){
|
|
const b = document.createElement('button');
|
|
b.type = 'button'; b.className = 'btn btn-sm btn-outline-secondary markup-insert-button';
|
|
b.dataset.type = type; b.textContent = text; return b;
|
|
};
|
|
group.appendChild(makeBtn('case','Insert case'));
|
|
group.appendChild(makeBtn('finding','Insert finding'));
|
|
group.appendChild(makeBtn('displayset','Insert displayset'));
|
|
const hint = document.createElement('div'); hint.className='flex-fill text-muted small align-self-center'; hint.textContent='Use the buttons to insert markup like [[case:123]]';
|
|
wrap.appendChild(group); wrap.appendChild(hint);
|
|
return wrap;
|
|
}
|
|
|
|
function attachToolbarToTextarea(textarea){
|
|
if(!textarea) return null;
|
|
// avoid double attaching
|
|
if(textarea._markupToolbarAttached) return null;
|
|
console.log('Attaching markup toolbar to textarea', textarea);
|
|
const toolbar = createToolbar();
|
|
textarea.parentNode.insertBefore(toolbar, textarea);
|
|
toolbar.querySelectorAll('.markup-insert-button').forEach(function(btn){
|
|
btn.addEventListener('click', function(){
|
|
const t = btn.dataset.type;
|
|
// Respect explicit dataset provided by the widget or page meta only
|
|
var detectedCase = textarea && textarea.dataset && textarea.dataset.currentCase ? textarea.dataset.currentCase : (document.getElementById('current-case-meta') ? document.getElementById('current-case-meta').getAttribute('data-current-case') : null);
|
|
if(t === 'case'){
|
|
openCasePicker(textarea);
|
|
} else if(t === 'finding'){
|
|
openFindingPicker(textarea, detectedCase);
|
|
} else if(t === 'displayset'){
|
|
openDisplaysetPicker(textarea, detectedCase);
|
|
}
|
|
});
|
|
});
|
|
textarea._markupToolbarAttached = true;
|
|
return toolbar;
|
|
}
|
|
|
|
function initForNames(names){
|
|
if(!Array.isArray(names)) return;
|
|
document.addEventListener('DOMContentLoaded', function(){
|
|
console.log('markupInserter.initForNames DOMContentLoaded handler running for', names);
|
|
names.forEach(function(name){
|
|
const el = document.querySelector('textarea[name="'+name+'"]');
|
|
if(el) attachToolbarToTextarea(el);
|
|
});
|
|
});
|
|
}
|
|
|
|
function attachToSelector(selector){
|
|
document.addEventListener('DOMContentLoaded', function(){
|
|
document.querySelectorAll(selector).forEach(function(el){
|
|
if(el.tagName === 'TEXTAREA') attachToolbarToTextarea(el);
|
|
});
|
|
});
|
|
}
|
|
|
|
// Auto-attach to placeholders rendered in HTMX responses or server-side includes.
|
|
document.addEventListener('DOMContentLoaded', function(){
|
|
console.log('markupInserter DOMContentLoaded auto-attach running');
|
|
// Attach to explicit placeholders
|
|
document.querySelectorAll('.markup-toolbar-placeholder').forEach(function(ph){
|
|
let ta = ph.nextElementSibling;
|
|
if(ta && ta.tagName === 'TEXTAREA') attachToolbarToTextarea(ta);
|
|
});
|
|
|
|
// Also auto-init for common case textarea names to cover full-page forms
|
|
try{ initForNames(['description','history','discussion','report','notes']); }catch(e){}
|
|
});
|
|
|
|
document.addEventListener('htmx:afterSwap', function(evt){
|
|
console.log('markupInserter htmx:afterSwap event', evt && evt.detail && evt.detail.target);
|
|
try{
|
|
const target = evt.detail && evt.detail.target ? evt.detail.target : document;
|
|
(target.querySelectorAll ? target : document).querySelectorAll('.markup-toolbar-placeholder').forEach(function(ph){
|
|
let ta = ph.nextElementSibling;
|
|
if(ta && ta.tagName === 'TEXTAREA') attachToolbarToTextarea(ta);
|
|
});
|
|
|
|
// Also attach to any textareas within the swapped target that match our common names
|
|
var ctx = evt.detail && evt.detail.target ? evt.detail.target : document;
|
|
['description','history','discussion','report','notes'].forEach(function(name){
|
|
ctx.querySelectorAll && ctx.querySelectorAll('textarea[name="'+name+'"]') && ctx.querySelectorAll('textarea[name="'+name+'"]').forEach(function(ta){ attachToolbarToTextarea(ta); });
|
|
});
|
|
}catch(e){/* ignore */}
|
|
});
|
|
|
|
// Fallback: attach toolbar when a textarea receives focus (covers dynamically created editors)
|
|
document.addEventListener('focusin', function(e){
|
|
console.log('markupInserter focusin event for', e && e.target);
|
|
try{
|
|
var el = e.target;
|
|
if(!el || el.tagName !== 'TEXTAREA') return;
|
|
// Attach for common names or if a placeholder is present
|
|
var name = el.getAttribute('name') || '';
|
|
if(['description','history','discussion','report','notes'].includes(name) || (el.previousElementSibling && el.previousElementSibling.classList && el.previousElementSibling.classList.contains('markup-toolbar-placeholder'))){
|
|
attachToolbarToTextarea(el);
|
|
}
|
|
}catch(err){/* ignore */}
|
|
}, true);
|
|
|
|
// Expose API
|
|
global.markupInserter = {
|
|
attachToolbarToTextarea: attachToolbarToTextarea,
|
|
initForNames: initForNames,
|
|
attachToSelector: attachToSelector
|
|
};
|
|
|
|
// --- Case picker modal ---
|
|
function openCasePicker(textarea){
|
|
try{
|
|
console.log('Opening case picker for', textarea);
|
|
// Create overlay
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'markup-case-picker-overlay';
|
|
Object.assign(overlay.style, {position:'fixed',left:0,top:0,right:0,bottom:0,background:'rgba(0,0,0,0.4)',zIndex:1050,display:'flex',alignItems:'center',justifyContent:'center'});
|
|
|
|
const dialog = document.createElement('div');
|
|
dialog.className = 'markup-case-picker-dialog card';
|
|
Object.assign(dialog.style, {width:'min(900px,95%)',maxHeight:'80vh',overflow:'auto'});
|
|
|
|
const header = document.createElement('div'); header.className='card-header d-flex justify-content-between align-items-center';
|
|
header.innerHTML = '<strong>Select case</strong>';
|
|
const closeBtn = document.createElement('button'); closeBtn.type='button'; closeBtn.className='btn-close'; closeBtn.setAttribute('aria-label','Close');
|
|
closeBtn.addEventListener('click', function(){ document.body.removeChild(overlay); });
|
|
header.appendChild(closeBtn);
|
|
|
|
const body = document.createElement('div'); body.className='card-body';
|
|
const input = document.createElement('input'); input.type='search'; input.className='form-control mb-2'; input.placeholder='Type to search cases...';
|
|
const results = document.createElement('div'); results.className='case-search-results';
|
|
body.appendChild(input); body.appendChild(results);
|
|
|
|
dialog.appendChild(header); dialog.appendChild(body); overlay.appendChild(dialog);
|
|
document.body.appendChild(overlay);
|
|
input.focus();
|
|
|
|
var timer = null;
|
|
input.addEventListener('input', function(){
|
|
if(timer) clearTimeout(timer);
|
|
timer = setTimeout(function(){
|
|
var q = encodeURIComponent(input.value || '');
|
|
// Fetch server-side partial that renders result list-group
|
|
var url = window.location.origin + '/atlas/search/cases/?q=' + q;
|
|
fetch(url, {credentials:'same-origin'}).then(function(r){ return r.text(); }).then(function(html){
|
|
results.innerHTML = html;
|
|
}).catch(function(e){ console.error('case picker fetch error', e); results.innerHTML = '<div class="alert alert-danger">Search failed</div>'; });
|
|
}, 300);
|
|
});
|
|
|
|
// Delegate click on result items
|
|
results.addEventListener('click', function(e){
|
|
var item = e.target.closest('.list-group-item');
|
|
if(!item) return;
|
|
var pk = item.getAttribute('data-case-pk');
|
|
if(!pk){
|
|
var a = item.querySelector('a');
|
|
if(a){ var m = a.getAttribute('href').match(/case\/(\d+)\/?/); if(m) pk = m[1]; }
|
|
}
|
|
if(!pk) return;
|
|
var defaultLabel = item.getAttribute('data-label') || (item.querySelector('h6') ? item.querySelector('h6').textContent.trim() : '');
|
|
var custom = window.prompt('Custom text (leave empty to use default)', defaultLabel || '');
|
|
if(custom === null){ if(overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay); return; }
|
|
var labelToUse = custom && custom.length ? custom : (defaultLabel || null);
|
|
var token = '[[' + 'case:' + pk + (labelToUse ? '|' + labelToUse : '') + ']]';
|
|
insertAtCursor(textarea, token);
|
|
// remove modal
|
|
if(overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
|
});
|
|
|
|
// Allow ESC to close
|
|
overlay.addEventListener('keydown', function(ev){ if(ev.key === 'Escape'){ if(overlay.parentNode) overlay.parentNode.removeChild(overlay); } });
|
|
// make overlay focusable so keydown works
|
|
overlay.tabIndex = -1; overlay.focus();
|
|
// load initial recent results (empty query)
|
|
fetch(window.location.origin + '/atlas/search/cases/').then(function(r){ return r.text(); }).then(function(html){ results.innerHTML = html; }).catch(function(){});
|
|
}catch(err){ console.error('openCasePicker error', err); }
|
|
}
|
|
|
|
function openFindingPicker(textarea, caseId){
|
|
try{
|
|
console.log('Opening finding picker for', textarea);
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'markup-case-picker-overlay';
|
|
Object.assign(overlay.style, {position:'fixed',left:0,top:0,right:0,bottom:0,background:'rgba(0,0,0,0.4)',zIndex:1050,display:'flex',alignItems:'center',justifyContent:'center'});
|
|
const dialog = document.createElement('div'); dialog.className='markup-case-picker-dialog card'; Object.assign(dialog.style, {width:'min(900px,95%)',maxHeight:'80vh',overflow:'auto'});
|
|
const header = document.createElement('div'); header.className='card-header d-flex justify-content-between align-items-center'; header.innerHTML = '<strong>Select finding</strong>';
|
|
// Determine case filter from explicit dataset on the textarea or provided caseId
|
|
var detectedF = (textarea && textarea.dataset && textarea.dataset.currentCase) ? { id: textarea.dataset.currentCase, title: textarea.dataset.currentCaseTitle } : {};
|
|
var currentCaseF = caseId || detectedF.id || null;
|
|
const closeBtn = document.createElement('button'); closeBtn.type='button'; closeBtn.className='btn-close'; closeBtn.setAttribute('aria-label','Close'); closeBtn.addEventListener('click', function(){ document.body.removeChild(overlay); }); header.appendChild(closeBtn);
|
|
const body = document.createElement('div'); body.className='card-body';
|
|
const input = document.createElement('input'); input.type='search'; input.className='form-control mb-2'; input.placeholder='Type to search findings...';
|
|
const results = document.createElement('div'); results.className='finding-search-results'; body.appendChild(input); body.appendChild(results);
|
|
dialog.appendChild(header); dialog.appendChild(body); overlay.appendChild(dialog); document.body.appendChild(overlay); input.focus();
|
|
|
|
var timer = null;
|
|
input.addEventListener('input', function(){ if(timer) clearTimeout(timer); timer = setTimeout(function(){ var q = encodeURIComponent(input.value || ''); var url = window.location.origin + '/atlas/search/findings/?q=' + q + (currentCaseF ? '&case=' + currentCaseF : ''); fetch(url, {credentials:'same-origin'}).then(function(r){ return r.text(); }).then(function(html){ results.innerHTML = html; }).catch(function(e){ console.error('finding picker fetch error', e); results.innerHTML = '<div class="alert alert-danger">Search failed</div>'; }); }, 300); });
|
|
|
|
results.addEventListener('click', function(e){
|
|
var item = e.target.closest('.list-group-item');
|
|
if(!item) return;
|
|
var sfpk = item.getAttribute('data-seriesfinding-pk');
|
|
var fpk = item.getAttribute('data-finding-pk');
|
|
var defaultLabel = item.getAttribute('data-label') || (item.querySelector('strong') ? item.querySelector('strong').textContent.trim() : '');
|
|
var custom = window.prompt('Custom text (leave empty to use default)', defaultLabel || '');
|
|
if(custom === null){ if(overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay); return; }
|
|
var labelToUse = custom && custom.length ? custom : (defaultLabel || null);
|
|
if(sfpk){
|
|
var token = '[['+'seriesfinding:'+sfpk + (labelToUse ? '|' + labelToUse : '') + ']]';
|
|
insertAtCursor(textarea, token);
|
|
} else if(fpk){
|
|
var token = '[['+'finding:'+fpk + (labelToUse ? '|' + labelToUse : '') + ']]';
|
|
insertAtCursor(textarea, token);
|
|
} else {
|
|
return;
|
|
}
|
|
if(overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
|
});
|
|
overlay.tabIndex = -1; overlay.focus();
|
|
function fetchInitialFindings(){ var initFindingsUrl = window.location.origin + '/atlas/search/findings/' + (currentCaseF ? '?case=' + currentCaseF : ''); fetch(initFindingsUrl).then(function(r){ return r.text(); }).then(function(html){ results.innerHTML = html; }).catch(function(){}); }
|
|
fetchInitialFindings();
|
|
}catch(err){ console.error('openFindingPicker error', err); }
|
|
}
|
|
|
|
function openDisplaysetPicker(textarea, caseId){
|
|
try{
|
|
console.log('Opening displayset picker for', textarea);
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'markup-case-picker-overlay';
|
|
Object.assign(overlay.style, {position:'fixed',left:0,top:0,right:0,bottom:0,background:'rgba(0,0,0,0.4)',zIndex:1050,display:'flex',alignItems:'center',justifyContent:'center'});
|
|
const dialog = document.createElement('div'); dialog.className='markup-case-picker-dialog card'; Object.assign(dialog.style, {width:'min(900px,95%)',maxHeight:'80vh',overflow:'auto'});
|
|
const header = document.createElement('div'); header.className='card-header d-flex justify-content-between align-items-center';
|
|
header.innerHTML = '<strong>Select display set</strong>';
|
|
// Determine case filter from explicit dataset on the textarea or provided caseId
|
|
var detectedD = (textarea && textarea.dataset && textarea.dataset.currentCase) ? { id: textarea.dataset.currentCase, title: textarea.dataset.currentCaseTitle } : {};
|
|
var currentCaseD = caseId || detectedD.id || null;
|
|
const closeBtn = document.createElement('button'); closeBtn.type='button'; closeBtn.className='btn-close'; closeBtn.setAttribute('aria-label','Close'); closeBtn.addEventListener('click', function(){ document.body.removeChild(overlay); }); header.appendChild(closeBtn);
|
|
const body = document.createElement('div'); body.className='card-body';
|
|
const input = document.createElement('input'); input.type='search'; input.className='form-control mb-2'; input.placeholder='Type to search display sets...';
|
|
const results = document.createElement('div'); results.className='displayset-search-results'; body.appendChild(input); body.appendChild(results);
|
|
dialog.appendChild(header); dialog.appendChild(body); overlay.appendChild(dialog); document.body.appendChild(overlay); input.focus();
|
|
|
|
var timer = null;
|
|
input.addEventListener('input', function(){ if(timer) clearTimeout(timer); timer = setTimeout(function(){ var q = encodeURIComponent(input.value || ''); var url = window.location.origin + '/atlas/search/displaysets/?q=' + q + (currentCaseD ? '&case=' + currentCaseD : ''); fetch(url, {credentials:'same-origin'}).then(function(r){ return r.text(); }).then(function(html){ results.innerHTML = html; }).catch(function(e){ console.error('displayset picker fetch error', e); results.innerHTML = '<div class="alert alert-danger">Search failed</div>'; }); }, 300); });
|
|
|
|
results.addEventListener('click', function(e){
|
|
var item = e.target.closest('.list-group-item');
|
|
if(!item) return;
|
|
var pk = item.getAttribute('data-ds-pk');
|
|
if(!pk) return;
|
|
var defaultLabel = item.getAttribute('data-label') || (item.querySelector('strong') ? item.querySelector('strong').textContent.trim() : '');
|
|
var custom = window.prompt('Custom text (leave empty to use default)', defaultLabel || '');
|
|
if(custom === null){ if(overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay); return; }
|
|
var labelToUse = custom && custom.length ? custom : (defaultLabel || null);
|
|
var token = '[['+'displayset:'+pk + (labelToUse ? '|' + labelToUse : '') + ']]';
|
|
insertAtCursor(textarea, token);
|
|
if(overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
|
});
|
|
overlay.tabIndex = -1; overlay.focus();
|
|
function fetchInitialDisplaysets(){ var initDisplaysetsUrl = window.location.origin + '/atlas/search/displaysets/' + (currentCaseD ? '?case=' + currentCaseD : ''); fetch(initDisplaysetsUrl).then(function(r){ return r.text(); }).then(function(html){ results.innerHTML = html; }).catch(function(){}); }
|
|
fetchInitialDisplaysets();
|
|
}catch(err){ console.error('openDisplaysetPicker error', err); }
|
|
}
|
|
|
|
// Simplified detection: rely on explicit dataset attributes set by the widget
|
|
// When a textarea includes `data-current-case` and optionally `data-current-case-title`, that will be used as the default filter.
|
|
|
|
// --- Render inserted markup tokens as styled items with popup/modal actions ---
|
|
function injectMarkupStyles(){
|
|
if(document.getElementById('markup-inserter-styles')) return;
|
|
var s = document.createElement('style'); s.id = 'markup-inserter-styles';
|
|
s.textContent = '\n.markup-token{cursor:pointer;display:inline-block;margin:0 .25rem .25rem 0;padding:.25rem .5rem;border-radius:.375rem;font-size:0.85em;}\n.markup-popup{position:fixed;z-index:1060;background:#fff;border:1px solid rgba(0,0,0,.12);box-shadow:0 6px 18px rgba(0,0,0,.12);padding:.5rem;border-radius:.25rem;}\n.markup-modal-backdrop{position:fixed;left:0;right:0;top:0;bottom:0;background:rgba(0,0,0,.45);z-index:1080;display:flex;align-items:flex-start;justify-content:center;padding-top:4vh;}\n.markup-modal{background:#fff;border-radius:.375rem;max-width:95%;max-height:85vh;overflow:auto;}\n';
|
|
document.head.appendChild(s);
|
|
}
|
|
|
|
function getDetailUrl(type, pk){
|
|
if(!type || !pk) return null;
|
|
if(type === 'seriesfinding') return window.location.origin + '/atlas/series_finding/' + pk + '/preview/';
|
|
if(type === 'finding') return window.location.origin + '/atlas/finding/' + pk;
|
|
if(type === 'displayset') return window.location.origin + '/atlas/displayset/' + pk + '/preview/';
|
|
if(type === 'case') return window.location.origin + '/atlas/case/' + pk + '/';
|
|
return null;
|
|
}
|
|
|
|
// Reusable modal element
|
|
var __markup_modal = null;
|
|
function ensureModal(){
|
|
if(__markup_modal) return __markup_modal;
|
|
var backdrop = document.createElement('div'); backdrop.className = 'markup-modal-backdrop'; backdrop.style.display='none';
|
|
var modal = document.createElement('div'); modal.className = 'markup-modal card'; modal.style.marginTop='1rem'; modal.style.maxHeight='80vh'; modal.style.overflow='auto';
|
|
var header = document.createElement('div'); header.className='card-header d-flex justify-content-between align-items-center';
|
|
var h = document.createElement('strong'); h.textContent = 'Detail';
|
|
var closeBtn = document.createElement('button'); closeBtn.type='button'; closeBtn.className='btn-close'; closeBtn.addEventListener('click', function(){ backdrop.style.display='none'; modal.querySelector('.card-body') && (modal.querySelector('.card-body').innerHTML=''); });
|
|
header.appendChild(h); header.appendChild(closeBtn);
|
|
var body = document.createElement('div'); body.className='card-body'; body.style.maxHeight='70vh'; body.style.overflow='auto';
|
|
modal.appendChild(header); modal.appendChild(body); backdrop.appendChild(modal); document.body.appendChild(backdrop);
|
|
__markup_modal = { backdrop: backdrop, modal: modal, body: body };
|
|
return __markup_modal;
|
|
}
|
|
|
|
function showModalForUrl(url, title){
|
|
var m = ensureModal();
|
|
m.backdrop.style.display = 'flex';
|
|
if(title){ var h = m.modal.querySelector('strong'); if(h) h.textContent = title; }
|
|
m.body.innerHTML = '<div class="p-3 text-muted">Loading…</div>';
|
|
fetch(url, {credentials:'same-origin'}).then(function(r){ return r.text(); }).then(function(html){ m.body.innerHTML = html; }).catch(function(e){ m.body.innerHTML = '<div class="p-3 text-danger">Failed to load</div>'; console.error('modal fetch error', e); });
|
|
}
|
|
|
|
function showPopupFor(element){
|
|
// remove existing
|
|
var existing = document.getElementById('markup-popup'); if(existing) existing.remove();
|
|
var type = element.getAttribute('data-type'); var pk = element.getAttribute('data-pk'); var label = element.getAttribute('data-label') || element.textContent || '';
|
|
var popup = document.createElement('div'); popup.id = 'markup-popup'; popup.className = 'markup-popup';
|
|
popup.innerHTML = '<div style="font-weight:600;margin-bottom:.25rem;">'+ (label||type+':'+pk) +'</div>';
|
|
var openBtn = document.createElement('button'); openBtn.type='button'; openBtn.className='btn btn-sm btn-outline-primary me-2'; openBtn.textContent='Open';
|
|
openBtn.addEventListener('click', function(){
|
|
var url = getDetailUrl(type, pk);
|
|
try{
|
|
// Prefer page-specific modal loaders if present so we reuse existing Bootstrap modals/viewers
|
|
if(type === 'displayset'){
|
|
if(typeof window.loadDisplaysetModal === 'function'){
|
|
window.loadDisplaysetModal(url, pk);
|
|
popup.remove();
|
|
return;
|
|
}
|
|
var dsBtn = document.querySelector('.view-displayset-modal[data-ds-id="'+pk+'"]');
|
|
if(dsBtn){ dsBtn.click(); popup.remove(); return; }
|
|
}
|
|
if(type === 'seriesfinding' || type === 'finding'){
|
|
if(typeof window.loadFindingModal === 'function'){
|
|
window.loadFindingModal(pk, url);
|
|
popup.remove();
|
|
return;
|
|
}
|
|
var fBtn = document.querySelector('.view-finding-modal[data-finding-id="'+pk+'"]');
|
|
if(fBtn){ fBtn.click(); popup.remove(); return; }
|
|
}
|
|
}catch(e){ console.warn('error invoking page modal loader', e); }
|
|
if(url) showModalForUrl(url, label);
|
|
popup.remove();
|
|
});
|
|
var closeBtn = document.createElement('button'); closeBtn.type='button'; closeBtn.className='btn btn-sm btn-outline-secondary'; closeBtn.textContent='Close'; closeBtn.addEventListener('click', function(){ popup.remove(); });
|
|
popup.appendChild(openBtn); popup.appendChild(closeBtn);
|
|
document.body.appendChild(popup);
|
|
// position near element
|
|
try{
|
|
var r = element.getBoundingClientRect();
|
|
popup.style.top = (window.scrollY + r.bottom + 6) + 'px';
|
|
popup.style.left = Math.min(window.innerWidth - 240, window.scrollX + r.left) + 'px';
|
|
popup.style.minWidth = '160px';
|
|
}catch(e){}
|
|
// clicking outside closes it
|
|
setTimeout(function(){
|
|
var onDoc = function(ev){ if(!popup.contains(ev.target) && ev.target !== element) { popup.remove(); document.removeEventListener('click', onDoc); } };
|
|
document.addEventListener('click', onDoc);
|
|
}, 10);
|
|
}
|
|
|
|
// Try to open a modal for a token (prefer page-specific modal loaders)
|
|
function openModalForToken(element){
|
|
var type = element.getAttribute('data-type'); var pk = element.getAttribute('data-pk'); var label = element.getAttribute('data-label') || element.textContent || '';
|
|
var url = getDetailUrl(type, pk);
|
|
try{
|
|
if(type === 'displayset'){
|
|
if(typeof window.loadDisplaysetModal === 'function'){
|
|
window.loadDisplaysetModal(url, pk); return;
|
|
}
|
|
var dsBtn = document.querySelector('.view-displayset-modal[data-ds-id="'+pk+'"]');
|
|
if(dsBtn){ dsBtn.click(); return; }
|
|
}
|
|
if(type === 'seriesfinding' || type === 'finding'){
|
|
if(typeof window.loadFindingModal === 'function'){
|
|
window.loadFindingModal(pk, url); return;
|
|
}
|
|
var fBtn = document.querySelector('.view-finding-modal[data-finding-id="'+pk+'"]');
|
|
if(fBtn){ fBtn.click(); return; }
|
|
}
|
|
}catch(e){ console.warn('error invoking page modal loader', e); }
|
|
if(url) showModalForUrl(url, label);
|
|
}
|
|
|
|
// Replace tokens in text nodes with styled spans
|
|
function renderMarkupTokens(root){
|
|
injectMarkupStyles();
|
|
root = root || document.body;
|
|
// Convert server-rendered anchor tokens into our interactive spans
|
|
try{
|
|
if(root.querySelectorAll){
|
|
root.querySelectorAll('a.markup-link').forEach(function(a){
|
|
try{
|
|
var type = a.getAttribute('data-type') || '';
|
|
var args = [];
|
|
try{ args = JSON.parse(a.getAttribute('data-args') || '[]'); }catch(e){ args = []; }
|
|
var pk = (args && args.length) ? (args[0] + '') : (a.getAttribute('data-pk') || '');
|
|
var label = a.textContent ? a.textContent.trim() : null;
|
|
var span = document.createElement('span');
|
|
span.className = 'markup-token';
|
|
if(type) span.setAttribute('data-type', type);
|
|
if(pk) span.setAttribute('data-pk', pk);
|
|
if(label) span.setAttribute('data-label', label);
|
|
if(type === 'case') span.className += ' badge bg-primary text-white';
|
|
else if(type === 'displayset') span.className += ' badge bg-success text-white';
|
|
else span.className += ' badge bg-info text-dark';
|
|
span.textContent = label ? label : (type + ':' + pk);
|
|
span.addEventListener('click', function(e){ e.stopPropagation(); openModalForToken(span); });
|
|
a.parentNode.replaceChild(span, a);
|
|
}catch(e){}
|
|
});
|
|
}
|
|
}catch(e){}
|
|
var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null, false);
|
|
var nodes = [];
|
|
while(walker.nextNode()) nodes.push(walker.currentNode);
|
|
var re = /\[\[(seriesfinding|finding|displayset|case):(\d+)(?:\|([^\]]+))?\]\]/gi;
|
|
nodes.forEach(function(textNode){
|
|
if(!textNode.nodeValue || !re.test(textNode.nodeValue)) return;
|
|
var parentTag = textNode.parentElement && textNode.parentElement.tagName ? textNode.parentElement.tagName.toLowerCase() : '';
|
|
if(['script','style','textarea','code','pre','a','button'].includes(parentTag)) return;
|
|
// rebuild the fragment
|
|
var frag = document.createDocumentFragment();
|
|
var txt = textNode.nodeValue;
|
|
var lastIndex = 0; var m;
|
|
re.lastIndex = 0;
|
|
while((m = re.exec(txt)) !== null){
|
|
var before = txt.slice(lastIndex, m.index);
|
|
if(before) frag.appendChild(document.createTextNode(before));
|
|
var type = m[1].toLowerCase(); var pk = m[2]; var label = m[3] ? m[3] : null;
|
|
var span = document.createElement('span'); span.className = 'markup-token'; span.setAttribute('data-type', type); span.setAttribute('data-pk', pk); if(label) span.setAttribute('data-label', label);
|
|
// style by type
|
|
if(type === 'case') span.className += ' badge bg-primary text-white';
|
|
else if(type === 'displayset') span.className += ' badge bg-success text-white';
|
|
else span.className += ' badge bg-info text-dark';
|
|
span.textContent = label ? label : (type + ':' + pk);
|
|
span.addEventListener('click', function(e){ e.stopPropagation(); openModalForToken(span); });
|
|
frag.appendChild(span);
|
|
lastIndex = re.lastIndex;
|
|
}
|
|
var rest = txt.slice(lastIndex); if(rest) frag.appendChild(document.createTextNode(rest));
|
|
textNode.parentNode.replaceChild(frag, textNode);
|
|
});
|
|
|
|
// Ensure any server-rendered span.markup-token elements have click handlers
|
|
try{
|
|
if(root.querySelectorAll){
|
|
root.querySelectorAll('span.markup-token').forEach(function(span){
|
|
try{
|
|
if(span._markupBound) return;
|
|
span._markupBound = true;
|
|
// ensure data attributes are present
|
|
var type = span.getAttribute('data-type') || '';
|
|
var pk = span.getAttribute('data-pk') || '';
|
|
var label = span.getAttribute('data-label') || span.textContent || '';
|
|
span.addEventListener('click', function(e){ e.stopPropagation(); openModalForToken(span); });
|
|
}catch(e){}
|
|
});
|
|
}
|
|
}catch(e){}
|
|
}
|
|
|
|
// Run rendering on DOMContentLoaded and after HTMX swaps
|
|
document.addEventListener('DOMContentLoaded', function(){ try{ renderMarkupTokens(document.body); }catch(e){console.error(e);} });
|
|
document.addEventListener('htmx:afterSwap', function(evt){ try{ var target = evt.detail && evt.detail.target ? evt.detail.target : document; renderMarkupTokens(target); }catch(e){/*ignore*/} });
|
|
|
|
})(window);
|
|
|