import React, { useState, useRef, useEffect, useCallback, type KeyboardEvent } from 'react'; import { FaSpinner, FaUser } from 'react-icons/fa'; import type { MentionUser } from '../services/commentService'; import API_CONFIG from '../config/api'; // ============================================================ // MentionInput – a textarea that shows a dropdown when user // types '@' and lets them pick a user to @mention. // // The final output is an HTML string with Frappe-style mention // markup so ERPNext recognises it exactly like its own editor. // ============================================================ interface MentionInputProps { value: string; // plain-text draft onChange: (text: string) => void; onSubmit: (html: string) => void; // returns formatted HTML placeholder?: string; disabled?: boolean; mentionUsers: MentionUser[]; mentionLoading: boolean; onMentionSearch: (query: string) => void; posting?: boolean; } /** Stores an inserted mention so we can convert to HTML later */ interface InsertedMention { startIndex: number; displayText: string; userId: string; // email fullName: string; } const MentionInput: React.FC = ({ value, onChange, onSubmit, placeholder = 'Type a comment… Use @ to mention someone', disabled = false, mentionUsers, mentionLoading, onMentionSearch, posting = false, }) => { const textareaRef = useRef(null); const dropdownRef = useRef(null); // Mention trigger state const [showMentionDropdown, setShowMentionDropdown] = useState(false); const [mentionQuery, setMentionQuery] = useState(''); const [mentionStartPos, setMentionStartPos] = useState(null); const [selectedIndex, setSelectedIndex] = useState(0); const [insertedMentions, setInsertedMentions] = useState([]); // Dropdown position const [dropdownPos, setDropdownPos] = useState({ top: 0, left: 0 }); // ── Calculate dropdown position relative to textarea ──── const updateDropdownPosition = useCallback(() => { const ta = textareaRef.current; if (!ta) return; // Place the dropdown above the textarea bottom const rect = ta.getBoundingClientRect(); const parentRect = ta.offsetParent?.getBoundingClientRect() ?? rect; setDropdownPos({ top: ta.offsetTop - 4, // above textarea left: ta.offsetLeft, }); }, []); // ── Handle text changes ───────────────────────────────── const handleChange = (e: React.ChangeEvent) => { const newValue = e.target.value; const cursorPos = e.target.selectionStart ?? 0; onChange(newValue); // Check for active mention trigger const textBeforeCursor = newValue.substring(0, cursorPos); const lastAtIndex = textBeforeCursor.lastIndexOf('@'); if (lastAtIndex !== -1) { // Check that @ is at start or preceded by a space/newline const charBefore = lastAtIndex > 0 ? newValue[lastAtIndex - 1] : ' '; if (charBefore === ' ' || charBefore === '\n' || lastAtIndex === 0) { const query = textBeforeCursor.substring(lastAtIndex + 1); // Only activate if query doesn't contain spaces (single-word search) if (!query.includes(' ') || query.length <= 30) { setShowMentionDropdown(true); setMentionQuery(query); setMentionStartPos(lastAtIndex); setSelectedIndex(0); onMentionSearch(query); updateDropdownPosition(); return; } } } // No active mention setShowMentionDropdown(false); setMentionStartPos(null); }; // ── Insert a mention into the text ────────────────────── const insertMention = useCallback( (user: MentionUser) => { if (mentionStartPos === null) return; const ta = textareaRef.current; const before = value.substring(0, mentionStartPos); const cursorPos = ta?.selectionStart ?? mentionStartPos + mentionQuery.length + 1; const after = value.substring(cursorPos); const displayText = user.full_name || user.name; const newText = `${before}@${displayText} ${after}`; // Track the mention setInsertedMentions((prev) => [ ...prev, { startIndex: mentionStartPos, displayText, userId: user.name, fullName: user.full_name || user.name, }, ]); onChange(newText); setShowMentionDropdown(false); setMentionStartPos(null); setMentionQuery(''); // Refocus and place cursor after mention setTimeout(() => { if (ta) { ta.focus(); const newPos = before.length + displayText.length + 2; // +2 for @ and space ta.selectionStart = newPos; ta.selectionEnd = newPos; } }, 0); }, [mentionStartPos, mentionQuery, value, onChange] ); // ── Keyboard navigation inside dropdown ───────────────── const handleKeyDown = (e: KeyboardEvent) => { if (showMentionDropdown && mentionUsers.length > 0) { switch (e.key) { case 'ArrowDown': e.preventDefault(); setSelectedIndex((prev) => Math.min(prev + 1, mentionUsers.length - 1)); return; case 'ArrowUp': e.preventDefault(); setSelectedIndex((prev) => Math.max(prev - 1, 0)); return; case 'Enter': e.preventDefault(); insertMention(mentionUsers[selectedIndex]); return; case 'Escape': e.preventDefault(); setShowMentionDropdown(false); return; } } // Ctrl/Cmd + Enter to submit if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); handleSubmit(); } }; // ── Build Frappe-compatible HTML from plain text + mentions ─ const buildHtml = useCallback( (text: string): string => { let html = text; const baseUrl = API_CONFIG.BASE_URL || window.location.origin; // Replace each tracked @mention with the Frappe mention markup // Process in reverse order of startIndex so positions don't shift const sorted = [...insertedMentions].sort((a, b) => b.startIndex - a.startIndex); for (const m of sorted) { const mentionText = `@${m.displayText}`; const idx = html.indexOf(mentionText); if (idx === -1) continue; const profileUrl = `${baseUrl}/app/user-profile/${encodeURIComponent(m.userId)}`; const mentionHtml = `` + `\uFEFF` + `@` + `${m.fullName}` + `\uFEFF`; html = html.substring(0, idx) + mentionHtml + html.substring(idx + mentionText.length); } // Escape remaining HTML chars (basic), then wrap newlines // We do NOT escape the mention markup we just inserted // Instead, split by mention spans, escape non-mention parts, and rejoin // Simple approach: since mentions are already HTML, just convert newlines to
html = html.replace(/\n/g, '
'); return `

${html}

`; }, [insertedMentions] ); // ── Submit handler ────────────────────────────────────── const handleSubmit = () => { const trimmed = value.trim(); if (!trimmed || posting) return; const html = buildHtml(trimmed); onSubmit(html); setInsertedMentions([]); }; // ── Close dropdown on outside click ───────────────────── useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if ( dropdownRef.current && !dropdownRef.current.contains(e.target as Node) && textareaRef.current && !textareaRef.current.contains(e.target as Node) ) { setShowMentionDropdown(false); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); // ── Scroll selected item into view ────────────────────── useEffect(() => { if (!dropdownRef.current) return; const item = dropdownRef.current.querySelector(`[data-idx="${selectedIndex}"]`); item?.scrollIntoView({ block: 'nearest' }); }, [selectedIndex]); const baseUrl = API_CONFIG.BASE_URL || ''; return (
{/* ── Mention dropdown ──────────────────────────────── */} {showMentionDropdown && (
{mentionLoading && mentionUsers.length === 0 ? (
Searching users…
) : mentionUsers.length === 0 ? (
No users found
) : ( mentionUsers.map((user, idx) => ( )) )}
)} {/* ── Textarea + submit ──────────────────────────────── */}