/**
* 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 };
})();