这篇文章发布于 2022年09月21日,星期三,23:50,归类于 JS实例。 阅读 13246 次, 今日 17 次 5 条评论
by zhangxinxu from https://www.zhangxinxu.com/wordpress/?p=10541 鑫空间-鑫生活
本文欢迎分享与聚合,全文转载就不必了,尊重版权,圈子就这么大,若急用可以联系授权。
一、先看大屏幕
最近在做划词评论相关的开发,此交互功能的开发还是有一些门槛的,想到可能有小伙伴也会遇到类似需求,决定分享一下若干处理经验。
在具体展开介绍之前,大家可以先看一下一个建议的demo演示效果:https://zhangxinxu.gitee.io/word-comment/
演示页面部分区域截图:
大家可能注意到了demo的域名是 gitee,没错,我将相关的实现开源了。
项目地址是:https://gitee.com/zhangxinxu/word-comment
不过此项目只有划词这部分的核心功能,评论一笔带过,实际生产过程中,评论和划词是有很多联动的,大家可以基于此项目的demo结合自己业务完成最终的开发。
好,下面开始正式讲讲实现过程中遇到的难点和技术实现。
二、先了解选区和范围的基本概念
在 Web 中,选区指 Selection,范围指 Range。
选区概念更大一点,一个选区中可能有多个范围,也就是可以从Selection中获取Range。
Selection和Range都提供了很多属性和方法,可以让我们对选区进行增删改等操作。
在过去,需要兼容IE的时代,选区和范围是比较混乱的,因为IE有自己的一套,其他浏览器有自己的一套。
现在IE已经长眠,就不需要那么多顾虑了,直接学习标准的 API 即可,幸福啊!
而划词评论的实现,本质上就是Selection、Range以及DOM之间的一些处理操作,换句话说,其实就是下面三个东西之间的舞台戏。
// 我是范围 const selection = document.getSelection(); // 我是选区 const range = selection.getRangeAt(0); // 我是元素 const container = document.querySelector('.xxx');
而所谓的“操作”,其实就是根据需求,让各个API在合适的位置执行而已,所以,需求实现的难点就在于对API掌握的熟悉程度。
所以,你只需MDN文档看一天,各个API试用一遍,结合本文内容,那么什么划词功能实现妥妥的。
且,不仅是划词评论,以后各种与选区相关的开发都是信手拈来。
这里推荐一篇关于选区介绍的好文:Web 中的“选区”和“光标”
我十几年前也写过关于Range的文章,不过内容已经太老了,IE时代的产物,如果你的项目还需要兼容IE浏览器,可以看看。
好了,热身足够了,可以进入正题了,基于实例学习,这个是大多数开发者喜欢的学习方式。
//zxx: 如果你看到这段文字,说明你现在访问是不是原文站点,更好的阅读体验在这里:https://www.zhangxinxu.com/wordpress/?p=10541(作者张鑫旭)
三、难点、问题与解决
1. 选区的居中定位
选择一段文字,然后显示添加评论的按钮,然后按钮要在选区的中间,如下图所示:
请问该如何实现?
此定位实现的关键就是需要知道选区的位置和大小,最好要有现成的API,那浏览器提供了相关的API了吗?
提供了!
我们打开MDN文档,可以看到下图所示的这个名为getBoundingClientRect
的API。
我们就可以借助这个API进行绝对定位了。
const selection = document.getSelection(); const range = selection.getRangeAt(0); const boundRange = range.getBoundingClientRect(); // 评论按钮就可以基于boundRange绝对定位啦
然而,事情并没有这么简单,选区不一定是规整的一行,可能是跨行的,就像下面这样:
此时,如果还是居中显示,那就会有问题,因为定位的评论按钮的箭头并没有指向选区,而是非选区的文字,此时该怎么办呢?
我的解决方法是直接定位到第一行选区的最右侧,就像这样:
判断逻辑很简单,只要选区的高度超过一行选区应有的高度即可,示意:
if (boundRange.height > 30) { // 认为是跨行选区 // 评论按钮右侧悬浮定位 }
然而,事情还没有结束,如果用户是双击选择,则极有可能会自带一个换行符(在特定的布局结构下),这个换行符会让选区的位置和视觉上表现不一致,也就是看起来只是现在了几个文字,实际上选择的是整行,如下图示意:
此时,需要对选区进行特殊处理,也就是修改选区,具体实现参见“3. 双击全选的特殊处理”。
2. 确定划词的起止位置
也就是当前选区的起止位置在整个段落中的索引值,例如,有个段落是“一船秋梦压星河”,则“秋梦”的起始索引就是1,结束索引就是3。
这个值是发给后端保存用的。
这样,下一次页面载入的时候,我们就可以基于这个起止位置,让对应的内容高亮显示了。
好,概念知晓了,具体该如何实现呢?
或者说,浏览器有没有提供原生的起止位置API呢?
还真有!不过不能直接使用!
这是什么意思呢?
看下图,Selection API提供了几个偏移属性。
Range API 也有几个偏移属性。
但是这些偏移都是相对于某个节点的,不一定是整个段落元素的偏移值,因此,不能直接拿来使用。
举个例子,有如下HTML代码:
<p> 前面文字<i>标签元素</i>后面文字 </p>
如果文字的选区是“后面”这两个字,那么Selection的anchorNode
就会是:#Text后面文字,anchorOffset也会是0,截图示意:
正确做法是对外部的段落元素进行节点遍历,如果节点(或节点的子节点)匹配anchorNode,则之前所有文字的长度加上anchorOffset就是真实的起始索引值。
下面就是实现的代码:
const selection = document.getSelection(); const range = selection.getRangeAt(0); let startNode = range.startContainer; let startOffset = range.startOffset; // 起始位置的计算 let startIndex = 0; let loopIndex = function (dom) { [...dom.childNodes].some(function (node) { if (!node.textContent) { return; } // 节点匹配了 // 不再遍历 if (node == startNode) { startIndex += startOffset; return true; } if (startNode.parentNode == node) { loopIndex(node); return true; } startIndex += node.textContent.length; }); }; // container是内容的容器元素 loopIndex(container); // 结束索引 let endIndex = startIndex + selection.toString().trim().length;
3. 双击全选的特殊处理
双击全选的选区和框选全部去文字的选区有可能是不一样的。
注意这里的措辞是有可能!
关于CSS布局对选区范围的影响,这个我虽然有研究过,不过都是10年前的事情了,还是IE浏览器为王的年代,详见“不同CSS布局实现与文字鼠标选择的可用性”一文,其中的知识已经跟不上时代了,而目前主流布局,以及各种内容混杂下的文字选区,我尚未深入研究过,暂时还没摸清楚其中的规律
所以,双击全选可能有坑的问题,在某些布局条件下会出现,通常,布局越复杂,内容越多,越可能选区不干净。
好,回到这里。
就我自己的这个项目开发而言,就遇到了双击文字的时候,会连同其他元素的文字一起选择的问题(当祖先元素设置了 user-select:none 的时候,没错,none范围选择,有点反直觉),或者选择的内容最后多了个换行符(会自动变成空格)的问题。
平时复制粘贴,上面这种问题不大。
但是,如果是划词评论这种对选区要求比较高的场景,上面的现象会有大问题,比方说此时选区的anchorNode或者focusNode也不是文本,而是更上层的元素,原本的执行逻辑就会出bug。
怎么办呢?只能对这样的选区进行专门的处理了。
处理方法很简单,重新设置Range的内容,然后再使用这个新的Range进行处理(可以是改变Range选区的节点,或者是改变Range选区的起止点,均有对应的API)。
此时,虽然视觉上用户是无感知的,但实际上,代码已经做了很多事情。
处理代码示意如下,也可以参见 gitee 项目中的 utils.js:
// eleTarget 就是内容所在的容器元素 // 获得选区 const selection = document.getSelection(); let selectContent = selection.toString(); let selectContentTrim = selectContent.trim(); if (!selectContentTrim) { return; } // 如果有超出范围的内容 if (eleTarget.textContent.indexOf(selectContentTrim) == -1) { return; } const range = selection.getRangeAt(0); // 重新修改选区 if (selectContent != selectContentTrim && eleTarget.textContent.trim() == selectContentTrim) { // 如果纯文本,使用当前节点 if (!eleTarget.children.length) { range.selectNode(range.startContainer); } else { // 如果包含子元素,则改变选区的起止点 range.setStartBefore(eleTarget.firstChild); range.setEndAfter(eleTarget.lastChild); } selectContent = selectContentTrim; }
接下来,就可以按照常规的文字选区进行处理就好了。
4. 反向高亮标记的实现
有了选区的起止位置,那重新进入页面的时候,如果让对应位置的文字高亮呢?
很显然,我们需要创建新的range,然后外面包裹一个高亮的span元素,这里我们需要用到Range对象的surroundContents()
API,此API可以让裸露的文字外面包裹HTML元素,而不至于影响其他文本内容。
好,下面的难点就变成了如何基于startIndex
和endIndex
创建精准的选区,这个还有些麻烦,因为Range的起止点创建需要精确找到对应的节点元素和偏移位置。
下面代码是实现的示意:
function getNodeAndOffset(dom, start = 0, end = 0){ const arrTextList = []; const map = function(chlids){ [...chlids].forEach(el => { if (el.nodeName === '#text') { arrTextList.push(el) } else if (el.textContent) { map(el.childNodes) } }) } map(dom.childNodes); let startNode = null; let startIndex = 0; let endNode = null; let endIndex = 0; // 总的字符长度 let total = startIndex; // 计算长度 arrTextList.forEach(function (node) { if (startNode && endNode) { return; } let length = node.textContent.length; // 当前节点,总的长度范围 const range = [total, total + length]; // 看看,start和end有没有在其中 // start在这个范围中 // 可以确定startIndex了 if (!startNode && start >= range[0] && start < range[1]) { startNode = node; startIndex = start - total; } // '我要' (0, 2) if (!endNode && end > range[0] && end <= range[1]) { endNode = node; endIndex = end - range[0]; } total = total + length; }); if (!startNode || !endNode) { return null; } return [startNode, startIndex, endNode, endIndex]; } var startIndex = obj.startIndex; var endIndex = obj.endIndex; // 创建 range const range = document.createRange(); // container是容器元素 const nodes = getNodeAndOffset(container, startIndex, endIndex); if (nodes) { range.setStart(nodes[0], nodes[1]); range.setEnd(nodes[2], nodes[3]); }
其中实现的核心就是getNodeAndOffset()
方法,大家可以直接复制粘贴拿来使用。
5. 编辑时候的起止点和内容实时保存
类似文档这种可编辑的划词评论,当对应内容修改的时候,需要实时保存选区的起止点和内容,此时,相关信息如何获取呢?
下面这段代码就是我使用的算法:
function getContentAndIndexList (target, selector) { const divTmp = document.createElement('div'); // 替换 divTmp.innerHTML = target.innerHTML; // 最终返回的数据 let operateCommentsList = []; // 遍历与匹配 const getRange = function () { let eleWrod = divTmp.querySelector(selector); if (!eleWrod) { return; } let text = ''; [...divTmp.childNodes].some(function (node) { if (node === eleWrod) { const selectContent = node.textContent; operateCommentsList.push({ selectContent: selectContent, startIndex: text.length, endIndex: text.length + selectContent.length, // 这里的 gid 命名是根据业务走的,你可以使用其他名称 gid: Number(node.dataset.gid) }); // 节点替换 node.replaceWith.apply(node, [...node.childNodes]); // 继续遍历 getRange(); return true; } text += node.textContent; }); }; getRange(); return operateCommentsList; }
原理为:创建个临时的div对象,完全克隆元素内容,然后遍历所有的高亮元素,并依次把此高亮元素替换成子元素,从而获得所有的高亮划词的数据。
正好和不断使用surroundContents()
进行高亮的做法相反。
好,有了以上几个核心难点的解决,再配合一些常见的交互逻辑,就能实现完整的划词功能了。
具体的实现逻辑和代码参见本文一开头展示的那个 gitee 项目。
四、结束语
可能有人注意到了,加上这篇文章,我这是3天3连发了,高产似啥啥。
奇怪,博主!我还以为你沉迷钓鱼无法自拔,以至于不再更新博客了。
NoNoNo!虽然钓鱼花的时间确实比以前多多了,但那是把之前写小说的时间腾出去的。
之所以这两个月文章更的完全没有之前勤快,是因为这两个月在忙着写《CSS选择器第二版》,这不,上周刚交完稿,这周我就爆更来了。
有时间了。
不过,别得意,等10月过后,我还要写《CSS世界修订版》,又要几个月忙得不可开交了。
所以,趁着10月还有一周,我打算再爆更几篇。
尽请期待~
本文为原创文章,欢迎分享,勿全文转载,如果实在喜欢,可收藏,永不过期,且会及时更新知识点及修正错误,阅读体验也更好。
本文地址:https://www.zhangxinxu.com/wordpress/?p=10541
(本篇完)
- gitee上撸了个类似飞书OKR输入框的@提及项目 (0.464)
- JS Range HTML文档/文字内容选中、库及应用介绍 (0.206)
- 如何使用CSS禁止元素拖拽? (0.206)
- 如何使用纯CSS鉴别是不是Safari浏览器 (0.206)
- JS文本选区变化selectionchange事件实践小记 (0.206)
- 利用剪切板JS API优化输入框的粘贴体验 (0.155)
- 从今天开始,请叫我Node文本节点处理大师 (0.155)
- CSSOM视图模式(CSSOM View Module)相关整理 (0.124)
- 小tips: 滚动容器尺寸变化子元素视觉上位置不变JS实现 (0.124)
- DOM基础小测27期答疑文字版-窗体滚动二三事 (0.124)
- 今日学习CSS文本自定义高亮API (RANDOM - 0.103)
微信读书经常用这个功能,来瞅瞅,表示支持~
起止位置,也可以直接把整个编辑好的html给后端,然后第二次进来时候,直接渲染html
是了,confluence 就是这种实现方式
学习了!划词评论的词语选中评论时,再次选中部分划词和一些未划词,控制台会报错。
demo有点小问题,当点击已选中文字的区域时,mouseup事件里window.getSelection.toString()的值还是之前选中的文字而不是空字符串,所以会导致你showSelectionPopover函数里if (!selectContentTrim || !target) { return;}这步不符合预期,结果就是当添加按钮显示时,再点击选中文字的区域后按钮不会隐藏,需要额外再点击一次才隐藏。这个是我以前写个划词翻译玩时发现的