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

这篇文章发布于 2022年09月21日,星期三,23:50,归类于 JS实例。 阅读 10215 次, 今日 2 次 5 条评论

 

万里挑一

一、先看大屏幕

最近在做划词评论相关的开发,此交互功能的开发还是有一些门槛的,想到可能有小伙伴也会遇到类似需求,决定分享一下若干处理经验。

在具体展开介绍之前,大家可以先看一下一个建议的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浏览器,可以看看。

好了,热身足够了,可以进入正题了,基于实例学习,这个是大多数开发者喜欢的学习方式。

三、难点、问题与解决

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元素,而不至于影响其他文本内容。

好,下面的难点就变成了如何基于startIndexendIndex创建精准的选区,这个还有些麻烦,因为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月还有一周,我打算再爆更几篇。

尽请期待~

(本篇完)

分享到:


发表评论(目前5 条评论)

  1. 灵感_idea说道:

    微信读书经常用这个功能,来瞅瞅,表示支持~

  2. 云飞扬说道:

    起止位置,也可以直接把整个编辑好的html给后端,然后第二次进来时候,直接渲染html

  3. 长江长江我是黄河说道:

    学习了!划词评论的词语选中评论时,再次选中部分划词和一些未划词,控制台会报错。

  4. 甘文崔说道:

    demo有点小问题,当点击已选中文字的区域时,mouseup事件里window.getSelection.toString()的值还是之前选中的文字而不是空字符串,所以会导致你showSelectionPopover函数里if (!selectContentTrim || !target) { return;}这步不符合预期,结果就是当添加按钮显示时,再点击选中文字的区域后按钮不会隐藏,需要额外再点击一次才隐藏。这个是我以前写个划词翻译玩时发现的