最近收到一個 issue 指望能在劃詞的時候同時保存單詞的上下文和來源網址。這個功能其實好久以前就想過,但感受很差實現一直拖延沒作。真作完發現其實並不複雜,完整代碼在這裏,或者繼續往下閱讀分析。javascript
經過 window.getSelection()
便可得到一個 Selection
對象,再利用 .toString()
便可得到選擇的文本。html
在 Selection
對象中還保存了兩個重要信息,anchorNode
和 focusNode
,分別表明選擇產生那一刻的節點和選擇結束時的節點,而 anchorOffset
和 focusOffset
則保存了選擇在這兩個節點裏的偏移值。java
這時你可能立刻就想到第一個方案:這不就好辦了麼,有了首尾節點和偏移,就能夠獲取句子的頭部和尾部,再把選擇文本做爲中間,整個句子不就出來了麼。node
固然不會這麼簡單哈。git
通常狀況下,anchorNode
和 focusNode
都是 Text
節點(並且由於這裏處理的是文本,因此其它狀況也會直接忽略),能夠考慮這種狀況:github
<strong>Saladict</strong> is awesome!
若是選擇的是「awesome」,那麼 anchorNode
和 focusNode
都是 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 }
【完】