即划即标 文本选中段落打标功能

需求

根据需求需要做一个,可以在一篇文章中,选择一段文字,给相应的文字打标签,同时相应的文字背景需要变色标签的颜色。如图这样:

image

由于选取标签避免麻烦,所以需要划出区域后立即弹出标签选择菜单,同时弹出菜单后可以支持快捷键的快速标注。在普通情况下,鼠标移入标签区域会浮现删除按钮,可以删除相应的标签,或者是点击标签区域,可以更换标签。

在其他一些阅读的场景中其实是有类似实现的,只是大部分是划线,而且并且没显示标签。

整理一下需求:

这个核心功能就是在相应的文字区域增加标记,标记对应选中标签,同时选中区域需要有与标签相同的颜色。

然后围绕此附加了几个功能:

  1. 选区完成后弹出标签菜单
  2. 菜单支持快捷键响应
  3. 可以删除标签
  4. 可以更换标签

方案选型

获取选中区域

这个是核心,只有先能获取到选中区域,才有办法做后续的动作。查资料发现浏览器有提供一个window.getSelection接口,可以获取用户选中区域的范围,而且兼容性很好。

window.getSelection返回的是一个Selection对象,里面记录了光标的信息。

{
    anchorNode: DOM     // 光标起始选区的DOM
    anchorOffset: 0     // 光标起始选区的偏移量
    focusNode: DOM      // 光标结束选区的DOM
    focusOffset: 0      // 光标结束选区的偏移量
    baseNode: DOM       // 与anchor的一致
    baseOffset: 0
    extentNode: text    // 与focus的一致
    extentOffset: 0
    isCollapsed: false  // 光标起始点与结束点是否处于同一处(即是否选取了一段文字)
    rangeCount: 1       // 对象获取到多少个range 一般是0或1
    type: "Range"       // 类型 range为选取了一段文字  caret为点击了某处
}

如果HTML结构是一级的,则可以直接使用。但如果选区里HTML包含子标签,或者起始点和结束点落在了两个HTML块中,这时候,结束的索引会从当前HTML快重新计算,这时候可能会出现起始索引8、结束索引2的状况,可能是是用户反向选择内容,也可能是这里落在了两个HTML块中,这些是需要自己处理的部分。

渲染排版

剩下的就是排版问题,有三个实现方案。

surroundContents

surroundContentsRange对象的一个方法,可以将对象的内容移动到一个新的节点,利用这个可以很方便的将一段文字加上标签包裹住。

Range对象通过window.getSelection.getRangeAt(0)可以获取,获取到的是当前选取区间的文字的range对象。

这种操作可以很快速的实现所需功能,但如果遇到稍微复杂一些的HTML元素,就会出问题,而且无法支持标签交错的情况。

HTML

最简单的是利用HTML本身的流式布局,只要在相关段落增加标签,这样就能通过CSS给标签增加底色、鼠标经过效果,还能增加相应的鼠标操作功能。

主要复杂度就是标签准确的安插,鼠标选择段落因为在不同标签下,起始的索引不同,需要处理。

硬伤就是无法支持标签区域交错的情况。

SVG

需要支持标签区域交错只能用SVG了,SVG元素移动比较自由,可以将底色的块覆盖在文字上,做成图片的效果。

但随之而来的问题就是无法流式布局,导致文本的换行、字的间隔,所有都需要自己手动计算。

于是又查询了W3C的手册,发现SVG有提供shape-inside功能,能够让文本流式排版,但本地测试并未正常实现。

又查询到<foreignObject>标签,这个可以在SVG标签中使用XHTML元素从而达到流式排版的目的。但这样就变成HTML方式了,没办法再在内部借用SVG的特性。

总结下来,如果标签区域不是必须交错的情况,可以使用surroundContentsHTML方式降低实现复杂度。刚好这个项目没有交错的需求,但为了保证有一定的扩展性,所以就定下使用HTML方式。

核心功能

实现同样分两步,一个是获取所需索引,一个是在指定索引位插入对应标签。

由于并非独立项目,所以只能抽出相关逻辑,脱敏掉无关信息,大概展示一下实现逻辑。代码是基于Vue2写的。

