教你用純JS實(shí)現(xiàn)語(yǔ)雀的劃詞高亮功能
前言
前段時(shí)間公司需要實(shí)現(xiàn)一個(gè)劃詞評(píng)論的功能,但是到網(wǎng)上找了一圈發(fā)現(xiàn)劃詞評(píng)論的庫(kù)并不多,而且大部分的實(shí)現(xiàn)都是需要破壞頁(yè)面 DOM 結(jié)構(gòu)的,也就是需要在頁(yè)面 DOM 結(jié)構(gòu)中拆分文本包裹一個(gè) mask 標(biāo)簽,但是由于我們做的是在線富文本文檔功能,文本的內(nèi)容是可以再編輯的,如果評(píng)論破壞了 DOM 結(jié)構(gòu)這樣對(duì)我們編輯的時(shí)候編輯器解析就不是很友好。找到最后發(fā)現(xiàn)語(yǔ)雀實(shí)現(xiàn)的劃詞評(píng)論功能是基于 canvas 實(shí)現(xiàn)的,與頁(yè)面結(jié)構(gòu)完全解耦,但是由于語(yǔ)雀沒(méi)有開(kāi)源,所以也沒(méi)辦法參考他們的代碼,只能順著他們的思路自己寫(xiě)。
實(shí)現(xiàn)效果
話不多說(shuō),先看看最終實(shí)現(xiàn)的效果:
當(dāng)然這個(gè)只是實(shí)現(xiàn)了核心功能的 demo,更多的交互和 UI 細(xì)節(jié)也可以基于這個(gè)功能進(jìn)行實(shí)現(xiàn)。
實(shí)現(xiàn)思路
主要思路:生成一個(gè) canvas 元素,讓 canvas 元素與需要?jiǎng)澰~高亮功能的文本容器元素等寬高,并且重疊在文本容器上,劃詞的時(shí)候獲取劃詞區(qū)域的文本節(jié)點(diǎn)相對(duì)于文本容器的位置信息,然后通過(guò)這些位置信息進(jìn)行高亮背景的渲染。
雖然思路看起來(lái)很簡(jiǎn)單,但是具體實(shí)現(xiàn)的過(guò)程還是有許多注意點(diǎn)的,接下來(lái)我就總結(jié)一下一些實(shí)現(xiàn)過(guò)程中的注意點(diǎn)和細(xì)節(jié)。
實(shí)現(xiàn)細(xì)節(jié)
1. 讓 canvas 與文本容器元素重疊
讓 canvas 與文本容器元素重疊最好的實(shí)現(xiàn)方式就是將 canvas 做為文本容器的直接子節(jié)點(diǎn),然后設(shè)置文本容易為相對(duì)定位,將 canvas 設(shè)置為絕對(duì)定位,然后將 top、left、right、bottom 都設(shè)置為 0,這樣就可以時(shí)刻保證 canvas 元素與文本容器元素始終等寬高,且 canvas 重疊在文本容器上。不過(guò)這種實(shí)現(xiàn)方式也有一個(gè)問(wèn)題,我們把 canvas 的層級(jí)提高了,蓋住了文本容器中的其他文本節(jié)點(diǎn),這樣就沒(méi)辦法進(jìn)行劃詞了,所以這時(shí)候我們需要給 canvas 再添加一個(gè) css 屬性:pointerEvents: 'none'
,這樣就可以讓 canvas 不響應(yīng)鼠標(biāo)事件,從而讓底部文本節(jié)點(diǎn)可以正常劃詞了。
2. 獲取劃詞區(qū)域文本節(jié)點(diǎn)的位置信息
獲取劃詞區(qū)域信息需要使用 document.getSelection().getRangeAt(0)
來(lái)獲得當(dāng)前劃詞區(qū)域的 range 對(duì)象,在這個(gè)對(duì)象上可以獲取到劃詞區(qū)域的起始和終止文本節(jié)點(diǎn)以及偏移量信息。
const { startContainer, // 起始節(jié)點(diǎn) startOffset, // 起始節(jié)點(diǎn)偏移量 endContainer, // 終止節(jié)點(diǎn) endOffset // 終止節(jié)點(diǎn)偏移量 } = document.getSelection().getRangeAt(0)
雖然我們拿到了節(jié)點(diǎn)信息,但是怎么獲得具體的位置信息呢?這時(shí)候就需要借助 Range
對(duì)象的強(qiáng)大功能了。
// 創(chuàng)建一個(gè) range 對(duì)象 const range = document.createRange() // 設(shè)置需要獲取位置信息的文本節(jié)點(diǎn)以及偏移量 range.setStart(startContainer, startOffset) range.setEnd(startContainer, startContainer.textContent.length) // 通過(guò) getBoundingClientRect 獲取位置信息 const rect = range.getBoundingClientRect()
通過(guò)創(chuàng)建 range 對(duì)象我們可以獲得任何一個(gè)文本節(jié)點(diǎn)中的任何一段文本相對(duì)與整個(gè)頁(yè)面的位置信息,然后再通過(guò)減去文本容器元素相對(duì)于整個(gè)頁(yè)面的位置信息,我們就可以得到劃詞區(qū)域文本相對(duì)與文本容器的位置信息了。
3. 獲取頭尾中間的文本節(jié)點(diǎn)
雖然我們通過(guò) document.getSelection().getRangeAt(0)
獲得了劃詞頭尾節(jié)點(diǎn)的信息,但是頭尾中間如果有其他的文本節(jié)點(diǎn)我們也需要進(jìn)行背景高亮,那么中間的文本節(jié)點(diǎn)我們?cè)撛趺传@得呢?這里我想到的辦法是從頭節(jié)點(diǎn)開(kāi)始進(jìn)行深度優(yōu)先遍歷,遍歷到尾節(jié)點(diǎn)為止,然后收集遍歷過(guò)程中的所有文本節(jié)點(diǎn),這樣我們就得到了整個(gè)劃詞區(qū)域內(nèi)的所有文本節(jié)點(diǎn),然后通過(guò)上面第 2 點(diǎn)的辦法我們也可以得到所有文本節(jié)點(diǎn)的位置信息。
// 獲取 start 到 end 深度優(yōu)先遍歷之間的所有 Text Node 節(jié)點(diǎn) function getTextNodesByDfs(start: Text, end: Text) { if (start === end) return [] const iterator = nodeDfsGenerator(start, false) const textNodes: Text[] = [] iterator.next() let value = iterator.next().value while (value && value !== end) { if (node.nodeType === 3) { textNodes.push(value) } value = iterator.next().value } if (!value) { return [] } return textNodes } // 返回節(jié)點(diǎn)的深度優(yōu)先迭代器 // 對(duì)于有子節(jié)點(diǎn)的 Node 會(huì)遍歷到兩次,不過(guò) Text Node 肯定沒(méi)有子節(jié)點(diǎn),所以不會(huì)重復(fù)統(tǒng)計(jì)到 function * nodeDfsGenerator(node: Node, isGoBack = false): Generator<Node, void, Node> { yield node // isGoBack 用于判斷是否屬于子節(jié)點(diǎn)遍歷結(jié)束回退到父節(jié)點(diǎn),如果是那么該節(jié)點(diǎn)不再遍歷其子節(jié)點(diǎn) if (!isGoBack && node.childNodes.length > 0) { yield * nodeDfsGenerator(node.childNodes[0], false) } else if (node.nextSibling) { yield * nodeDfsGenerator(node.nextSibling, false) } else if (node.parentNode) { yield * nodeDfsGenerator(node.parentNode, true) } }
4. 處理跨行文本節(jié)點(diǎn)的位置信息
其實(shí)我們之前第 2 點(diǎn)獲取劃詞區(qū)域文本節(jié)點(diǎn)的位置信息的方案還有缺陷,對(duì)于跨行的文本節(jié)點(diǎn)我們?nèi)绻匀徊捎靡粋€(gè) range 去獲取位置信息,那么得到的就是下面這種情況:
沒(méi)錯(cuò),位置信息是錯(cuò)誤的,因?yàn)楹苊黠@ range 只能是一個(gè)矩形,并沒(méi)有辦法表示我們跨行選中時(shí)的不規(guī)則圖形的位置信息。
既然一個(gè) range 不行,那么多個(gè)呢?所以我們的解決思路就是將一個(gè)跨行的 range 拆分成多個(gè)不跨行的 range。
怎么拆呢?我使用的辦法是通過(guò)二分法的方式去找到每一行的最后一個(gè)文本節(jié)點(diǎn)去拆分,怎么判斷兩個(gè)字符是否在同一行采用的創(chuàng)建一個(gè)單位長(zhǎng)度的 range,比較 range 位置信息中的 top 是否相同來(lái)進(jìn)行判斷。
// 將一個(gè)跨行的 range 切割為多個(gè)不跨行的 range function splitRange(node: Text, startOffset: number, endOffset: number): Range[] { const range = document.createRange() const rowTop = getCharTop(node, startOffset) // 字符數(shù)小于兩個(gè)不用判斷是否跨行 // 頭尾高度一致說(shuō)明在同一行 if ((endOffset - startOffset < 2) || rowTop === getCharTop(node, endOffset - 1)) { range.setStart(node, startOffset) range.setEnd(node, endOffset) return [range] } else { const last = findRowLastChar(rowTop, node, startOffset, endOffset - 1) range.setStart(node, startOffset) range.setEnd(node, last + 1) const others = splitRange(node, last + 1, endOffset) return [range, ...others] } } // 二分法找到 range 某一行的最右字符 function findRowLastChar(top: number, node: Text, start: number, end: number): number { if (end - start === 1) { return getCharTop(node, end) === top ? end : start } const mid = (end + start) >> 1 return getCharTop(node, mid) === top ? findRowLastChar(top, node, mid, end) : findRowLastChar(top, node, start, mid) } // 獲取 range 某個(gè)字符位置的 top 值 function getCharTop(node: Text, offset: number) { return getCharRect(node, offset).top } // 獲取 range 某個(gè)字符位置的 DOMRect function getCharRect(node: Text, offset: number) { const range = document.createRange() range.setStart(node, offset) range.setEnd(node, offset + 1 > node.textContent!.length ? offset : offset + 1) return range.getBoundingClientRect() }
這樣位置信息的問(wèn)題我們就徹底解決了,接下來(lái)我們就可以使用這些信息去我們的 canvas 上渲染我們想要的高亮背景效果了。
5. 劃詞信息持久化與返顯
雖然我們實(shí)現(xiàn)了高亮的功能,但是設(shè)想如果我們做的是劃詞評(píng)論功能,那么肯定還涉及到將劃詞信息保存到后端,但是我們這一切的開(kāi)頭都是從系統(tǒng)提供的一個(gè) range 對(duì)象開(kāi)始的,但是 range 對(duì)象上的 startContainer 和 endContainer 是保存著 DOM 節(jié)點(diǎn)的引用,這肯定沒(méi)辦法序列化存儲(chǔ)到后端的,所以我們需要一種方式能讓我們準(zhǔn)確的找到我們想要的文本節(jié)點(diǎn)。
這里一開(kāi)始我是參考了語(yǔ)雀的實(shí)現(xiàn)方式,但是發(fā)現(xiàn)語(yǔ)雀中的每一個(gè)文本標(biāo)簽都有一個(gè)固定的 id,這樣他們實(shí)現(xiàn)起來(lái)就很簡(jiǎn)單了,只需要保存對(duì)應(yīng)的 id 就行,但是采用這種方式就需要你對(duì)頁(yè)面的每個(gè)文本標(biāo)簽都設(shè)置一個(gè)文本 id,這樣顯然與我們最初與頁(yè)面文本結(jié)構(gòu)解耦的想法不符了,所以這里我采用的是類(lèi)似 XPath 的方式進(jìn)行儲(chǔ)存,對(duì)于頭尾節(jié)點(diǎn),我們保存一個(gè)路徑數(shù)組,里面儲(chǔ)存的是從文本容器通過(guò) childNodes 屬性遍歷下去找到該節(jié)點(diǎn)的信息,這樣對(duì)于任何的頁(yè)面結(jié)構(gòu)我們都可以使用了。
// 獲取從文本容器到文本節(jié)點(diǎn)的路徑信息,用于存儲(chǔ) function getPath(textNode: Text) { const path = [0] let parentNode = textNode.parentNode let cur: Node = textNode while (parentNode) { if (cur === parentNode.firstChild) { // this.root 為文本容器 if (parentNode === this.root) { break } else { cur = parentNode parentNode = cur.parentNode path.unshift(0) } } else { cur = cur.previousSibling! path[0]++ } } return parentNode ? path : null } // 根據(jù)路徑信息獲取文本節(jié)點(diǎn),用于返顯 getNodeByPath(path: number[]) { // this.root 為文本容器 let node: Node = this.root 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 }
源碼地址
雖然是一個(gè)小小的功能,但是其實(shí)實(shí)現(xiàn)起來(lái)也是挺復(fù)雜的,所以我將這個(gè)功能封裝成了一個(gè)工具庫(kù):canvas-highlighter
里面也提供了使用這個(gè)庫(kù)的一些用法的在線演示,有不能實(shí)現(xiàn)的功能點(diǎn)大家也可以提 issue。
總結(jié)
到此這篇關(guān)于用純JS實(shí)現(xiàn)語(yǔ)雀的劃詞高亮功能的文章就介紹到這了,更多相關(guān)JS實(shí)現(xiàn)語(yǔ)雀劃詞高亮內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Typescript協(xié)變與逆變簡(jiǎn)單理解
深入學(xué)習(xí)TypeScript類(lèi)型系統(tǒng)的話,逆變、協(xié)變、雙向協(xié)變、不變是繞不過(guò)去的概念。這些概念看起來(lái)挺高大上的,其實(shí)并不復(fù)雜,這篇文章我們就來(lái)學(xué)習(xí)下協(xié)變和逆變吧2022-10-10JavaScript使用delete刪除數(shù)組元素用法示例【數(shù)組長(zhǎng)度不變】
這篇文章主要介紹了JavaScript使用delete刪除數(shù)組元素用法,結(jié)合實(shí)例形式分析了delete刪除數(shù)組元素的具體用法與注意事項(xiàng),需要的朋友可以參考下2017-01-01js中用事實(shí)證明cssText性能高的問(wèn)題
首先要感謝 EtherDream 的不同觀點(diǎn),在 巧用cssText屬性批量操作樣式 一篇中由于他的質(zhì)疑態(tài)度使我做了進(jìn)一步的測(cè)試。2011-03-03JavaScript實(shí)現(xiàn)網(wǎng)頁(yè)電子時(shí)鐘
這篇文章主要為大家詳細(xì)介紹了JavaScript實(shí)現(xiàn)網(wǎng)頁(yè)電子時(shí)鐘,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2022-06-06JavaScript用二分法查找數(shù)據(jù)的實(shí)例代碼
本篇文章主要介紹了JavaScript用二分法查找數(shù)據(jù)的實(shí)例代碼,小編覺(jué)得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧2017-06-06關(guān)于uniApp editor微信滑動(dòng)問(wèn)題
這篇文章主要介紹了關(guān)于uniApp editor微信滑動(dòng)問(wèn)題,本文給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2021-01-01