自定义文档高亮 hooks

在现在项目开发中,文本高亮是一个常见且实用的功能,尤其在一些涉及做题网站、阅读类应用、笔记工具或内容管理系统中。废话不多说直接看实现:

功能亮点

  • 支持鼠标选中文本后自动高亮
  • 可选的双击高亮功能
  • 处理跨段落、跨元素的文本高亮
  • 点击高亮区域可直接取消高亮
  • 提供清除单个或所有高亮的方法
  • 支持自定义高亮颜色
  • 高亮事件回调机制
  • 自动处理文本节点合并,保持 DOM 结构整洁

核心实现解析

1. 类型定义与初始化

typescript 复制代码
export interface HighlightRange {
  id: string;          // 唯一标识
  startContainer: Node; // 起始节点
  startOffset: number;  // 起始偏移量
  endContainer: Node;   // 结束节点
  endOffset: number;    // 结束偏移量
  text: string;         // 高亮文本内容
}

组合式函数的初始化部分处理配置选项和响应式变量:

typescript 复制代码
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 方法巧妙解决了这个问题:

typescript 复制代码
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;
  }
};

实现思路是:

  1. 使用 TreeWalker 遍历所有被选中范围包含的文本节点
  2. 为每个文本节点创建子范围(subRange
  3. 为每个子范围创建高亮标记并应用样式

3. 高亮与取消高亮的完整流程

高亮选择的文本

highlightSelection 方法处理用户选中文本后的高亮逻辑:

typescript 复制代码
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 方法负责移除特定高亮,关键在于正确恢复原始文本结构:

typescript 复制代码
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 绑定了多种事件:

typescript 复制代码
// 事件处理函数
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 中通过 onMountedonUnmountedwatch 钩子,确保事件在正确的时机绑定和解绑,避免内存泄漏:

typescript 复制代码
onMounted(() => {
  addStyles();
  nextTick(() => {
    if (containerRef.value) attachListeners();
  });
});

watch(containerRef, (newVal, oldVal) => {
  if (oldVal) detachListeners();
  if (newVal) nextTick(() => attachListeners());
});

onUnmounted(() => detachListeners());

5. 样式管理

工具自动注入基础样式,确保高亮显示的一致性,并支持自定义颜色:

typescript 复制代码
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 非常简单:

vue 复制代码
<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>

完整代码

ts 复制代码
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 的进行了运用,优雅地解决了文本高亮的核心难题,特别是跨节点高亮的处理。

可以考虑的扩展方向:

  1. 高亮样式的更多自定义选项(边框、圆角、透明度等)
  2. 高亮的持久化存储(结合 localStorage 或后端 API,便于一些场景需要进行回显)
  3. 高亮分组和批量操作
  4. 为高亮添加注释或标签功能,允许右键显示一些其他的扩展功能
  5. 支持键盘快捷键操作

希望能帮助 everybody 理解下文本高亮的实现,如果有任何改进建议,欢迎各位大佬评论区交流!