React函数组件实现可编辑区域词汇高亮 解决光标错位问题
替代遮罩方案的可编辑文本高亮实现方案
原方案核心问题根源
基于highlight npm包的遮罩式实现,本质是靠“原生输入域设为透明+上层叠加可视化高亮层”完成效果,两层DOM完全独立、排版计算不同步,只要存在1px的对齐误差、滚动偏差,就会直接导致光标坐标错位、输入时光标无故跳动的问题,同时因为高亮层承接了部分交互,还会出现选不中文字、输入焦点丢失的额外问题。
核心实现思路
- 完全保留原生可编辑元素的所有默认交互行为,不替换、不遮挡输入域的原生事件逻辑
- 新增一层无交互的纯渲染层,和原生输入域做1:1的排版参数完全对齐,叠在输入域下层,输入域设置透明背景承接所有输入、光标渲染逻辑
- 基于绑定到useState的输入值实时拆分匹配片段,在渲染层输出带高亮样式的内容,视觉上实现匹配词高亮,完全不影响原生输入的体验
- 所有按键监听逻辑直接绑定在原生输入元素上,可自由叠加自定义的按键处理,不存在逻辑冲突
具体实现代码
import { useState, useMemo, useRef } from 'react'; // 待匹配高亮词汇数组 const MATCH_WORDS = ['cat', 'dog']; export default function HighlightEditableArea() { // 可编辑区域值绑定到useState const [inputContent, setInputContent] = useState(''); // 标签列表状态 const [tagList, setTagList] = useState([]); const editableRef = useRef(null); // 实时拆分文本片段,匹配到目标词就标记为高亮类型 const renderSegments = useMemo(() => { if (!inputContent) return []; const matchRegex = new RegExp(`\\b(${MATCH_WORDS.join('|')})\\b`, 'gi'); const segmentList = []; let cursor = 0; let currentMatch; while ((currentMatch = matchRegex.exec(inputContent)) !== null) { // 匹配位置之前的普通文本 if (currentMatch.index > cursor) { segmentList.push({ type: 'normal', text: inputContent.slice(cursor, currentMatch.index) }) } // 命中的高亮文本 segmentList.push({ type: 'highlight', text: currentMatch[0] }) cursor = currentMatch.index + currentMatch[0].length; } // 最后剩余的普通文本 if (cursor < inputContent.length) { segmentList.push({ type: 'normal', text: inputContent.slice(cursor) }) } return segmentList; }, [inputContent]); // 统一按键监听逻辑 const handleKeyDown = (e) => { // 可直接叠加其他自定义按键处理逻辑,完全兼容原有onKeyPress等监听需求 if (e.keyCode === 32) { // 检测光标前的#标签内容 const currentSelection = window.getSelection(); const caretPos = currentSelection.anchorOffset; const textBeforeCaret = inputContent.slice(0, caretPos); const tagPattern = /#([^#\s]+)$/; const matchedTag = textBeforeCaret.match(tagPattern); if (matchedTag) { e.preventDefault(); // 新建标签并更新列表 setTagList(prev => [...prev, matchedTag[1]]); // 从输入内容中移除已转为标签的#片段 const remainingContent = inputContent.slice(0, caretPos - matchedTag[0].length) + inputContent.slice(caretPos); setInputContent(remainingContent); } } }; return ( <div className="relative w-[420px] h-[220px]"> {/* 纯渲染高亮层:完全不承接交互,和输入层排版1:1对齐 */} <div className="absolute inset-0 p-4 font-sans text-base leading-normal whitespace-pre-wrap break-words pointer-events-none" aria-hidden="true" > {renderSegments.map((seg, idx) => ( seg.type === 'highlight' ? <span key={idx} className="px-0.5 rounded bg-blue-100 text-blue-600">{seg.text}</span> : seg.text ))} </div> {/* 原生可编辑域:设置透明背景承接所有输入交互,光标完全由浏览器原生渲染 */} <textarea ref={editableRef} value={inputContent} onChange={(e) => setInputContent(e.target.value)} onKeyDown={handleKeyDown} className="absolute inset-0 w-full h-full p-4 font-sans text-base leading-normal bg-transparent outline-none resize-none whitespace-pre-wrap break-words z-10" /> {/* 标签展示区域 */} <div className="absolute bottom-[-40px] left-0 flex flex-wrap gap-2"> {tagList.map(tag => ( <span className="px-2 py-0.5 text-sm rounded bg-gray-100">#{tag}</span> ))} </div> </div> ) }
多场景适配说明
- 适配单行input:将核心元素替换为单行input,两层保持相同的padding、字号、行高,取消换行属性即可
- 适配可编辑div(contenteditable):移除textarea的value绑定,通过onInput事件将div内的纯文本同步到useState中,其余对齐逻辑、高亮渲染逻辑完全不变
- 解决光标错位的核心逻辑:因为高亮层完全不参与交互、不计算光标位置,所有光标渲染、文本选择、输入逻辑全部由浏览器原生处理,从根源上杜绝了双DOM排版不一致导致的光标跳动问题
内容的提问来源于stack exchange,提问作者Sergey Kyharyonok
相关产品推荐
相关产品推荐

