import { type MentionItem, type CreateOption, fetchMentionItems, createMentionContext, quoteMention, renderDropdown } from './mentionCore'; import './mention.css'; export function mention(node: HTMLInputElement | HTMLTextAreaElement) { let dropdown: HTMLDivElement | null = null; let items: MentionItem[] = []; let createOptions: CreateOption[] = []; let selectedIndex = 0; let mentionStart = -1; let mentionMode: '@' | '->' = '@'; let active = false; let suppressInput = false; function getQuery(): { query: string; mode: '@' | '->' } | null { const pos = node.selectionStart ?? 0; const text = node.value; if (active && mentionStart >= 0 && mentionStart < pos) { const trigger = text[mentionStart] === '@' ? '@' : '->'; if (trigger !== mentionMode) return null; const offset = mentionMode === '->' ? 2 : 1; return { query: text.slice(mentionStart + offset, pos), mode: mentionMode }; } for (let i = pos - 1; i >= 0; i--) { const ch = text[i]; if (ch === '@') { if (i === 0 || /\s/.test(text[i - 1])) { mentionStart = i; mentionMode = '@'; return { query: text.slice(i + 1, pos), mode: '@' }; } return null; } // Check for -> trigger: '>' at position i, '-' at i-1. // Allow a single optional space between -> and the query (e.g. "-> ANDST"). if (ch === '>' && i > 0 && text[i - 1] === '-') { if (i - 1 === 0 || /[\s\n]/.test(text[i - 2])) { mentionStart = i - 1; mentionMode = '->'; return { query: text.slice(i + 1, pos).trimStart(), mode: '->' }; } return null; } // Allow spaces only when we haven't yet passed a non-word char other than space // (needed for "-> ANDST" where the space is between trigger and query). // For @-mentions, spaces are not allowed in the query (quoted names use autocomplete). if (/\s/.test(ch)) return null; } return null; } function totalCount(): number { return items.length + createOptions.length; } function show() { if (!dropdown) { dropdown = document.createElement('div'); dropdown.className = 'mention-dropdown'; node.parentElement!.style.position = 'relative'; node.parentElement!.appendChild(dropdown); } active = true; render(); } function hide() { if (dropdown) { dropdown.remove(); dropdown = null; } active = false; items = []; createOptions = []; mentionStart = -1; } function render() { if (!dropdown) return; if (totalCount() === 0) { hide(); return; } renderDropdown(dropdown, items, createOptions, selectedIndex, selectItem, (i) => { selectedIndex = i; render(); }); } function insertTextAt(from: number, to: number, text: string) { const before = node.value.slice(0, from); const after = node.value.slice(to); node.value = before + text + ' ' + after; const newPos = from + text.length + 1; node.selectionStart = newPos; node.selectionEnd = newPos; node.dispatchEvent(new Event('input', { bubbles: true })); } async function selectItem(index: number) { // Save positions before any async work (hide/blur could reset mentionStart) const savedFrom = mentionStart; const savedTo = node.selectionStart ?? node.value.length; console.log('[mention-action] selectItem', index, 'items:', items.length, 'create:', createOptions.length, 'from:', savedFrom, 'to:', savedTo); suppressInput = true; try { if (index < items.length) { const text = items[index].insertText; console.log('[mention-action] inserting:', text); insertTextAt(savedFrom, savedTo, text); console.log('[mention-action] after insert, value:', JSON.stringify(node.value)); } else { const opt = createOptions[index - items.length]; if (!opt) return; await createMentionContext(opt); const tag = opt.type === 'company' ? quoteMention('@F:', opt.query) : opt.type === 'person' ? quoteMention('@', opt.query) : quoteMention('@P:', opt.query); console.log('[mention-action] inserting after create:', tag); insertTextAt(savedFrom, savedTo, tag); console.log('[mention-action] after create insert, value:', JSON.stringify(node.value)); } } catch (err) { console.error('mention selectItem error:', err); } finally { hide(); suppressInput = false; } } async function handleInput() { if (suppressInput) return; const qResult = getQuery(); if (qResult === null) { if (active) hide(); return; } const result = await fetchMentionItems(qResult.query, qResult.mode); // Re-check after async: state may have changed (e.g. selectItem ran) if (suppressInput) return; const qResultNow = getQuery(); if (qResultNow === null) { if (active) hide(); return; } items = result.items; createOptions = result.createOptions; selectedIndex = 0; if (totalCount() > 0) { show(); } else { hide(); } } function handleKeydown(e: KeyboardEvent) { if (!active) return; const count = totalCount(); if (e.key === 'ArrowDown') { e.preventDefault(); selectedIndex = (selectedIndex + 1) % count; render(); } else if (e.key === 'ArrowUp') { e.preventDefault(); selectedIndex = (selectedIndex - 1 + count) % count; render(); } else if (e.key === 'Enter') { e.preventDefault(); selectItem(selectedIndex); } else if (e.key === 'Escape') { e.preventDefault(); hide(); } } function handleBlur() { if (suppressInput) return; setTimeout(hide, 150); } node.addEventListener('input', handleInput); node.addEventListener('keydown', handleKeydown as EventListener); node.addEventListener('blur', handleBlur); return { destroy() { node.removeEventListener('input', handleInput); node.removeEventListener('keydown', handleKeydown as EventListener); node.removeEventListener('blur', handleBlur); hide(); } }; }