/** * otel-todo.js - file todos / issues straight from the page. * * Shows a small floating symbol and listens for a keyboard shortcut. Opens a * native in the bottom-right corner with a text box, an optional * component picker, and an "Reference an element" toggle that lets you click * any node on the page (with a hover outline) to attach its CSS selector and * a short label to the note. POSTs the note (with the current path, hash and * any picked element) to the collector's /v1/todos endpoint. * * * * Attributes (or OtelTodo.init({...}) keys): data-base-url, data-service-name * (the project), data-ingest-key, data-components (comma list, also * discovered from [data-component] elements on the page), data-shortcut * (default "alt+shift+t"), data-todo-endpoint, data-hide-button="true". */ (function (global) { 'use strict'; var DEFAULT_BASE_URL = 'https://otel.k6n.net'; var scriptEl = document.currentScript; function attr(name) { return (scriptEl && scriptEl.getAttribute(name)) || ''; } function parseShortcut(s) { var parts = String(s).toLowerCase().split('+'); var sc = { key: parts.pop(), alt: false, shift: false, ctrl: false, meta: false }; parts.forEach(function (p) { if (p === 'alt' || p === 'option') sc.alt = true; else if (p === 'shift') sc.shift = true; else if (p === 'ctrl' || p === 'control') sc.ctrl = true; else if (p === 'meta' || p === 'cmd') sc.meta = true; }); return sc; } function init(config) { config = config || {}; if (global.OtelTodo && global.OtelTodo.instance) return global.OtelTodo.instance; var baseUrl = (config.baseUrl || attr('data-base-url') || DEFAULT_BASE_URL).replace(/\/+$/, ''); var endpoint = config.endpoint || attr('data-todo-endpoint') || baseUrl + '/v1/todos'; var project = config.serviceName || attr('data-service-name') || attr('data-service') || 'frontend'; var ingestKey = config.ingestKey || attr('data-ingest-key'); var shortcut = parseShortcut(config.shortcut || attr('data-shortcut') || 'alt+shift+t'); var hideButton = config.hideButton || attr('data-hide-button') === 'true'; var staticComponents = config.components || attr('data-components').split(','); staticComponents = staticComponents.map(function (c) { return c.trim(); }).filter(Boolean); var sessionId = ''; try { sessionId = global.sessionStorage.getItem('otel-rum-session') || ''; } catch (e) {} var css = document.createElement('style'); css.textContent = '.otel-todo-btn{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:32px;height:32px;border-radius:50%;' + 'border:1px solid #8886;background:#fffd;color:#333;font:16px/1 system-ui,sans-serif;cursor:pointer;opacity:.55;padding:0}' + '.otel-todo-btn:hover,.otel-todo-btn:focus-visible{opacity:1}' + 'dialog.otel-todo{position:fixed;inset:auto 16px 60px auto;margin:0;width:min(340px,calc(100vw - 32px));padding:12px;' + 'border:1px solid #8886;border-radius:8px;background:Canvas;color:CanvasText;font:13px system-ui,sans-serif}' + 'dialog.otel-todo *{box-sizing:border-box;font:inherit}' + 'dialog.otel-todo textarea,dialog.otel-todo select{display:block;width:100%;margin:6px 0;padding:6px}' + 'dialog.otel-todo textarea{min-height:90px;resize:vertical}' + 'dialog.otel-todo .row{display:flex;gap:8px;justify-content:flex-end;align-items:center}' + 'dialog.otel-todo .meta{opacity:.65;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' + 'dialog.otel-todo .status{margin-right:auto;font-size:12px}' + '.otel-todo-pick{display:inline-flex;align-items:center;gap:5px;border:1px solid #8886;border-radius:6px;' + 'background:transparent;color:inherit;padding:5px 8px;margin:0 0 6px;cursor:pointer;font-size:12px}' + '.otel-todo-pick:hover,.otel-todo-pick:focus-visible{background:#8882}' + '.otel-todo-pick[aria-pressed="true"]{background:#3b82f633;border-color:#3b82f6aa;color:#3b82f6}' + '.otel-todo-ref{display:none;align-items:center;gap:6px;margin:0 0 6px;padding:5px 7px;border:1px solid #8886;' + 'border-radius:6px;background:#8881;font:11px/1.3 ui-monospace,monospace}' + '.otel-todo-ref.show{display:flex}' + '.otel-todo-ref span{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' + '.otel-todo-ref button{border:0;background:none;cursor:pointer;color:inherit;opacity:.6;font:12px/1 inherit;padding:0 2px}' + '.otel-todo-ref button:hover{opacity:1}' + '.otel-todo-hover-outline{position:fixed;z-index:2147483645;pointer-events:none;border:2px solid #3b82f6;' + 'background:#3b82f626;border-radius:3px;transition:all .05s linear}' + '.otel-todo-pick-hint{position:fixed;left:50%;top:16px;transform:translateX(-50%);z-index:2147483647;' + 'background:#3b82f6;color:#fff;font:12px/1 system-ui,sans-serif;padding:7px 12px;border-radius:20px;' + 'box-shadow:0 4px 14px #0004}'; document.head.appendChild(css); var dlg = document.createElement('dialog'); dlg.className = 'otel-todo'; dlg.setAttribute('aria-label', 'Add todo'); dlg.innerHTML = '
' + '
' + '' + '
' + '' + '' + '
' + '
' + '
'; var form = dlg.firstChild; var textEl = form.elements.text; var selectEl = form.elements.component; var statusEl = form.querySelector('.status'); var metaEl = form.querySelector('.meta'); var pickBtn = form.querySelector('.otel-todo-pick'); var refEl = form.querySelector('.otel-todo-ref'); var refLabelEl = refEl.querySelector('span'); document.body.appendChild(dlg); // --- element picker: click a node on the page to attach its selector --- var picked = null; // { selector, label } var hoverBox = null; var picking = false; var hint = null; function cssPath(el) { if (!(el instanceof Element)) return ''; var parts = []; var node = el; while (node && node.nodeType === 1 && parts.length < 6) { if (node.id) { parts.unshift('#' + CSS.escape(node.id)); break; } var part = node.tagName.toLowerCase(); var cls = (node.getAttribute('class') || '').trim().split(/\s+/).filter(Boolean).slice(0, 2); if (cls.length) part += '.' + cls.map(function (c) { return CSS.escape(c); }).join('.'); var parent = node.parentElement; if (parent) { var siblings = [].filter.call(parent.children, function (c) { return c.tagName === node.tagName; }); if (siblings.length > 1) part += ':nth-of-type(' + (siblings.indexOf(node) + 1) + ')'; } parts.unshift(part); node = parent; } return parts.join(' > '); } function describeElement(el) { var tag = el.tagName.toLowerCase(); var text = (el.getAttribute('aria-label') || el.textContent || '').replace(/\s+/g, ' ').trim(); if (text.length > 60) text = text.slice(0, 60) + '…'; return { selector: cssPath(el), label: text ? tag + ' – "' + text + '"' : tag }; } function setRef(ref) { picked = ref; if (ref) { refLabelEl.textContent = ref.label; refLabelEl.title = ref.selector; refEl.classList.add('show'); } else { refEl.classList.remove('show'); } } function ensureHoverBox() { if (!hoverBox) { hoverBox = document.createElement('div'); hoverBox.className = 'otel-todo-hover-outline'; document.body.appendChild(hoverBox); } return hoverBox; } function onPickMove(e) { var el = document.elementFromPoint(e.clientX, e.clientY); if (!el || dlg.contains(el)) { ensureHoverBox().style.display = 'none'; return; } var r = el.getBoundingClientRect(); var box = ensureHoverBox(); box.style.display = 'block'; box.style.left = r.left + 'px'; box.style.top = r.top + 'px'; box.style.width = r.width + 'px'; box.style.height = r.height + 'px'; } function onPickClick(e) { var el = document.elementFromPoint(e.clientX, e.clientY); if (!el || dlg.contains(el)) return; // let dialog controls work normally e.preventDefault(); e.stopPropagation(); setRef(describeElement(el)); stopPicking(); } function onPickKey(e) { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); stopPicking(); } } function startPicking() { if (picking) return; picking = true; pickBtn.setAttribute('aria-pressed', 'true'); pickBtn.textContent = '⌖ Click an element… (Esc to cancel)'; hint = document.createElement('div'); hint.className = 'otel-todo-pick-hint'; hint.textContent = 'Click the element you mean — Esc to cancel'; document.body.appendChild(hint); document.addEventListener('mousemove', onPickMove, true); document.addEventListener('click', onPickClick, true); document.addEventListener('keydown', onPickKey, true); } function stopPicking() { if (!picking) return; picking = false; pickBtn.setAttribute('aria-pressed', 'false'); pickBtn.textContent = '⌖ Reference an element'; document.removeEventListener('mousemove', onPickMove, true); document.removeEventListener('click', onPickClick, true); document.removeEventListener('keydown', onPickKey, true); if (hoverBox) { hoverBox.style.display = 'none'; } if (hint) { hint.remove(); hint = null; } } pickBtn.addEventListener('click', function () { picking ? stopPicking() : startPicking(); }); refEl.querySelector('[data-clear-ref]').addEventListener('click', function () { setRef(null); }); function components() { var seen = {}; var list = []; staticComponents.concat([].map.call(document.querySelectorAll('[data-component]'), function (el) { return el.getAttribute('data-component'); })).forEach(function (c) { if (c && !seen[c]) { seen[c] = true; list.push(c); } }); return list; } function open() { if (dlg.open) { textEl.focus(); return; } selectEl.innerHTML = ''; var none = document.createElement('option'); none.value = ''; none.textContent = 'No component'; selectEl.appendChild(none); var list = components(); list.forEach(function (c) { var o = document.createElement('option'); o.value = o.textContent = c; selectEl.appendChild(o); }); selectEl.hidden = list.length === 0; metaEl.textContent = project + ' · ' + location.pathname + location.hash; statusEl.textContent = ''; setRef(null); // Non-modal so the page stays usable while the note is being written. dlg.show(); textEl.focus(); } function close() { stopPicking(); if (dlg.open) dlg.close(); } form.querySelector('[data-cancel]').addEventListener('click', close); dlg.addEventListener('keydown', function (e) { if (e.key === 'Escape') { e.preventDefault(); close(); } else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); form.requestSubmit(); } }); form.addEventListener('submit', function (e) { e.preventDefault(); var text = textEl.value.trim(); if (!text) return; var headers = { 'Content-Type': 'application/json' }; var url = endpoint; if (ingestKey) headers['X-Otel-Ingest-Key'] = ingestKey; statusEl.textContent = 'Sending…'; global.fetch(url, { method: 'POST', mode: 'cors', headers: headers, body: JSON.stringify({ project: project, text: text, component: selectEl.value, path: location.pathname, hash: location.hash, url: location.href, ua: navigator.userAgent, session: sessionId, elementSelector: picked ? picked.selector : '', elementLabel: picked ? picked.label : '' }) }).then(function (res) { if (!res.ok) throw new Error('HTTP ' + res.status); textEl.value = ''; setRef(null); close(); }).catch(function (err) { statusEl.textContent = 'Failed: ' + err.message; }); }); global.addEventListener('keydown', function (e) { var k = e.key.toLowerCase(); var code = (e.code || '').replace(/^(Key|Digit)/, '').toLowerCase(); if ((k === shortcut.key || code === shortcut.key) && e.altKey === shortcut.alt && e.shiftKey === shortcut.shift && e.ctrlKey === shortcut.ctrl && e.metaKey === shortcut.meta) { e.preventDefault(); dlg.open ? close() : open(); } }); if (!hideButton) { var btn = document.createElement('button'); btn.type = 'button'; btn.className = 'otel-todo-btn'; btn.textContent = '✎'; btn.title = 'Add todo (' + (config.shortcut || attr('data-shortcut') || 'alt+shift+t') + ')'; btn.setAttribute('aria-label', 'Add todo'); btn.addEventListener('click', function () { dlg.open ? close() : open(); }); document.body.appendChild(btn); } var instance = { open: open, close: close }; global.OtelTodo.instance = instance; return instance; } global.OtelTodo = { init: init, instance: undefined }; if (scriptEl && (attr('data-auto-init') === 'true' || attr('data-service-name') || attr('data-service'))) { if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function () { init(); }); else init(); } })(window);