獲取選擇文本所在的句子

最近收到一個 issue 指望能在劃詞的時候同時保存單詞的上下文和來源網址。這個功能其實好久以前就想過,但感受很差實現一直拖延沒作。真作完發現其實並不複雜,完整代碼在這裏,或者繼續往下閱讀分析。javascript

原理分析

獲取選擇文本

經過 window.getSelection() 便可得到一個 Selection 對象,再利用 .toString() 便可得到選擇的文本。html

錨節點與焦節點

Selection 對象中還保存了兩個重要信息,anchorNodefocusNode,分別表明選擇產生那一刻的節點和選擇結束時的節點,而 anchorOffsetfocusOffset 則保存了選擇在這兩個節點裏的偏移值。java

這時你可能立刻就想到第一個方案:這不就好辦了麼,有了首尾節點和偏移,就能夠獲取句子的頭部和尾部,再把選擇文本做爲中間,整個句子不就出來了麼。node

固然不會這麼簡單哈git

強調一下

通常狀況下,anchorNodefocusNode 都是 Text 節點(並且由於這裏處理的是文本,因此其它狀況也會直接忽略),能夠考慮這種狀況:github

<strong>Saladict</strong> is awesome!

若是選擇的是「awesome」,那麼 anchorNodefocusNode 都是 is awesome!,因此取不到前面的 「Saladict」。ruby

另外還有嵌套的狀況,也是一樣的問題。spa

Saladict is <strong><a href="#">awesome</a></strong>!

因此咱們還須要遍歷兄弟和父節點來獲取完整的句子。code

遍歷到哪?

因而接下就是解決遍歷邊界的問題了。遍歷到什麼地方爲止呢?個人判斷標準是:跳過 inline-level 元素,遇到 block-level 元素爲止。而判斷一個元素是 inline-level 仍是 block-level 最準確的方式應該是用 window.getComputedStyle()。但我認爲這麼作過重了,也不須要嚴格的準確性,因此用了常見的 inline 標籤來判斷。htm

const INLINE_TAGS = new Set([
  // Inline text semantics
  'a', 'abbr', 'b', 'bdi', 'bdo', 'br', 'cite', 'code', 'data', 'dfn', 'em', 'i',
  'kbd', 'mark', 'q', 'rp', 'rt', 'rtc', 'ruby', 's', 'samp', 'small',
  'span', 'strong', 'sub', 'sup', 'time', 'u', 'var', 'wbr'
])

原理總結

句子由三塊組成,選擇文本做爲中間,而後遍歷兄弟和父節點獲取首尾補上。

實現

選擇文本

先獲取文本,若是沒有則退出

const selection = window.getSelection()
const selectedText = selection.toString()
if (!selectedText.trim()) { return '' }

獲取首部

對於 anchorNode 只考慮 Text 節點,經過 anchorOffset 獲取選擇在 anchorNode 的前半段內容。

而後開始補全在 anchorNode 以前的兄弟節點,最後補全在 anchorNode 父元素以前的兄弟元素。注意後面是元素,這樣能夠減小遍歷的次數,並且考慮到一些被隱藏的內容不須要獲取,用 innerText 而不是 textContent 屬性。

let sentenceHead = ''
const anchorNode = selection.anchorNode
if (anchorNode.nodeType === Node.TEXT_NODE) {
  let leadingText = anchorNode.textContent.slice(0, selection.anchorOffset)
  for (let node = anchorNode.previousSibling; node; node = node.previousSibling) {
    if (node.nodeType === Node.TEXT_NODE) {
      leadingText = node.textContent + leadingText
    } else if (node.nodeType === Node.ELEMENT_NODE) {
      leadingText = node.innerText + leadingText
    }
  }

  for (
    let element = anchorNode.parentElement;
    element && INLINE_TAGS.has(element.tagName.toLowerCase()) && element !== document.body;
    element = element.parentElement
  ) {
    for (let el = element.previousElementSibling; el; el = el.previousElementSibling) {
      leadingText = el.innerText + leadingText
    }
  }

  sentenceHead = (leadingText.match(sentenceHeadTester) || [''])[0]
}

