Quelltext des aktuellen Standes
/**
* @Mention-Autocomplete für den Ticket-Editor (WYSIWYG contenteditable).
*
* Tippt ein Bearbeiter „@" gefolgt von Buchstaben, erscheint eine gefilterte
* Liste der Mitarbeiter mit Ticket-Zugriff. Die Auswahl fügt ein Mention-Token
* <span class="ts-mention" data-uid="…"> ein; die Server-Seite liest data-uid
* aus dem Nachrichtentext und benachrichtigt die erwähnten Mitarbeiter.
*
* Nutzung: OpenNIT.attachMention(editableEl, users)
* users = [{ id, display_name }, …]
*/
(function () {
'use strict';
window.OpenNIT = window.OpenNIT || {};
function esc(s) {
return String(s).replace(/[&<>"]/g, function (c) {
return { '&': '&', '<': '<', '>': '>', '"': '"' }[c];
});
}
OpenNIT.attachMention = function (editable, users) {
if (!editable || !Array.isArray(users) || !users.length) { return; }
var dd = null; // Dropdown-Element
var items = []; // aktuell gefilterte Nutzer
var active = 0; // hervorgehobener Eintrag
var ctx = null; // aktueller @-Kontext { node, atOffset, endOffset }
function closeDD() {
if (dd) { dd.remove(); dd = null; }
items = []; active = 0; ctx = null;
}
function renderDD() {
if (!dd) {
dd = document.createElement('div');
dd.className = 'ts-mention-dd';
document.body.appendChild(dd);
}
dd.innerHTML = items.map(function (u, i) {
return '<div class="ts-mention-opt' + (i === active ? ' active' : '') +
'" data-i="' + i + '">' + esc(u.display_name) + '</div>';
}).join('');
var sel = window.getSelection();
if (sel && sel.rangeCount) {
var rects = sel.getRangeAt(0).getClientRects();
var r = rects.length ? rects[0] : editable.getBoundingClientRect();
dd.style.left = (window.scrollX + r.left) + 'px';
dd.style.top = (window.scrollY + r.bottom + 4) + 'px';
}
}
// Ermittelt einen laufenden „@query" direkt vor dem Cursor.
function findContext() {
var sel = window.getSelection();
if (!sel || !sel.rangeCount || !sel.isCollapsed) { return null; }
var node = sel.focusNode;
if (!node || node.nodeType !== 3) { return null; } // nur reine Textknoten
var offset = sel.focusOffset;
var m = node.textContent.slice(0, offset).match(/(?:^|\s)@([\wäöüÄÖÜß.\-]*)$/);
if (!m) { return null; }
return { node: node, query: m[1], atOffset: offset - m[1].length - 1, endOffset: offset };
}
function filter(q) {
q = q.toLowerCase();
return users.filter(function (u) {
return String(u.display_name).toLowerCase().indexOf(q) !== -1;
}).slice(0, 8);
}
function insertMention(u) {
if (!u || !ctx) { closeDD(); return; }
var node = ctx.node;
var full = node.textContent;
var beforeNode = document.createTextNode(full.slice(0, ctx.atOffset));
var afterNode = document.createTextNode(full.slice(ctx.endOffset));
var span = document.createElement('span');
span.className = 'ts-mention';
span.setAttribute('data-uid', String(u.id));
span.setAttribute('contenteditable', 'false');
span.textContent = '@' + u.display_name;
var spaceNode = document.createTextNode(' ');
var parent = node.parentNode;
parent.insertBefore(beforeNode, node);
parent.insertBefore(span, node);
parent.insertBefore(spaceNode, node);
parent.insertBefore(afterNode, node);
parent.removeChild(node);
// Cursor hinter das eingefügte Leerzeichen setzen.
var sel = window.getSelection();
var range = document.createRange();
range.setStartAfter(spaceNode);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
editable.dispatchEvent(new Event('input', { bubbles: true }));
closeDD();
}
editable.addEventListener('input', function () {
ctx = findContext();
if (!ctx) { closeDD(); return; }
items = filter(ctx.query);
if (!items.length) { closeDD(); return; }
active = 0;
renderDD();
});
editable.addEventListener('keydown', function (e) {
if (!dd || !items.length) { return; }
if (e.key === 'ArrowDown') { e.preventDefault(); active = (active + 1) % items.length; renderDD(); }
else if (e.key === 'ArrowUp') { e.preventDefault(); active = (active - 1 + items.length) % items.length; renderDD(); }
else if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); insertMention(items[active]); }
else if (e.key === 'Escape') { e.preventDefault(); closeDD(); }
});
// Auswahl per Maus. mousedown statt click, damit der Cursor im Editor
// erhalten bleibt (kein Blur vor dem Einfügen).
document.addEventListener('mousedown', function (e) {
if (!dd) { return; }
var opt = e.target.closest ? e.target.closest('.ts-mention-opt') : null;
if (opt && dd.contains(opt)) {
e.preventDefault();
insertMention(items[parseInt(opt.getAttribute('data-i'), 10)]);
} else if (!dd.contains(e.target)) {
closeDD();
}
});
editable.addEventListener('blur', function () {
setTimeout(closeDD, 150);
});
};
})();