首页 / 资讯中心 / 文章详情

前端批注实现:基于Selection、Range与文本偏移的高亮重建

前端批注实现:基于Selection、Range与文本偏移的高亮重建 ★ FEATURED ARTICLE
简介在页面中实现文字批注和选中高亮往往需要组合事件监听、DOM插入与样式控制。这套压缩包正为此类需求提供了基于jQuery的轻量级示例面向前端开发者演示了选中文本后动态添加批注、变更背景颜色以及删除批注的完整交互流程也适用于快速搭建带标注功能的阅读器或文档管理界面。压缩包共10个文件含3个JavaScript脚本、2个CSS样式、1个HTML页面、2个图片及2个数据库文件总大小仅80KB结构精简便于直接运行查看效果并上手改造。目前已有1223人学习浏览通过示例可学到检测文本选区、利用元素样式进行背景高亮、将批注动态写入DOM并响应删除操作等关键手段若结合富文本编辑器和本地存储还可进一步扩展出可编辑、可持久化的批注系统。1. 页面添加批注选中文字上背景色这个包到底解决了什么不少人看到“页面添加批注”这五个字第一反应是“加个输入框存个备注很难吗”。真写到代码里你就会发现最难的不是输入框而是“选中添加背景色”这一下鼠标选中一段文字松手高亮出现批注条弹出关页面再打开高亮和批注还在原位置。这个压缩包把这整条链路做了下来从 Selection 选区捕获、Range 边界提取到文本偏移序列化再到重渲染时恢复高亮都收在了里面。适合在阅读器、文档预览、编辑审校这类页面里快速嵌入批注能力的开发者。拆这个包重点不在高亮样式而在“坐标怎么记、漂移怎么防、重建怎么找”。2. 把“选中”变成坐标Range 与 Selection 的核心处理2.1 Selection 取数与 Range 边界startContainer 与 endContainer浏览器给到开发者的第一道手就是 window.getSelection()。多数人的习惯是取 selection.toString() 拿到选中文字然后直接包个 span 上背景色。这个流程在小范围演示里确实能跑但一旦你的页面里混着图片、换行、内嵌标签toString() 返回的文本和页面里的真实文本节点对不上你存下来的东西就可能是错的。先看这段最基础的标准取法const selection window.getSelection(); if (!selection.rangeCount || selection.isCollapsed) return; const range selection.getRangeAt(0); const startNode range.startContainer; // 选区的起点容器节点 const startOffset range.startOffset; // 起点在容器内的偏移 const endNode range.endContainer; // 选区的终点容器节点 const endOffset range.endOffset; // 终点偏移 const selectedText range.toString(); console.log({ startNode, startOffset, endNode, endOffset, selectedText });这里有三个关键点isCollapsed 是判断用户是否真的选中了一段内容而不是只点了光标startContainer 可能是元素节点也可能是文本节点offset 的含义也因此不同range.toString() 拿到的是用户“看得见的”纯文本但缺少位置信息所以还需要配合 getRangeAt(0) 拿到真实边界。我在实际拆这个包时第一件事就是确认它有没有把 startContainer 的节点类型分情况处理而不是直接把 offset 当成字符下标。startContainer 是文本节点时startOffset 就是字符偏移是元素节点时startOffset 表示第几个子节点。这两个含义混在一起很容易把批注坐标记错。包里通常会封装一个 normalize 函数来做统一转换。2.2 偏移序列化为什么“纯文本偏移”比 DOM 路径更稳包裹高亮之后马上会遇到一个问题你修改了 DOM原来存下来的 startContainer 和 endContainer 引用已经失效了。如果存 DOM 路径比如 body div p:nth-child(2) 这种页面稍微动态几分就崩。更稳的方案是“纯文本偏移”——不管页面里嵌套多少层标签把所有文本节点拼起来算总长度批注只存起点偏移和终点偏移。计算文本偏移的常见做法是用 TreeWalker 遍历全部文本节点function getTextOffset(rootNode, targetNode, charOffset) { const walker document.createTreeWalker( rootNode, NodeFilter.SHOW_TEXT, null ); let currentIndex 0; let node walker.nextNode(); while (node) { const nodeLength node.nodeValue.length; if (node targetNode) { return currentIndex charOffset; } currentIndex nodeLength; if (currentIndex /* 所有文本总量 */) break; node walker.nextNode(); } return -1; }计算全文总长度和文本节点的累计偏移时要先约定好换行算不算长度。浏览器里文本节点的换行通常会被折叠成空格或直接消失这个差异容易导致重建时的偏移错位我一般会约定好“批注基于 rendered 文本”也就是用户看到什么就记什么出现换行差异时不强行靠拢。这段代码里的 currentIndex 是累计偏移targetNode 来自上一节 getRangeAt(0) 拿到的容器节点把选区起止各跑一遍 getTextOffset就能得到一个可以持久化的区间。2.3 松手就要弹工具栏mousedown 与选区保持的配合选中文本后点击弹出的批注条是另一个高频翻车点。mousedown 一点工具栏按钮还没敲选区就没了。原因在于浏览器在 mousedown 时会把默认选区清掉按钮的 click 事件根本来不及响应。处理方式是在工具栏容器上用 preventDefault 拦掉 mousedowntoolbar.addEventListener(mousedown, (event) { event.preventDefault(); // 阻止默认行为保住当前选区 });mousedown 阶段被阻止后选区不会因为点击工具栏而消失click 事件照常触发。某些浏览器还会在 mouseup 前重绘选区因此需要在 selectionchange 事件里判断当前选区是否和记录的一致不一致就隐藏工具栏。这就是为什么很多成熟批注库的工具栏窗口会带一个“保持聚焦”的选项本质上都是在处理同一个坑。3. 高亮包裹与批注落库数据结构和 DOM 操作写法3.1 用 surroundContents 包裹还是 extractContents 重建拿到选区和偏移之后下一步是给选中文字上背景色。最直接的想法是 range.surroundContents(span)但这个 API 有条件限制选区的边界必须落在文本节点边界上跨节点选区会直接抛错。比如选中“今天天气不错”里的一部分surroundContents 大概率就挂了。通用做法是先用 extractContents 把选中内容抽出来包一层 span 再插回去。这是我在这个包里看到的标准思路。每个文本节点被切分再包裹后DOM 结构会变化所以包裹完成后要重新计算偏移这部分逻辑要和持久化时用的偏移算法保持一致。function highlightRange(range) { const span document.createElement(span); span.className text-highlight; // 把选中内容从原位置抽出来 const extracted range.extractContents(); span.appendChild(extracted); range.insertNode(span); return span; }extractContents 会连同文本节点一起抽出span 包裹后重新插入range 会自动指向插入的位置。批量批注时注意每包裹一段之后原始 range 已经变了要先保存偏移包裹完成后 setStart / setEnd 重新定位。这里容易踩的坑是 extractContents 会把选中区域内的标签结构也抽出来比如选中内容里刚好横跨了一个重建时文本节点数量与原索引对不上逐字符去对齐比较稳。3.2 批注数据的最小结构设计这个包的数据结构设计得比较精简。核心不依赖后端数据库一份 JSON 结构就够把批注和页面绑定在一起。参考的最小结构能拆成这样字段类型作用idstring批注唯一标识重建高亮时对应回挂载节点startOffsetnumber选区起点在整个文本中的字符偏移endOffsetnumber选区终点偏移selectedTextstring选中的原始文本用于兜底校验和展示colorstring背景色值如 #ffeb3bnotestring批注内容createdAtnumber时间戳用于排序和分组展示selectedText 一定要存。重建高亮时如果发现偏移对不上可以用它做文本片段的二次校验减少错误高亮。这个字段的价值在动态页面里会被放大后面会专门讲。3.3 从偏移重建高亮TreeWalker splitText 的正向做法重建是整个批注功能里最容易出 bug 的部分——页面加载完成后DOM 结构可能和你存批注时不完全一样纯文本偏移就成了唯一的锚点。重建的思路是从根节点出发用 TreeWalker 依次累加文本节点的长度找到目标偏移所在的文本节点然后用 splitText 从节点中间切割。一次可以切出一个 start、一个 end中间整段文本节点直接包 span。function rebuildRange(rootNode, startOffset, endOffset) { const walker document.createTreeWalker(rootNode, NodeFilter.SHOW_TEXT, null); let currentOffset 0; let node walker.nextNode(); let startNode null, startNodeOffset 0; let endNode null, endNodeOffset 0; while (node) { const len node.nodeValue.length; const nodeStart currentOffset; const nodeEnd currentOffset len; // 起点落在当前文本节点内 if (startNode null startOffset nodeStart startOffset nodeEnd) { startNode node; startNodeOffset startOffset - nodeStart; } // 终点落在当前文本节点内 if (endOffset nodeStart endOffset nodeEnd) { endNode node; endNodeOffset endOffset - nodeStart; } if (startNode endNode) break; currentOffset nodeEnd; node walker.nextNode(); } const range document.createRange(); range.setStart(startNode, startNodeOffset); range.setEnd(endNode, endNodeOffset); return range; }这个实现里最需要注意两个点一是 startOffset 等于 nodeStart 时要防止重复命中二是空文本节点的长度是 0不会影响偏移累加但要跳过避免误判。边界命中逻辑写成 而不是 的原因就是允许选区起点恰好落在某个文本节点的第 0 个字符上。重建后调用 highlightRange 包 span就能把存下来的批注位置重新渲染出来。splitText 切出来的新文本节点在渲染时不会自动合并但重建同一条批注时只要每次从头遍历结构变化不影响结果。之后可以用 parentNode.normalize() 把多余空节点合并掉减少 DOM 噪音。4. 避坑篇选中批注最常见的一组翻车现场4.1 双击选中时多吃了换行和空格现象用户双击一个词高亮区域却把前后空格和换行都带上了。原因双击选中的范围本身包含了词边界外的空白字符range.toString() 里也能看到这些不可见字符。解决保存偏移前对文本两侧做 trim 处理并同步修正 startOffset 和 endOffset。注意中英文混排时trim 只处理 ASCII 空格全角空格要用 \u3000 一起处理。const text range.toString(); const leading text.match(/^[\s\u3000]/)[0]?.length ?? 0; const trailing text.match(/[\s\u3000]$/)[0]?.length ?? 0; const finalStart getTextOffset(root, range.startContainer, range.startOffset) leading; const finalEnd getTextOffset(root, range.endContainer, range.endOffset) - trailing;代码里 leading 和 trailing 分别算出了选中文本两侧多余空白的长度修正到最终偏移上。不要用 text.trim() 之后直接拿 trim 后文本长度去推导因为空白字符在多个文本节点间分布时两侧的 offset 需要分别修正一次 trim 会把对齐关系弄乱。4.2 点工具栏选区就消失现象选中文字后弹出批注条点击批注条上的任意按钮高亮没出来选区先没了。原因mousedown 的默认行为清除了选区。解决前面第 2.3 节提到的 toolbar mousedown preventDefault 是第一步。另一个隐蔽问题是如果你在 selectionchange 事件里做了“选区状态变化就刷新工具栏”的逻辑preventDefault 也救不回来——selectionchange 在 preventDefault 后依然会触发需要缓存一份 lastRange 做对比只有真正变化时才关工具栏。4.3 重建时 splitText 把前面的 Range 全部打乱现象一次渲染 10 条批注第一条能精确高亮第二条开始位置全部漂移。原因每重建一条批注都会调用 splitText文本节点被切碎后续偏移累计的起点变化了。解决重建前先把整份批注按 startOffset 从大到小排序或者每次都从根节点重新遍历。前一种做法性能好但注意创建 range 的实际顺序仍然要按 startOffset 从小到大否则 DOM 插入顺序错乱会导致后面的 range 锚点失效。简单可靠的方法是全部基于初始文本结构计算好 node 引用和偏移再一次插入避免边切边算。4.4 高亮标签嵌套后背景色串层现象批注 A 和批注 B 有重叠区域渲染后背景色变成一层叠一层文字被包了两层 span取消批注时删不干净。原因连续批注时第二个 range 的起止点可能落在第一个批注 span 内部extractContents 把 span 又抽了一层出来。解决插入新批注前先检测重叠区。一种常用策略是“完全重叠才合并半重叠时按整段文本重新拆分”function getOverlaps(startOffset, endOffset, annotations) { return annotations.filter( (item) startOffset item.endOffset endOffset item.startOffset ); }查到重叠批注后把新批注的区间拆成多个子区间分别落高亮。每次渲染前也可以统一做一次区间排序和合并把相邻同色批注合并成一个完整区间页面结构会干净很多。4.5 动态加载页面重建失败但数据明明没坏现象SPA 里路由切换后批注位置整体错乱或直接消失。原因页面数据是异步加载的重建批注时文本还没渲染完偏移量按照空容器的文本长度去匹配自然全错。解决在数据渲染完成的回调之后启动重建函数并保留一个“兜底重试”标志。文本长度不足预期时用 MutationObserver 监听容器子树变化新内容插入后再跑一次重建超过三次仍失败则放弃定位并提示“批注位置已失效”。5. 进阶批注定位回跳与动态内容的容错技巧批注功能做到能存能渲染只是及格真正好用要能“点批注列表页面滚到对应高亮处”。实现回跳的核心不是 scrollIntoView而是找到高亮 span 之后用它附近的上下文去校验位置是否正确。function jumpToAnnotation(id) { const span document.querySelector([data-annotation-id${id}]); if (!span) return; span.scrollIntoView({ block: center, behavior: smooth }); span.classList.add(annotation-active); setTimeout(() span.classList.remove(annotation-active), 1200); }给每个高亮 span 挂上 style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />
阅读完成 · 觉得有帮助?
咨询建站