电子书阅读器之笔记高亮(跨段处理)

文本高亮

之前介绍了电子书阅读器之笔记高亮,主要展示处理逻辑。当时,只能处理同一段内的文字,不能跨段处理。现在就介绍如何处理跨段高亮。

Selection

window.getSelection()返回一个 Selection 对象,表示用户选择的文本范围或光标的当前位置。它代表页面中的文本选区,可能横跨多个元素

  • selection.toString():获取选中文本
jsx 复制代码
const selection = window.getSelection();
console.log("[selection]", selection);
const selectedText = selection.toString().trim();
console.log("[选中文本]", selectedText);

Range

Range表示一个包含节点与文本节点的一部分的文档片段

  • getRangeAt:获取选区包含的指定区域(Range)的引用
jsx 复制代码
const range = selection.getRangeAt(0);
console.log("[选中Range]", range);

const {
  startContainer, // 起始节点
  startOffset, // 起始节点偏移量
  endContainer, // 终止节点
  endOffset, // 终止节点偏移量
} = range;
console.log("[起始节点]", startContainer, "偏移量", startOffset);
console.log("[终止节点]", endContainer, "偏移量", endOffset);

getBoundingClientRect

jsx 复制代码
const rectList = range.getClientRects();
console.log("[边界对象集]", rectList);
const rect = range.getBoundingClientRect();
console.log("[边界矩形]", rect);

选中位置定位

  1. 使用 rect 的位置,显示效果如下:
jsx 复制代码
const position = { top: rect.top, left: rect.left };
console.log("[选中位置]", position);
  1. 使用 rectList 的位置,显示效果如下:
jsx 复制代码
const position = { top: rectList[0].top, left: rectList[0].left };
console.log("[选中位置]", position);

我选择是这种方式,展示在选中文本第一个元素的位置。

  1. 滚动条影响

如果内容区域有滚动条,那么选中位置的定位坐标需要加上滚动条的值,即 top 需要加上滚动条的偏移量。

jsx 复制代码
const position = {
  top: rectList[0].top + contentRef.current.scrollTop,
  left: rectList[0].left,
};

文本高亮

监听鼠标抬起(onMouseUp)事件,获取选中的文本,并高亮。

jsx 复制代码
export default function Page() {
  // 处理鼠标抬起事件
  const handleMouseUp = () => {
    // 获取选中文本信息,包括位置信息
    const selectedRange = getSelectionPosition();
    if (selectedRange) {
      console.log(selectedRange);
      highlightText(selectedRange);
    }
  };

  return (
    <div ref={contentRef} onMouseUp={handleMouseUp} onClick={handleClick}>
      {/* 数字教材预览区 */}
      <MPreview list={list} />
      {/* 选中文本操作弹框 */}
      <PopupMenu ref={popupRef} />
      {/* 笔记编辑弹框 */}
      <PopupModify ref={modifyRef} />
    </div>
  );
}

文本节点

jsx 复制代码
// 获取所有文本节点(带位置信息)
const getAllNodes = (contentRef: HTMLDivElement) => {
  if (!contentRef) return [];

  // 获取所有文本节点
  const textNodes: Node[] = [];
  const walker = document.createTreeWalker(contentRef, NodeFilter.SHOW_TEXT, {
    acceptNode: (node) =>
      node.textContent?.trim()
        ? NodeFilter.FILTER_ACCEPT
        : NodeFilter.FILTER_REJECT,
  });

  let node: Node | null = null;
  while (true) {
    node = walker.nextNode();
    if (!node) break;
    textNodes.push(node);
  }

  // 计算累积文本长度和节点位置
  let totalLength = 0;
  return textNodes.map((textNode) => {
    const text = textNode.textContent ?? "";
    const start = totalLength;
    totalLength += text.length;
    return { node: textNode, text, start, end: totalLength };
  });
};

高亮数据