最後從提取句子首部用的正則是這個

// match head                 a.b is ok    chars that ends a sentence
const sentenceHeadTester = /((\.(?![ .]))|[^.?!。?!…\r\n])+$/

前面的 ((\.(?![ .])) 主要是爲了跳過 a.b 這樣的特別是在技術文章中常見的寫法。

獲取尾部

跟首部同理,換成日後遍歷。最後的正則保留了標點符號

// match tail                                                    for "..."
const sentenceTailTester = /^((\.(?![ .]))|[^.?!。?!…\r\n])+(.)\3{0,2}/

壓縮換行

拼湊完句子以後壓縮多個換行爲一個空白行,以及刪除每行開頭結尾的空白符

return (sentenceHead + selectedText + sentenceTail)
  .replace(/(^\s+)|(\s+$)/gm, '\n') // allow one empty line & trim each line
  .replace(/(^\s+)|(\s+$)/g, '') // remove heading or tailing \n

完整代碼

const INLINE_TAGS = new Set([
  // Inline text semantics
  'a', 'abbr', 'b', 'bdi', 'bdo', 'br', 'cite', 'code', 'data', 'dfn', 'em', 'i',
  'kbd', 'mark', 'q', 'rp', 'rt', 'rtc', 'ruby', 's', 'samp', 'small',
  'span', 'strong', 'sub', 'sup', 'time', 'u', 'var', 'wbr'
])

/**
* @returns {string}
*/
export function getSelectionSentence () {
  const selection = window.getSelection()
  const selectedText = selection.toString()
  if (!selectedText.trim()) { return '' }

  var sentenceHead = ''
  var sentenceTail = ''

  const anchorNode = selection.anchorNode
  if (anchorNode.nodeType === Node.TEXT_NODE) {
    let leadingText = anchorNode.textContent.slice(0, selection.anchorOffset)
    for (let node = anchorNode.previousSibling; node; node = node.previousSibling) {
      if (node.nodeType === Node.TEXT_NODE) {
        leadingText = node.textContent + leadingText
      } else if (node.nodeType === Node.ELEMENT_NODE) {
        leadingText = node.innerText + leadingText
      }
    }

    for (
      let element = anchorNode.parentElement;
      element && INLINE_TAGS.has(element.tagName.toLowerCase()) && element !== document.body;
      element = element.parentElement
    ) {
      for (let el = element.previousElementSibling; el; el = el.previousElementSibling) {
        leadingText = el.innerText + leadingText
      }
    }

    sentenceHead = (leadingText.match(sentenceHeadTester) || [''])[0]
  }

  const focusNode = selection.focusNode
  if (selection.focusNode.nodeType === Node.TEXT_NODE) {
    let tailingText = selection.focusNode.textContent.slice(selection.focusOffset)
    for (let node = focusNode.nextSibling; node; node = node.nextSibling) {
      if (node.nodeType === Node.TEXT_NODE) {
        tailingText += node.textContent
      } else if (node.nodeType === Node.ELEMENT_NODE) {
        tailingText += node.innerText
      }
    }

    for (
      let element = focusNode.parentElement;
      element && INLINE_TAGS.has(element.tagName.toLowerCase()) && element !== document.body;
      element = element.parentElement
    ) {
      for (let el = element.nextElementSibling; el; el = el.nextElementSibling) {
        tailingText += el.innerText
      }
    }

    sentenceTail = (tailingText.match(sentenceTailTester) || [''])[0]
  }

  return (sentenceHead + selectedText + sentenceTail)
    .replace(/(^\s+)|(\s+$)/gm, '\n') // allow one empty line & trim each line
    .replace(/(^\s+)|(\s+$)/g, '') // remove heading or tailing \n
}

【完】

相關文章
相關標籤/搜索