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

如何在React中实现Input/Textarea的@提及/话题高亮功能?

实现@提及高亮的可编辑输入框(无npm包)

问题描述

需要实现类似WhatsApp的功能:在输入框中用@提及用户时,提及内容高亮显示,其余文本保持正常。要求自定义实现,不能使用任何npm包。

之前尝试用textarea配合独立div实现了视觉高亮,但无法直接编辑高亮后的内容;改用contenteditable div时,遇到光标位置丢失、内容同步异常的问题,相关尝试代码如下:

视觉效果尝试代码

import React, { useState } from "react";

const App = () => {
  const [inputVal, setInputVal] = useState("");

  const handleChange = (e) => {
    setInputVal(e.target.value);
  };

  const formatText = (text) => {
    const formattedText = text.replace(
      /(@\w+)(\s|$)/g,
      `<span style="color: red;">$1</span>$2`
    );
    return { __html: formattedText };
  };

  return (
    <div>
      <textarea
        type="text"
        placeholder="Add message..."
        value={inputVal}
        onChange={handleChange}
        style={{ width: "100%", height: "100px" }}
      />
      <div
        style={{ whiteSpace: "pre-wrap", color: "black" }} 
        dangerouslySetInnerHTML={formatText(inputVal)}
      ></div>
    </div>
  );
};

export default App;

可编辑div尝试代码

<div
   contentEditable = "true"
  aria-placeholder = "Add message..."
  role = "textbox"
  // data-lexical-editor="true"
  onInput = {
  (e) => setInputVal(e.target.innerText)
          }
  style = {{
    whiteSpace: "pre-wrap",
    color: "black",
    border: "1px solid red",
    width: "100%",
    userSelect: "text",
    maxHeight: "200px",
    outline: "none",
    // minHeight: "40px",
    // wordBreak: "break-word",
  }}
  dangerouslySetInnerHTML = {formatText(inputVal)} >
</div>

解决方案

核心是使用contenteditable div作为输入容器,实时处理@提及并添加高亮,同时解决光标位置丢失的问题。

完整实现代码

import React, { useState, useRef, useEffect } from "react";

const App = () => {
  const [inputContent, setInputContent] = useState("");
  const editableRef = useRef(null);

  // 保存当前光标位置(基于文本长度计算)
  const saveCursorPosition = () => {
    const selection = window.getSelection();
    if (selection.rangeCount === 0) return null;
    const range = selection.getRangeAt(0);
    const preSelectionRange = range.cloneRange();
    preSelectionRange.selectNodeContents(editableRef.current);
    preSelectionRange.setEnd(range.startContainer, range.startOffset);
    return preSelectionRange.toString().length;
  };

  // 恢复光标位置
  const restoreCursorPosition = (pos) => {
    if (pos === null || !editableRef.current) return;
    
    const selection = window.getSelection();
    const range = document.createRange();
    range.setStart(editableRef.current, 0);
    range.collapse(true);
    
    const nodeStack = [editableRef.current];
    let currentNode = null;
    let charCount = 0;
    let found = false;

    while (nodeStack.length > 0 && !found) {
      currentNode = nodeStack.pop();
      if (currentNode.nodeType === Node.TEXT_NODE) {
        const textLength = currentNode.textContent.length;
        if (charCount + textLength >= pos) {
          range.setStart(currentNode, pos - charCount);
          range.setEnd(currentNode, pos - charCount);
          found = true;
        } else {
          charCount += textLength;
        }
      } else {
        // 倒序遍历子节点,保证顺序正确
        const children = Array.from(currentNode.childNodes).reverse();
        children.forEach(child => nodeStack.push(child));
      }
    }

    selection.removeAllRanges();
    selection.addRange(range);
  };

  // 格式化文本:给@提及添加高亮样式
  const formatText = (text) => {
    // 匹配@开头的用户名(支持字母、数字、下划线、短横线)
    return text.replace(/(@[\w-]+)(\s|$)/g, '<span style="color: #2563eb; font-weight: 500;">$1</span>$2');
  };

  // 处理输入事件
  const handleInput = (e) => {
    const currentText = e.target.innerText;
    const cursorPos = saveCursorPosition();
    setInputContent(currentText);
    // 延迟恢复光标,确保DOM已更新
    setTimeout(() => restoreCursorPosition(cursorPos), 0);
  };

  // 内容更新时重新渲染格式化后的HTML
  useEffect(() => {
    if (editableRef.current) {
      editableRef.current.innerHTML = formatText(inputContent);
    }
  }, [inputContent]);

  // 处理占位符显示逻辑
  const handleFocus = () => {
    editableRef.current.classList.add("focused");
  };

  const handleBlur = () => {
    if (!inputContent) {
      editableRef.current.classList.remove("focused");
    }
  };

  return (
    <div style={{ width: "100%", maxWidth: "600px", margin: "20px auto" }}>
      <div
        ref={editableRef}
        contentEditable
        role="textbox"
        aria-multiline="true"
        onInput={handleInput}
        onFocus={handleFocus}
        onBlur={handleBlur}
        style={{
          minHeight: "80px",
          padding: "12px 16px",
          border: "1px solid #e5e7eb",
          borderRadius: "8px",
          fontSize: "16px",
          lineHeight: "1.5",
          whiteSpace: "pre-wrap",
          outline: "none",
          fontFamily: "system-ui, -apple-system, sans-serif"
        }}
        placeholder="输入消息,用@提及用户..."
      />
      {/* 模拟占位符的CSS */}
      <style>{`
        [contenteditable]:empty:before {
          content: attr(placeholder);
          color: #9ca3af;
          pointer-events: none;
        }
        [contenteditable].focused:empty:before {
          content: "";
        }
      `}</style>
    </div>
  );
};

export default App;

核心要点

  1. 光标位置管理:通过saveCursorPosition和restoreCursorPosition方法,在内容更新前后保存并恢复光标,避免光标跳转到开头的问题。
  2. 实时文本格式化:使用正则匹配@提及内容,用<span>包裹添加高亮样式,正则可根据需求调整支持的用户名格式。
  3. 占位符实现:利用CSS伪元素模拟原生输入框的占位符效果,解决contenteditable不支持原生placeholder的问题。
  4. 内容同步:在onInput事件中获取纯文本内容,更新状态后通过useEffect重新渲染格式化后的HTML。

内容的提问来源于stack exchange,提问作者Kia S

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.17 17:49:54