Ka-Note/ka-note/client/src/lib/actions/mention.ts

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();
}
};
}