jsx 复制代码
const getSelectionPosition = () => {
  const selection = window.getSelection();
  // 1. 获取高亮文本
  const selectedText = selection.toString().trim();

  // 2. 获取高亮文本定位(用于弹框定位显示)
  const range = selection.getRangeAt(0);
  const rectList = range.getClientRects();
  const position = { top: rectList[0].top, left: rectList[0].left };

  // 3. 获取高亮文本位置(相对于全局文本的位置,用于高亮标色)
  let globalStart = -1;
  let globalEnd = -1;
  const nodeMap = getAllNodes();
  for (const { node, start } of nodeMap) {
    // 查找起始节点位置
    if (node === startContainer) globalStart = start + range.startOffset;
    // 查找结束节点位置
    if (node === endContainer) globalEnd = start + range.endOffset;
    // 提前退出
    if (globalStart >= 0 && globalEnd > 0) break;
  }

  if (globalStart >= 0 && globalEnd > 0) {
    console.log(`[选中范围]${globalStart}-${globalEnd}`);
    // 多行选择时,会跨元素,要找到第一个元素的id
    const startElement = getElementWithAttributes(selection.anchorNode);
    console.log("[开始元素]", startElement);

    return {
      startIndex: globalStart,
      endIndex: globalEnd,
      selectedText,
      className: "orange",
      chapterId,
      divId: startElement.id,
      top: position.top + contentRef.current.scrollTop,
      left: position.left,
    };
  }
};

高亮文本

jsx 复制代码
const highlightText = (note) => {
  // 1. 先清除现有高亮
  removeHighlights(contentRef);

  // 2. 获取节点映射
  const nodeMap = getAllNodes(contentRef);

  // 3. 创建高亮范围
  const highlights: { range: Range, className: string, id: string }[] = [];

  // 4. 遍历nodeMap,处理每个文本节点
  for (const { node, text, start, end } of nodeMap) {
    const nodeStart = Math.max(0, note.startIndex - start);
    const nodeEnd = Math.min(text.length, note.endIndex - start);

    if (nodeStart >= nodeEnd) continue;
    console.log(`处理节点: "${text}" (${start}-${end})`);
    console.log(`节点高亮范围: ${nodeStart}-${nodeEnd}`);

    // 创建范围并高亮
    const range = document.createRange();
    range.setStart(node, nodeStart);
    range.setEnd(node, nodeEnd);
    highlights.push({
      range,
      className: `highlight-${note.className}`,
      id: note.divId,
    });
  }

  // 按位置从后往前高亮(避免位置偏移)
  highlights.sort((a, b) => b.range.startOffset - a.range.startOffset);
  for (const { range, className, id } of highlights) {
    // 创建高亮元素
    const span = document.createElement("span");
    span.className = className;
    // 添加笔记ID到data属性
    span.dataset.noteId = id;
    // 插入高亮内容
    span.appendChild(range.extractContents());
    range.insertNode(span);
  }
};

创建高亮元素并插入内容:

高亮前:

高亮后:

选中操作

复制

jsx 复制代码
if (!note?.selectedText) return;
navigator.clipboard.writeText(note.selectedText);
toast.success("已复制到剪贴板");

报错提示:Uncaught TypeError: Cannot read properties of undefined (reading 'writeText')

报错原因:navigator.clipboard.writeText() 方法需要浏览器支持,才能正常执行。浏览器会禁用非安全域(http)的 navigator.clipboard 对象,而在 localhosthttps 下不会禁用。

相关推荐
Dabei1 小时前
Android 副屏(Virtual Display)创建与悬浮窗画中画显示实战
前端·架构
Hello-Mr.Wang1 小时前
【保姆级教程】MasterGo MCP + Cursor 一键实现 UI 设计稿还原
前端·javascript·vue.js·ai编程
Dabei1 小时前
Android 无障碍服务实现美团/微信自动化:客户端开发实践
前端·设计模式
华超磊2 小时前
关于手动实现滚动的尝试
前端
宁雨桥2 小时前
前端修行日记之JS 原型与 AI基础常识
前端·javascript·原型模式
程序员陆通2 小时前
月烧 400 刀到不到 20 刀:我是怎么把 OpenClaw 的 Token 账单砍掉 95% 的
java·前端·数据库
水云桐程序员2 小时前
前端教程官方文档|HTML、CSS、JavaScript教程官方文档
前端·javascript·css·html·学习方法
SsunmdayKT2 小时前
前后端项目部署与运行机制全流程详解
前端·后端
本末倒置1832 小时前
Vue 3 开发者转型 React 指南:保姆级教程
前端·javascript·vue.js