获取索引

前面提过Selection对象的索引数据起始位是依当前HTML块计算的,我们需要换算成同一起点的索引值,并且要排除掉一些不能渲染的情况,然后才能传递给渲染逻辑使用。

进入页面后获取对应的DOM元素,监听mouseup事件。

window.getSelection()拿到Selection对象后,记录下当前的索引与DOM结构,为之后索引计算做准备。

监听事件函数

eventListener(e) {
  const selection = window.getSelection();
  if (selection.type === "Range") {
    e.stopPropagation();
    const range = selection.getRangeAt(0);

    let allNodes = selection.focusNode.parentNode;
    while (
      // 选取在子元素内的情况
      !allNodes.classList.contains("entity-distinguish-text") &&
      allNodes
    ) {
      if (allNodes.classList.contains("label"))
        allNodes = allNodes.parentNode;
      else allNodes = false;
    }

    // 反向选择时保持大数在后
    let startOffset = range.startOffset;
    let endOffset = range.endOffset;
    if (range.startContainer === range.endContainer) {
      // 保证处于同一选区,不同选区不做转换处理
      if (startOffset > endOffset)
        [endOffset, startOffset] = [startOffset, endOffset];
    }

    return {
      start_offset: startOffset,
      end_offset: endOffset,
      startContainer: range.startContainer,
      endContainer: range.endContainer,
      focusNode: selection.focusNode, // 当前文本区DOM信息
      anchorNode: selection.anchorNode, // 光标起始文本区的DOM信息
      parentNode: selection.focusNode.parentNode, // 当前光标停留文本区中父级节点信息
      allNodes: allNodes.childNodes, // 当前光标停留文本区中所有兄弟节点的信息 textContent重新计算偏移量
      tagNum: selection.focusNode.parentNode.children.length
    };
  }
}

偏移量计算函数
eventListener获得的数据传入processOffset中,计算出展平后的索引数据。

/**
 * 重新计算偏移量信息
 * @param offset
 * @returns {{end_offset: number, start_offset: number}}
 */
processOffset(offset) {
  let size = 0;
  // 起始结束区域为不同块(中间跨区)
  if (offset.startContainer !== offset.endContainer) {
    return false; // 暂不支持此种划区
  }
  // 起始结束区域为同一块
  if (offset.parentNode.classList.contains("label")) {
    return false; // 暂不支持此种划区
  } else if (
    offset.parentNode.classList.contains("entity-distinguish-text")
  ) {
    // 在父区块划区
    const target = offset.anchorNode.textContent;
    for (const node of offset.allNodes) {
      if (node.textContent === target) break;
      size += node.textContent.length;
    }
  }

  return {
    startOffset: size + offset.startOffset,
    endOffset: size + offset.endOffset
  };
}

偏移量验证函数
计算出坐标数据后验证索引是否可用。

/**
 * 验证偏移量是否可用
 * @param tagList
 * @param textOffset
 * @returns {boolean}
 */
verifyOffset(tagList, textOffset) {
  if (!textOffset) return false;
  let flag = true;
  // 禁止区域交错
  for (const item of tagList) {
    // 起点在其他区间内
    if (
      textOffset.startOffset >= item.startOffset &&
      textOffset.startOffset < item.endOffset
    ) {
      // 结束位置不能超出区间
      if (textOffset.endOffset > item.endOffset) flag = false;
    }
    // 起点在区间外 终点在区间内
    else if (
      textOffset.endOffset > item.startOffset &&
      textOffset.endOffset < item.end_offset
    )
      flag = false;
  }
  return flag;
}

渲染文本

与后端约定了数据保存的形式:{ id: Number, label: String, startOffset: Number, endOffset: Number },无论是通过菜单新增标签,还是直接从后端渲染已有标签,都是传入这个格式。

插入标签的主函数

insertLabel(text) {
  let array = [];
  const textArray = xss(text).split(""); // xss函数为xss.js库,过滤数据防止XSS
  let tagData = this.processTagData(); // 处理标签

  let spanNum = 0;
  textArray.forEach((word, index) => {
    let span = this.insert(tagData[index]); // 拼接HTML
    if (span) array[index + spanNum++] = span;
    array[index + spanNum] = word;
    this.textCut(tagData[index], word); // 截取标签对应文本
  });
  this.$emit("update:tagTextData", this.tagTextData);
  return array.join("");
}

