关于前端:即划即标-文本选中段落打标功能

需要

依据需要须要做一个,能够在一篇文章中,抉择一段文字,给相应的文字打标签,同时相应的文字背景须要变色标签的色彩。如图这样:

因为选取标签防止麻烦,所以须要划出区域后立刻弹出标签抉择菜单,同时弹出菜单后能够反对快捷键的疾速标注。在一般状况下,鼠标移入标签区域会浮现删除按钮,能够删除相应的标签,或者是点击标签区域,能够更换标签。

在其余一些浏览的场景中其实是有相似实现的,只是大部分是划线,而且并且没显示标签。

整顿一下需要:

这个外围性能就是在相应的文字区域减少标记,标记对应选中标签,同时选中区域须要有与标签雷同的色彩。

而后围绕此附加了几个性能:

  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库来做过滤。

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

这个站点使用 Akismet 来减少垃圾评论。了解你的评论数据如何被处理