| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209 |
- '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<HTMLTextAreaElement|null>;
- 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<MemberSuggestion[]>([]);
- const [selectedIndex, setSelectedIndex] = useState(0);
- const [mentionStart, setMentionStart] = useState(-1);
- const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
- const containerRef = useRef<HTMLDivElement>(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<MemberSuggestion[]>(
- `/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 (
- <div ref={containerRef} className="mention-suggestion">
- <ul>
- {results.map((member, index) => (
- <li
- key={member.id}
- className={index === selectedIndex ? 'selected' : ''}
- onMouseEnter={() => setSelectedIndex(index)}
- onMouseDown={(e) => {
- e.preventDefault();
- handleSelect(member);
- }}
- >
- <Image
- src={member.thumb ?? '/resources/thumb.gif'}
- alt={member.name ?? member.sid}
- width={28}
- height={28}
- />
- <span>{member.name ?? member.sid}</span>
- {member.name && <small>{member.sid}</small>}
- </li>
- ))}
- </ul>
- </div>
- );
- }
|