手摸手搞一个划词高亮组件 | 牛气冲天新年征文

1,368 阅读11分钟

转眼,今天已经是大年29了。而我已经在家打了两天王者了(还没放假了好兄弟们辛苦了,打工人牛逼--超大声)

为啥要写这篇文章?额...这要从两幅图片说起。

打王者打的把自己打自闭了😭,于是乎跑到掘金打算看看究竟是谁今天还在上班(嘿嘿)。

再于是乎发现又有征文活动,故心存歹意的我就来拉低hxd们的中奖率了。😄😄😄

正文开始

这篇文章我们来封装一个react的划词高亮组件。划词功能在我们周围算是很常见了。比如微信读书里的

再比如像咱们这些好学习的人儿(呸)装的一些chrome的扩展插件

它的功能很简单,就是选中文字然后进行高亮。

但是,虽然这个东西的功能就这么点东西。想要实现它也是有一点难度的,接下来小白一点一点的带大家领略一下

一:需求分析与寻找思路

需求分析这里就不需要了,功能已经简单的不能再简单了。这里我们主要来想一下怎么去实现

传统的划词功能主要有三大点

  1. 遍历dom树收集需要mark的节点
  2. dom序列化与反序列化
  3. 处理重复问题

处理重复的问题其实不难,只是看你要选择一个什么样的策略,比如你是一层包一层、还是压根就不允许重复。这完全却决于你。故这篇文章我只详解前两个

分析(我们先说思路,下面再说具体实现)

1.1 遍历dom树收集需要mark的节点

如上图,(p代表一个p标签)我们选中了我是文字我是,想让它进行高亮我们必须要拿到这段文本节点吧。怎么拿呢

其实我在上篇文章中使过一个api用于获取光标。害,忘了反正都么得人看

直接说吧,前端中有一个api可以拿到选区得一些信息。比如选中的是什么节点、开始还是结束,偏移量等(感兴趣的可以去看看文档)

它的详细使用我觉得没有必要在这篇文章中单独拿出来解释,所以我可以告诉你。我们有办法拿到选区的起始结束节点以及他们中的文本偏移量

光这样说可能不太直观,举个例子(我们选中p节点中的前面一段文字)

则可以看到它的起始节点是一个文本节点,偏移量为0 。结束节点也是一个文本节点,偏移量为6

细看看是不是呢

   <p>过年我最高兴,不用担心迟到,不用做作业,每天睡到自然醒,就算偶尔调皮,大人也都会一笑了之,不会责备。<span>层级2<span>层级三</span></span>

当前这个p标签就有两个孩子,第一个为文本节点剩下的是一个容器节点。

再看一下文本偏移量。起始中确实前面没有东西了是从0开始的,结束呢?过年我最高兴确实是6站个偏移量

可是我们想要拿到的是过年我最高兴,这个文本节点。当前拿到的这个文本节点可是有些长啊,有没有一个方法可以将一个比较长的文本节点截断呢?

答案当然也是有的

node. splitText()这个方法正是我们需要的

现在我们就可以拿到过年我最高兴这个文本节点了吧。怎么实现高亮呢?

这个好说,在这个文本节点外面包一个容器节点。再给这个容器节点加一点样式不就可以了嘛

像这样

呀,这么简单啊。这也没什么啊?

额,不会吧...你以为到这步骤一就结束了嘛。不好意思兄弟,到这我们才是刚开始玩进去

在实际中,虽然眼看着文本是这样并排的。但是实际上它们可是这样的。

通常情况下,我们需要处理的是这种(比如从第一个文本节点的本字到最后一个文本节点的文字)

是不是上来了一点复杂度呢哈哈,其实这时候只要你双手离开键盘,拿起旁边早已泡好的茶叶轻轻的抿上一口。就会很轻蔑的看着屏幕说了声:“就这?”

哈哈,玩笑一句。确实这也很好处理。明显就是一个图结构嘛(虽然我图上没有画,别忘了dom节点的兄弟之间也是有联系的),dfs或者bfs不是随便选嘛。我们可是知道起点和终点的。要注意的只是看一下边界问题咯,你可别跑到顶点去咯。

