'use client'; import Image from 'next/image'; import { useState, useEffect, useRef, useCallback } from 'react'; import { fetchApi } from '@/lib/utils/client'; import MemberSuggestion from '@/types/forum/memtionSuggetion'; type Props = { postID: number; textareaRef: React.RefObject; onSelect: (handle: string) => void; }; export default function MentionSuggestion({ postID, textareaRef, onSelect }: Props) { const [show, setShow] = useState(false); const [keyword, setKeyword] = useState(''); const [results, setResults] = useState([]); const [selectedIndex, setSelectedIndex] = useState(0); const [mentionStart, setMentionStart] = useState(-1); const debounceRef = useRef>(null); const containerRef = useRef(null); // textarea 입력 감지 const detectMention = useCallback(() => { const textarea = textareaRef.current; if (!textarea) return; const value = textarea.value; const cursor = textarea.selectionStart; // 커서 앞에서 가장 가까운 @ 찾기 const before = value.slice(0, cursor); const atIndex = before.lastIndexOf('@'); if (atIndex === -1) { setShow(false); return; } // @ 앞이 공백이거나 맨 처음이어야 함 if (atIndex > 0 && before[atIndex - 1] !== ' ' && before[atIndex - 1] !== '\n') { setShow(false); return; } // @ 뒤 텍스트에 공백이 있으면 멘션이 아님 const query = before.slice(atIndex + 1); if (query.includes(' ') || query.includes('\n')) { setShow(false); return; } setMentionStart(atIndex); setKeyword(query); setShow(true); setSelectedIndex(0); }, [textareaRef]); // 검색 API 호출 (debounce) useEffect(() => { if (!show) { return; } if (!show || keyword.length === 0) { return; } if (debounceRef.current) { clearTimeout(debounceRef.current); } debounceRef.current = setTimeout(async () => { try { const res = await fetchApi( `/api/forum/comments/mention?postID=${postID}&keyword=${encodeURIComponent(keyword)}`, ); if (res.data) { setResults(res.data); if (res.data.length === 0) { setShow(false); } } } catch { setShow(false); } }, 300); return () => { if (debounceRef.current) { clearTimeout(debounceRef.current); } }; }, [keyword, show, postID]); // 회원 선택 const handleSelect = useCallback((member: MemberSuggestion) => { const textarea = textareaRef.current; if (!textarea) return; const handle = member.name ?? member.sid; const value = textarea.value; const after = value.slice(textarea.selectionStart); // @keyword 부분을 @handle 로 교체 const newValue = value.slice(0, mentionStart) + `@${handle} ` + after; onSelect(newValue); setShow(false); setResults([]); // 커서를 멘션 뒤로 이동 setTimeout(() => { const cursorPos = mentionStart + handle.length + 2; // @handle + space textarea.setSelectionRange(cursorPos, cursorPos); textarea.focus(); }, 0); }, [textareaRef, mentionStart, onSelect]); // 키보드 네비게이션 const handleKeyDown = useCallback((e: KeyboardEvent) => { if (!show || results.length === 0) return; switch (e.key) { case 'ArrowDown': e.preventDefault(); setSelectedIndex(prev => (prev + 1) % results.length); break; case 'ArrowUp': e.preventDefault(); setSelectedIndex(prev => (prev - 1 + results.length) % results.length); break; case 'Enter': if (show && results.length > 0) { e.preventDefault(); handleSelect(results[selectedIndex]); } break; case 'Escape': setShow(false); break; } }, [show, results, selectedIndex, handleSelect]); // textarea 이벤트 리스너 useEffect(() => { const textarea = textareaRef.current; if (!textarea) { return; } textarea.addEventListener('input', detectMention); textarea.addEventListener('click', detectMention); textarea.addEventListener('keydown', handleKeyDown); return () => { textarea.removeEventListener('input', detectMention); textarea.removeEventListener('click', detectMention); textarea.removeEventListener('keydown', handleKeyDown); }; }, [textareaRef, detectMention, handleKeyDown]); // 외부 클릭 시 닫기 useEffect(() => { if (!show) return; const handleClickOutside = (e: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { setShow(false); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, [show]); if (!show || results.length === 0) { return null; } return (
    {results.map((member, index) => (
  • setSelectedIndex(index)} onMouseDown={(e) => { e.preventDefault(); handleSelect(member); }} > {member.name {member.name ?? member.sid} {member.name && {member.sid}}
  • ))}
); }