import React, { useState, useRef, useEffect, useLayoutEffect } from 'react'; import ReactDOM from 'react-dom'; import { sortByProperty } from '../utils/sort-utils'; // Default prefix for TMS username search const TMS_USERNAME_PREFIX = 'DKI_'; // Simple cache for user search results const userCache = new Map(); interface TmsUser { email: string; name: string; } interface UsernameAutocompleteProps { value: string; onChange: (value: string) => void; onUserSelect: (username: string) => void; disabled?: boolean; placeholder?: string; rows?: number; onHeightChange?: (heightPx: number) => void; } const UsernameAutocomplete: React.FC = ({ value, onChange, onUserSelect, disabled = false, placeholder = '', rows = 5, onHeightChange, }) => { // Use internal state for instant typing, sync to parent via onChange const [internalValue, setInternalValue] = useState(value); const [suggestions, setSuggestions] = useState([]); const [showSuggestions, setShowSuggestions] = useState(false); const [selectedIndex, setSelectedIndex] = useState(-1); const [isLoading, setIsLoading] = useState(false); const [justSelected, setJustSelected] = useState(false); const textareaRef = useRef(null); const suggestionsRef = useRef(null); const [portalStyle, setPortalStyle] = useState(null); const debounceTimerRef = useRef(0); // Sync external value changes to internal state useEffect(() => { setInternalValue(value); }, [value]); // report textarea height to parent so they can keep other inputs in sync const onHeightChangeRef = useRef(onHeightChange); onHeightChangeRef.current = onHeightChange; useEffect(() => { const ta = textareaRef.current; if (!ta) return; const obs = new ResizeObserver(entries => { if (!entries || !entries.length) return; const h = (entries[0].target as HTMLElement).offsetHeight; if (typeof onHeightChangeRef.current === 'function') onHeightChangeRef.current(h); }); obs.observe(ta); // report initial size if (typeof onHeightChangeRef.current === 'function') onHeightChangeRef.current(ta.offsetHeight); return () => obs.disconnect(); }, []); // Get current line content (word being typed) const getCurrentWord = () => { if (!textareaRef.current) return ''; const textarea = textareaRef.current; const text = textarea.value; const cursorPos = textarea.selectionStart; const beforeCursor = text.substring(0, cursorPos); const lines = beforeCursor.split('\n'); const currentLine = lines[lines.length - 1]; return currentLine; }; // Get all existing usernames from textarea (excluding current line being typed) const getExistingUsernames = (): Set => { if (!textareaRef.current) return new Set(); const textarea = textareaRef.current; const text = textarea.value; const cursorPos = textarea.selectionStart; const beforeCursor = text.substring(0, cursorPos); const afterCursor = text.substring(cursorPos); const linesBefore = beforeCursor.split('\n'); const currentLineIndex = linesBefore.length - 1; // Get all lines except the current one being typed const allLines = text.split('\n'); const existingNames = new Set(); allLines.forEach((line, index) => { const trimmed = line.trim().toLowerCase(); if (trimmed && index !== currentLineIndex) { existingNames.add(trimmed); } }); return existingNames; }; // Fetch suggestions from TMS API (only DKI_ users) with cache const fetchSuggestions = async (query: string) => { // Don't fetch if just selected a suggestion if (justSelected) return; if (query.length < 1) { setSuggestions([]); setShowSuggestions(false); return; } const cacheKey = query.toLowerCase(); // Check cache first if (userCache.has(cacheKey)) { const cached = userCache.get(cacheKey)!; const existingUsernames = getExistingUsernames(); const filteredUsers = cached.filter(u => !existingUsernames.has(u.name.toLowerCase())); setSuggestions(filteredUsers); setShowSuggestions(filteredUsers.length > 0); setSelectedIndex(filteredUsers.length > 0 ? 0 : -1); return; } setIsLoading(true); try { // Search with original query const response = await fetch('/api/user/search', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query }), }); const data = await response.json(); if (data.success && Array.isArray(data.data)) { // Filter: only DKI_ users, then sort by name const usersData = data.data as TmsUser[]; const dkiUsers = sortByProperty( usersData.filter((user) => user.name.toUpperCase().includes(TMS_USERNAME_PREFIX)), (user) => user.name ); // Cache the result userCache.set(cacheKey, dkiUsers); // Filter out existing usernames for display const existingUsernames = getExistingUsernames(); const filteredUsers = dkiUsers.filter(u => !existingUsernames.has(u.name.toLowerCase())); setSuggestions(filteredUsers); setShowSuggestions(filteredUsers.length > 0); setSelectedIndex(filteredUsers.length > 0 ? 0 : -1); } } catch (error) { console.error('Error fetching suggestions:', error); setSuggestions([]); } finally { setIsLoading(false); } }; // Handle input change - update internal state immediately, debounce parent update const handleInputChange = (e: React.ChangeEvent) => { const newValue = e.target.value; setInternalValue(newValue); // Instant update for smooth typing // Clear previous timer window.clearTimeout(debounceTimerRef.current); // Debounce: update parent and fetch suggestions after 100ms debounceTimerRef.current = window.setTimeout(() => { onChange(newValue); // Sync to parent const currentWord = getCurrentWord(); fetchSuggestions(currentWord); }, 100); }; // Cleanup debounce timer on unmount useEffect(() => { return () => window.clearTimeout(debounceTimerRef.current); }, []); // Handle keyboard navigation const handleKeyDown = (e: React.KeyboardEvent) => { if (!showSuggestions || suggestions.length === 0) { if (e.key === 'ArrowDown' && e.ctrlKey) { e.preventDefault(); setShowSuggestions(true); } return; } switch (e.key) { case 'ArrowDown': e.preventDefault(); setSelectedIndex((prev) => (prev + 1) % suggestions.length); break; case 'ArrowUp': e.preventDefault(); setSelectedIndex((prev) => (prev - 1 + suggestions.length) % suggestions.length); break; case 'Enter': if (selectedIndex >= 0) { e.preventDefault(); selectSuggestion(suggestions[selectedIndex]); } break; case 'Escape': e.preventDefault(); setShowSuggestions(false); setSelectedIndex(-1); break; default: break; } }; // Select suggestion and replace current line with user's name const selectSuggestion = (user: TmsUser) => { if (!textareaRef.current) return; const textarea = textareaRef.current; const text = internalValue; const cursorPos = textarea.selectionStart; const beforeCursor = text.substring(0, cursorPos); const afterCursor = text.substring(cursorPos); const lines = beforeCursor.split('\n'); const currentLineIndex = lines.length - 1; lines[currentLineIndex] = user.name; // Use name as the selected value const newText = lines.join('\n') + afterCursor; setInternalValue(newText); onChange(newText); // Hide suggestions and set flag to prevent re-showing setShowSuggestions(false); setSelectedIndex(-1); setSuggestions([]); setJustSelected(true); // Focus back to textarea and move cursor to end of suggestion setTimeout(() => { textarea.focus(); const newCursorPos = lines.join('\n').length; textarea.setSelectionRange(newCursorPos, newCursorPos); // Reset flag after a short delay setTimeout(() => setJustSelected(false), 200); }, 0); }; // Close suggestions when clicking outside useEffect(() => { const handleClickOutside = (e: MouseEvent) => { const sugEl = suggestionsRef.current; const taEl = textareaRef.current; if (sugEl && !sugEl.contains(e.target as Node)) { if (taEl && !taEl.contains(e.target as Node)) { setShowSuggestions(false); } } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); // compute portal position based on textarea bounding rect const updatePortalPosition = () => { const ta = textareaRef.current; if (!ta) return; const rect = ta.getBoundingClientRect(); const top = rect.bottom + window.scrollY; const left = rect.left + window.scrollX; const width = rect.width; setPortalStyle({ position: 'absolute', top: `${top}px`, left: `${left}px`, width: `${width}px`, zIndex: 99999 }); }; useLayoutEffect(() => { if (showSuggestions) updatePortalPosition(); }, [showSuggestions, suggestions]); useEffect(() => { const onScroll = () => { if (showSuggestions) updatePortalPosition(); }; const onResize = () => { if (showSuggestions) updatePortalPosition(); }; window.addEventListener('scroll', onScroll, true); window.addEventListener('resize', onResize); return () => { window.removeEventListener('scroll', onScroll, true); window.removeEventListener('resize', onResize); }; }, [showSuggestions]); return (