到此为止,第一大问题我们就分析完了。

1.2 dom序列化与反序列化

在上面我们已经有了如何实现选中一段文本并使其高亮的思路,但是它只是一次性的,什么意思呢?即上面的东西可还没有进行持久化的操作,页面一刷新可就无了。所以我们必须得记录一下放进服务器或者本地中

怎么记录呢?

一般用于传给后台或者放在本地是有两种常见的数据格式

  1. json
  2. 字符串

字符串肯定不行,唯一的选择就是json串了。故接下来我们的主要任务就是将mark的文本节点转成对应json信息实现定位记录(即进行序列化处理)

我们从简到难慢慢实现,首先我们可以先定位到mark文本节点的父节点

通过上图来理解一下,假设mark文本节点为“文本”。我们可以先记录它的父节点,那么在反序列化过程中,我们通过json信息就可以进行定位拿到这个父节点,进而获取到它的孩子节点

怎么记录这个父节点呢?

其实也简单,假设上图中的容器节点是这样的

那么我们可以这样去记录mark文本的父节点

  1. 拿到父节点的标签“span”,在当前这棵树上收集所有的span节点

    像这样 (当然如果你的包裹容器是用的span,那么在收集span节点时最好还是将mark的span节点去掉)

    const tagName = node.tagName
    const list = root.getElementsByTagName(tagName)
    

    现在lis就是一个记录着该dom树的所有span节点的一个类数组容器,那么里面的个个span节点是不是都有下标呢,我们就用这个下标进行目标容器节点的定位。

    比如上图(比较简单就一个span节点)

    list容器中是这样的

那么我们仅需要一个下标index就完全可以定位到这个span节点。可能现在的你有一些好奇怎么反序列化定位呢

哈哈哈,简单的很。在反序列化的中时候你反过来这样做

 const parent = root.getElementsByTagName(tagName)[index]

接下来要继续往下升级难度咯,大部分的情况我们处理选区都不会是像上面那种。太简单了

大部分是这样的

仅仅可以定位到其父亲的容器节点是远远不够的,我们的目标是要定位到“文本啊”这段文本节点

这里我曾用过两种方法来实现,第一种是在节点的维度上去处理的(这样有问题,因为这棵dom树是在不断变化的被我最后舍去了)第二种也即我最终使用的方法在文本的维度上去处理

什么意思呢?

因为我们的高亮处理是在文本节点的外层套span,这会导致dom处于不断变化中。故在节点维度上,节点之间的相对位置不再可信。

那么分析一下什么才是一成不变的呢?

稍微观察一下上图就会发现,文本之间得位置是永远不会被改变的。那就好说了

我们可以进行一下文本的"扁平化处理",即拿到span这个父节点之后,还是再dfs拿到它内部所有的文本节点。收集完是这样的

要知道的是,序列化的执行维度在mark文本节点上,也就是说此时在当前例子中是有两个mark节点来进行序列化的。它们分别是“文本”和“啊”

先来看怎么定位到文本

假设上面的收集文本节点的容器叫做allTextNode,mark文本节点为textnode

那我们就直接看textnodeallTextNode的左右偏移量

let Index = allTextNode.findIndex(textnode => textnode === textNode)

最终我想要的结果是拿到文本在它的父节点种所有的文本节点上的一个准确位置,这样说可能很不清晰

直接举例子,如“文本”这段mark节点我想要最终拿到这样的结果

childIndexStart :2

childIndexend :4

也即这样可以使用我们利用这两个偏移量可以准确定位到“文本”。它是从2开始的到4结束

好了,现在我们所有需要的信息基本上是都搞定了

序列化之后的节点信息最终形态

childIndexStart: 2
childIndexend: 4
index: 0
tagName: "SPAN"

好了搞定,思路都有了接下是具体实现了

二:具体实现

思路都在上面,这里我主要是把我写的demo代码帖一下。凑一下字数...

2.1 遍历dom树收集需要mark的节点

