MentionSuggestion.tsx 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209
  1. 'use client';
  2. import Image from 'next/image';
  3. import { useState, useEffect, useRef, useCallback } from 'react';
  4. import { fetchApi } from '@/lib/utils/client';
  5. import MemberSuggestion from '@/types/forum/memtionSuggetion';
  6. type Props = {
  7. postID: number;
  8. textareaRef: React.RefObject<HTMLTextAreaElement|null>;
  9. onSelect: (handle: string) => void;
  10. };
  11. export default function MentionSuggestion({ postID, textareaRef, onSelect }: Props)
  12. {
  13. const [show, setShow] = useState(false);
  14. const [keyword, setKeyword] = useState('');
  15. const [results, setResults] = useState<MemberSuggestion[]>([]);
  16. const [selectedIndex, setSelectedIndex] = useState(0);
  17. const [mentionStart, setMentionStart] = useState(-1);
  18. const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
  19. const containerRef = useRef<HTMLDivElement>(null);
  20. // textarea 입력 감지
  21. const detectMention = useCallback(() => {
  22. const textarea = textareaRef.current;
  23. if (!textarea) return;
  24. const value = textarea.value;
  25. const cursor = textarea.selectionStart;
  26. // 커서 앞에서 가장 가까운 @ 찾기
  27. const before = value.slice(0, cursor);
  28. const atIndex = before.lastIndexOf('@');
  29. if (atIndex === -1) {
  30. setShow(false);
  31. return;
  32. }
  33. // @ 앞이 공백이거나 맨 처음이어야 함
  34. if (atIndex > 0 && before[atIndex - 1] !== ' ' && before[atIndex - 1] !== '\n') {
  35. setShow(false);
  36. return;
  37. }
  38. // @ 뒤 텍스트에 공백이 있으면 멘션이 아님
  39. const query = before.slice(atIndex + 1);
  40. if (query.includes(' ') || query.includes('\n')) {
  41. setShow(false);
  42. return;
  43. }
  44. setMentionStart(atIndex);
  45. setKeyword(query);
  46. setShow(true);
  47. setSelectedIndex(0);
  48. }, [textareaRef]);
  49. // 검색 API 호출 (debounce)
  50. useEffect(() => {
  51. if (!show) {
  52. return;
  53. }
  54. if (!show || keyword.length === 0) {
  55. return;
  56. }
  57. if (debounceRef.current) {
  58. clearTimeout(debounceRef.current);
  59. }
  60. debounceRef.current = setTimeout(async () => {
  61. try {
  62. const res = await fetchApi<MemberSuggestion[]>(
  63. `/api/forum/comments/mention?postID=${postID}&keyword=${encodeURIComponent(keyword)}`,
  64. );
  65. if (res.data) {
  66. setResults(res.data);
  67. if (res.data.length === 0) {
  68. setShow(false);
  69. }
  70. }
  71. } catch {
  72. setShow(false);
  73. }
  74. }, 300);
  75. return () => {
  76. if (debounceRef.current) {
  77. clearTimeout(debounceRef.current);
  78. }
  79. };
  80. }, [keyword, show, postID]);
  81. // 회원 선택
  82. const handleSelect = useCallback((member: MemberSuggestion) => {
  83. const textarea = textareaRef.current;
  84. if (!textarea) return;
  85. const handle = member.name ?? member.sid;
  86. const value = textarea.value;
  87. const after = value.slice(textarea.selectionStart);
  88. // @keyword 부분을 @handle 로 교체
  89. const newValue = value.slice(0, mentionStart) + `@${handle} ` + after;
  90. onSelect(newValue);
  91. setShow(false);
  92. setResults([]);
  93. // 커서를 멘션 뒤로 이동
  94. setTimeout(() => {
  95. const cursorPos = mentionStart + handle.length + 2; // @handle + space
  96. textarea.setSelectionRange(cursorPos, cursorPos);
  97. textarea.focus();
  98. }, 0);
  99. }, [textareaRef, mentionStart, onSelect]);
  100. // 키보드 네비게이션
  101. const handleKeyDown = useCallback((e: KeyboardEvent) => {
  102. if (!show || results.length === 0) return;
  103. switch (e.key) {
  104. case 'ArrowDown':
  105. e.preventDefault();
  106. setSelectedIndex(prev => (prev + 1) % results.length);
  107. break;
  108. case 'ArrowUp':
  109. e.preventDefault();
  110. setSelectedIndex(prev => (prev - 1 + results.length) % results.length);
  111. break;
  112. case 'Enter':
  113. if (show && results.length > 0) {
  114. e.preventDefault();
  115. handleSelect(results[selectedIndex]);
  116. }
  117. break;
  118. case 'Escape':
  119. setShow(false);
  120. break;
  121. }
  122. }, [show, results, selectedIndex, handleSelect]);
  123. // textarea 이벤트 리스너
  124. useEffect(() => {
  125. const textarea = textareaRef.current;
  126. if (!textarea) {
  127. return;
  128. }
  129. textarea.addEventListener('input', detectMention);
  130. textarea.addEventListener('click', detectMention);
  131. textarea.addEventListener('keydown', handleKeyDown);
  132. return () => {
  133. textarea.removeEventListener('input', detectMention);
  134. textarea.removeEventListener('click', detectMention);
  135. textarea.removeEventListener('keydown', handleKeyDown);
  136. };
  137. }, [textareaRef, detectMention, handleKeyDown]);
  138. // 외부 클릭 시 닫기
  139. useEffect(() => {
  140. if (!show) return;
  141. const handleClickOutside = (e: MouseEvent) => {
  142. if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
  143. setShow(false);
  144. }
  145. };
  146. document.addEventListener('mousedown', handleClickOutside);
  147. return () => document.removeEventListener('mousedown', handleClickOutside);
  148. }, [show]);
  149. if (!show || results.length === 0) {
  150. return null;
  151. }
  152. return (
  153. <div ref={containerRef} className="mention-suggestion">
  154. <ul>
  155. {results.map((member, index) => (
  156. <li
  157. key={member.id}
  158. className={index === selectedIndex ? 'selected' : ''}
  159. onMouseEnter={() => setSelectedIndex(index)}
  160. onMouseDown={(e) => {
  161. e.preventDefault();
  162. handleSelect(member);
  163. }}
  164. >
  165. <Image
  166. src={member.thumb ?? '/resources/thumb.gif'}
  167. alt={member.name ?? member.sid}
  168. width={28}
  169. height={28}
  170. />
  171. <span>{member.name ?? member.sid}</span>
  172. {member.name && <small>{member.sid}</small>}
  173. </li>
  174. ))}
  175. </ul>
  176. </div>
  177. );
  178. }