如何在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;
核心要点
- 光标位置管理:通过
saveCursorPosition和restoreCursorPosition方法,在内容更新前后保存并恢复光标,避免光标跳转到开头的问题。 - 实时文本格式化:使用正则匹配@提及内容,用
<span>包裹添加高亮样式,正则可根据需求调整支持的用户名格式。 - 占位符实现:利用CSS伪元素模拟原生输入框的占位符效果,解决
contenteditable不支持原生placeholder的问题。 - 内容同步:在
onInput事件中获取纯文本内容,更新状态后通过useEffect重新渲染格式化后的HTML。
内容的提问来源于stack exchange,提问作者Kia S
相关产品推荐
相关产品推荐

