天天看點

手把手教你實作web文本劃線的功能

手把手教你實作web文本劃線的功能

開篇

文本劃線是目前逐漸流行的一個功能,不管你是小說閱讀網站,還是賣教程的的網站,一般都會有記筆記或者評論的功能,傳統的做法都是在文章底部加一個評論區,優點是簡單,統一。

缺點是不友善對文章的某一段或一句話進行針對性的評論,是以出現了劃線及評論的需求,目前我見到的産品有劃線功能的有:微信閱讀APP、極客時間:

手把手教你實作web文本劃線的功能

InfoQ寫作平台:

手把手教你實作web文本劃線的功能

等等,這個功能看似簡單,實際上難點還是很多的,比如如何高性能的對各種複雜的文本結構劃線、如何盡可能少的存儲資料、如何精準的回顯劃線、如何處理重複劃線、如何應對文本後續編輯的情況等等。

作為一個前端搬磚工,每當看到一個有意思的小功能時我都想自己去把它做出來,但是看了僅有的幾篇相關文章之後,發現,不會😓,這些文章介紹的都隻是一個大概思路,看完讓人感覺好像會了,但是細想就會發現很多問題,隻能去看源碼,看源碼總是費時的,還不一定能看懂。

想要實作一個生産可用的難度還是很大的,是以本文退而求其次,單純的寫一個demo開心開心。

demo效果請點選:http://lxqnsys.com/#/demo/textUnderline。

總體思路

總體思路很簡單,周遊選區内的所有文本,切割成單個字元,給每個字元都包裹上劃線元素,重複劃線的話就在最深層繼續包裹,事件處理的話從最深的元素開始。

存儲的方式是記錄該劃線文本外層第一個非劃線元素的标簽名和索引,以及字元在其内所有字元裡總的偏移量。

回顯的方式是擷取到上述存儲資料對應的元素,然後周遊該元素的字元添加劃線元素。

實作

HTML結構

<div class="article" ref="article"></div>      

文本内容就放在上述的div裡,我從掘金小冊裡随便挑選了一篇文章,把它的html結構原封不動的複制粘貼進去:

手把手教你實作web文本劃線的功能

顯示tooltip

首先要做的是在選區上顯示一個劃線按鈕,這個很簡單,我們監聽一下mouseup事件,然後擷取一下選區對象,調用它的getBoundingClientRect方法擷取位置資訊,然後設定到我們的tooltip元素上:

document.addEventListener('mouseup', this.onMouseup)


onMouseup () {
    // 擷取Selection對象,裡面可能包含多個`ranges`(區域)
    let selObj = window.getSelection()
    // 一般就隻有一個Range對象
    let range = selObj.getRangeAt(0)
    // 如果選區起始位置和結束位置相同,那代表沒有選到任何東西
    if (range.collapsed) {
        return
    }
    this.range = range.cloneRange()
    this.tipText = '劃線'
    this.setTip(range)
}


setTip (range) {
    let { left, top, width } = range.getBoundingClientRect()
    this.tipLeft = left + (width - 80) / 2
    this.tipTop = top - 40
    this.showTip = true
}      
手把手教你實作web文本劃線的功能

劃線

給tooltip綁定一下點選事件,點選後需要擷取到選區内的所有文本節點,先看一下Range對象的結構:

手把手教你實作web文本劃線的功能

簡單介紹一下:

collapsed屬性表示開始和結束的位置是否相同;

commonAncestorContainer屬性傳回包含startContainer和endContainer的公共父節點;

endContainer屬性傳回包含range終點的節點,通常是文本節點;

endOffset傳回range終點在endContainer内的位置的數字;

startContainer屬性傳回包含range起點的節點,通常是文本節點;

startContainer傳回range起點在startContainer内的位置的數字;

是以目标是要周遊startContainer和endContainer兩個節點之間的所有節點來收集文本節點,受限于筆者匮乏的算法和資料結構知識,隻能選擇一個投機取巧的方法,周遊commonAncestorContainer節點。

然後使用range對象的isPointInRange()方法來檢測目前周遊的節點是否在選區範圍内,這個方法需要注意的兩個點地方,一個是isPointInRange()方法目前不支援IE,二是首尾節點需要單獨處理,因為首尾節點可能部分在選區内,這樣這個方法是傳回false的。

mark () 
  this.textNodes = []
  let { commonAncestorContainer, startContainer, endContainer } = this.range
  this.walk(commonAncestorContainer, (node) => {
    if (
      node === startContainer ||
      node === endContainer ||
      this.range.isPointInRange(node, 0)
    ) {// 起始和結束節點,或者在範圍内的節點,如果是文本節點則收集起來
      if (node.nodeType === 3) {
        this.textNodes.push(node)
      }
    }
  })
  this.handleTextNodes()
  this.showTip = false
  this.tipText = ''
}      

