Selection 鼠标动作与划词文本原理解析

Selection 鼠标动作与划词文本原理解析

概述

本文档详细解析了基于 Selection API 的鼠标动作与划词文本处理机制,特别针对复杂 HTML 结构(包含脱敏、分段、特殊格式)的文本选择和位置计算。

1. Selection API 基础原理

1.1 Selection 对象

const selection = iframeDocument.getSelection();
  • Selection 是浏览器提供的 API,用于获取用户在页面上选中的文本
  • 当用户用鼠标拖拽选择文本时,浏览器会自动创建一个 Selection 对象
  • Selection 包含一个或多个 Range 对象,每个 Range 代表一个连续的选中区域

1.2 Range 对象

const range = selection.getRangeAt(0);

Range 对象精确描述了选中文本的起始和结束位置,包含以下关键属性:

  • startContainer: 选中开始的 DOM 节点
  • startOffset: 在开始节点中的偏移量
  • endContainer: 选中结束的 DOM 节点
  • endOffset: 在结束节点中的偏移量
  • commonAncestorContainer: 包含整个选中区域的最小公共祖先节点

2. 鼠标事件处理流程

2.1 事件触发时机

const handleMouseUp = useCallback((e) => {
  // 在mouseup事件中处理文本选择
}, []);
  • 使用 mouseup 事件而非 click,因为文本选择通常在鼠标释放时完成
  • 此时 Selection 对象已经包含了用户选中的完整文本

2.2 选中文本获取

const text = selection.toString();
  • selection.toString() 获取选中的纯文本内容
  • 这个文本是用户在界面上看到的最终文本

2.3 处理流程图

用户拖拽鼠标 → mouseup事件触发 → 获取Selection对象 → 提取Range信息 → 验证选择有效性 → 计算文本位置 → 触发回调

3. 复杂文本位置计算

3.1 问题背景

在复杂的 HTML 文档中,文本位置计算面临以下挑战:

  1. HTML 结构复杂:包含脱敏容器、章节标记等特殊元素
  2. 文本分段:文档被分为多个 prime-section 段落
  3. 脱敏处理:某些文本被替换为 *** 显示,但需要基于原文计算位置
  4. 空白字符处理:HTML 中的空格、换行等需要特殊处理

3.2 位置计算核心逻辑

步骤 1:获取章节信息

const currentPrimeSection = targetElement?.closest?.("[prime-section-id]");
const primeSectionId = currentPrimeSection?.getAttribute("prime-section-id");
const primeSection = Array.from(
  iframeDocument.body.querySelectorAll(`[prime-section-id="${primeSectionId}"]`)
);
const currentPrimeSectionIndex = primeSection.indexOf(currentPrimeSection);

步骤 2:文本重建和标记

const startIndex = primeSection
  .map((node) => {
    return node.cloneNode(true);
  })
  .map((node) => {
    // 使用通用工具标记节点中的空格
    htmlWhitespaceMarker.markInNode(node);

    // 处理脱敏节点 - 用原文替换脱敏显示
    const tuoMinNodes = Array.from(
      node.querySelectorAll(".prime-tuo-min-container")
    );
    tuoMinNodes.forEach((tuoMinNode) => {
      const originContent = tuoMinNode.querySelector(
        ".prime-tuo-min-origin-content"
      );
      if (originContent) {
        tuoMinNode.parentElement.replaceChild(
          document.createTextNode(originContent.textContent),
          tuoMinNode
        );
      }
    });
    return node;
  });

步骤 3:累积计算绝对位置

.reduce((acc, node, index) => {
  const originalNodeText = htmlWhitespaceMarker.restore(node.textContent)
  totalText = totalText + originalNodeText

  if (index < currentPrimeSectionIndex){
    // 当前章节之前的所有文本长度
    acc += originalNodeText.length
  } else if (index === currentPrimeSectionIndex) {
    // 当前章节内的相对位置
    const realRangeIndex = htmlWhitespaceMarker.findWithMarking(commonAncestorContainerText, node.textContent)

    if (realRangeIndex !== -1) {
      acc += (realRangeIndex + startOffset)
    } else {
      acc += startOffset
    }
  }
  return acc
}, null)

4. 文本标记工具详解

4.1 htmlWhitespaceMarker 工具

这个工具解决了 HTML 中空白字符处理的问题:

// 标记空白字符
htmlWhitespaceMarker.markInNode(node); // 将空格标记为 %SPACE%

// 恢复空白字符
htmlWhitespaceMarker.restore(node.textContent); // 将 %SPACE% 恢复为空格

// 安全匹配
htmlWhitespaceMarker.findWithMarking(searchText, targetText); // 带标记的文本匹配

4.2 标记规则

HTML_WHITESPACE: {
  ' ': '%SPACE%',
  '\u00A0': '%NBSP%', // &nbsp;
  '\u2002': '%EN_SPACE%', // &ensp;
  '\u2003': '%EM_SPACE%', // &emsp;
  '\u2009': '%THIN_SPACE%' // &thinsp;
}

