需要
依据需要须要做一个,能够在一篇文章中,抉择一段文字,给相应的文字打标签,同时相应的文字背景须要变色标签的色彩。如图这样:
因为选取标签防止麻烦,所以须要划出区域后立刻弹出标签抉择菜单,同时弹出菜单后能够反对快捷键的疾速标注。在一般状况下,鼠标移入标签区域会浮现删除按钮,能够删除相应的标签,或者是点击标签区域,能够更换标签。
在其余一些浏览的场景中其实是有相似实现的,只是大部分是划线,而且并且没显示标签。
整顿一下需要:
这个外围性能就是在相应的文字区域减少标记,标记对应选中标签,同时选中区域须要有与标签雷同的色彩。
而后围绕此附加了几个性能:
- 选区实现后弹出标签菜单
- 菜单反对快捷键响应
- 能够删除标签
- 能够更换标签
计划选型
获取选中区域
这个是外围,只有先能获取到选中区域,才有方法做后续的动作。查资料发现浏览器有提供一个 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
库来做过滤。