純 JS 實現語雀的劃詞高亮功能

前言

前段時間公司需要實現一個劃詞評論的功能,但是到網上找了一圈發現劃詞評論的庫並不多,而且大部分的實現都是需要破壞頁面 DOM 結構的,也就是需要在頁面 DOM 結構中拆分文本包裹一個 mask 標籤,但是由於我們做的是在線富文本文檔功能,文本的內容是可以再編輯的,如果評論破壞了 DOM 結構這樣對我們編輯的時候編輯器解析就不是很友好。找到最後發現語雀實現的劃詞評論功能是基於 canvas 實現的,與頁面結構完全解耦,但是由於語雀沒有開源,所以也沒辦法參考他們的代碼,只能順着他們的思路自己寫。

實現效果

話不多說,先看看最終實現的效果:

當然這個只是實現了核心功能的 demo,更多的交互和 UI 細節也可以基於這個功能進行實現。

實現思路

主要思路:生成一個 canvas 元素,讓 canvas 元素與需要劃詞高亮功能的文本容器元素等寬高,並且重疊在文本容器上,劃詞的時候獲取劃詞區域的文本節點相對於文本容器的位置信息,然後通過這些位置信息進行高亮背景的渲染。

雖然思路看起來很簡單,但是具體實現的過程還是有許多注意點的,接下來我就總結一下一些實現過程中的注意點和細節。

實現細節

1. 讓 canvas 與文本容器元素重疊

讓 canvas 與文本容器元素重疊最好的實現方式就是將 canvas 做爲文本容器的直接子節點,然後設置文本容易爲相對定位,將 canvas 設置爲絕對定位,然後將 top、left、right、bottom 都設置爲 0,這樣就可以時刻保證 canvas 元素與文本容器元素始終等寬高,且 canvas 重疊在文本容器上。不過這種實現方式也有一個問題,我們把 canvas 的層級提高了,蓋住了文本容器中的其他文本節點,這樣就沒辦法進行劃詞了,所以這時候我們需要給 canvas 再添加一個 css 屬性:pointerEvents: 'none',這樣就可以讓 canvas 不響應鼠標事件,從而讓底部文本節點可以正常劃詞了。

2. 獲取劃詞區域文本節點的位置信息

獲取劃詞區域信息需要使用 document.getSelection().getRangeAt(0) 來獲得當前劃詞區域的 range 對象,在這個對象上可以獲取到劃詞區域的起始和終止文本節點以及偏移量信息。

const {
  startContainer, // 起始節點
  startOffset, // 起始節點偏移量
  endContainer, // 終止節點
  endOffset // 終止節點偏移量
} = document.getSelection().getRangeAt(0)

雖然我們拿到了節點信息,但是怎麼獲得具體的位置信息呢?這時候就需要藉助 Range 對象的強大功能了。

// 創建一個 range 對象
const range = document.createRange()
// 設置需要獲取位置信息的文本節點以及偏移量
range.setStart(startContainer, startOffset)
range.setEnd(startContainer, startContainer.textContent.length)
// 通過 getBoundingClientRect 獲取位置信息
const rect = range.getBoundingClientRect()

通過創建 range 對象我們可以獲得任何一個文本節點中的任何一段文本相對與整個頁面的位置信息,然後再通過減去文本容器元素相對於整個頁面的位置信息,我們就可以得到劃詞區域文本相對與文本容器的位置信息了。

3. 獲取頭尾中間的文本節點

雖然我們通過 document.getSelection().getRangeAt(0) 獲得了劃詞頭尾節點的信息,但是頭尾中間如果有其他的文本節點我們也需要進行背景高亮,那麼中間的文本節點我們該怎麼獲得呢?這裏我想到的辦法是從頭節點開始進行深度優先遍歷,遍歷到尾節點爲止,然後收集遍歷過程中的所有文本節點,這樣我們就得到了整個劃詞區域內的所有文本節點,然後通過上面第 2 點的辦法我們也可以得到所有文本節點的位置信息。

// 獲取 start 到 end 深度優先遍歷之間的所有 Text Node 節點
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
}

// 返回節點的深度優先迭代器
// 對於有子節點的 Node 會遍歷到兩次,不過 Text Node 肯定沒有子節點,所以不會重複統計到
function * nodeDfsGenerator(node: Node, isGoBack = false): Generator<Node, void, Node> {
  yield node
  // isGoBack 用於判斷是否屬於子節點遍歷結束回退到父節點,如果是那麼該節點不再遍歷其子節點
  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. 處理跨行文本節點的位置信息

其實我們之前第 2 點獲取劃詞區域文本節點的位置信息的方案還有缺陷,對於跨行的文本節點我們如果仍然採用一個 range 去獲取位置信息,那麼得到的就是下面這種情況:

沒錯,位置信息是錯誤的,因爲很明顯 range 只能是一個矩形,並沒有辦法表示我們跨行選中時的不規則圖形的位置信息。

既然一個 range 不行,那麼多個呢?所以我們的解決思路就是將一個跨行的 range 拆分成多個不跨行的 range。

怎麼拆呢?我使用的辦法是通過二分法的方式去找到每一行的最後一個文本節點去拆分,怎麼判斷兩個字符是否在同一行採用的創建一個單位長度的 range,比較 range 位置信息中的 top 是否相同來進行判斷。

// 將一個跨行的 range 切割爲多個不跨行的 range
function splitRange(node: Text, startOffset: number, endOffset: number): Range[] {
  const range = document.createRange()
  const rowTop = getCharTop(node, startOffset)
  // 字符數小於兩個不用判斷是否跨行
  // 頭尾高度一致說明在同一行
  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 某個字符位置的 top 值
function getCharTop(node: Text, offset: number) {
  return getCharRect(node, offset).top
}

// 獲取 range 某個字符位置的 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()
}

這樣位置信息的問題我們就徹底解決了,接下來我們就可以使用這些信息去我們的 canvas 上渲染我們想要的高亮背景效果了。

5. 劃詞信息持久化與返顯

雖然我們實現了高亮的功能,但是設想如果我們做的是劃詞評論功能,那麼肯定還涉及到將劃詞信息保存到後端,但是我們這一切的開頭都是從系統提供的一個 range 對象開始的,但是 range 對象上的 startContainer 和 endContainer 是保存着 DOM 節點的引用,這肯定沒辦法序列化存儲到後端的,所以我們需要一種方式能讓我們準確的找到我們想要的文本節點。

這裏一開始我是參考了語雀的實現方式,但是發現語雀中的每一個文本標籤都有一個固定的 id,這樣他們實現起來就很簡單了,只需要保存對應的 id 就行,但是採用這種方式就需要你對頁面的每個文本標籤都設置一個文本 id,這樣顯然與我們最初與頁面文本結構解耦的想法不符了,所以這裏我採用的是類似 XPath 的方式進行儲存,對於頭尾節點,我們保存一個路徑數組,裏面儲存的是從文本容器通過 childNodes 屬性遍歷下去找到該節點的信息,這樣對於任何的頁面結構我們都可以使用了。

// 獲取從文本容器到文本節點的路徑信息,用於存儲
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
}

// 根據路徑信息獲取文本節點,用於返顯
function 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
}

源碼地址

雖然是一個小小的功能,但是其實實現起來也是挺複雜的,所以我將這個功能封裝成了一個工具庫:canvas-highlighter[1]

裏面也提供了使用這個庫的一些用法的在線演示 [2],有不能實現的功能點大家也可以提 issue。

關於本文

作者:DLillard0

https://juejin.cn/post/7140078451205079054

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/F2OVWaMLFg8EofNr3M-8sg