this.textCut是后续增加了一个需求,需要在另一处展示标签的内容与标签,可以给两个标签增加关系属性数据。但后端又没返回文本数据,所以前端在拼接的时候同时记录上文本内容。

处理标签数据函数

由于渲染逻辑是通过循环文本,在适当位置插入标签,所以需要将约定的标签格式处理成有序的标签数据。

processTagData() {
  let tagData = {};
  this.tag.forEach(item => { // this.tag数据为当前文本段落的所有标签数据。
    const startData = {
      type: "start",
      label: item.label,
      id: item.id,
      index: item.startOffset
    };
    if (tagData[item.startOffset]) tagData[item.startOffset].push(startData);
    else tagData[item.startOffset] = [startData];

    const endData = {
      type: "end",
      label: item.label,
      id: item.id,
      index: item.endOffset
    };
    if (tagData[item.endOffset]) tagData[item.endOffset].push(endData);
    else tagData[item.endOffset] = [endData];
  });
  return tagData;
}

HTML代码拼接逻辑

HTML代码拼接逻辑,逻辑比较简单,就是拼装的时候同时加上颜色信息、id信息。初始选中的状态为空标签状态,默认添加empty类名。结尾插入一个span存放标签名和关闭按钮。

insert(item) {
  if (!item) return null;
  let span = [];
  item.forEach(tag => {
    if (tag.type === "start") {
      if (tag.label) {
        let dim = "dim"; // 置灰类名
        if (tag.label === this.highlightTag) dim = "";
        span.push(
          `<span class="label ${dim}" style="background-color: ${xss(this.tagColor[tag.label])}" data-id="${xss(tag.id)}" >`
        );
      } else {
        // 空标签
        span.push(
          `<span class="label empty" style="background-color: ${xss(this.tagColor[tag.label])}" data-id="${xss(tag.id)}" >`
        );
      }
    } else if (tag.type === "end") {
      span.push(
        `<span class="label-info" data-id="${xss(tag.id)}" data-tag="${xss(tag.label)}"><span class="label-delete" data-id="${xss(tag.id)}"></span></span></span>`
      );
    }
  });
  return span.join("");
}
这里由于还实现了一个鼠标移过左侧标签区,相应的文本内容里的标签段落要高亮保持颜色,其他变为灰色,所以这里增加了一个置灰类名的处理。

在展示标签文本这里,可以将文本写入HTML标签内容中,也可以像是现在这样放入自定义属性里。这里选择了后者是因为,这样用户在复制文本的时候是不会将标签文字复制进去的,用户体验会比较好。

大概的CSS逻辑

span.label {
    &.empty {
      background-color: #e4e0e0;
      color: #666;

      .label-info::after, .label-delete {
        display: none;
      }
    }

    .label-info {
      // 防止 after后的position:absolute定位不准
      transform: translateY(0px);
      display: inline-block;
    }

    .label-info::after {
      content: attr(data-tag);
      color: #333333;
      background-color: #fff;
      border-radius: 4px;
      padding: 2px 4px;
      margin-left: 7px;
      opacity: .9;
      font-size: 12px;
      transform: translateY(-1px);
      display: inline-block;
      height: 19px;
      line-height: 17px;
      cursor: pointer;
    }
}

到此核心的划区与标签渲染就完成了。

辅助功能

菜单功能

在划区的时候,如果划区是合理的,则预先添加上空标签,在this.tag里添加一个新的空标签,并且带上随机的id,以供删除使用。

这里我们在监听mouseup事件的时候就要加一个前置处理,不能直接使用之前的eventListener函数。

需要在触发函数时,做如下几个操作:

  • 关闭之前的菜单窗口
  • 判断是修改标签还是新增标签(菜单弹出位置不一样)
  • 获取当前鼠标点击的位置信息并传出
  • 执行eventListener逻辑功能

菜单弹出的时候还需要判断一下可标注区域范围,防止菜单过边被遮盖。

