需求
根据需求需要做一个,可以在一篇文章中,选择一段文字,给相应的文字打标签,同时相应的文字背景需要变色标签的颜色。如图这样:
由于选取标签避免麻烦,所以需要划出区域后立即弹出标签选择菜单,同时弹出菜单后可以支持快捷键的快速标注。在普通情况下,鼠标移入标签区域会浮现删除按钮,可以删除相应的标签,或者是点击标签区域,可以更换标签。
在其他一些阅读的场景中其实是有类似实现的,只是大部分是划线,而且并且没显示标签。
整理一下需求:
这个核心功能就是在相应的文字区域增加标记,标记对应选中标签,同时选中区域需要有与标签相同的颜色。
然后围绕此附加了几个功能:
- 选区完成后弹出标签菜单
- 菜单支持快捷键响应
- 可以删除标签
- 可以更换标签
方案选型
获取选中区域
这个是核心,只有先能获取到选中区域,才有办法做后续的动作。查资料发现浏览器有提供一个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
功能,能够让文本流式排版,但本地测试并未正常实现。
又查询到
标签,这个可以在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(
``
);
} else {
// 空标签
span.push(
``
);
}
} else if (tag.type === "end") {
span.push(
``
);
}
});
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
库来做过滤。