OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

ticket-mention.js

6 KB
/**
 * @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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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);
        });
    };
})();