b232b4b3d3
- Mentions now insert a span carrying the user id (handles names with spaces): the sanitizer permits <span class="mention" data-user-card="ULID">, comment and chat notifications resolve by id and only fire for users with access, and mentions render as clickable chips with the shared hover user-card. - Messages composer inserts an atomic, non-editable mention chip so the trailing space no longer collapses while typing. - Links inside chat messages and task comments are underlined/accented so they read as clickable. - Atomization: hold Ctrl/Cmd while dragging an effort slider to rebalance the sibling coefficients proportionally (sum stays ~1.00); added a hint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
169 lines
6.1 KiB
JavaScript
169 lines
6.1 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;
|
||
el._mentions = el._mentions || new Map(); // display name -> user id
|
||
|
||
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) {
|
||
el._mentions.set(u.name, u.id);
|
||
if (isCE) {
|
||
// insert an atomic mention chip + a trailing space in the text node
|
||
const node = ctx.node;
|
||
const full = node.textContent;
|
||
const after = full.slice(ctx.end);
|
||
node.textContent = full.slice(0, ctx.start);
|
||
const span = document.createElement('span');
|
||
span.className = 'mention';
|
||
span.dataset.userCard = u.id;
|
||
span.contentEditable = 'false';
|
||
span.textContent = '@' + u.name;
|
||
// the chip is an atomic, non-editable element, so the trailing space in
|
||
// this separate text node stays put as the user keeps typing
|
||
const tail = document.createTextNode(' ' + after);
|
||
const parent = node.parentNode;
|
||
const ref = node.nextSibling;
|
||
parent.insertBefore(span, ref);
|
||
parent.insertBefore(tail, ref);
|
||
const sel = window.getSelection();
|
||
const range = document.createRange();
|
||
range.setStart(tail, 1);
|
||
range.collapse(true);
|
||
sel.removeAllRanges();
|
||
sel.addRange(range);
|
||
} else {
|
||
const text = '@' + u.name + ' ';
|
||
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));
|
||
}
|
||
|
||
// mentionHTML turns the plain text of a textarea/input into safe HTML, wrapping
|
||
// any recorded "@Name" run in a mention span carrying the user id. Names with
|
||
// spaces are matched exactly (longest first) against what the picker inserted.
|
||
export function mentionHTML(text, el) {
|
||
const mentions = (el && el._mentions) || new Map();
|
||
const names = [...mentions.keys()].sort((a, b) => b.length - a.length);
|
||
let out = '';
|
||
let i = 0;
|
||
while (i < text.length) {
|
||
if (text[i] === '@') {
|
||
const name = names.find((n) => text.startsWith('@' + n, i));
|
||
if (name) {
|
||
out += `<span class="mention" data-user-card="${escapeHtml(mentions.get(name))}">@${escapeHtml(name)}</span>`;
|
||
i += 1 + name.length;
|
||
continue;
|
||
}
|
||
}
|
||
out += escapeHtml(text[i]);
|
||
i += 1;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
return String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||
}[c]));
|
||
}
|