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

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.10.28 00:06:03