如何用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
相关产品推荐
相关产品推荐

