generated from coulomb/repo-seed
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>
This commit is contained in:
@@ -3,9 +3,13 @@ module Config where
|
|||||||
import IHP.Prelude
|
import IHP.Prelude
|
||||||
import IHP.Environment
|
import IHP.Environment
|
||||||
import IHP.FrameworkConfig
|
import IHP.FrameworkConfig
|
||||||
|
import System.Environment (lookupEnv)
|
||||||
|
|
||||||
|
-- | Feature flag: set IHP_ANNOTATION_LAUNCHER=true to inject the JS launcher.
|
||||||
|
newtype AnnotationLauncherEnabled = AnnotationLauncherEnabled Bool deriving (Typeable)
|
||||||
|
|
||||||
config :: ConfigBuilder
|
config :: ConfigBuilder
|
||||||
config = do
|
config = do
|
||||||
-- See https://ihp.digitallyinduced.com/Guide/config.html
|
launcherEnv <- liftIO (lookupEnv "IHP_ANNOTATION_LAUNCHER")
|
||||||
-- for what you can do here
|
option (AnnotationLauncherEnabled (launcherEnv == Just "true"))
|
||||||
pure ()
|
pure ()
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ module Web.FrontController where
|
|||||||
|
|
||||||
import IHP.RouterPrelude
|
import IHP.RouterPrelude
|
||||||
import IHP.LoginSupport.Middleware
|
import IHP.LoginSupport.Middleware
|
||||||
|
import IHP.ControllerPrelude (getAppConfig)
|
||||||
import Generated.Types
|
import Generated.Types
|
||||||
import Web.Types
|
import Web.Types
|
||||||
import Web.Routes ()
|
import Web.Routes ()
|
||||||
|
import Config (AnnotationLauncherEnabled (..))
|
||||||
|
|
||||||
-- Controllers
|
-- Controllers
|
||||||
import Web.Controller.Hubs ()
|
import Web.Controller.Hubs ()
|
||||||
@@ -47,6 +49,13 @@ instance InitControllerContext WebApplication where
|
|||||||
setLayout defaultLayout
|
setLayout defaultLayout
|
||||||
initAuthentication @User
|
initAuthentication @User
|
||||||
|
|
||||||
|
annotationLauncherScript :: (?context :: ControllerContext) => Html
|
||||||
|
annotationLauncherScript =
|
||||||
|
let AnnotationLauncherEnabled enabled = getAppConfig @AnnotationLauncherEnabled
|
||||||
|
in if enabled
|
||||||
|
then [hsx|<script src="/js/ihf-annotation-launcher.js"></script>|]
|
||||||
|
else mempty
|
||||||
|
|
||||||
defaultLayout :: Layout
|
defaultLayout :: Layout
|
||||||
defaultLayout inner = [hsx|
|
defaultLayout inner = [hsx|
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
@@ -59,6 +68,7 @@ defaultLayout inner = [hsx|
|
|||||||
<link rel="stylesheet" href="/app.css" />
|
<link rel="stylesheet" href="/app.css" />
|
||||||
<script src="/vendor/morphdom.js"></script>
|
<script src="/vendor/morphdom.js"></script>
|
||||||
<script src="/vendor/ihp-auto-refresh.js"></script>
|
<script src="/vendor/ihp-auto-refresh.js"></script>
|
||||||
|
{annotationLauncherScript}
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-50 text-gray-900">
|
<body class="bg-gray-50 text-gray-900">
|
||||||
<nav class="bg-white border-b border-gray-200 px-6 py-3 flex items-center gap-6">
|
<nav class="bg-white border-b border-gray-200 px-6 py-3 flex items-center gap-6">
|
||||||
|
|||||||
79
docs/annotation-launcher.md
Normal file
79
docs/annotation-launcher.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# IHF Annotation Launcher
|
||||||
|
|
||||||
|
`static/js/ihf-annotation-launcher.js` is a self-contained vanilla JS module
|
||||||
|
that injects an **Annotate** button adjacent to every element that carries a
|
||||||
|
`data-widget-id` attribute.
|
||||||
|
|
||||||
|
It works in IHP server-rendered pages **and** in React/Vue pages where IHP does
|
||||||
|
not own the DOM — the launcher relies solely on the presence of `data-widget-id`,
|
||||||
|
not on any framework-specific structure.
|
||||||
|
|
||||||
|
## Activation
|
||||||
|
|
||||||
|
Set the environment variable before starting the server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
IHP_ANNOTATION_LAUNCHER=true devenv up
|
||||||
|
```
|
||||||
|
|
||||||
|
This causes IHP to emit the following tag in every page's `<head>`:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script src="/js/ihf-annotation-launcher.js"></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
When `IHP_ANNOTATION_LAUNCHER` is absent or not `"true"`, the script is not
|
||||||
|
loaded.
|
||||||
|
|
||||||
|
## Data attributes
|
||||||
|
|
||||||
|
| Attribute | Required | Description |
|
||||||
|
|--------------------|----------|--------------------------------------------------|
|
||||||
|
| `data-widget-id` | Yes | UUID of the widget (from the widget registry) |
|
||||||
|
| `data-hub-id` | No | UUID of the hub — read from element or nearest ancestor |
|
||||||
|
| `data-view-context`| No | Logical UI location (informational) |
|
||||||
|
|
||||||
|
## Usage in React
|
||||||
|
|
||||||
|
Because the launcher uses `MutationObserver`, it will pick up React-rendered
|
||||||
|
elements after mount:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
function MyWidget({ widgetId, hubId }) {
|
||||||
|
return (
|
||||||
|
<div data-widget-id={widgetId} data-hub-id={hubId}>
|
||||||
|
<button>Do something</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
No other integration is required. The launcher injects the Annotate button and
|
||||||
|
handles POST submission to `/widgets/:widgetId/annotations`.
|
||||||
|
|
||||||
|
## Programmatic re-scan
|
||||||
|
|
||||||
|
If your SPA does client-side navigation without full page reloads, call:
|
||||||
|
|
||||||
|
```js
|
||||||
|
window.IHFAnnotationLauncher.scan();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Submission
|
||||||
|
|
||||||
|
The launcher POSTs `application/x-www-form-urlencoded` to:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /widgets/:widgetId/annotations
|
||||||
|
```
|
||||||
|
|
||||||
|
Fields submitted: `body`, `category`, `severity` (fixed `low`), CSRF token.
|
||||||
|
|
||||||
|
Responses:
|
||||||
|
- `200` or redirect → success (form closes with confirmation)
|
||||||
|
- Any non-OK response → inline error message
|
||||||
|
|
||||||
|
## CSRF
|
||||||
|
|
||||||
|
The launcher reads the CSRF token from `<meta name="csrf-token" content="...">`.
|
||||||
|
IHP emits this tag automatically in authenticated layouts.
|
||||||
260
static/js/ihf-annotation-launcher.js
Normal file
260
static/js/ihf-annotation-launcher.js
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
/**
|
||||||
|
* 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 };
|
||||||
|
})();
|
||||||
@@ -190,7 +190,7 @@ an `InteractionEvent`; invalid payloads return `422`; contract show page renders
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: IHUB-WP-0006-T04
|
id: IHUB-WP-0006-T04
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "e84016d0-60c0-48cb-ad70-2c054d2530db"
|
state_hub_task_id: "e84016d0-60c0-48cb-ad70-2c054d2530db"
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user