選中滑鼠附近的文字

CRIMX發表於2019-02-16

最近終於抽空給 Saladict 實現了滑鼠懸浮取詞功能,使用了較為簡潔的實現方式,這裡分享一下原理以及坑的處理。

初嘗試

這個需求其實很早就被人提 issue 了,當時做了一番搜尋,最後嘗試了 document.caretPositionFromPoint / document.caretRangeFromPoint ,效果不太理想。

如果看 mdn 給的例子,就會發現,它是遍歷每個元素新增事件的。這麼做的原因是當使用這個方法的時候,如果滑鼠指向元素空白的地方,它會就近取位置。所以例子通過給粒度更細的元素繫結來避免這個問題。然而實際上這麼做還是不足夠的,一個段落末行也許只有幾個字元,這時空出接近一行,也會有上面的問題。

所以當時就擱置了這個功能。

靈感

直到最近,看到一個同類的開源劃詞翻譯擴充套件 FairyDict 實現了取詞功能,遍觀摩了一番原始碼

它的原理是深度優先遞迴遍歷這個元素以及其子元素,通過不斷試探選中區域,並與滑鼠座標對比來定位確切位置。

有沒有發現問題,這個遍歷過程不正是上面 document.caretPositionFromPoint 乾的事麼,那麼我們只需要最後量一下滑鼠是否在取詞範圍中即可。

原理

現在總結一下原理:

  1. 通過 document.caretPositionFromPoint 獲得滑鼠所指最接近的元素以及文字位置 offset。
  2. 找出 offset 最接近的單詞。
  3. 通過 Range 獲得部分文字(單詞)的尺寸和座標。
  4. 驗證滑鼠此時在單詞區域範圍中。
  5. 選中這個單詞。Selection 支援直接新增 Range

實現

按原理來實現就很簡單了。本文上按 alt 可體驗取詞效果。

/**
 * @param {MouseEvent} e
 * @returns {void}
 */
function selectCursorWord (e) {
  const x = e.clientX
  const y = e.clientY

  let offsetNode
  let offset

  const sel = window.getSelection()
  sel.removeAllRanges()

  if (document[`caretPositionFromPoint`]) {
    const pos = document[`caretPositionFromPoint`](x, y)
    if (!pos) { return }
    offsetNode = pos.offsetNode
    offset = pos.offset
  } else if (document[`caretRangeFromPoint`]) {
    const pos = document[`caretRangeFromPoint`](x, y)
    if (!pos) { return }
    offsetNode = pos.startContainer
    offset = pos.startOffset
  } else {
    return
  }

  if (offsetNode.nodeType === Node.TEXT_NODE) {
    const textNode = offsetNode
    const content = textNode.data
    const head = (content.slice(0, offset).match(/[-_a-z]+$/i) || [``])[0]
    const tail = (content.slice(offset).match(/^([-_a-z]+|[u4e00-u9fa5])/i) || [``])[0]
    if (head.length <= 0 && tail.length <= 0) {
      return
    }

    const range = document.createRange()
    range.setStart(textNode, offset - head.length)
    range.setEnd(textNode, offset + tail.length)
    const rangeRect = range.getBoundingClientRect()

    if (rangeRect.left <= x &&
        rangeRect.right >= x &&
        rangeRect.top <= y &&
        rangeRect.bottom >= y
    ) {
      sel.addRange(range)
    }

    range.detach()
  }
}

互動

最後,如果要提供功能開關或者設定不同按鍵的話,簡單的處理可以參考 FairyDict 讓事件處理空轉。但對於 mousemove 這類比較頻繁的事件,在關閉的時候取消事件監聽可能更好一些。在 Saladict 中甚至將“皮膚被釘住”跟“普通情況”分開為不同的模式,這裡藉助 RxJS 來處理複雜的邏輯,可參考原始碼

相關文章