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>
This commit is contained in:
@@ -22,6 +22,7 @@ export function attachMentions(el) {
|
||||
let items = [];
|
||||
let active = 0;
|
||||
let seq = 0;
|
||||
el._mentions = el._mentions || new Map(); // display name -> user id
|
||||
|
||||
function close() {
|
||||
menu.hidden = true;
|
||||
@@ -89,18 +90,33 @@ export function attachMentions(el) {
|
||||
}
|
||||
|
||||
function pick(u, ctx) {
|
||||
const text = '@' + u.name + ' ';
|
||||
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 t = node.textContent;
|
||||
node.textContent = t.slice(0, ctx.start) + text + t.slice(ctx.end);
|
||||
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(node, ctx.start + text.length);
|
||||
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;
|
||||
@@ -122,6 +138,29 @@ export function attachMentions(el) {
|
||||
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) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
|
||||
Reference in New Issue
Block a user