获取起始节点和结束节点及其偏移量

 const electoral = () => {
        markArr = []
        flag = 0
        let range = getDomRange()
        if (range) {
            // 获取起始位置和终止位置
            const start = {
                node: range.startContainer,
                offset: range.startOffset
            }
            const end = {
                node: range.endContainer,
                offset: range.endOffset
            }
            console.dir(start)
            console.dir(end)
            let newNode
            // 2. 处理头尾-----首尾是一个节点的情况,应该是取一个交集
            if (start.node === end.node) {
                newNode = splitNode(start.node, start.offset, end.offset)
                data.push(serialize(newNode))
                parseToDOM(newNode)
            } else {
                // 多节点的情况
                traversalDom(start, end)
                markArr[0] = splitHeader(start)
                markArr[markArr.length - 1] = splitTail(end)
                let RDArr = [...new Set(markArr)]
                //序列化处理
                RDArr.forEach(node => data.push(serialize(node)))
                //包裹处理
                RDArr.forEach(node => {
                    parseToDOM(node)
                })
            }
            localStorage.setItem('markDom', JSON.stringify(data))
        }
    }
export const getDomRange = () => {
    const selection = window.getSelection();

    if (selection.isCollapsed) {
        return null;
    }

    return selection.getRangeAt(0);
};

处理节点的时候肯定会存在两种情况

  1. 起始节点和结束节点都是同一个文本节点,这就比较简单了
    const splitNode = (node, header, tail) => {
        let newNode = node.splitText(header)
        newNode.splitText(tail - header)
        return newNode
    }

  1. 不是同一个节点,这干的事情有点多。(这里我写的复杂了,可自行简化。虽然代码有点乱但是思路就是上面的那些思路而已)

    1. 首先我们要去遍历dom树去收集在起始节点和结束节点中间的所有文本节点
    2. 再单独处理首尾节点
       /**
         * 
         * @param {*} start 
         * @param {*} end 
         * dom树遍历
         */
        const traversalDom = (start, end) => {
            let currentNode = start.node
            if (currentNode.nextSibling) {
                while (currentNode != end.node && currentNode.nextSibling != null) {
                    collectTextNode(currentNode, end.node)
                    currentNode = currentNode.nextSibling
                }
                if (flag == 0) {
                    collectTextNode(currentNode, end.node)
                    findUncle(currentNode, end.node)
                } else {
                    return
                }
            } else {
                collectTextNode(currentNode, end.node)
                findUncle(currentNode, end.node)
            }
        }
    
        /**
         * 
         * @param {*} node 
         * @param {*} endNode 
         * 找亲叔叔😀
         */
        const findUncle = (node, endNode) => {
            if (node == markRef.current) {
                return
            }
            let currentNode = node
            let current_fa = findFatherNode(currentNode)
            // 看它老子是不是当前节点的最后一个呢  (╯‵□′)╯炸弹!•••*~●
            if (current_fa.nextSibling) {
                collectTextNode(current_fa.nextSibling, endNode)
                if (flag == 1) {
                    return
                } else {
                    currentNode = current_fa.nextSibling
                    while (currentNode.nextSibling != null && flag === 0) {
                        collectTextNode(currentNode.nextSibling, endNode)
                        currentNode = currentNode.nextSibling
                    }
                    if (flag == 0) {
                        collectTextNode(currentNode, endNode)
                        findUncle(currentNode, endNode)
                    } else {
                        return
                    }
                }
            } else {
                collectTextNode(currentNode, endNode)
                findUncle(current_fa, endNode)
            }
        }
    
        /**
         * 
         * @param {*} node 
         * @param {*} endNode 
         *  dfs收集
         */
        const collectTextNode = (node, endNode) => {
            // dfs
            if (node.nodeType === 3) {
                pushTextNode(node)
            } else {
                let childNodes = node.childNodes
                if (childNodes) {
                    for (let i = 0; i < childNodes.length; i++) {
                        if (childNodes[i].nodeType === 3) {
                            pushTextNode(childNodes[i])
                            if (childNodes[i] == endNode) {
                                flag = 1
                                return
                            }
                        } else {
                            collectTextNode(childNodes[i], endNode)
                        }
                    }
                } else {
                    return
                }
            }
        }
    
    
        /**
         * 
         * @param {*} node
         * mark收集 
         */
        const pushTextNode = (node) => {
            if (markArr.findIndex(item => node === item) === -1) {
                markArr.push(node)
            }
        }
    
  2. 对于mark节点外层包裹span

    /**
     * 
     * @param {*} node
     * 进行包裹 
     */
    const parseToDOM = (node) => {

        const parentNode = node.parentNode
        if (parentNode) {
            const span = document.createElement("span");
            const newNode = node.cloneNode(true);
            span.appendChild(newNode)
            span.className = 'mark'
            parentNode.replaceChild(span, node)
        }
    }

