ge-tool/components/UsernameAutocomplete.tsx
2025-12-10 13:41:43 +07:00

343 lines
13 KiB
TypeScript
Executable File

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<string, TmsUser[]>();
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<UsernameAutocompleteProps> = ({
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<TmsUser[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [isLoading, setIsLoading] = useState(false);
const [justSelected, setJustSelected] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const suggestionsRef = useRef<HTMLDivElement | null>(null);
const [portalStyle, setPortalStyle] = useState<React.CSSProperties | null>(null);
const debounceTimerRef = useRef<number>(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<string> => {
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<string>();
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<HTMLTextAreaElement>) => {
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<HTMLTextAreaElement>) => {
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 (
<div className="relative">
<textarea
ref={textareaRef}
value={internalValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
className="bg-slate-900/50 border border-slate-700 text-slate-100 text-sm rounded-lg focus:ring-indigo-500 focus:border-indigo-500 block w-full p-2.5 transition-colors duration-200 resize-y min-h-32"
placeholder={placeholder}
disabled={disabled}
rows={rows}
/>
{showSuggestions && suggestions.length > 0 && portalStyle && ReactDOM.createPortal(
<div
ref={suggestionsRef}
style={portalStyle}
>
<div className="bg-slate-800 border border-slate-700 rounded-lg shadow-lg max-h-48 overflow-y-auto" style={{ maxHeight: '240px' }}>
{suggestions.map((user, index) => (
<div
key={user.email}
onClick={() => selectSuggestion(user)}
className={`px-3 py-2 cursor-pointer transition-colors ${index === selectedIndex
? 'bg-indigo-600 text-white'
: 'text-slate-200 hover:bg-slate-700'
}`}
>
<div className="font-medium">{user.name}</div>
<div className="text-xs text-slate-400">{user.email}</div>
</div>
))}
</div>
</div>, document.body
)}
{isLoading && (
<div className="absolute right-2 top-2 text-xs text-slate-400">
<span className="animate-pulse">...</span>
</div>
)}
</div>
);
};
export default UsernameAutocomplete;