/* * Copyright (C) 2026 Mateusz GruszczyƄski @linuxiarz.pl * Source-Available Code / Dual-Licensed. * * Free for non-commercial and evaluation use under terms of BSL/GPLv3. * Commercial or production use requires a valid paid license. * See LICENSE file in repository root for details. */ import { t } from "@rustpad/i18n"; const MAX_RESULTS = 8; const MENTION_SUFFIX = /[\p{L}\p{M}\p{N}_.+-]{0,64}$/u; const MENTION_BOUNDARY = /[\p{L}\p{N}_@]/u; export function mentionAtCaret(value, caret) { if (caret < 1 || caret > value.length) return null; const lineStart = value.lastIndexOf("\n", caret - 1) + 1; const before = value.slice(lineStart, caret); const suffix = MENTION_SUFFIX.exec(before); if (!suffix) return null; const at = caret - suffix[0].length - 1; if (at < lineStart || value[at] !== "@") return null; if (at > 0 && MENTION_BOUNDARY.test(value[at - 1])) return null; return { start: at, end: caret, query: suffix[0] }; } function fallbackColor(name) { let hash = 0; for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) % 360; return `hsl(${hash} 70% 62%)`; } export function normalizeMentionUsers(users) { const seen = new Set(); return (Array.isArray(users) ? users : []).flatMap(user => { const nickname = String(user?.nickname || "").trim(); const key = nickname.toLocaleLowerCase(); if (!nickname || seen.has(key)) return []; seen.add(key); return [{ nickname, color: /^#[0-9a-f]{6}$/i.test(user?.color || "") ? user.color : fallbackColor(nickname), }]; }); } export function filterMentionUsers(users, query) { const lower = String(query || "").toLocaleLowerCase(); return users.filter(user => user.nickname.toLocaleLowerCase().includes(lower)) .sort((a, b) => { const aStarts = a.nickname.toLocaleLowerCase().startsWith(lower); const bStarts = b.nickname.toLocaleLowerCase().startsWith(lower); return Number(bStarts) - Number(aStarts) || a.nickname.localeCompare(b.nickname); }) .slice(0, MAX_RESULTS); } export function createMentionAutocomplete(editor) { const shell = editor.closest(".editor-shell"); if (!shell) throw new Error("Mention suggestions require an editor shell."); const menu = document.createElement("div"); menu.id = "mention-suggestions"; menu.className = "mention-suggestions"; menu.setAttribute("role", "listbox"); menu.setAttribute("aria-label", t("editor.mentionMembers", {}, "Mention a member")); menu.hidden = true; shell.append(menu); const mirror = document.createElement("div"); mirror.className = "mention-caret-mirror"; mirror.setAttribute("aria-hidden", "true"); shell.append(mirror); editor.setAttribute("aria-controls", menu.id); editor.setAttribute("aria-haspopup", "listbox"); editor.setAttribute("aria-expanded", "false"); let members = []; let matches = []; let activeMatch = null; let selectedIndex = 0; let composing = false; let dismissedAt = null; let blurTimer = null; function close({ dismissed = false } = {}) { if (dismissed && activeMatch) dismissedAt = `${activeMatch.start}:${activeMatch.end}:${activeMatch.query}`; activeMatch = null; matches = []; menu.hidden = true; menu.replaceChildren(); editor.setAttribute("aria-expanded", "false"); editor.removeAttribute("aria-activedescendant"); } function position() { if (menu.hidden || !activeMatch) return; const style = getComputedStyle(editor); const lineStart = editor.value.lastIndexOf("\n", activeMatch.end - 1) + 1; const beforeCaret = editor.value.slice(lineStart, activeMatch.end); mirror.style.font = style.font; mirror.style.letterSpacing = style.letterSpacing; mirror.style.tabSize = style.tabSize; mirror.textContent = beforeCaret; const cursor = document.createElement("span"); cursor.textContent = "\u200b"; mirror.append(cursor); const measuredX = cursor.getBoundingClientRect().left - mirror.getBoundingClientRect().left; const shellRect = shell.getBoundingClientRect(); const editorRect = editor.getBoundingClientRect(); const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.72; const lineIndex = editor.value.slice(0, lineStart).split("\n").length - 1; const x = editorRect.left - shellRect.left + (parseFloat(style.borderLeftWidth) || 0) + (parseFloat(style.paddingLeft) || 0) + measuredX - editor.scrollLeft; const y = editorRect.top - shellRect.top + (parseFloat(style.borderTopWidth) || 0) + (parseFloat(style.paddingTop) || 0) + lineIndex * lineHeight - editor.scrollTop; if (x < -5 || x > shell.clientWidth + 5 || y < -lineHeight || y > shell.clientHeight) { close(); return; } const edge = 6; const bottom = y + lineHeight + 4; const top = bottom + menu.offsetHeight > shell.clientHeight - edge ? y - menu.offsetHeight - 4 : bottom; menu.style.left = `${Math.max(edge, Math.min(x, shell.clientWidth - menu.offsetWidth - edge))}px`; menu.style.top = `${Math.max(edge, Math.min(top, shell.clientHeight - menu.offsetHeight - edge))}px`; } function markSelected() { [...menu.querySelectorAll('[role="option"]')].forEach((option, i) => { const selected = i === selectedIndex; option.setAttribute("aria-selected", String(selected)); if (selected) { editor.setAttribute("aria-activedescendant", option.id); if (option.offsetTop < menu.scrollTop) menu.scrollTop = option.offsetTop; else if (option.offsetTop + option.offsetHeight > menu.scrollTop + menu.clientHeight) { menu.scrollTop = option.offsetTop + option.offsetHeight - menu.clientHeight; } } }); } function accept(index) { const user = matches[index]; if (!user || !activeMatch || editor.readOnly) return; const { start, end } = activeMatch; if (editor.value.slice(start, end) !== `@${activeMatch.query}`) { close(); return; } const after = editor.value[end] || ""; const separator = !after || !/[\s.,;:!?)}\]]/u.test(after) ? " " : ""; close(); dismissedAt = null; editor.focus(); const inserted = `@${user.nickname}${separator}`; editor.setRangeText(inserted, start, end, "end"); editor.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertReplacementText", data: inserted })); } function refresh() { if (composing || editor.readOnly || document.activeElement !== editor || editor.selectionStart !== editor.selectionEnd || !members.length) { close(); return; } const match = mentionAtCaret(editor.value, editor.selectionStart); if (!match) { dismissedAt = null; close(); return; } const key = `${match.start}:${match.end}:${match.query}`; if (dismissedAt === key) return; const choices = filterMentionUsers(members, match.query); if (!choices.length) { close(); return; } const previous = activeMatch; matches = choices; activeMatch = match; if (!previous || previous.start !== match.start || previous.query !== match.query) selectedIndex = 0; menu.replaceChildren(); choices.forEach((user, index) => { const button = document.createElement("button"); button.type = "button"; button.id = `mention-suggestion-${index}`; button.className = "mention-suggestions__option"; button.setAttribute("role", "option"); button.style.setProperty("--owner", user.color); const dot = document.createElement("span"); dot.className = "mention-suggestions__dot"; dot.setAttribute("aria-hidden", "true"); const label = document.createElement("span"); label.textContent = `@${user.nickname}`; button.append(dot, label); button.addEventListener("mousedown", event => event.preventDefault()); button.addEventListener("click", () => accept(index)); menu.append(button); }); menu.hidden = false; editor.setAttribute("aria-expanded", "true"); markSelected(); position(); } function onKeydown(event) { if (menu.hidden || !activeMatch || event.isComposing || event.ctrlKey || event.metaKey || event.altKey) return; if (event.key === "Escape") { event.preventDefault(); event.stopImmediatePropagation(); close({ dismissed: true }); } else if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); event.stopImmediatePropagation(); selectedIndex = (selectedIndex + (event.key === "ArrowDown" ? 1 : -1) + matches.length) % matches.length; markSelected(); } else if (event.key === "Enter" || event.key === "Tab") { event.preventDefault(); event.stopImmediatePropagation(); accept(selectedIndex); } } function onBlur(event) { clearTimeout(blurTimer); if (event.relatedTarget && menu.contains(event.relatedTarget)) return; blurTimer = setTimeout(() => { if (document.activeElement !== editor && !menu.contains(document.activeElement)) close(); }, 120); } function onOutsidePointer(event) { if (!menu.contains(event.target) && event.target !== editor) close(); } function onCompositionStart() { composing = true; close(); } function onCompositionEnd() { composing = false; refresh(); } const onScroll = () => { if (!menu.hidden) position(); }; const onSelection = () => { if (document.activeElement === editor && !menu.hidden) refresh(); }; editor.addEventListener("keydown", onKeydown); editor.addEventListener("input", refresh); editor.addEventListener("click", refresh); editor.addEventListener("keyup", refresh); editor.addEventListener("focus", refresh); editor.addEventListener("blur", onBlur); editor.addEventListener("scroll", onScroll); editor.addEventListener("compositionstart", onCompositionStart); editor.addEventListener("compositionend", onCompositionEnd); document.addEventListener("pointerdown", onOutsidePointer); document.addEventListener("selectionchange", onSelection); window.addEventListener("resize", onScroll); return { setUsers(users) { members = normalizeMentionUsers(users); dismissedAt = null; close(); refresh(); }, close, refresh, destroy() { clearTimeout(blurTimer); editor.removeEventListener("keydown", onKeydown); editor.removeEventListener("input", refresh); editor.removeEventListener("click", refresh); editor.removeEventListener("keyup", refresh); editor.removeEventListener("focus", refresh); editor.removeEventListener("blur", onBlur); editor.removeEventListener("scroll", onScroll); editor.removeEventListener("compositionstart", onCompositionStart); editor.removeEventListener("compositionend", onCompositionEnd); document.removeEventListener("pointerdown", onOutsidePointer); document.removeEventListener("selectionchange", onSelection); window.removeEventListener("resize", onScroll); editor.removeAttribute("aria-controls"); editor.removeAttribute("aria-haspopup"); editor.removeAttribute("aria-expanded"); editor.removeAttribute("aria-activedescendant"); menu.remove(); mirror.remove(); }, }; }