196 lines
5.7 KiB
TypeScript
196 lines
5.7 KiB
TypeScript
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();
|
|
}
|
|
};
|
|
}
|