自定义文档高亮 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> 标签)。useTextHighlight 通过 highlightCrossNodes 方法巧妙解决了这个问题:
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;
}
};
实现思路是:
- 使用
TreeWalker遍历所有被选中范围包含的文本节点 - 为每个文本节点创建子范围(
subRange) - 为每个子范围创建高亮标记并应用样式
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 方法负责移除特定高亮,关键在于正确恢复原始文本结构:
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);
}
};
特别注意 normalize() 方法的使用,它能合并相邻的文本节点,避免 DOM 结构碎片化。
4. 事件处理与生命周期管理
为了实现流畅的用户交互,useTextHighlight 绑定了多种事件:
// 事件处理函数
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 = () => {
// 移除事件监听
// ...
};
vue 中通过 onMounted、onUnmounted 和 watch 钩子,确保事件在正确的时机绑定和解绑,避免内存泄漏:
onMounted(() => {
addStyles();
nextTick(() => {
if (containerRef.value) attachListeners();
});
});
watch(containerRef, (newVal, oldVal) => {
if (oldVal) detachListeners();
if (newVal) nextTick(() => attachListeners());
});
onUnmounted(() => detachListeners());
5. 样式管理
工具自动注入基础样式,确保高亮显示的一致性,并支持自定义颜色:
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);
}
};
其中 box-decoration-break: clone 确保高亮样式在跨多行时能正确显示。
如何使用
在 Vue 组件中使用 useTextHighlight 非常简单:
<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,
};
}
总结与扩展方向
本次实现的 useTextHighlight 对 DOM Range API 和 TreeWalker 的进行了运用,优雅地解决了文本高亮的核心难题,特别是跨节点高亮的处理。
可以考虑的扩展方向:
- 高亮样式的更多自定义选项(边框、圆角、透明度等)
- 高亮的持久化存储(结合 localStorage 或后端 API,便于一些场景需要进行回显)
- 高亮分组和批量操作
- 为高亮添加注释或标签功能,允许右键显示一些其他的扩展功能
- 支持键盘快捷键操作
希望能帮助 everybody 理解下文本高亮的实现,如果有任何改进建议,欢迎各位大佬评论区交流!

浙公网安备 33010602011771号