Files
inter-hub/static/js/ihf-annotation-launcher.js
Bernd Worsch 04eb4643b0 feat(P6/T05): cross-framework annotation launcher JS widget
ihf-annotation-launcher.js: vanilla JS, no framework dependency. Scans DOM for
data-widget-id elements, injects Annotate trigger, opens inline form, POSTs to
/widgets/:widgetId/annotations. Works in React/Vue-rendered pages via
MutationObserver. Feature-gated by IHP_ANNOTATION_LAUNCHER=true env var
(Config.hs AnnotationLauncherEnabled, FrontController layout conditional).
Docs: docs/annotation-launcher.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 21:17:43 +00:00

261 lines
8.8 KiB
JavaScript

/**
* 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 <script> tag is emitted at all.
*
* Data attributes read:
* data-widget-id — UUID of the widget (required)
* data-hub-id — UUID of the owning hub (read from element or nearest ancestor)
*
* The annotation form POSTs to /annotations (IHP's existing AnnotationsController).
*
* Usage (static HTML test):
* <div data-widget-id="<uuid>" data-hub-id="<uuid>" data-view-context="main">
* <button>My Widget</button>
* </div>
* <script src="/js/ihf-annotation-launcher.js"></script>
*/
(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 '<option value="' + o.value + '">' + o.label + '</option>';
}).join('');
form.innerHTML = [
'<div style="font-weight:600;margin-bottom:8px;color:#374151">Add Annotation</div>',
'<textarea id="ihf-body" placeholder="Describe your feedback…"',
' style="width:100%;height:70px;resize:vertical;border:1px solid #d1d5db;',
' border-radius:4px;padding:6px;font-size:12px;box-sizing:border-box"></textarea>',
'<select id="ihf-category"',
' style="width:100%;margin-top:6px;border:1px solid #d1d5db;border-radius:4px;',
' padding:5px;font-size:12px">',
categoryOptions,
'</select>',
'<div style="display:flex;gap:6px;margin-top:8px">',
' <button id="ihf-submit"',
' style="background:#4f46e5;color:#fff;border:none;border-radius:4px;',
' padding:5px 12px;font-size:12px;cursor:pointer">Submit</button>',
' <button id="ihf-cancel"',
' style="background:transparent;border:1px solid #d1d5db;border-radius:4px;',
' padding:5px 10px;font-size:12px;cursor:pointer">Cancel</button>',
' <span id="ihf-msg" style="align-self:center;font-size:11px;color:#6b7280"></span>',
'</div>',
].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 };
})();