之后在菜单区域内相应快捷键和单击标签事件。触发事件后,会搜索对应文本区的this.tag数据,找到空标签或者对应id并写入新标签数据,此时由于渲染逻辑监控this.tag数据,所以会自动触发重新渲染逻辑。这里就不贴代码了。

当前标签高亮效果功能

当鼠标滑过左侧标签区域时,停在某个标签上,右侧的文本区相同的标签就会保持当前颜色,其余无关标签会变色淡灰色。

这里可以通过CSS覆盖来实现。当鼠标进入左侧标签区域时,获取当前指向区域的标签。在右侧文本区的主节点上添加class标记,进入高亮模式,通过insert函数中的标签名匹配,判断是否加上dim类名,拥有此类名的会强制覆盖掉当前颜色变为浅灰色。这样在重渲染出来之后就有高亮效果了。

优化代码

拆分代码

由于逻辑比较多,代码是需要拆开来写的。通过mixins功能,将代码获取划区索引与渲染功能拆成了独立文件。菜单也同样写成了独立的组件以供调用。

渲染文本循环

在文本循环的时候,将数据处理成有序的标签数据,这样一段文本只要循环一次就能插入所有标签,同时也满足后期增加的截取标签对应文字功能。

XSS预防

由于使用的是v-html插入HTML代码,所以获取的数据需要过滤一下避免产生XSS漏洞,这里使用了xss.js库来做过滤。


01小径
在路上,遇见了一只BUG,我将它抓住,收藏在了这里。<( ̄︶ ̄)>
648 声望
14 粉丝
0 条评论
推荐阅读
Chrome 历史版本下载
由于测试时候会需要用到历史版本,但发现不是很好找,于是记录一下。window/MAC/Linux:[链接]Window:[链接] MAC:[链接]firefox的历史版本:[链接]

LnEoi阅读 55

手把手教你写一份优质的前端技术简历
不知不觉一年一度的秋招又来了,你收获了哪些大厂的面试邀约,又拿了多少offer呢?你身边是不是有挺多人技术比你差,但是却拿到了很多大厂的offer呢?其实,要想面试拿offer,首先要过得了简历那一关。如果一份简...

tonychen152阅读 17.7k评论 5

封面图
正则表达式实例
收集在业务中经常使用的正则表达式实例,方便以后进行查找,减少工作量。常用正则表达式实例1. 校验基本日期格式 {代码...} {代码...} 2. 校验密码强度密码的强度必须是包含大小写字母和数字的组合,不能使用特殊...

寒青56阅读 8.5k评论 11

JavaScript有用的代码片段和trick
平时工作过程中可以用到的实用代码集棉。判断对象否为空 {代码...} 浮点数取整 {代码...} 注意:前三种方法只适用于32个位整数,对于负数的处理上和Math.floor是不同的。 {代码...} 生成6位数字验证码 {代码...} ...

jenemy48阅读 7k评论 12

从零搭建 Node.js 企业级 Web 服务器(十五):总结与展望
总结截止到本章 “从零搭建 Node.js 企业级 Web 服务器” 主题共计 16 章内容就更新完毕了,回顾第零章曾写道:搭建一个 Node.js 企业级 Web 服务器并非难事,只是必须做好几个关键事项这几件必须做好的关键事项就...

乌柏木75阅读 7.1k评论 16

再也不学AJAX了!(二)使用AJAX ① XMLHttpRequest
「再也不学 AJAX 了」是一个以 AJAX 为主题的系列文章,希望读者通过阅读本系列文章,能够对 AJAX 技术有更加深入的认识和理解,从此能够再也不用专门学习 AJAX。本篇文章为该系列的第二篇,最近更新于 2023 年 1...

libinfs42阅读 6.8k评论 12

封面图
从零搭建 Node.js 企业级 Web 服务器(一):接口与分层
分层规范从本章起,正式进入企业级 Web 服务器核心内容。通常,一块完整的业务逻辑是由视图层、控制层、服务层、模型层共同定义与实现的,如下图:从上至下,抽象层次逐渐加深。从下至上,业务细节逐渐清晰。视图...

乌柏木45阅读 8.5k评论 6

648 声望
14 粉丝
宣传栏