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

如何用React实现Medium风格的文本高亮组件?

问题描述

我在React博客项目里想实现Medium风格的文本高亮功能,具体需求:

  • 用户激活高亮工具后,选中文本区域时弹出Popover让用户选颜色
  • 鼠标悬停已高亮文本时,弹出Popover支持改色或取消高亮

试过几个库都不满意:

  • get-selected-text:没法指定高亮区域,Popover修改难度大
  • react-highlight-selection、use-text-selection:无法操作选中文本
  • 尝试原生JavaScript示例时还报错

现在自己写组件,已经能监听文本选择,但不知道怎么用span包裹选中文本,也不知道怎么在不破坏React特性的前提下跟踪多个高亮文本实现删除操作。现有代码如下:

//articleRef is the id of article area where i want to Highlighter to work.

const Highlighter = ({articleRef}) => {

  const { isActive } = useSelector((state) => state.highlighter)
  const [selectedText, setSelection] = useState("")
  const [selectedNode, setNodeSelection] = useState("")
  const [selectedNodes, setNodesSelection] = useState()

  useEffect(() => {
   
    document.onselectionchange = function() {
      console.log(isActive)
      console.log(articleRef.current.id)
      // check if the selection done on the article area and check highlighter tool is active 
      if(articleRef.current.id === 'article-area' && isActive === true  ){
        console.log("Highlighter is active")
        if(document.getSelection() !== (undefined || null ) ){
          setNodeSelection(document.getSelection())
        }
        for(let i = 0; i < selectedNode.rangeCount; i++) {
          if( selectedNode.getRangeAt(i) !== (undefined || null )){
            console.log(selectedNode.getRangeAt(i))
            setSelection(selectedNode.getRangeAt(i).cloneContents())
          }
        };
        console.log(selectedNode)
        console.log(selectedText)
      }
    }
    
  }, [selectedText, selectedNode, isActive, articleRef])  

return null;

}
解决方案

1. 用span包裹选中文本

直接操作DOM时要注意避免和React虚拟DOM冲突,优先通过维护结构化的文本数据实现,若暂时需要直接处理选中文本,可借助Range API的surroundContents方法,同时处理跨节点选择的异常情况:

// 在selectionchange事件中处理选中文本
const selection = document.getSelection();
if (!selection.isCollapsed && isActive) {
  const range = selection.getRangeAt(0);
  const highlightSpan = document.createElement('span');
  highlightSpan.className = 'highlight';
  highlightSpan.dataset.id = `highlight-${Date.now()}`; // 给每个高亮加唯一标识
  try {
    // 包裹选中区域
    range.surroundContents(highlightSpan);
  } catch (e) {
    // 处理跨多个标签的选中情况
    const fragment = range.extractContents();
    highlightSpan.appendChild(fragment);
    range.insertNode(highlightSpan);
  }
  selection.removeAllRanges(); // 清除选中文本状态
}

2. 跟踪高亮文本(不破坏React特性)

不要直接依赖DOM节点跟踪,而是维护一个状态数组存储所有高亮的信息,通过状态驱动渲染:

const [highlights, setHighlights] = useState([]);

// 添加新高亮
const addHighlight = (id, text, color = '#fff3cd') => {
  setHighlights(prev => [...prev, { id, text, color }]);
};

// 删除指定高亮
const removeHighlight = (id) => {
  setHighlights(prev => prev.filter(h => h.id !== id));
};

// 修改高亮颜色
const updateHighlightColor = (id, color) => {
  setHighlights(prev => prev.map(h => h.id === id ? {...h, color} : h));
};

渲染文章时,根据这个数组把对应文本替换为带样式的span:

// 处理纯文本内容的渲染(富文本需解析DOM树后处理)
const renderArticleContent = (content) => {
  let processedContent = content;
  highlights.forEach(highlight => {
    // 转义正则特殊字符,避免匹配出错
    const escapedText = highlight.text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    const regex = new RegExp(escapedText, 'g');
    processedContent = processedContent.replace(regex, `<span class="highlight" data-id="${highlight.id}" style="background-color: ${highlight.color}">$&</span>`);
  });
  return <div dangerouslySetInnerHTML={{__html: processedContent}} />;
};

3. Popover交互实现

  • 选中文本弹出:监听selectionchange事件,当有有效选中且高亮工具激活时,用range.getBoundingClientRect()获取选中区域位置,控制Popover显示。
  • 悬停高亮弹出:给所有高亮span绑定mouseenter/mouseleave事件,触发Popover显示/隐藏,同时传递当前高亮的id用于后续操作。

修改后的完整组件示例

import { useState, useEffect, useRef } from 'react';
import { useSelector } from 'react-redux';