4.3 为什么需要标记?

  1. HTML 渲染差异:浏览器渲染 HTML 时会合并多个空格,但原文可能包含多个空格
  2. 文本匹配准确性:确保选中的文本能在原文中准确定位
  3. 位置计算一致性:保证前端显示和后端存储的文本位置一致

5. 脱敏功能的特殊处理

5.1 划词验证

if (isDesensitization) {
  const has = hasElementWithoutPrimeSectionContent(range); // 检查是否包含特殊标记
  const hasDiffSection = hasDifferentPrimeSectionIds(range); // 检查是否跨段
  const hasNewline = hasLineBreakInRange(range); // 检查是否换行

  if (has || hasDiffSection || hasNewline) {
    return; // 不允许划词
  }

  if (!text || text.includes("***")) return; // 不允许选择脱敏文本
  if (target.closest(".prime-tuo-min-container")) return; // 不允许在脱敏容器内划词
}

5.2 脱敏操作处理

// 处理撤销/重做操作
const operateType = isUndo ? CANCEL : REDO;
const container = target.closest(".prime-tuo-min-container");

// 获取对应的文本内容
if (operateType === CANCEL) {
  text = container?.querySelector(".prime-tuo-min-restore-hidden")?.textContent; // 原文
} else {
  text = container?.querySelector(".prime-tuo-min-restore-show")?.textContent; // 脱敏文本
}

5.3 脱敏验证规则

验证项 函数 说明
特殊标记检查 hasElementWithoutPrimeSectionContent 确保选中区域包含有效的章节内容
跨段检查 hasDifferentPrimeSectionIds 不允许跨越不同的章节进行选择
换行检查 hasLineBreakInRange 不允许选择包含换行的文本

6. 调试和日志

6.1 调试输出

console.group("mouseUp");
console.log("总文本\n", totalText);
console.log(
  `原文裁剪【${text}】`,
  "start-end",
  startIndex,
  "-",
  endIndex,
  "\n文本全长" + totalText.length
);
console.log(
  "substring 得到的内容:",
  `"${totalText.substring(startIndex, endIndex)}"`
);
console.log(
  "文本匹配:",
  text === totalText.substring(startIndex, endIndex) ? "✅" : "❌"
);
console.groupEnd();

6.2 验证机制

通过对比选中文本和计算出的文本片段,确保位置计算的准确性:

  • ✅ 表示文本匹配成功,位置计算正确
  • ❌ 表示文本不匹配,需要检查位置计算逻辑

7. 最终输出数据结构

7.1 rangeText(DOM 相对位置)

const rangeText = {
  start: start, // XPath起始位置
  startOffset: startOffset, // 起始偏移量
  end: end, // XPath结束位置
  endOffset: endOffset, // 结束偏移量
  primeSectionId: primeSectionId, // 章节ID
};

7.2 rangeRelativeToOrigin(绝对位置)

const rangeRelativeToOrigin = {
  startOffset: startIndex, // 在完整原文中的起始位置
  start: 0, // 固定为0
  endOffset: endIndex, // 在完整原文中的结束位置
  end: 0, // 固定为0
};

7.3 回调参数

onTextSelect?.({
  range: rangeText, // DOM相对位置信息
  text, // 选中的文本内容
  e, // 原始事件对象
  rangeRelativeToOrigin, // 绝对位置信息
  operateType, // 操作类型(可选,用于脱敏操作)
});

8. 技术要点总结

8.1 核心技术

  • Selection API: 获取用户选中的文本
  • Range API: 精确定位文本位置
  • XPath: 用于 DOM 节点定位
  • TreeWalker: 遍历 DOM 文本节点
  • 文本标记: 处理空白字符差异

8.2 关键算法

  1. 文本重建算法: 从 DOM 结构重建完整的原始文本
  2. 位置映射算法: 将 DOM 位置映射到原文绝对位置
  3. 空白字符标记算法: 统一处理各种空白字符
  4. 脱敏文本处理算法: 在计算时使用原文,显示时使用脱敏文本

8.3 设计优势

  • 精确性: 能够准确计算复杂 HTML 结构中的文本位置
  • 兼容性: 处理各种特殊情况(脱敏、分段、空白字符等)
  • 可维护性: 模块化设计,职责分离
  • 可扩展性: 支持自定义标记规则和验证逻辑

9. 使用场景

这套机制特别适用于以下场景:

  • 文档标注系统: 需要精确记录文本位置的标注功能
  • 脱敏系统: 需要在原文和脱敏文本间切换的系统
  • 多段落文档: 包含复杂章节结构的长文档
  • 富文本编辑器: 需要精确文本操作的编辑器

通过这套完整的机制,可以确保无论文档如何复杂,都能准确处理用户的文本选择操作,为后续的文本处理和存储提供可靠的基础。

posted @ 2025-06-11 14:11  乐盘游  阅读(35)  评论(0)    收藏  举报