如何自定义文档高亮hooks为?

摘要:在现在项目开发中,文本高亮是一个常见且实用的功能,尤其在一些涉及做题网站、阅读类应用、笔记工具或内容管理系统中。废话不多说直接看实现: 功能亮点 支持鼠标选中文本后自动高亮 可选的双击高亮功能 处理跨段落、跨元素的文本高亮 点击高亮区域可直
在现在项目开发中,文本高亮是一个常见且实用的功能,尤其在一些涉及做题网站、阅读类应用、笔记工具或内容管理系统中。废话不多说直接看实现: 功能亮点 支持鼠标选中文本后自动高亮 可选的双击高亮功能 处理跨段落、跨元素的文本高亮 点击高亮区域可直接取消高亮 提供清除单个或所有高亮的方法 支持自定义高亮颜色 高亮事件回调机制 自动处理文本节点合并,保持 DOM 结构整洁 核心实现解析 1. 类型定义与初始化 export interface HighlightRange { id: string; // 唯一标识 startContainer: Node; // 起始节点 startOffset: number; // 起始偏移量 endContainer: Node; // 结束节点 endOffset: number; // 结束偏移量 text: string; // 高亮文本内容 } 初始化部分处理配置选项和响应式变量: export function useTextHighlight( containerRef: Ref<HTMLElement | null>, options: { highlightColor?: string; enableDoubleClick?: boolean; onHighlight?: (range: HighlightRange) => void; } = {} ) { const { highlightColor = "#ffeb3b", // 默认黄色高亮 enableDoubleClick = false, onHighlight, } = options; const highlights = ref<Map<string, HighlightRange>>(new Map()); const isHighlighting = ref(false); // ... } 2. 跨节点高亮的核心解决方案 文本高亮的主要难点是跨节点选择(例如选中的文本跨越多个 <p> 标签或 <span> 标签)。这边用了 highlightCrossNodes 方法处理这个问题。 实现思路是: 使用 TreeWalker 遍历所有被选中范围包含的文本节点 为每个文本节点创建子范围(subRange) 为每个子范围创建高亮标记并应用样式 const highlightCrossNodes = (range: Range, highlightId: string): boolean => { try { // 使用 TreeWalker 遍历所有选中的文本节点 const walker = document.createTreeWalker( range.commonAncestorContainer, NodeFilter.SHOW_TEXT, { acceptNode(node: Node) { return range.intersectsNode(node) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; }, } ); const nodes: Text[] = []; let node: Node | null; while ((node = walker.nextNode())) { if (node.nodeType === Node.TEXT_NODE) { nodes.push(node as Text); } } // 为每个文本节点创建高亮标记 nodes.forEach((textNode, index) => { const subRange = document.createRange(); const isFirst = index === 0; const isLast = index === nodes.length - 1; // 设置子范围 subRange.setStart(textNode, isFirst ? range.startOffset : 0); subRange.setEnd( textNode, isLast ? range.endOffset : textNode.textContent?.length || 0 ); // 创建并处理高亮标记... }); return true; } catch (error) { console.error("跨节点高亮失败:", error); return false; } }; 3. 高亮与取消高亮的完整流程 高亮选择的文本 highlightSelection 方法处理选中文本后的高亮逻辑。 思路:首先验证选中内容的有效性,然后尝试简单高亮方法,失败则自动切换到跨节点高亮方案,确保各种场景下的兼容性。 const highlightSelection = () => { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) { return; } const range = selection.getRangeAt(0); const selectedText = range.toString().trim(); // 验证选中内容... try { // 尝试使用 surroundContents(适用于简单情况) range.surroundContents(mark); } catch (e) { // 失败则使用跨节点高亮方法 const success = highlightCrossNodes(range, highlightId); // ... } // 保存高亮信息并触发回调 // ... }; 取消高亮的实现 removeHighlight 方法负责移除特定高亮,关键在于正确恢复原始文本结构: 特别注意 normalize() 方法的使用,它能合并相邻的文本节点,避免 DOM 结构碎片化。 const removeHighlight = (highlightId: string) => { if (!containerRef.value) return; // 查找所有相同 ID 的高亮标记 const marks = containerRef.value.querySelectorAll( `mark.text-highlight[data-highlight-id="${highlightId}"]` ); if (marks.length > 0) { marks.forEach((mark) => { const parent = mark.parentNode; if (parent && parent.nodeType === Node.ELEMENT_NODE) { // 保存下一个兄弟节点用于正确插入 const nextSibling = mark.nextSibling; // 将高亮内容替换为普通文本 const fragment = document.createDocumentFragment(); while (mark.firstChild) { fragment.appendChild(mark.firstChild); } // 插入到正确位置 if (nextSibling) { parent.insertBefore(fragment, nextSibling); } else { parent.appendChild(fragment); } parent.removeChild(mark); } }); // 合并相邻的文本节点,保持 DOM 整洁 containerRef.value.normalize(); highlights.value.delete(highlightId); } }; 4. 事件处理与生命周期管理 vue 中通过 onMounted、onUnmounted 和 watch 钩子,确保事件在正确的时机绑定和解绑,避免内存泄漏: // 事件处理函数 const handleMouseUp = (e: MouseEvent) => { setTimeout(() => highlightSelection(), 10); }; const handleDoubleClick = () => { if (enableDoubleClick) highlightSelection(); }; const handleHighlightClick = (e: MouseEvent) => { // 处理高亮区域点击事件,取消高亮 // ... }; // 事件绑定与解绑 const attachListeners = () => { if (!containerRef.value) return; containerRef.value.addEventListener("mouseup", handleMouseUp); containerRef.value.addEventListener("dblclick", handleDoubleClick); containerRef.value.addEventListener("click", handleHighlightClick); }; const detachListeners = () => { // 移除事件监听 // ... }; onMounted(() => { addStyles(); nextTick(() => { if (containerRef.value) attachListeners(); }); }); watch(containerRef, (newVal, oldVal) => { if (oldVal) detachListeners(); if (newVal) nextTick(() => attachListeners()); }); onUnmounted(() => detachListeners()); 5. 样式管理 工具自动注入基础样式,确保高亮显示的一致性,并支持自定义颜色。 注意:确保高亮样式在跨多行时能正确显示的样式:box-decoration-break: clone 。 const addStyles = () => { if (!document.getElementById("text-highlight-styles")) { const style = document.createElement("style"); style.id = "text-highlight-styles"; style.textContent = ` .text-highlight { display: inline !important; padding: 0 !important; margin: 0 !important; line-height: inherit !important; vertical-align: baseline !important; transition: background-color 0.2s; cursor: pointer; box-decoration-break: clone; -webkit-box-decoration-break: clone; } .text-highlight:hover { opacity: 0.8; } `; document.head.appendChild(style); } }; 如何使用 <template> <div class="app"> <div ref="contentContainer" class="content"> <h2>可高亮的文本内容</h2> <p>这是一段可以被高亮的文本示例,尝试选中其中一部分文字看看效果。</p> <p>这个工具支持跨段落高亮,试着选中这一段和上一段的部分内容。</p> <blockquote>甚至可以高亮引用块中的文本,双击也能触发高亮(如果启用)。</blockquote> </div> <div class="controls"> <button @click="clearAllHighlights">清除所有高亮</button> <p>当前高亮数量: {{ highlights.size }}</p> </div> </div> </template> <script setup lang="ts"> import { ref } from 'vue'; import { useTextHighlight } from './useTextHighlight'; const contentContainer = ref<HTMLElement | null>(null); const { highlights, clearAllHighlights } = useTextHighlight(contentContainer, { highlightColor: '#a8d1ff', // 自定义高亮颜色 enableDoubleClick: true, // 启用双击高亮 onHighlight: (range) => { console.log('新的高亮内容:', range.text); } }); </script> 完整代码 import { ref, onMounted, onUnmounted, watch, nextTick, type Ref } from "vue"; export interface HighlightRange { id: string; startContainer: Node; startOffset: number; endContainer: Node; endOffset: number; text: string; } /** * 文本高亮功能 Composable * @param containerRef 需要高亮的容器元素引用 * @param options 配置选项 */ export function useTextHighlight( containerRef: Ref<HTMLElement | null>, options: { highlightColor?: string; // 高亮颜色,默认 '#ffeb3b' enableDoubleClick?: boolean; // 是否启用双击高亮,默认 false onHighlight?: (range: HighlightRange) => void; // 高亮回调 } = {} ) { const { highlightColor = "#ffeb3b", enableDoubleClick = false, onHighlight, } = options; const highlights = ref<Map<string, HighlightRange>>(new Map()); const isHighlighting = ref(false); // 生成唯一ID const generateId = () => { return `highlight-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; }; // 跨节点高亮处理(支持跨段落) const highlightCrossNodes = (range: Range, highlightId: string): boolean => { try { // 使用 TreeWalker 遍历所有选中的文本节点 const walker = document.createTreeWalker( range.commonAncestorContainer, NodeFilter.SHOW_TEXT, { acceptNode(node: Node) { return range.intersectsNode(node) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; }, } ); const nodes: Text[] = []; let node: Node | null; while ((node = walker.nextNode())) { if (node.nodeType === Node.TEXT_NODE) { nodes.push(node as Text); } } if (nodes.length === 0) { return false; } // 为每个文本节点创建高亮标记 nodes.forEach((textNode, index) => { const subRange = document.createRange(); const isFirst = index === 0; const isLast = index === nodes.length - 1; // 设置子范围 subRange.setStart(textNode, isFirst ? range.startOffset : 0); subRange.setEnd( textNode, isLast ? range.endOffset : textNode.textContent?.length || 0 ); const text = subRange.toString(); if (!text.trim()) { return; // 跳过纯空白的节点 } // 创建高亮标记 const mark = document.createElement("mark"); mark.className = "text-highlight"; mark.style.backgroundColor = highlightColor; mark.style.color = "inherit"; mark.style.cursor = "pointer"; mark.style.padding = "0"; mark.style.display = "inline"; mark.style.lineHeight = "inherit"; mark.style.verticalAlign = "baseline"; mark.setAttribute("data-highlight-id", highlightId); try { subRange.surroundContents(mark); } catch (e) { // 如果 surroundContents 失败,尝试使用 extractContents try { const contents = subRange.extractContents(); mark.appendChild(contents); subRange.insertNode(mark); } catch (err) { console.error("高亮文本节点失败:", err); } } }); return true; } catch (error) { console.error("跨节点高亮失败:", error); return false; } }; // 获取文本节点和偏移量 const getTextNodeAndOffset = ( container: Node, offset: number ): { node: Text; offset: number } | null => { let node: Node | null = container; let currentOffset = offset; // 如果是文本节点,直接返回 if (node.nodeType === Node.TEXT_NODE) { return { node: node as Text, offset: currentOffset }; } // 如果是元素节点,找到对应的文本节点 if (node.nodeType === Node.ELEMENT_NODE) { const element = node as Element; const childNodes = Array.from(element.childNodes); for (const child of childNodes) { if (child.nodeType === Node.TEXT_NODE) { const textLength = (child as Text).textContent?.length || 0; if (currentOffset <= textLength) { return { node: child as Text, offset: currentOffset }; } currentOffset -= textLength; } else if (child.nodeType === Node.ELEMENT_NODE) { // 跳过高亮标记元素 if ( (child as Element).tagName === "MARK" || (child as Element).classList.contains("text-highlight") ) { const textLength = (child as Element).textContent?.length || 0; if (currentOffset <= textLength) { // 进入高亮元素内部 const result = getTextNodeAndOffset(child, currentOffset); if (result) return result; } currentOffset -= textLength; continue; } const textLength = (child as Element).textContent?.length || 0; if (currentOffset <= textLength) { return getTextNodeAndOffset(child, currentOffset); } currentOffset -= textLength; } } } return null; }; // 高亮选中的文本 const highlightSelection = () => { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) { return; } const range = selection.getRangeAt(0); const selectedText = range.toString().trim(); // 如果没有选中文本,返回 if (!selectedText) { return; } // 检查选中内容是否在容器内 if ( !containerRef.value || !containerRef.value.contains(range.commonAncestorContainer) ) { return; } // 检查是否点击在高亮区域上(如果是,不进行高亮) const commonAncestor = range.commonAncestorContainer; const clickedElement = commonAncestor.nodeType === Node.ELEMENT_NODE ? (commonAncestor as Element) : (commonAncestor as Node).parentElement; if (clickedElement) { const highlightParent = clickedElement.closest(".text-highlight"); if (highlightParent) { selection.removeAllRanges(); return; } } // 防止重复高亮(检查是否已经高亮) const markElements = containerRef.value.querySelectorAll( "mark.text-highlight" ); for (const mark of Array.from(markElements)) { const markRange = document.createRange(); markRange.selectNodeContents(mark); if ( range.intersectsNode(mark) || (markRange.compareBoundaryPoints(Range.START_TO_START, range) <= 0 && markRange.compareBoundaryPoints(Range.END_TO_END, range) >= 0) ) { // 已经高亮,取消选择 selection.removeAllRanges(); return; } } try { // 保存范围信息 const startContainer = range.startContainer; const startOffset = range.startOffset; const endContainer = range.endContainer; const endOffset = range.endOffset; // 创建高亮标记 const mark = document.createElement("mark"); mark.className = "text-highlight"; mark.style.backgroundColor = highlightColor; mark.style.color = "inherit"; mark.style.cursor = "pointer"; mark.style.padding = "0"; mark.style.display = "inline"; mark.style.lineHeight = "inherit"; mark.style.verticalAlign = "baseline"; const highlightId = generateId(); mark.setAttribute("data-highlight-id", highlightId); // 使用安全的方法处理高亮,支持跨段落 try { // 尝试使用 surroundContents(适用于简单情况,同一节点内) range.surroundContents(mark); } catch (e) { // surroundContents 失败(跨元素),使用跨节点高亮方法 try { const success = highlightCrossNodes(range, highlightId); if (!success) { selection.removeAllRanges(); return; } // 跨节点高亮成功,直接返回(不需要后续的 mark 处理) highlights.value.set(highlightId, { id: highlightId, startContainer: range.startContainer, startOffset: range.startOffset, endContainer: range.endContainer, endOffset: range.endOffset, text: selectedText, }); onHighlight?.({ id: highlightId, startContainer: range.startContainer, startOffset: range.startOffset, endContainer: range.endContainer, endOffset: range.endOffset, text: selectedText, }); selection.removeAllRanges(); isHighlighting.value = true; return; } catch (err) { console.error("高亮失败:", err); selection.removeAllRanges(); return; } } // 保存高亮信息 const highlightRange: HighlightRange = { id: highlightId, startContainer, startOffset, endContainer, endOffset, text: selectedText, }; highlights.value.set(highlightId, highlightRange); // 触发回调 onHighlight?.(highlightRange); // 清除选择 selection.removeAllRanges(); isHighlighting.value = true; } catch (error) { console.error("高亮失败:", error); selection.removeAllRanges(); } }; // 取消高亮(支持跨段落高亮,可能有多个 mark 元素共享同一个 highlightId) const removeHighlight = (highlightId: string) => { if (!containerRef.value) return; // 查找所有具有相同 highlightId 的 mark 元素(跨段落高亮可能有多个) const marks = containerRef.value.querySelectorAll( `mark.text-highlight[data-highlight-id="${highlightId}"]` ); if (marks.length > 0) { marks.forEach((mark) => { const parent = mark.parentNode; if (parent && parent.nodeType === Node.ELEMENT_NODE) { // 保存下一个兄弟节点,用于正确插入 const nextSibling = mark.nextSibling; // 将高亮内容替换为普通文本 const fragment = document.createDocumentFragment(); while (mark.firstChild) { fragment.appendChild(mark.firstChild); } // 插入到正确位置 if (nextSibling) { parent.insertBefore(fragment, nextSibling); } else { parent.appendChild(fragment); } parent.removeChild(mark); } }); // 合并相邻的文本节点 if (containerRef.value) { containerRef.value.normalize(); } highlights.value.delete(highlightId); // 如果没有高亮了,更新状态 if (highlights.value.size === 0) { isHighlighting.value = false; } } }; // 取消所有高亮 const clearAllHighlights = () => { if (!containerRef.value) return; const marks = containerRef.value.querySelectorAll("mark.text-highlight"); marks.forEach((mark) => { const parent = mark.parentNode; if (parent) { while (mark.firstChild) { parent.insertBefore(mark.firstChild, mark); } parent.removeChild(mark); } }); // 合并所有文本节点 containerRef.value.normalize(); highlights.value.clear(); isHighlighting.value = false; }; // 点击高亮区域取消高亮 const handleHighlightClick = (e: MouseEvent) => { const target = e.target as HTMLElement; // 检查是否点击在高亮区域上 const highlightElement = target.closest(".text-highlight") as HTMLElement; if (highlightElement) { e.preventDefault(); e.stopPropagation(); const highlightId = highlightElement.getAttribute("data-highlight-id"); if (highlightId) { removeHighlight(highlightId); } // 清除选择 const selection = window.getSelection(); if (selection) { selection.removeAllRanges(); } } }; // 鼠标抬起时高亮 const handleMouseUp = (e: MouseEvent) => { // 延迟执行,确保 selection 已经更新 setTimeout(() => { highlightSelection(); }, 10); }; // 双击高亮(可选) const handleDoubleClick = () => { if (enableDoubleClick) { highlightSelection(); } }; // 添加事件监听器 const attachListeners = () => { if (!containerRef.value) return; containerRef.value.addEventListener("mouseup", handleMouseUp); containerRef.value.addEventListener("dblclick", handleDoubleClick); containerRef.value.addEventListener("click", handleHighlightClick); }; // 移除事件监听器 const detachListeners = () => { if (!containerRef.value) return; containerRef.value.removeEventListener("mouseup", handleMouseUp); containerRef.value.removeEventListener("dblclick", handleDoubleClick); containerRef.value.removeEventListener("click", handleHighlightClick); }; // 添加样式 const addStyles = () => { if (!document.getElementById("text-highlight-styles")) { const style = document.createElement("style"); style.id = "text-highlight-styles"; style.textContent = ` .text-highlight { display: inline !important; padding: 0 !important; margin: 0 !important; line-height: inherit !important; vertical-align: baseline !important; transition: background-color 0.2s; cursor: pointer; box-decoration-break: clone; -webkit-box-decoration-break: clone; } .text-highlight:hover { opacity: 0.8; } `; document.head.appendChild(style); } }; // 初始化 onMounted(() => { addStyles(); // 等待 DOM 更新后再绑定事件 nextTick(() => { if (containerRef.value) { attachListeners(); } }); }); // 监听 ref 变化,确保元素渲染后绑定事件 watch(containerRef, (newVal, oldVal) => { // 先移除旧的事件监听器 if (oldVal) { detachListeners(); } // 如果新元素存在,绑定事件 if (newVal) { nextTick(() => { attachListeners(); }); } }); // 清理 onUnmounted(() => { detachListeners(); }); return { highlights, isHighlighting, highlightSelection, removeHighlight, clearAllHighlights, }; } 扩展方向 可以考虑的扩展方向: 高亮样式的更多自定义选项(边框、圆角、透明度等) 高亮的持久化存储(结合 localStorage 或后端 API,便于一些场景需要进行回显) 高亮分组和批量操作 为高亮添加注释或标签功能,允许右键显示一些其他的扩展功能 支持键盘快捷键操作 希望能帮助 everybody 理解下文本高亮的实现,如果有任何改进建议,欢迎各位大佬评论区交流!