walk是一個深度優先周遊的函數:

walk (node, callback = () => {}) {
    callback(node)
    if (node && node.childNodes) {
        for (let i = 0; i < node.childNodes.length; i++) {
            this.walk(node.childNodes[i], callback)
        }
    }
}      

擷取到選區範圍内的所有文本節點後就可以切割字元進行元素替換:

handleTextNodes () {
    // 生成本次的唯一id
    let id = ++this.idx
    // 周遊文本節點
    this.textNodes.forEach((node) => {
        // 範圍的首尾元素需要判斷一下偏移量,用來截取字元
        let startOffset = 0
        let endOffset = node.nodeValue.length
        if (
            node === this.range.startContainer &&
            this.range.startOffset !== 0
        ) {
            startOffset = this.range.startOffset
        }
        if (node === this.range.endContainer && this.range.endOffset !== 0) {
            endOffset = this.range.endOffset
        }
        // 替換該文本節點
        this.replaceTextNode(node, id, startOffset, endOffset)
    })
    // 序列化進行存儲,擷取剛剛生成的所有該id的劃線元素
    this.serialize(this.$refs.article.querySelectorAll('.mark_id_' + id))
}      

如果是首節點,且startOffset不為0,那麼startOffset之前的字元不需要添加劃線包裹元素,如果是尾節點,且endOffset不為0,那麼endOffset之後的字元不需要劃線,中間的其他所有文本都需要進行切割及劃線:

replaceTextNode (node, id, startOffset, endOffset) {
    // 建立一個文檔片段用來替換文本節點
    let fragment = document.createDocumentFragment()
    let startNode = null
    let endNode = null
    // 截取前一段不需要劃線的文本
    if (startOffset !== 0) {
        startNode = document.createTextNode(
            node.nodeValue.slice(0, startOffset)
        )
    }
    // 截取後一段不需要劃線的文本
    if (endOffset !== 0) {
        endNode = document.createTextNode(node.nodeValue.slice(endOffset))
    }
    startNode && fragment.appendChild(startNode)
    // 切割中間的所有文本
    node.nodeValue
        .slice(startOffset, endOffset)
        .split('')
        .forEach((text) => {
        // 建立一個span标簽用來作為劃線包裹元素
        let textNode = document.createElement('span')
        textNode.className = 'markLine mark_id_' + id
        textNode.setAttribute('data-id', id)
        textNode.textContent = text
        fragment.appendChild(textNode)
    })
    endNode && fragment.appendChild(endNode)
    // 替換文本節點
    node.parentNode.replaceChild(fragment, node)
}      

效果如下:

手把手教你實作web文本劃線的功能

此時html結構:

手把手教你實作web文本劃線的功能

序列化存儲

一次性的劃線是沒啥用的,那還不如在文章上面蓋一個canvas元素,給使用者一個自由畫布,是以還需要進行儲存,下次打開還能重新顯示之前畫的線。

存儲的關鍵是要能讓下次還能定位回去,參考其他文章介紹的方法,本文選擇的是存儲劃線元素外層的第一個非劃線元素的标簽名,以及在指定節點範圍内的同類型元素裡的索引,以及該字元在該非劃線元素裡的總的字元偏移量。

描述起來可能有點繞,看代碼:

serialize (markNodes) {
    // 選擇article元素作為根元素,這樣的好處是頁面的其他結構如果改變了不影響劃線元素的定位
    let root = this.$refs.article
    // 周遊剛剛生成的本次劃線的所有span節點
    markNodes.forEach((markNode) => {
        // 計算該字元離外層第一個非劃線元素的總的文本偏移量
        let offset = this.getTextOffset(markNode)
        // 找到外層第一個非劃線元素
        let { tagName, index } = this.getWrapNode(markNode, root)
        // 儲存相關資料
        this.serializeData.push({
          tagName,
          index,
          offset,
          id: markNode.getAttribute('data-id')
        })
    })
}      

計算字元離外層第一個非劃線元素的總的文本偏移量的思路是先算擷取同級下之前的兄弟元素的總字元數,再依次向上周遊父元素及其之前的兄弟節點的總字元數,直到外層元素:

手把手教你實作web文本劃線的功能
getTextOffset (node) {
    let offset = 0
    let parNode = node
    // 周遊直到外層第一個非劃線元素
    while (parNode && parNode.classList.contains('markLine')) {
        // 擷取前面的兄弟元素的總字元數
        offset += this.getPrevSiblingOffset(parNode)
        parNode = parNode.parentNode
    }
    return offset
}      

擷取前面的兄弟元素的總字元數:

getPrevSiblingOffset (node) {
    let offset = 0
    let prevNode = node.previousSibling
    while (prevNode) {
        offset +=
            prevNode.nodeType === 3
            ? prevNode.nodeValue.length
        : prevNode.textContent.length
        prevNode = prevNode.previousSibling
    }
    return offset
}      

擷取外層第一個非劃線元素在上面擷取字元數的方法裡其實已經有了:

getWrapNode (node, root) {
    // 找到外層第一個非劃線元素
    let wrapNode = node.parentNode
    while (wrapNode.classList.contains('markLine')) {
        wrapNode = wrapNode.parentNode
    }
    let wrapNodeTagName = wrapNode.tagName
    // 計算索引
    let wrapNodeIndex = -1
    // 使用标簽選擇器擷取所有該标簽元素
    let els = root.getElementsByTagName(wrapNodeTagName)
    els = [...els].filter((item) => {// 過濾掉劃線元素
      return !item.classList.contains('markLine');
    }).forEach((item, index) => {// 計算目前元素在其中的索引
      if (wrapNode === item) {
        wrapNodeIndex = index
      }
    })
    return {
        tagName: wrapNodeTagName,
        index: wrapNodeIndex
    }
}      

最後存儲的資料示例如下:

手把手教你實作web文本劃線的功能

反序列化顯示

顯示就是根據上面存儲的資料把線畫上,周遊上面的資料,先根據tagName和index擷取到指定元素,然後周遊該元素下的所有文本節點,根據offset找到需要劃線的字元:

deserialization () {
    let root = this.$refs.article
    // 周遊序列化的資料
    markData.forEach((item) => {
        // 擷取到指定元素
        let els = root.getElementsByTagName(item.tagName)
        els = [...els].filter((item) => {// 過濾掉劃線元素
          return !item.classList.contains('markLine');
        })
        let wrapNode = els[item.index]
        let len = 0
        let end = false
        // 周遊該元素所有節點
        this.walk(wrapNode, (node) => {
            if (end) {
                return
            }
            // 如果是文本節點
            if (node.nodeType === 3) {
                // 如果目前文本節點的字元數+之前的總數大于offset,說明要找的字元就在該文本内
                if (len + node.nodeValue.length > item.offset) {
                    // 計算在該文本裡的偏移量
                    let startOffset = item.offset - len
                    // 因為我們是切割到單個字元,是以總長度也就是1
                    let endOffset = startOffset + 1
                    this.replaceTextNode(node, item.id, startOffset, endOffset)
                    end = true
                }
                // 累加字元數
                len += node.nodeValue.length
            }
        })
    })
}      

結果如下:

手把手教你實作web文本劃線的功能

删除劃線

删除劃線很簡單,我們監聽一下點選事件,如果目标元素是劃線元素,那麼擷取一下所有該id的劃線元素,建立一個range,顯示一下tooltip,然後點選後把該劃線元素删除即可。

// 顯示取消劃線的tooltip
showCancelTip (e) {
    let tar = e.target
    if (tar.classList.contains('markLine')) {
        e.stopPropagation()
        e.preventDefault()
        // 擷取劃線id
        this.clickId = tar.getAttribute('data-id')
        // 擷取該id的所有劃線元素
        let markNodes = document.querySelectorAll('.mark_id_' + this.clickId)
        // 選擇第一個和最後一個文本節點來作為range邊界
        let startContainer = markNodes[0].firstChild
        let endContainer = markNodes[markNodes.length - 1].lastChild
        this.range = document.createRange()
        this.range.setStart(startContainer, 0)
        this.range.setEnd(
          endContainer,
          endContainer.nodeValue.length
        )
        this.tipText = '取消劃線'
        this.setTip(this.range)
    }
}      

點選了取消按鈕後周遊該id的所有劃線節點,進行元素替換:

cancelMark () {
    this.showTip = false
    this.tipText = ''
    let markNodes = document.querySelectorAll('.mark_id_' + this.clickId)
    // 周遊所有劃線街道
    for (let i = 0; i < markNodes.length; i++) {
        let item = markNodes[i]
        // 如果還有子節點,也就是其他id的劃線元素
        if (item.children[0]) {
            let node = item.children[0].cloneNode(true)
            // 子節點替換目前節點
            item.parentNode.replaceChild(node, item)
        } else {// 否則隻有文本的話直接建立一個文本節點來替換
            let textNode = document.createTextNode(item.textContent)
            item.parentNode.replaceChild(textNode, item)
        }
    }
    // 從序列化資料裡删除該id的資料
    this.serializeData = this.serializeData.filter((item) => {
        return item.id !== this.clickId
    })
}      
手把手教你實作web文本劃線的功能

缺點

到這裡這個極簡劃線就結束了,現在來看一下這個極簡的方法有什麼缺點.