const Highlighter = ({ articleRef, onHighlightsChange }) => {
  const { isActive } = useSelector((state) => state.highlighter);
  const [highlights, setHighlights] = useState([]);
  const popoverRef = useRef(null);

  // 监听文本选择事件
  useEffect(() => {
    const handleSelectionChange = () => {
      if (!isActive || articleRef.current.id !== 'article-area') return;

      const selection = document.getSelection();
      if (selection.isCollapsed || selection.toString().trim() === '') return;

      const range = selection.getRangeAt(0);
      const selectedText = selection.toString().trim();
      const highlightId = `highlight-${Date.now()}`;

      // 创建高亮节点
      const highlightSpan = document.createElement('span');
      highlightSpan.className = 'highlight';
      highlightSpan.dataset.id = highlightId;
      highlightSpan.style.backgroundColor = '#fff3cd';

      try {
        range.surroundContents(highlightSpan);
      } catch (e) {
        const fragment = range.extractContents();
        highlightSpan.appendChild(fragment);
        range.insertNode(highlightSpan);
      }

      addHighlight(highlightId, selectedText);
      selection.removeAllRanges();

      // 定位并显示Popover
      const rect = highlightSpan.getBoundingClientRect();
      if (popoverRef.current) {
        popoverRef.current.style.top = `${rect.bottom + window.scrollY}px`;
        popoverRef.current.style.left = `${rect.left + window.scrollX}px`;
        popoverRef.current.style.display = 'block';
        popoverRef.current.dataset.targetId = highlightId;
      }
    };

    document.addEventListener('selectionchange', handleSelectionChange);
    return () => document.removeEventListener('selectionchange', handleSelectionChange);
  }, [isActive, articleRef]);

  // 监听高亮文本悬停事件
  useEffect(() => {
    const handleMouseEnter = (e) => {
      if (e.target.classList.contains('highlight')) {
        const highlightId = e.target.dataset.id;
        const rect = e.target.getBoundingClientRect();
        if (popoverRef.current) {
          popoverRef.current.style.top = `${rect.bottom + window.scrollY}px`;
          popoverRef.current.style.left = `${rect.left + window.scrollX}px`;
          popoverRef.current.style.display = 'block';
          popoverRef.current.dataset.targetId = highlightId;
        }
      }
    };

    const handleMouseLeave = () => {
      if (popoverRef.current) popoverRef.current.style.display = 'none';
    };

    articleRef.current.addEventListener('mouseenter', handleMouseEnter, true);
    articleRef.current.addEventListener('mouseleave', handleMouseLeave, true);
    return () => {
      articleRef.current.removeEventListener('mouseenter', handleMouseEnter, true);
      articleRef.current.removeEventListener('mouseleave', handleMouseLeave, true);
    };
  }, [articleRef]);

  // 高亮状态操作函数
  const addHighlight = (id, text, color = '#fff3cd') => {
    const newHighlight = { id, text, color };
    setHighlights(prev => [...prev, newHighlight]);
    onHighlightsChange?.(newHighlight);
  };

  const removeHighlight = (id) => {
    const highlightElement = articleRef.current.querySelector(`[data-id="${id}"]`);
    if (highlightElement) {
      // 将span内的内容放回原位置,移除高亮节点
      const parent = highlightElement.parentNode;
      while (highlightElement.firstChild) {
        parent.insertBefore(highlightElement.firstChild, highlightElement);
      }
      parent.removeChild(highlightElement);
    }
    setHighlights(prev => prev.filter(h => h.id !== id));
    onHighlightsChange?.(null, id);
  };

  const updateHighlightColor = (id, color) => {
    const highlightElement = articleRef.current.querySelector(`[data-id="${id}"]`);
    if (highlightElement) highlightElement.style.backgroundColor = color;
    setHighlights(prev => prev.map(h => h.id === id ? {...h, color} : h));
    onHighlightsChange?.(null, id, color);
  };

  // Popover操作逻辑
  const handleColorSelect = (color) => {
    const targetId = popoverRef.current?.dataset.targetId;
    if (targetId) {
      updateHighlightColor(targetId, color);
      popoverRef.current.style.display = 'none';
    }
  };

  const handleDeleteHighlight = () => {
    const targetId = popoverRef.current?.dataset.targetId;
    if (targetId) {
      removeHighlight(targetId);
      popoverRef.current.style.display = 'none';
    }
  };

  return (
    <>
      {/* 高亮操作Popover */}
      <div ref={popoverRef} className="highlight-popover" style={{ display: 'none', position: 'absolute', zIndex: 1000, padding: '8px', backgroundColor: '#fff', borderRadius: '4px', boxShadow: '0 2px 8px rgba(0,0,0,0.15)' }}>
        <div className="color-options" style={{ display: 'flex', gap: '4px', marginBottom: '8px' }}>
          <button onClick={() => handleColorSelect('#fff3cd')} style={{ backgroundColor: '#fff3cd', width: '24px', height: '24px', border: 'none', borderRadius: '4px', cursor: 'pointer' }}></button>
          <button onClick={() => handleColorSelect('#ffeeba')} style={{ backgroundColor: '#ffeeba', width: '24px', height: '24px', border: 'none', borderRadius: '4px', cursor: 'pointer' }}></button>
          <button onClick={() => handleColorSelect('#d1ecf1')} style={{ backgroundColor: '#d1ecf1', width: '24px', height: '24px', border: 'none', borderRadius: '4px', cursor: 'pointer' }}></button>
        </div>
        <button onClick={handleDeleteHighlight} style={{ border: 'none', backgroundColor: '#dc3545', color: '#fff', padding: '4px 8px', borderRadius: '4px', cursor: 'pointer' }}>取消高亮</button>
      </div>
    </>
  );
};

export default Highlighter;

注意事项

  • 直接操作DOM时,要避免和React的渲染逻辑冲突,优先通过状态驱动渲染,而非直接修改原生DOM。
  • 如果文章是富文本内容,需要先解析DOM树,再针对性替换文本节点,避免破坏原有HTML结构。
  • 跨节点选择时surroundContents会抛出异常,必须用extractContents和insertNode组合处理。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.12 07:05:32