需要

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

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

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

整顿一下需要:

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

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

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