模拟微信读书网页版实现文本标注

看到微信读书网页版的划词标注比较感兴趣,想用自己的方式实现一下,参考了网上其他大佬的方案,和微信读书实现的方式还是不同的。

效果展示:模拟微信读书划词标注(暂只支持pc)

确定划词范围显示弹出工具栏

当我们用鼠标选中文字的时候,其实是可以获取到选中文字的范围的

let selection = document.getSelection();
let range = selection.getRangeAt(0);

range对象就是划词范围,他包含了文字的开始节点和结束节点,以及相对于开始节点和结束节点的偏移量。

image-20221010101614384

我们要实现在划词过后,将悬浮弹框展示在选中段落第一行文字的中间位置。那么,我们就要确定,选中段落的宽高定位。我们可以通过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去实现,毕竟是个后端开发,对前端的功力还不够,还有很多需要完善的地方。

参考链接

教你用纯JS实现语雀的划词高亮功能 - 爱码帮™分享编程知识和开发经验 (popnic.cn)

划词评论与Range开发若干经验分享 « 张鑫旭-鑫空间-鑫生活 (zhangxinxu.com)


模拟微信读书网页版实现文本标注
https://www.zhaojun.inkhttps://www.zhaojun.ink/archives/weread-text-mark-v1
作者
卑微幻想家
发布于
2022-10-10
许可协议