Files
BountyBoard/web/static/js/mention.js
T
etalon 70cb664246 feat: @-mention autocomplete; consultant bounty-board access; clickable atomization tasks
- @-mentions: typing "@" in task comments, the messages composer and the chat
  widget now opens a user picker (new shared mention.js) that inserts "@Name ".
  Backed by the existing /api/v1/users search; menus de-dupe per field.
- Consultants can now open the bounty board (read-only): nav link + page/API
  access extended to consultants, board visibility resolves their assigned
  customers, and cards show "Open" instead of claim actions for non-developers.
- Atomization board task titles (roots and subtasks) link to /tasks/{id} so
  consultants can reach the detail view (comments, assignment, timeline).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 11:54:44 +02:00

130 lines
4.6 KiB
JavaScript

// @-mention autocomplete. Attaches to a <textarea>/<input> or a
// contenteditable element: typing "@" + letters shows a user picker; choosing
// one inserts "@Name ". While the menu is open the host element has
// data-mention-active="1" so the host's own Enter handler can defer to us.
import { api } from '/static/js/api.js';
const TOKEN_RE = /(?:^|\s)@([\w.\-]{0,30})$/;
export function attachMentions(el) {
const isCE = el.isContentEditable;
// drop any stale menu left over from a previous attach on the same field
// (task/comment views recreate their composer on every re-render)
if (el.id) {
document.querySelectorAll(`.mention-menu[data-mention-for="${el.id}"]`).forEach((m) => m.remove());
}
const menu = document.createElement('div');
menu.className = 'mention-menu';
menu.hidden = true;
if (el.id) menu.dataset.mentionFor = el.id;
document.body.appendChild(menu);
let items = [];
let active = 0;
let seq = 0;
function close() {
menu.hidden = true;
items = [];
delete el.dataset.mentionActive;
}
function context() {
if (isCE) {
const sel = window.getSelection();
if (!sel || !sel.rangeCount) return null;
const range = sel.getRangeAt(0);
const node = range.startContainer;
if (node.nodeType !== 3) return null;
const before = node.textContent.slice(0, range.startOffset);
const m = before.match(TOKEN_RE);
if (!m) return null;
return { query: m[1], node, end: range.startOffset, start: range.startOffset - m[1].length - 1 };
}
const pos = el.selectionStart;
const m = el.value.slice(0, pos).match(TOKEN_RE);
if (!m) return null;
return { query: m[1], end: pos, start: pos - m[1].length - 1 };
}
async function update() {
const ctx = context();
if (!ctx) { close(); return; }
const mySeq = ++seq;
let users;
try { users = (await api('GET', `/api/v1/users?q=${encodeURIComponent(ctx.query)}`)).users; }
catch (e) { return; }
if (mySeq !== seq) return; // a newer keystroke superseded this fetch
const ctx2 = context();
if (!ctx2) { close(); return; }
items = users.slice(0, 6);
if (!items.length) { close(); return; }
active = 0;
render(ctx2);
}
function render(ctx) {
menu.replaceChildren(...items.map((u, i) => {
const row = document.createElement('button');
row.type = 'button';
row.className = 'mention-row' + (i === active ? ' active' : '');
row.innerHTML = `<strong>${escapeHtml(u.name)}</strong> <span class="muted">${escapeHtml(u.email)}</span>`;
row.addEventListener('mousedown', (e) => { e.preventDefault(); pick(u, ctx); });
return row;
}));
position();
menu.hidden = false;
el.dataset.mentionActive = '1';
}
function position() {
const rect = el.getBoundingClientRect();
menu.style.left = `${rect.left + window.scrollX}px`;
menu.style.top = `${rect.bottom + window.scrollY + 2}px`;
menu.style.minWidth = `${Math.min(Math.max(rect.width, 220), 360)}px`;
}
function highlight() {
[...menu.children].forEach((c, i) => c.classList.toggle('active', i === active));
}
function pick(u, ctx) {
const text = '@' + u.name + ' ';
if (isCE) {
const node = ctx.node;
const t = node.textContent;
node.textContent = t.slice(0, ctx.start) + text + t.slice(ctx.end);
const sel = window.getSelection();
const range = document.createRange();
range.setStart(node, ctx.start + text.length);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
} else {
const v = el.value;
el.value = v.slice(0, ctx.start) + text + v.slice(ctx.end);
const caret = ctx.start + text.length;
el.setSelectionRange(caret, caret);
}
close();
el.focus();
el.dispatchEvent(new Event('input', { bubbles: true }));
}
el.addEventListener('input', update);
el.addEventListener('keydown', (e) => {
if (menu.hidden) return;
if (e.key === 'ArrowDown') { e.preventDefault(); active = (active + 1) % items.length; highlight(); }
else if (e.key === 'ArrowUp') { e.preventDefault(); active = (active - 1 + items.length) % items.length; highlight(); }
else if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); e.stopPropagation(); pick(items[active], context()); }
else if (e.key === 'Escape') { e.preventDefault(); close(); }
});
el.addEventListener('blur', () => setTimeout(close, 150));
}
function escapeHtml(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[c]));
}