Files
f0ckm/public/s/js/mention_autocomplete.js
2026-04-25 19:51:52 +02:00

200 lines
6.7 KiB
JavaScript

/**
* MentionAutocomplete — Real-time user mention suggestions for textareas
* Detects "@" and fetches matching users from the backend API.
*/
window.MentionAutocomplete = (() => {
let activeDropdown = null;
let selectedIndex = -1;
let suggestions = [];
let currentInput = null;
let mentionStart = -1;
let query = '';
const DEBOUNCE_MS = 200;
let debounceTimer = null;
function destroy() {
if (activeDropdown) {
activeDropdown.remove();
activeDropdown = null;
}
selectedIndex = -1;
suggestions = [];
mentionStart = -1;
query = '';
}
function positionDropdown(input) {
if (!activeDropdown) return;
const rect = input.getBoundingClientRect();
activeDropdown.style.left = `${rect.left}px`;
activeDropdown.style.width = `${rect.width}px`;
// layout-modern: input is near the top of the sidebar → open downward
if (document.body.classList.contains('layout-modern')) {
activeDropdown.style.top = `${rect.bottom}px`;
activeDropdown.style.bottom = 'auto';
} else {
activeDropdown.style.bottom = `${window.innerHeight - rect.top + 5}px`;
activeDropdown.style.top = 'auto';
}
}
async function fetchUsers(q) {
try {
const res = await fetch(`/api/v2/users/suggest?q=${encodeURIComponent(q)}`);
const data = await res.json();
return data.suggestions || [];
} catch (e) {
return [];
}
}
function renderDropdown() {
if (!activeDropdown) {
activeDropdown = document.createElement('div');
activeDropdown.className = 'mention-suggestions';
document.body.appendChild(activeDropdown);
}
activeDropdown.innerHTML = '';
if (suggestions.length === 0) {
destroy();
return;
}
suggestions.forEach((user, i) => {
const item = document.createElement('div');
item.className = 'mention-suggestion-item';
if (i === selectedIndex) item.classList.add('active');
const avatarSrc = user.avatar_file ? `/a/${user.avatar_file}` : (user.avatar ? `/t/${user.avatar}.webp` : '/a/default.png');
const img = document.createElement('img');
img.src = avatarSrc;
img.onerror = () => { img.src = '/a/default.png'; };
const nameSpan = document.createElement('span');
nameSpan.className = 'mention-name';
nameSpan.textContent = user.user;
item.appendChild(img);
item.appendChild(nameSpan);
if (user.display_name) {
const displaySpan = document.createElement('span');
displaySpan.className = 'mention-display';
displaySpan.textContent = user.display_name;
item.appendChild(displaySpan);
}
item.addEventListener('mousedown', (e) => {
e.preventDefault();
insertMention(user.user);
});
activeDropdown.appendChild(item);
});
// Ensure the active item is visible in the scrollable container
const activeItem = activeDropdown.querySelector('.mention-suggestion-item.active');
if (activeItem) {
activeItem.scrollIntoView({ block: 'nearest', inline: 'nearest' });
}
positionDropdown(currentInput);
}
let skipNextInput = false;
function insertMention(username) {
if (!currentInput) return;
const text = currentInput.value;
const before = text.substring(0, mentionStart);
const after = text.substring(currentInput.selectionStart);
const insert = username.includes(' ') ? `[@${username}]` : `@${username}`;
currentInput.value = before + insert + after;
const newPos = before.length + insert.length;
currentInput.setSelectionRange(newPos, newPos);
currentInput.focus();
destroy();
// Trigger generic input/change events for other modules (like auto-resize or emoji autocomplete)
skipNextInput = true;
currentInput.dispatchEvent(new Event('input', { bubbles: true }));
currentInput.dispatchEvent(new Event('change', { bubbles: true }));
}
function handleKeyDown(e) {
if (!activeDropdown) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
selectedIndex = (selectedIndex + 1) % suggestions.length;
renderDropdown();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
selectedIndex = (selectedIndex - 1 + suggestions.length) % suggestions.length;
renderDropdown();
} else if (e.key === 'Enter' || e.key === 'Tab') {
if (selectedIndex >= 0) {
e.preventDefault();
e.stopImmediatePropagation();
insertMention(suggestions[selectedIndex].user);
} else {
destroy();
}
} else if (e.key === 'Escape') {
e.preventDefault();
destroy();
}
}
function handleInput(e) {
if (skipNextInput) {
skipNextInput = false;
return;
}
const input = e.target;
const pos = input.selectionStart;
const text = input.value.substring(0, pos);
// Detect @ followed by alphanum, _, -, . (standard username chars)
const match = text.match(/@([a-zA-Z0-9_\-\.]{0,})$/);
if (match) {
currentInput = input;
mentionStart = pos - match[0].length;
query = match[1];
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
suggestions = await fetchUsers(query);
if (suggestions.length > 0) {
selectedIndex = 0;
renderDropdown();
} else {
destroy();
}
}, DEBOUNCE_MS);
} else {
destroy();
}
}
return {
/**
* Attach mention autocomplete to a textarea
* @param {HTMLTextAreaElement} textarea
*/
attach(textarea) {
if (!textarea || textarea._mentionsAttached) return;
textarea._mentionsAttached = true;
textarea.addEventListener('input', handleInput);
textarea.addEventListener('keydown', handleKeyDown);
textarea.addEventListener('blur', () => {
setTimeout(destroy, 200);
});
}
};
})();