/** * IHF Annotation Launcher — ihf-annotation-launcher.js * * A self-contained vanilla JS module (no framework dependency) that injects * an "Annotate" trigger adjacent to every element carrying a `data-widget-id` * attribute. Works in IHP server-rendered pages and in React/Vue pages where * IHP does not own the DOM. * * Activation: include this script in the page layout (or load it dynamically). * It is gated by the IHP_ANNOTATION_LAUNCHER environment variable on the server, * which controls whether the */ (function () { 'use strict'; var LAUNCHER_CLASS = 'ihf-launcher-injected'; var TRIGGER_CLASS = 'ihf-annotate-trigger'; var FORM_CLASS = 'ihf-annotate-form'; var CATEGORY_OPTIONS = [ { value: 'friction', label: 'Friction' }, { value: 'suggestion', label: 'Suggestion' }, { value: 'confusing', label: 'Confusing' }, { value: 'praise', label: 'Praise' }, { value: 'bug', label: 'Bug' }, { value: 'other', label: 'Other' }, ]; /** Find data-hub-id from element or nearest ancestor. */ function resolveHubId(el) { var cur = el; while (cur && cur !== document.body) { var v = cur.getAttribute('data-hub-id'); if (v) return v; cur = cur.parentElement; } return null; } /** Build the inline annotation form element. */ function buildForm(widgetId, hubId, trigger) { var form = document.createElement('div'); form.className = FORM_CLASS; form.style.cssText = [ 'position:absolute', 'z-index:9999', 'background:#fff', 'border:1px solid #d1d5db', 'border-radius:8px', 'padding:12px', 'box-shadow:0 4px 16px rgba(0,0,0,0.12)', 'width:280px', 'font-family:sans-serif', 'font-size:13px', ].join(';'); var categoryOptions = CATEGORY_OPTIONS.map(function (o) { return ''; }).join(''); form.innerHTML = [ '
Add Annotation
', '', '', '
', ' ', ' ', ' ', '
', ].join(''); // Position below the trigger button. function reposition() { var rect = trigger.getBoundingClientRect(); form.style.top = (window.scrollY + rect.bottom + 4) + 'px'; form.style.left = (window.scrollX + rect.left) + 'px'; } document.body.appendChild(form); form.style.position = 'absolute'; reposition(); var bodyEl = form.querySelector('#ihf-body'); var catEl = form.querySelector('#ihf-category'); var submitBtn = form.querySelector('#ihf-submit'); var cancelBtn = form.querySelector('#ihf-cancel'); var msgEl = form.querySelector('#ihf-msg'); function closeForm() { if (form.parentNode) form.parentNode.removeChild(form); } cancelBtn.addEventListener('click', closeForm); // Close on outside click. function onOutside(e) { if (!form.contains(e.target) && e.target !== trigger) { closeForm(); document.removeEventListener('mousedown', onOutside); } } setTimeout(function () { document.addEventListener('mousedown', onOutside); }, 10); submitBtn.addEventListener('click', function () { var body = bodyEl.value.trim(); var category = catEl.value; if (!body) { msgEl.textContent = 'Please enter feedback.'; msgEl.style.color = '#ef4444'; return; } submitBtn.disabled = true; msgEl.textContent = 'Saving…'; msgEl.style.color = '#6b7280'; // Fetch CSRF token from meta tag if present (IHP injects it). var csrfMeta = document.querySelector('meta[name="csrf-token"]'); var csrfToken = csrfMeta ? csrfMeta.getAttribute('content') : ''; // Build form-encoded body matching IHP's AnnotationsController. var payload = [ 'body=' + encodeURIComponent(body), 'category=' + encodeURIComponent(category), 'severity=low', 'IHFToken=' + encodeURIComponent(csrfToken), ].join('&'); fetch('/widgets/' + widgetId + '/annotations', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRF-Token': csrfToken, }, body: payload, }) .then(function (res) { if (res.ok || res.redirected) { msgEl.textContent = '✓ Saved'; msgEl.style.color = '#16a34a'; setTimeout(closeForm, 800); } else { return res.text().then(function (t) { throw new Error('Server returned ' + res.status); }); } }) .catch(function (err) { submitBtn.disabled = false; msgEl.textContent = 'Error: ' + err.message; msgEl.style.color = '#ef4444'; }); }); bodyEl.focus(); return form; } /** Inject an Annotate trigger button adjacent to a widget envelope. */ function injectTrigger(el) { if (el.classList.contains(LAUNCHER_CLASS)) return; el.classList.add(LAUNCHER_CLASS); var widgetId = el.getAttribute('data-widget-id'); var hubId = resolveHubId(el); if (!widgetId) return; var trigger = document.createElement('button'); trigger.className = TRIGGER_CLASS; trigger.textContent = '+ Annotate'; trigger.setAttribute('type', 'button'); trigger.setAttribute('title', 'Add annotation for widget ' + widgetId); trigger.style.cssText = [ 'font-size:11px', 'color:#6b7280', 'background:transparent', 'border:1px solid #e5e7eb', 'border-radius:4px', 'padding:2px 8px', 'cursor:pointer', 'margin-top:4px', ].join(';'); trigger.addEventListener('mouseover', function () { trigger.style.color = '#4f46e5'; trigger.style.borderColor = '#a5b4fc'; }); trigger.addEventListener('mouseout', function () { trigger.style.color = '#6b7280'; trigger.style.borderColor = '#e5e7eb'; }); var activeForm = null; trigger.addEventListener('click', function (e) { e.stopPropagation(); if (activeForm && activeForm.parentNode) { activeForm.parentNode.removeChild(activeForm); activeForm = null; return; } activeForm = buildForm(widgetId, hubId, trigger); }); el.appendChild(trigger); } /** Scan the DOM for widget envelopes and inject triggers. */ function scan() { var envelopes = document.querySelectorAll('[data-widget-id]'); for (var i = 0; i < envelopes.length; i++) { injectTrigger(envelopes[i]); } } // Initial scan on DOM ready. if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', scan); } else { scan(); } // Re-scan when IHP AutoRefresh updates the DOM (morphdom fires a custom event). document.addEventListener('ihp:morphdom:updated', scan); // Also observe mutations for React/Vue SPA rendering. if (typeof MutationObserver !== 'undefined') { var observer = new MutationObserver(function (mutations) { var shouldScan = false; for (var i = 0; i < mutations.length; i++) { if (mutations[i].addedNodes.length > 0) { shouldScan = true; break; } } if (shouldScan) scan(); }); observer.observe(document.body, { childList: true, subtree: true }); } // Expose for programmatic re-scanning. window.IHFAnnotationLauncher = { scan: scan }; })();