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 文档中,文本位置计算面临以下挑战:
- HTML 结构复杂:包含脱敏容器、章节标记等特殊元素
- 文本分段:文档被分为多个
prime-section
段落 - 脱敏处理:某些文本被替换为
***
显示,但需要基于原文计算位置 - 空白字符处理: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%', //
'\u2002': '%EN_SPACE%', //  
'\u2003': '%EM_SPACE%', //  
'\u2009': '%THIN_SPACE%' //  
}
4.3 为什么需要标记?
- HTML 渲染差异:浏览器渲染 HTML 时会合并多个空格,但原文可能包含多个空格
- 文本匹配准确性:确保选中的文本能在原文中准确定位
- 位置计算一致性:保证前端显示和后端存储的文本位置一致
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 关键算法
- 文本重建算法: 从 DOM 结构重建完整的原始文本
- 位置映射算法: 将 DOM 位置映射到原文绝对位置
- 空白字符标记算法: 统一处理各种空白字符
- 脱敏文本处理算法: 在计算时使用原文,显示时使用脱敏文本
8.3 设计优势
- 精确性: 能够准确计算复杂 HTML 结构中的文本位置
- 兼容性: 处理各种特殊情况(脱敏、分段、空白字符等)
- 可维护性: 模块化设计,职责分离
- 可扩展性: 支持自定义标记规则和验证逻辑
9. 使用场景
这套机制特别适用于以下场景:
- 文档标注系统: 需要精确记录文本位置的标注功能
- 脱敏系统: 需要在原文和脱敏文本间切换的系统
- 多段落文档: 包含复杂章节结构的长文档
- 富文本编辑器: 需要精确文本操作的编辑器
通过这套完整的机制,可以确保无论文档如何复杂,都能准确处理用户的文本选择操作,为后续的文本处理和存储提供可靠的基础。
人生到处知何似,应似飞鸿踏雪泥。