前言
朋友公司接到一个需求: 把所有静态网页打包到APP里, 然后通过WebView访问这些网页时, 可以对网页文本内容选中标记, 并可以进行其它操作, 如: 评论、笔记、百科等操作, 然后把相关信息存到服务器上, 下次打开时能显示上次标记的内容信息. 如下图所示, 当然后面我精简了, 因为核心不是有哪些按钮, 而是怎么对选中文本进行操作. 当然,需求中也提到一点: 不要求跨多节点操作, 因为估计跨节点会很难, 然后朋友找到了我, 话不多说, 开干!
先上效果图:
![c9e84ea69c65b124fab345bfa583bf59.png](https://img-blog.csdnimg.cn/img_convert/c9e84ea69c65b124fab345bfa583bf59.png)
demo地址: web-marker demo
git: github.com/cashon1120/…
npm: www.npmjs.com/package/web…
分析
插件, 不存在框架的选择, 打包用rollup, 严谨一点用了typescript, 别的eslint这些就没配了, 别问为什么, 因为懒.
分析需求: 最直观的问题有3点:
- 获取用户选中的文本内容;
- 标记用户选中的文本节点;
- 获取用户选中的文本节点所在的父节点;
下面一个一个来解决
一: 获取用户选中的文本内容
这个功能可能平时很少接触到, 其实有相关接口, window.getSelection(), 查一查就清楚了, 会返回当前选中的文本节点信息, 包括开始位置和结束位置, 文中用主要用到下面几个方法 ,
const selectedText = window.getSelection()
selectedText.getRangeAt(0) // 必须传0, 好像也只能传0, 返回节点信息;
selectedText.toString() // 返回文本内容;
selectedText.getRangeAt(0).surroundContents(dom) // 用传进来的dom替换当前选中内容
selectedText.removeAllRanges() // 这个好理解, 手动移除选中内容
看上去通过上面接口就基本上实现了需求, 但理想很丰满, 现实很骨感, 遇到的第一个难题就是怎么获取节点位置, 因为上面的接口能实现视觉上的标记(高亮)功能, 但不能保存位置信息, 比如当前选中的节点的父节点(这个好办), 选中的文本从第几个字开始到第几个字结束(有点懵了), 在同一个节点选中多个标记又怎么计算?
首先获取当前标记父节点, 为了方便操作, 我比较暴力, 初始化的时候把页面上所有节点加上一个新的class, 这样的好处就是后面计算位置和以后进入页面加载已标记数据时就方便多了, 弊端嘛, 就是有点耗性能, 也不优雅(管它的, 先实现功能再说)
export const setMarkClassName = (dom: HTMLElement, index:string = '1') => {
if(dom === document.body){
dom.className = '_WM-0'
}
if (dom.childNodes) {
for (let i = 0; i < dom.childNodes.length; i++) {
const childNode = dom.childNodes[i] as HTMLElement
if (childNode.nodeType === 1) {
const ingoreNodes = ['BR', 'HR', 'SCRIPT', 'BUTTON']
if (!ingoreNodes.includes(childNode.nodeName)) {
childNode.className = childNode.className ? childNode.className + ` _WM-${index}-${i}` : `_WM-${index}-${i}`
}
if (childNode.childNodes.length > 0) {
setMarkClassName(childNode, index + 1 + `-${i}`)
}
}
}
}
}
好, 开始操作, 监听用户的 'mouseup' 事件, 这里提一嘴, 移动端不能用同类型 'touchend' 事件, 操作方式完全不一样, 只能用'selectionchange', 而两者的区别就是 PC 端在鼠标抬起时就可以给选中的文本加节点, 然后能获取定位, 然后就可以对操作框进行位置设定, 移动端就不能精准的定位操作框位置了. 回到前面, 用户选中文本后给选中的文本设置新的节点, surroundContents() 出场:
handleMouseUp(){
// ...
const text = this.selectedText.toString()
const rang = this.selectedText.getRangeAt(0)
const span = setTextSelected(this.TEMP_MARKED_CLASSNAME, text, this.tempMarkerInfo.id)
rang.surroundContents(span)
// ...
}
// setTextSelected 方法在utils中
export const setTextSelected = (className: string, text: string, id: string) => {
const span = document.createElement('span')
span.className = className
span.id = id
span.innerHTML = text
return span
}
同时保存选中节点信息, getRangeAt(0) 有很多实用的东西, 感兴趣的同学还可以深入挖掘:
handleMouseUp(){
// ...
const {commonAncestorContainer} = this.selectedText.getRangeAt(0)
const {anchorOffset, focusOffset} = this.selectedText
const startIndex = Math.min(anchorOffset, focusOffset)
const endIndex = Math.max(anchorOffset, focusOffset)
const className = commonAncestorContainer.parentNode.className.split(' ')
let parentClassName = className[className.length - 1]
this.tempMarkerInfo = new Marker(setuuid(), parentClassName, 0, startIndex, endIndex)
// ...
}
// Marker 是一个类,
class Marker implements IMarker {
id : string;
childIndex : number;
start : number;
end : number;
parentClassName?: string;
constructor(id : string, parentClassName : string, childIndex : number, start : number, end : number) {
this.id = id
this.childIndex = childIndex
this.start = start
this.end = end
this.parentClassName = parentClassName || ''
}
}
同时调用 this.show(), 显示操作框, 显示操作框的时候会有一些判断, 比如是否选的多个节点, 接上面需求, 不要求跨多节点操, 然后根据上面新替换的节点控制显示位置, 这些都不难.
二: 添加标记
前面没说清楚, 其实用户选中文本的时候临时设置了一个样式 TEMP_MARKED_CLASSNAME, 这里只需要替换成 MARKED_CLASSNAME 就可以了
mark(){
const tempMarkDom = document.getElementsByClassName(this.TEMP_MARKED_CLASSNAME)[0]
tempMarkDom.className = this.MARKED_CLASSNAME
this.currentId = this.tempMarkerInfo.id
this.tempMarkerInfo = null
this.resetMarker(parentClassName)
this.selectedText.removeAllRanges()
this.hide()
}
重点是 this.resetMarker(domClassName), 传入的parentClassName 就是标记所在的父节点, 这里代码只贴出核心部分:
private resetMarker(domClassName : string) {
const dom = document.getElementsByClassName(domClassName)[0]
const newMarkerArr : Marker[] = []
let preNodeLength = 0
for (let i = 0; i < dom.childNodes.length; i++) {
const node = dom.childNodes[i]
if (node.nodeName === '#text') {
preNodeLength = node.textContent.length
}
// childIndex 为什么是 i - 1 ? 根据当前已经标记节点索引,在后面反序列的时候才能找到正确位置 比如当前节点内容为"xxx
// <标记节点>ooo</标记节点>", i 就是 1, 反序列的时候其实他是处于 0 的位置
const childIndex = i - 1
this.selectedMarkers[domClassName].forEach((marker : Marker) => {
const child = dom.childNodes[i] as HTMLElement
if (child.id == marker.id) {
newMarkerArr.push(new Marker(marker.id, '', childIndex, preNodeLength, preNodeLength + node.textContent.length))
}
})
}
// 保存
this.selectedMarkers[domClassName] = newMarkerArr
}
下面来分析这段代码, 首先目前的页面差不多应该是这个样子
![40b699c9adedeb3ba29068d85b1626bb.png](https://img-blog.csdnimg.cn/img_convert/40b699c9adedeb3ba29068d85b1626bb.png)
而节点差不多是这样的:
![32f5b75fb68855507a9cc0e8a233d512.png](https://img-blog.csdnimg.cn/img_convert/32f5b75fb68855507a9cc0e8a233d512.png)
那resetMarker要处理的dom就是"_wm-11-1-3", 要实现的目的就是每标记一次, 就要对这个节点("_wm-11-1-3")内的所有标记节点重新进行计算, 然后保存, 包括下面的删除标记功能.
三.删除标记
删除标记相对来说就简单得多, 点击已经标记的文本时, 会缓存currentId, 并显示操作框, 点击删除执行del()
del() {
if (!this.currentId) return
this.tempMarkDom = null
const dom = document.getElementById(this.currentId) as any
// 获取自己设置的class
const className = dom.parentNode.className.split(' ')
const parentClassName = className[className.length - 1]
mergeTextNode(dom)
this.resetMarker(parentClassName)
}
删除时有比较重要的一步, mergeTextNode(), 合并文本内容,
export const mergeTextNode = (dom: HTMLElement) => {
const parentNode = dom.parentNode
if(!parentNode) return
const text = dom.innerText
const replaceTextNode = document.createTextNode(text)
// 到这里就是把标记的span节点替换成文本节点, 但现在还是和前后的文本内容分开的,
parentNode.replaceChild(replaceTextNode, dom)
const preDom = replaceTextNode.previousSibling
const nextDom = replaceTextNode.nextSibling
// 合并文本节点
if (preDom && preDom.nodeType === 3) {
preDom.textContent = preDom.textContent + text
parentNode.removeChild(replaceTextNode)
if (nextDom && nextDom.nodeType === 3) {
preDom.textContent = preDom.textContent + nextDom.textContent
parentNode.removeChild(nextDom)
}
} else {
if (nextDom && nextDom.nodeType === 3) {
replaceTextNode.textContent = replaceTextNode.textContent + nextDom.textContent
parentNode.removeChild(nextDom)
}
}
}
当我们标记一段文字后, 这段文字就成了三个节点, 前面一个文本节点, 中间是我们标记的节点, 后面又是一个文本节点, 删除后,这里其实就是三个文本节点, preDom, nextDom 就代表前后的文本节点, 这时需要将这三个文本节点合并成一个, 还原成标记前的样子.
到此, 基本上就实现了网页标记的功能, 代码最后提供了几个接口:
// 返回当前选中标记ID
getCurrentId() {
return this.currentId
}
// 返回当前页所有已标记数据
getAllMarkes() {
return this.selectedMarkers
}
// 返回当前选中的文本内容, 用于百科, 字典, 拷贝等操作
getSelectedText() {
return this.selectedText.toString()
}
后面不忙的时候还会新增一些功能, 比如可选择多种标记颜色, 实现跨节点操作等等.
第一次发文, 写得不好的地方请多多包涵, 多多指教, 谢谢各位看官!