首先毋庸置疑的就是如果劃線字元很多,重複劃線很多次,那麼會生成非常多的span标簽及嵌套層次,節點數量是影響頁面性能的一個大問題。

第二個問題是需要存儲的資料也會很大,增加存儲成本和網絡傳輸時間:

手把手教你實作web文本劃線的功能

這可以通過把字段名字壓縮一下,改成一個字母,另外可以把連續的字元合并一下來稍微優化一下,但是然并卵。

第三個問題是如其名,文本劃線,真的是隻能給文本進行劃線,其他的圖檔上面的就不行了:

手把手教你實作web文本劃線的功能

第四個問題是無法應對如果劃線後文章被修改了,html結構變化了的問題。

這幾個問題個個紮心,導緻它隻能是個demo。

稍微優化一下

很容易想到的一個優化方法是不要把字元單個切割,整塊包裹不就好了嗎,道理是這個道理:

replaceTextNode (node, id, startOffset, endOffset) {
    // ...
    startNode && fragment.appendChild(startNode)


    // 改成直接包裹整塊文本
    let textNode = document.createElement('span')
    textNode.className = 'markLine mark_id_' + id
    textNode.setAttribute('data-id', id)
    textNode.textContent = node.nodeValue.slice(startOffset, endOffset)
    fragment.appendChild(textNode)


    endNode && fragment.appendChild(endNode)
    // ...
}      

這樣序列化時需要增加一個長度的字段:

let textLength = markNode.textContent.length
if (textLength > 0) {// 過濾掉長度為0的空字元,否則會有不可預知的問題
  this.serializeData.push({
      tagName,
      index,
      offset,
      length: textLength,// ++
      id: markNode.getAttribute('data-id')
  })
}      

這樣序列化後的資料量會大大減少:

手把手教你實作web文本劃線的功能

接下來反序列化也需要修改,字元長度不定的話就可能跨文本節點了:

deserialization () {
    let root = this.$refs.article
    markData.forEach((item) => {
        let wrapNode = root.getElementsByTagName(item.tagName)[item.index]
        let len = 0
        let end = false
        let first = true
        let _length = item.length
        this.walk(wrapNode, (node) => {
            if (end) {
                return
            }
            if (node.nodeType === 3) {
                let nodeTextLength = node.nodeValue.length
                if (len + nodeTextLength > _offset) {
                    // startOffset之前的文本不需要劃線
                    let startOffset = (first ? item.offset - len : 0)
                    first = false
                    // 如果該文本節點剩餘的字元數量小于劃線文本的字元長度的話代表該文本節點還隻是劃線文本的一部分,還需要到下一個文本節點裡去處理
                    let endOffset = startOffset + (nodeTextLength - startOffset >= _length ? _length : nodeTextLength - startOffset)
                    this.replaceTextNode(node, item.id, startOffset, endOffset)
                    // 長度需要減去之前節點已經處理掉的長度
                    _length = _length - (nodeTextLength - startOffset)
                    // 如果剩餘要處理的劃線文本的字元數量為0代表已經處理完了,可以結束了
                    if (_length <= 0) {
                      end = true
                    }
                  }
                len += nodeTextLength
            }
        })
    })
}      

最後取消劃線也需要修改,因為子節點可能就不是隻有單純的一個劃線節點或文本節點了,需要周遊全部子節點:

cancelMark () {
    this.showTip = false
    this.tipText = ''
    let markNodes = document.querySelectorAll('.mark_id_' + this.clickId)
    for (let i = 0; i < markNodes.length; i++) {
        let item = markNodes[i]
        let fregment = document.createDocumentFragment()
        for (let j = 0; j < item.childNodes.length; j++) {
            fregment.appendChild(item.childNodes[j].cloneNode(true))
        }
        item.parentNode.replaceChild(fregment, item)
    }
    this.serializeData = this.serializeData.filter((item) => {
        return item.id !== this.clickId
    })
}      

現在再來看一下效果:

手把手教你實作web文本劃線的功能

html結構:

手把手教你實作web文本劃線的功能

可以看到無論是序列化的資料還是DOM結構都已經簡潔了很多。

但是,如果文檔結構很複雜或者多次重複劃線最終産生的節點和資料還是比較大的。

總結

本文介紹了一個實作web文本劃線功能的極簡實作,最初的想法是通過切割成單個字元來進行包裹,這樣的優點是十分簡單,缺點也很明顯,産生的序列号資料很大、修改的DOM結構很複雜,在文章及demo的寫作過程中經過實踐,發現直接包裹整塊文字也并不會帶來太多問題,但是卻能減少和優化很多要存儲的資料和DOM結構,是以很多時候,想當然是不對的,最後想說,資料結構和算法真的很重要😭。

示例代碼在:https://github.com/wanglin2/textUnderline。

學習更多技能

請點選下方公衆号