2.2 序列化与反序列化

序列化


    /**
     * 
     * @param {*} textNode 
     * @param {*} root 
     * 开始进行DOM的序列化
     * 
     */
    const serialize = (textNode, root = document) => {
        allTextNode = []
        const node = findFatherNode(textNode)
        getAllTextNode(node)
        let childIndexStart = -1
        let childIndexend = -1

        // 计算前置偏移
        const calcLeftLength = (index) => {
            let length = 0
            for (let i = 0; i < index; i++) {
                length = length + allTextNode[i].length
            }
            return length
        }
        let Index = allTextNode.findIndex(textnode => textnode === textNode)
        if (Index === 0) {
            childIndexStart = 0     //前偏移
            childIndexend = childIndexStart + textNode.length //后偏移
        } else if (Index === allTextNode.length - 1) {
            childIndexStart = calcLeftLength(Index)
            childIndexend = childIndexStart + textNode.length
        } else {
            childIndexStart = calcLeftLength(Index)
            childIndexend = childIndexStart + textNode.length
        }

        // 通过它父亲的节点进行定位就可以😬
        const tagName = node.tagName
        const list = root.getElementsByTagName(tagName)
        // 去掉mark所占的位置
        const newList = [...list].filter(node => node.className !== "mark")
        for (let index = 0; index < newList.length; index++) {
            if (node === newList[index]) {
                return { tagName, index, childIndexStart, childIndexend }
            }
        }
        return { tagName, index: -1, childIndexStart, childIndexend }
    }

    /**
     * 
     * @param {*} proNode 
     * 获取全部文本节点,还是dfs
     */
    const getAllTextNode = (proNode) => {
        if (proNode.childNodes.length === 0) return
        console.log(proNode);
        for (let i = 0; i < proNode.childNodes.length; i++) {
            if (proNode.childNodes[i].nodeType === 3) {
                allTextNode.push(proNode.childNodes[i])
            } else {
                getAllTextNode(proNode.childNodes[i])
            }
        }
    }

反序列化

 /**
     * 
     * @param {*} meta 
     * @param {*} root 
     * 反序列化
     */
    const deSerialize = (meta, root = document) => {
        const { tagName, index, childIndexStart, childIndexend } = meta
        const parent = root.getElementsByTagName(tagName)[index]
        allTextNode = []
        if (parent) {
            getAllTextNode(parent)
            let nodeIndexStart = -1
            let length = 0
            let length3 = 0
            for (let i = 0; i < allTextNode.length; i++) {
                length = length + allTextNode[i].length
                if (length >= childIndexStart) {
                    nodeIndexStart = i
                    break;
                }
            }
            const calcLeftLength = (index) => {
                let length = 0
                for (let i = 0; i < index; i++) {
                    length = length + allTextNode[i].length
                }
                return length
            }
            length3 = calcLeftLength(nodeIndexStart)
            return splitNode(parent.childNodes[nodeIndexStart], childIndexStart - length3, childIndexend - length3)
        }
    }

三:写到最后

这里实现的仅是划词高亮中的一些最最基本的功能。因为一些不可告人的原因我github上可能不会再对其进行更新(看情况吧)水文到此结束

完整代码git地址 (demo上还有不少的问题,仅供参考代码。出错了清除一下本地存储的序列化信息,年后我优化一下demo)

最近真的没有一点文笔可言了,原来1w字不再话下,现在到4k都很艰难...额,我终究不再是那个少年了

提前祝福各位新年快乐,天天牛批

参考感谢: github.com/alienzhou/w…