需要
依据需要须要做一个,能够在一篇文章中,抉择一段文字,给相应的文字打标签,同时相应的文字背景须要变色标签的色彩。如图这样:
因为选取标签防止麻烦,所以须要划出区域后立刻弹出标签抉择菜单,同时弹出菜单后能够反对快捷键的疾速标注。在一般状况下,鼠标移入标签区域会浮现删除按钮,能够删除相应的标签,或者是点击标签区域,能够更换标签。
在其余一些浏览的场景中其实是有相似实现的,只是大部分是划线,而且并且没显示标签。
整顿一下需要:
这个外围性能就是在相应的文字区域减少标记,标记对应选中标签,同时选中区域须要有与标签雷同的色彩。
而后围绕此附加了几个性能:
- 选区实现后弹出标签菜单
- 菜单反对快捷键响应
- 能够删除标签
- 能够更换标签
计划选型
获取选中区域
这个是外围,只有先能获取到选中区域,才有方法做后续的动作。查资料发现浏览器有提供一个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
surroundContents
是Range
对象的一个办法,能够将对象的内容挪动到一个新的节点,利用这个能够很不便的将一段文字加上标签包裹住。
Range
对象通过window.getSelection.getRangeAt(0)
能够获取,获取到的是以后选取区间的文字的range
对象。
这种操作能够很疾速的实现所需性能,但如果遇到略微简单一些的HTML
元素,就会出问题,而且无奈反对标签交织的状况。
HTML
最简略的是利用HTML
自身的流式布局,只有在相干段落减少标签,这样就能通过CSS
给标签减少底色、鼠标通过成果,还能减少相应的鼠标操作性能。
次要复杂度就是标签精确的安插,鼠标抉择段落因为在不同标签下,起始的索引不同,须要解决。
硬伤就是无奈反对标签区域交织的状况。
SVG
须要反对标签区域交织只能用SVG
了,SVG
元素挪动比拟自在,能够将底色的块笼罩在文字上,做成图片的成果。
但随之而来的问题就是无奈流式布局,导致文本的换行、字的距离,所有都须要本人手动计算。
于是又查问了W3C的手册,发现SVG
有提供shape-inside
性能,可能让文本流式排版,但本地测试并未失常实现。
又查问到<foreignObject>
标签,这个能够在SVG
标签中应用XHTML
元素从而达到流式排版的目标。但这样就变成HTML
形式了,没方法再在外部借用SVG
的个性。
总结下来,如果标签区域不是必须交织的状况,能够应用surroundContents
、HTML
形式升高实现复杂度。刚好这个我的项目没有交织的需要,但为了保障有肯定的扩展性,所以就定下应用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
库来做过滤。