You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

React/Next.js中输入#或@触发事件并显示选择对话框的实现方法

实现React/Next.js中textarea的@/#触发选择弹窗

这是社交类产品里很常见的输入交互,给你一套可直接落地的实现方案:

核心逻辑拆解

  • 监听输入框的内容变化与光标位置,检测是否输入了@或#
  • 根据触发字符(@/#)加载对应的用户/话题候选列表
  • 将弹窗定位到输入框的光标附近,选中选项后自动插入到输入框指定位置
  • 处理输入过程中的弹窗显隐逻辑(比如输入其他字符时关闭弹窗、输入关键词时实时过滤列表)

具体实现步骤&代码示例

1. 基础组件结构

用Next.js函数组件搭配useState管理状态、useRef获取DOM元素:

import { useState, useRef } from 'react';

export default function MentionTextarea() {
  const [inputValue, setInputValue] = useState('');
  const [showPopup, setShowPopup] = useState(false);
  const [triggerType, setTriggerType] = useState(null); // 'user'对应@,'topic'对应#
  const [currentQuery, setCurrentQuery] = useState('');
  const textareaRef = useRef(null);
  const popupRef = useRef(null);

  // 监听输入框内容变化
  const handleInputChange = (e) => {
    const value = e.target.value;
    setInputValue(value);
    const cursorPos = e.target.selectionStart;
    
    // 从光标位置往前匹配最近的@或#
    const preText = value.substring(0, cursorPos);
    const match = preText.match(/(@|#)([^@#\s]*)$/);
    
    if (match) {
      const [, trigger, query] = match;
      setTriggerType(trigger === '@' ? 'user' : 'topic');
      setCurrentQuery(query);
      setShowPopup(true);
      // 计算并设置弹窗位置
      positionPopup(cursorPos);
    } else {
      setShowPopup(false);
    }
  };

  // 把弹窗定位到光标正下方
  const positionPopup = (cursorPos) => {
    if (!textareaRef.current || !popupRef.current) return;
    const textarea = textareaRef.current;
    const popup = popupRef.current;
    
    // 获取光标在页面中的坐标
    const range = document.createRange();
    const selection = window.getSelection();
    range.setStart(textarea.firstChild, cursorPos);
    range.setEnd(textarea.firstChild, cursorPos);
    const rect = range.getBoundingClientRect();
    
    popup.style.position = 'absolute';
    popup.style.left = `${rect.left}px`;
    popup.style.top = `${rect.bottom + window.scrollY + 5}px`;
  };

  // 将选中的内容插入输入框
  const handleSelectOption = (option) => {
    const { value } = option;
    // 替换当前输入的关键词部分
    const preText = inputValue.substring(0, inputValue.length - currentQuery.length);
    const newText = `${preText}${triggerType === 'user' ? '@' : '#'}${value} `;
    setInputValue(newText);
    setShowPopup(false);
    // 光标自动移到插入内容的末尾
    setTimeout(() => {
      textareaRef.current.selectionStart = textareaRef.current.selectionEnd = newText.length;
    }, 0);
  };

  // 模拟用户/话题列表(实际项目替换为接口请求)
  const getOptions = () => {
    if (triggerType === 'user') {
      return [
        { id: 1, value: 'alice' },
        { id: 2, value: 'bob' },
        { id: 3, value: 'charlie' }
      ].filter(opt => opt.value.includes(currentQuery));
    } else if (triggerType === 'topic') {
      return [
        { id: 1, value: 'reactjs' },
        { id: 2, value: 'nextjs' },
        { id: 3, value: 'frontend' }
      ].filter(opt => opt.value.includes(currentQuery));
    }
    return [];
  };

  return (
    <div style={{ position: 'relative', width: '500px', margin: '20px auto' }}>
      <textarea
        ref={textareaRef}
        value={inputValue}
        onChange={handleInputChange}
        style={{ width: '100%', height: '150px', padding: '10px', fontSize: '16px' }}
        placeholder="输入@提及用户,#选择话题..."
      />
      {showPopup && (
        <div ref={popupRef} style={{ background: '#fff', border: '1px solid #ccc', borderRadius: '4px', padding: '8px', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', zIndex: 1000 }}>
          <h4 style={{ margin: '0 0 8px 0', fontSize: '14px' }}>
            {triggerType === 'user' ? '选择用户' : '选择话题'}
          </h4>
          <div>
            {getOptions().map(option => (
              <div
                key={option.id}
                style={{ padding: '6px 8px', cursor: 'pointer', borderRadius: '4px', marginBottom: '4px' }}
                onClick={() => handleSelectOption(option)}
                onMouseEnter={(e) => e.target.style.background = '#f0f0f0'}
                onMouseLeave={(e) => e.target.style.background = 'transparent'}
              >
                {option.value}
              </div>
            ))}
          </div>
        </div>
      )}
    </div>
  );
}

2. 关键细节说明

  • 光标检测:通过selectionStart获取光标位置,往前匹配最近的@或#,确保只响应当前输入的触发词
  • 弹窗定位:利用Range API获取光标在页面中的精确坐标,让弹窗刚好出现在光标下方
  • 内容插入:替换当前输入的关键词部分,插入内容后自动调整光标位置,避免用户手动移动光标
  • 实时过滤:根据当前输入的关键词实时筛选候选列表,模拟搜索效果

3. 扩展优化建议

  • 监听keydown事件,支持按ESC键关闭弹窗
  • 优化弹窗定位逻辑,避免弹窗超出视口范围
  • 接入真实后端接口,替换模拟的用户/话题列表
  • 支持连续输入多个@/#触发内容,处理重叠触发的情况
  • 添加加载状态,请求接口时显示loading动画

内容的提问来源于stack exchange,提问作者Ansh Rathod

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.19 01:46:31