IT博客汇
  • 首页
  • 精华
  • 技术
  • 设计
  • 资讯
  • 扯淡
  • 权利声明
  • 登录 注册

    划词评论与Range开发若干经验分享

    张 鑫旭发表于 2022-09-21 15:50:59
    love 0

    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 即可,幸福啊!

    IE死亡

    而划词评论的实现,本质上就是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。

    Range Bounding 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提供了几个偏移属性。

    selection 偏移 API

    Range API 也有几个偏移属性。

    range offset相关API

    但是这些偏移都是相对于某个节点的,不一定是整个段落元素的偏移值,因此,不能直接拿来使用。

    举个例子,有如下HTML代码:

    <p>
        前面文字<i>标签元素</i>后面文字
    </p>

    如果文字的选区是“后面”这两个字,那么Selection的anchorNode就会是:#Text后面文字,anchorOffset也会是0,截图示意:

    #text节点示意

    偏移offset是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

    (本篇完)



沪ICP备19023445号-2号
友情链接