模拟微信读书网页版实现文本标注
看到微信读书网页版的划词标注比较感兴趣,想用自己的方式实现一下,参考了网上其他大佬的方案,和微信读书实现的方式还是不同的。
效果展示:模拟微信读书划词标注(暂只支持pc)
确定划词范围显示弹出工具栏
当我们用鼠标选中文字的时候,其实是可以获取到选中文字的范围的
let selection = document.getSelection();
let range = selection.getRangeAt(0);
range对象就是划词范围,他包含了文字的开始节点和结束节点,以及相对于开始节点和结束节点的偏移量。
我们要实现在划词过后,将悬浮弹框展示在选中段落第一行文字的中间位置。那么,我们就要确定,选中段落的宽高定位。我们可以通过range对象获取。
range.getClientRects()
range.getClientRects()
对象返回的是一个数组,对应的是多行,目前这里我们只需要第一行的信息。
if (range.toString().length !== 0) {
const clientRects = range.getClientRects()[0];
if (clientRects.height === 29) { // 高度为0就不显示
readerToolbar.style.left = (clientRects.x + clientRects.width - 344 / 2 - clientRects.width / 2 + 5) + 'px';
} else {
readerToolbar.style.left = (clientRects.x + clientRects.width - 344 / 2 - clientRects.width / 2 + 5) + 'px';
}
readerToolbar.style.top = (clientRects.y - 68 - 8 + document.documentElement.scrollTop) + 'px';
}
我们通过上面的计算,确定弹框的显示位置。需要注意的是,在弹框的top需要加上document.documentElement.scrollTop
因为有可能滑动了滚动条。
多行问题解决
目前的方案,标注高亮以及下划线的方式,都是在选中的文本包裹标签的方式实现,但是当选区选择多行如果跨了快标签,就会导致高亮失效。参考了网上大佬的方案(链接在文末),将range拆分为多行。
如何确定是否为一行呢?就要用到上面我们讲到的range.getClientRects()
,其中top和height都可以确定是否为一行。我将所有的高度都放到一个Set中,就可以确定一共有多少行,以及每行的高度。
然后需要将容器内的所有文字节点都保存起来,然后再从中筛选出,选中的所有文本节点
//判断是否换行
let clientRects = range.getClientRects();
let topSet = new Set();
for (let i = 0; i < clientRects.length; i++) {
topSet.add(clientRects[i].top)
}
let topArray = Array.from(topSet)
const addTextNode = (childNodes) => {
[...childNodes].forEach(el => {
if (el.nodeName === '#text') {
textNodeList.push(el)
} else {
addTextNode(el.childNodes)
}
})
}
addTextNode(readerContent.childNodes)
for (let i = 0; i < textNodeList.length; i++) {
if (textNodeList[i] === startNode) {
startAddFlag = true
}
if (startAddFlag) {
selectTextNodeList.push(textNodeList[i])
}
if (endNode === textNodeList[i]) {
break
}
}
然后我们便利选中节点的每一个字符,生成range,判断是否是同一个高度,然后进行分类处理,将同一高度的放到map中,key为高度值,value为文本节点以及遍历的偏移量
selectTextNodeList.forEach((textNode, index) => {
let start = startOffset;
if (index !== 0) {
start = 0
}
let textLength = textNode === endNode ? endOffset : textNode.length
for (let i = start; i < textLength; i++) {
let endOffSet = i + 1 > textNode.length ? textNode.length : i + 1
classifyRange(textNode, i, endOffSet)
}
})
const classifyRange = (textNode, startOffset, endOffset) => {
let range = document.createRange();
range.setStart(textNode, startOffset)
range.setEnd(textNode, endOffset)
let rects = range.getClientRects()
if (rects.length !== 0) {
let top = rects[0].top
let nodeInfo = {
textNode,
startOffset,
endOffset
}
if (!topAndNodeOffsetMap.has(top)) {
let arr = Array(nodeInfo)
topAndNodeOffsetMap.set(top, arr)
} else {
topAndNodeOffsetMap.get(top).push(nodeInfo)
}
}
}
然后我们遍历所有高度,并从map中取出高度对应的数组,数组中第一个元素就是这一行的开始节点,最后一个元素就是结束节点。但是要考虑到因为文字容器宽度不足导致,在同一个元素内导致的跨行。这里的判断依据为,上一行的结束元素等于下一行的开始元素,说明是在同一个元素内部的跨行。我们只需要把开始节点改为该元素第一行的开始节点和偏移量即可。
if (topArray.size !== 1) {
selectTextNodeList.forEach((textNode, index) => {
let start = startOffset;
if (index !== 0) {
start = 0
}
let textLength = textNode === endNode ? endOffset : textNode.length
for (let i = start; i < textLength; i++) {
let endOffSet = i + 1 > textNode.length ? textNode.length : i + 1
classifyRange(textNode, i, endOffSet)
}
})
let mergeTop = []
topArray.forEach((top, index) => {
let nodeInfoList = topAndNodeOffsetMap.get(top);
if (nodeInfoList) {
let first = nodeInfoList[0];
let end = nodeInfoList[nodeInfoList.length - 1];
// 判断同一行因父元素宽度不足导致的跨行
let nextTopIndex = index + 1;
let nextTextNode = topAndNodeOffsetMap.get(topArray[nextTopIndex]) ? topAndNodeOffsetMap.get(topArray[nextTopIndex])[0].textNode : null;
if (nextTopIndex < topArray.length && end.textNode === nextTextNode) {
mergeTop.push(top)
} else {
let lineRange = document.createRange();
let startRangeNode = first.textNode;
let endRangeNode = end.textNode;
let startRangeOffset = first.startOffset;
let endRangeOffset = end.endOffset;
if (mergeTop.length) {
let firstLine = topAndNodeOffsetMap.get(mergeTop[0])[0];
startRangeNode = firstLine.textNode;
startRangeOffset = firstLine.startOffset;
mergeTop = []
}
lineRange.setStart(startRangeNode, startRangeOffset)
lineRange.setEnd(endRangeNode, endRangeOffset)
console.log('lineRange', lineRange)
rangeList.push(lineRange)
}
}
})
} else {
rangeList.push(range);
}
拿到range信息后,我们只需要把标记的元素包裹即可
/**
* 添加高亮节点
* @param range
*/
const addHighLight = (range) => {
let highLightSpan = document.createElement('span');
highLightSpan.className = 'high-light clickable';
highLightSpan.append(range.extractContents())
range.insertNode(highLightSpan)
}
持久化标注信息
由于range对象中的开始结束节点为dom中元素的应用,无法序列化。我学习了网上大佬的实现思路。利用路径信息,保存节点信息。
const saveContainerDomRangeOffset = (range, type) => {
let startPath = getPath(range.startContainer)
let endPath = getPath(range.endContainer)
let rangeObj = {
startPath,
endPath,
startOffset: range.startOffset,
endOffset: range.endOffset,
type
}
let rangeObjData = localStorage.getItem(STORAGE_KEY);
rangeObjData = rangeObjData ? JSON.parse(rangeObjData) : {
list: []
};
rangeObjData.list.push(rangeObj)
localStorage.setItem(STORAGE_KEY, JSON.stringify(rangeObjData));
}
const getPath = (textNode) => {
const path = [0]
let parentNode = textNode.parentNode
let cur = textNode
while (parentNode) {
if (cur === parentNode.firstChild) {
// readerContent 为文本信息的容器元素
if (parentNode === readerContent) {
break
} else {
cur = parentNode
parentNode = cur.parentNode
path.unshift(0)
}
} else {
cur = cur.previousSibling
path[0]++
}
}
return parentNode ? path : null
}
回显标注信息
回显只需要把持久化的数据,重新标注一次即可
window.onload = () => {
let rangeObjData = localStorage.getItem(STORAGE_KEY);
rangeObjData = rangeObjData ? JSON.parse(rangeObjData) : {
list: []
};
let list = rangeObjData.list;
for (let i = 0; i < list.length; i++) {
let startContainer = getNodeByPath(list[i].startPath);
let endContainer = getNodeByPath(list[i].endPath);
const range = document.createRange();
range.setStart(startContainer, list[i].startOffset)
range.setEnd(endContainer, list[i].endOffset)
switch (list[i].type) {
case 'underlineBg':
addHighLight(range)
break;
case 'underlineHandWrite':
addUnderlineHandWrite(range)
break;
case 'underlineStraight':
addUnderlineStraight(range)
break;
}
}
}
const getNodeByPath = (path) => {
let node = readerContent
for (let i = 0; i < path.length; i++) {
if (node && node.childNodes && node.childNodes[path[i]]) {
node = node.childNodes[path[i]]
} else {
return null
}
}
return node
}
这个案例中,我使用的是包裹元素的方式实现,后续打算用canvas去实现,毕竟是个后端开发,对前端的功力还不够,还有很多需要完善的地方。
参考链接