Vue 实战:从零实现“划词标注”与“高亮笔记”功能

在在线教育、文档阅读或博客系统中,划词标注(Highlight & Note) 是一个非常实用的功能。它允许用户像在纸质书上一样,用鼠标选中一段文字,进行高亮标记或添加读书笔记。

本文将拆解如何在 Vue 项目中实现这一功能,涵盖从底层的 Selection API 调用到 DOM 操作,再到数据状态管理的完整流程。


核心原理

实现划词标注的核心在于浏览器提供的 Selection APIRange API

  1. Selection: 代表用户当前选中的文本范围(可能跨越多个节点)。
  2. Range: 代表文档中一个连续的区域(Selection 通常包含一个 Range)。
  3. 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)包裹起来。这个标签通常有两个作用:

  1. 视觉反馈:给用户一个"预选中"的状态(例如浅蓝色背景)。
  2. 定位锚点:用于计算后续"操作菜单"的显示位置。

核心代码

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);
  }
}

操作菜单("高亮"、"笔记")通常悬浮在选区上方。我们可以利用 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 转换为持久化的状态。

  1. 修改样式 :将 temp-selection-highlight 类替换为 permanent-highlight(黄色)或 note-highlight(蓝色)。
  2. 生成 ID :给 span 添加一个唯一 ID(如 data-id="167...")。
  3. 保存数据:将笔记内容推入 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 替换可能会丢失事件绑定。

更稳健的做法是:

  1. 保存 选区路径(如:第 X 个段落,第 Y 个字符开始,长度 Z)。
  2. 页面加载时,遍历数据,利用 createRange() 重新定位并包裹 DOM。

由于这通常涉及到复杂的 DOM 遍历算法,生产环境中推荐结合成熟库(如 Rangy 或自行实现基于 XPath 的定位)来处理复杂场景。


总结

实现一个划词笔记功能,本质上是对 DOM Range 的灵活运用。通过 监听(Listen) -> 包裹(Wrap) -> 存储(Store) -> 还原(Restore) 这四个步骤,我们就能为用户提供流畅的沉浸式阅读体验。

相关推荐
上海合宙LuatOS2 小时前
LuatOS核心库API——【fatfs】支持FAT32文件系统
java·前端·网络·数据库·单片机·嵌入式硬件·物联网
wuhen_n2 小时前
JavaScript 手写 new 操作符:深入理解对象创建
前端·javascript
m0_528749002 小时前
linux编程----目录流
java·前端·数据库
集成显卡2 小时前
前端视频播放方案选型:主流 Web 播放器对比 + Vue3 实战
前端·vue·音视频
前端 贾公子2 小时前
Vue3 业务组件库按需加载的实现原理(中)
前端·javascript·vue.js
温轻舟2 小时前
前端可视化大屏【附源码】
前端·javascript·css·html·可视化·可视化大屏·温轻舟
北极象2 小时前
Flying-Saucer HTML到PDF渲染引擎核心流程分析
前端·pdf·html
weixin199701080162 小时前
Tume商品详情页前端性能优化实战
大数据·前端·java-rabbitmq
梦里寻码2 小时前
深入解析 SmartChat 的 RAG 架构设计 — 如何用 pgvector + 本地嵌入打造企业级智能客服
前端·agent