在在线教育、文档阅读或博客系统中,划词标注(Highlight & Note) 是一个非常实用的功能。它允许用户像在纸质书上一样,用鼠标选中一段文字,进行高亮标记或添加读书笔记。
本文将拆解如何在 Vue 项目中实现这一功能,涵盖从底层的 Selection API 调用到 DOM 操作,再到数据状态管理的完整流程。
核心原理
实现划词标注的核心在于浏览器提供的 Selection API 和 Range API。
- Selection: 代表用户当前选中的文本范围(可能跨越多个节点)。
- Range: 代表文档中一个连续的区域(Selection 通常包含一个 Range)。
- DOM 操作 : 将选中的文本用一个特定样式的标签(如
<span>)包裹起来,从而实现高亮效果。
Step 1: 监听选区 (Capture Selection)
首先,我们需要在用户松开鼠标(mouseup)时捕获选区。
HTML 结构 : 在内容容器上绑定 mouseup 事件。
html
<div class="content-container" @mouseup="handleTextSelection">
<!-- 文章内容 -->
<p>这是一段可以被选中的文本...</p>
</div>
JavaScript 实现:
javascript
handleTextSelection() {
// 延时保证选区状态已更新
setTimeout(() => {
const selection = window.getSelection();
// 1. 基础校验:必须是 Range 类型且非空
if (selection.toString().trim() === '' || selection.type !== 'Range' || selection.isCollapsed) {
this.selectionMenuVisible = false; // 隐藏菜单
return;
}
// 2. 获取核心 Range 对象
const range = selection.getRangeAt(0);
// 3. (可选) 进阶校验:禁止跨特定区域选择
// 比如:不能同时选中 A 选项和 B 选项
if (this.isCrossBlockSelection(range)) {
selection.removeAllRanges();
return;
}
// 4. 执行高亮包裹逻辑(见下文)
this.createTempHighlight(range, selection);
}, 0);
}
Step 2: 包裹文本 (Wrap Text)
获取到 Range 后,我们需要将选中的文本用一个临时标签(Temp Span)包裹起来。这个标签通常有两个作用:
- 视觉反馈:给用户一个"预选中"的状态(例如浅蓝色背景)。
- 定位锚点:用于计算后续"操作菜单"的显示位置。
核心代码:
javascript
createTempHighlight(range, selection) {
// 创建一个包裹标签
const span = document.createElement('span');
span.className = 'temp-selection-highlight'; // 自定义样式类
try {
// 核心操作:提取内容 -> 插入节点
// range.extractContents() 会将选区内容从 DOM 树中移除并返回 DocumentFragment
span.appendChild(range.extractContents());
// 将包裹后的 span 插入回原处
range.insertNode(span);
// ⚠️重要:重置选区
// 因为 DOM 结构改变了,原有的 selection 会失效或错位
// 我们需要重新选中这个 span 的内容,让用户感觉"高亮还在"
const newRange = document.createRange();
newRange.selectNodeContents(span);
selection.removeAllRanges();
selection.addRange(newRange);
// 保存当前 Range 引用,供后续操作使用
this.currentRange = newRange;
// 5. 显示操作菜单
this.showActionMenu(span);
} catch (e) {
console.error('Wrapping failed:', e);
}
}
Step 3: 菜单定位 (Positioning Menu)
操作菜单("高亮"、"笔记")通常悬浮在选区上方。我们可以利用 getBoundingClientRect() 或 getClientRects() 来获取位置。
javascript
showActionMenu(spanElement) {
// 获取元素的位置信息
// getClientRects() 对于跨行文本更准确,取最后一行
const rects = spanElement.getClientRects();
const lastRect = rects.length > 0 ? rects[rects.length - 1] : spanElement.getBoundingClientRect();
// 计算菜单坐标(相对于视口)
this.selectionMenuPosition = {
top: (lastRect.bottom + 5) + 'px', // 显示在下方 5px 处
left: (lastRect.right + 5) + 'px'
};
this.selectionMenuVisible = true;
}
Step 4: 确认与状态管理 (Confirm & State)
用户点击菜单中的"高亮"或"笔记"按钮后,我们需要将临时的 span 转换为持久化的状态。
- 修改样式 :将
temp-selection-highlight类替换为permanent-highlight(黄色)或note-highlight(蓝色)。 - 生成 ID :给 span 添加一个唯一 ID(如
data-id="167...")。 - 保存数据:将笔记内容推入 Vue 的数据数组中。
javascript
applyHighlight(type) {
const span = document.querySelector('.temp-selection-highlight');
if (!span) return;
// 1. 生成唯一 ID
const id = Date.now().toString();
// 2. 更新 DOM 类名和属性
span.className = type === 'note' ? 'note-highlight' : 'highlight-text';
span.setAttribute('data-id', id);
// 3. 存入数据层
const newNote = {
id,
text: span.innerText, // 选中的原文
type, // 'highlight' or 'note'
color: type === 'note' ? '#e6f7ff' : '#ffeb3b',
createTime: new Date().toISOString()
};
this.notesList.push(newNote);
// 4. 持久化(保存到后端或 LocalStorage)
this.saveData();
// 5.如果是笔记,打开侧边栏供用户输入
if (type === 'note') {
this.openNoteSidebar(id);
}
// 清除选中状态
window.getSelection().removeAllRanges();
this.selectionMenuVisible = false;
}
Step 5: 取消高亮 (Unwrap)
如果用户想删除高亮,我们需要执行"反向操作":将 span 去掉,保留里面的文字。
javascript
removeHighlight(id) {
const span = document.querySelector(`span[data-id="${id}"]`);
if (span) {
const parent = span.parentNode;
// 将 span 的子节点(文本)移动到父节点中 span 的前面
while (span.firstChild) {
parent.insertBefore(span.firstChild, span);
}
// 移除空 span
parent.removeChild(span);
// 规范化节点,合并相邻的文本节点
parent.normalize();
}
// 同步删除数据
this.notesList = this.notesList.filter(n => n.id !== id);
}
进阶技巧:从数据还原 DOM
最大的难点在于:页面刷新后,如何重新渲染这些高亮?
如果你的内容是纯静态的,可以直接保存包含 span 标签的 HTML 字符串。但由于 Vue 的 v-html 或 React 的 dangerouslySetInnerHTML 会导致 DOM 重绘,简单的 HTML 替换可能会丢失事件绑定。
更稳健的做法是:
- 保存 选区路径(如:第 X 个段落,第 Y 个字符开始,长度 Z)。
- 页面加载时,遍历数据,利用
createRange()重新定位并包裹 DOM。
由于这通常涉及到复杂的 DOM 遍历算法,生产环境中推荐结合成熟库(如 Rangy 或自行实现基于 XPath 的定位)来处理复杂场景。
总结
实现一个划词笔记功能,本质上是对 DOM Range 的灵活运用。通过 监听(Listen) -> 包裹(Wrap) -> 存储(Store) -> 还原(Restore) 这四个步骤,我们就能为用户提供流畅的沉浸式阅读体验。