Files
etalon b232b4b3d3 feat: structured @-mentions (notify+clickable+hover), distinct links, Ctrl-rebalance sliders
- 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>
2026-06-13 12:09:17 +02:00

169 lines
6.1 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @-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) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[c]));
}