JS 實現滑鼠框選(頁面選擇)時返回對應的程式碼或文字內容

日升_rs發表於2024-05-23

JS 實現滑鼠框選(頁面選擇)時返回對應的程式碼或文案內容

一、需求背景

1、專案需求

當使用者進行滑鼠框選選擇了頁面上的內容時,把選擇的內容進行上報。

2、需求解析

雖然這需求就一句話的事,但是很顯然,沒那麼簡單...

因為滑鼠框選說起來簡單,就是選擇的內容,但是這包含很多中情況,比如:只選擇文案、選擇圖片、選擇輸入框、輸入框中的內容選擇、iframe、等。

簡單總結,分為以下幾點:

  1. 選擇文案時

  2. 選擇圖片、svgiframevideoaudio 等標籤時

  3. 選擇 inputselecttextarea 等標籤時

  4. 選擇 inputtextarea 標籤內容時

  5. 選擇類似   字元時

  6. 鍵盤全選時

  7. 滑鼠右鍵選擇

  8. 以上各模組結合時

  9. 當包含標籤的時候,返回 html 結構,只有文字時返回文字內容

二、技術要點

滑鼠框選包含以下幾點:

  1. debounce 防抖

  2. addEventListener 事件監聽

  3. Range 物件

  4. Selection 物件

1、debounce

老生常談的技術點了,這裡不能用節流,因為肯定不能你滑鼠選擇的時候,隔一段時間返回一段內容,肯定是選擇之後一起返回。

這裡用 debounce 主要也是用在事件監聽和事件處理上。

  • 【debounce 掘金】
  • 【debounce CSDN】

2、addEventListener

事件監聽,因為滑鼠選擇,不僅僅是滑鼠按下到滑鼠抬起,還包括雙擊、右鍵、全選。

需要使用事件監聽對事件作處理。

  • 【addEventListener MDN】

3、Range

Range 介面表示一個包含節點與文字節點的一部分的文件片段。

Range 是瀏覽器原生的物件。

image

3.1. 建立 Range 例項,並設定起始位置

<body>
  <ul>
    <li>Vite</li>
    <li>Vue</li>
    <li>React</li>
    <li>VitePress</li>
    <li>NaiveUI</li>
  </ul>
</body>
<script>
  // 建立 Range 物件
  const range = new Range()
  const liDoms = document.querySelectorAll("li");
  // Range 起始位置在 li 2
  range.setStartBefore(liDoms[1]);
  // Range 結束位置在 li 3
  range.setEndAfter(liDoms[2]);
  // 獲取 selection 物件
  const selection = window.getSelection();
  // 新增游標選擇的範圍
  selection.addRange(range);
</script>

image

可以看到,選擇內容為第二行和第三行

3.1.1 瀏覽器相容情況

image

3.2. Range 屬性

  1. startContainer:起始節點。

  2. startOffset:起始節點偏移量。

  3. endContainer:結束節點。

  4. endOffset:結束節點偏移量。

  5. collapsed:範圍的開始和結束是否為同一點。

  6. commonAncestorContainer:返回完整包含 startContainerendContainer 的最深一級的節點。

3.2.1. 用我們上面建立的例項來看下 range 屬性的值

image

3.2.2. 如果我們只選擇文字內容時

只選擇 li 中的 itePres

image

可以看出 range 屬性對應的值

image

3.3. Range 方法

  1. cloneContents():複製範圍內容,並將複製的內容作為 DocumentFragment 返回。

  2. cloneRange():建立一個具有相同起點/終點的新範圍, 非引用,可以隨意改變,不會影響另一方。

  3. collapse(toStart):如果 toStart=true 則設定 end=start,否則設定 start=end,從而摺疊範圍。

  4. compareBoundaryPoints(how, sourceRange):兩個範圍邊界點進行比較,返回一個數字 -1、0、1。

  5. comparePoint(referenceNode, offset):返回-1、0、1具體取決於 是 referenceNode 在 之前、相同還是之後。

  6. createContextualFragment(tagString):返回一個 DocumentFragment

  7. deleteContents():刪除框選的內容。

  8. extractContents():從文件中刪除範圍內容,並將刪除的內容作為 DocumentFragment 返回。

  9. getBoundingClientRect():和 dom 一樣,返回 DOMRect 物件。

  10. getClientRects():返回可迭代的物件序列 DOMRect

  11. insertNode(node):在範圍的起始處將 node 插入文件。

  12. intersectsNode(referenceNode):判斷與給定的 node 是否相交。

  13. selectNode(node):設定範圍以選擇整個 node

  14. selectNodeContents(node):設定範圍以選擇整個 node 的內容。

  15. setStart(startNode, startOffset):設定起點。

  16. setEnd(endNode, endOffset):設定終點。

  17. setStartBefore(node):將起點設定在 node 前面。

  18. setStartAfter(node):將起點設定在 node 後面。

  19. setEndBefore(node):將終點設定為 node 前面。

  20. setEndAfter(node):將終點設定為 node 後面。

  21. surroundContents(node):使用 node 將所選範圍內容包裹起來。

3.4. 建立 Range 的方法

3.4.1. Document.createRange
const range = document.createRange();
3.4.2. Selection 的 getRangeAt() 方法
const range = window.getSelection().getRangeAt(0)
3.4.3. caretRangeFromPoint() 方法
if (document.caretRangeFromPoint) {
    range = document.caretRangeFromPoint(e.clientX, e.clientY);
}
3.4.4. Range() 建構函式
const range = new Range()

3.5. Range 相容性

image

  • 【詳細相容性:Can I use】

4、Selection

Selection 物件表示使用者選擇的文字範圍或插入符號的當前位置。它代表頁面中的文字選區,可能橫跨多個元素。

4.1. 獲取文字物件

window.getSelection()

image

image

4.2. Selection 術語

4.2.1. 錨點 (anchor)

錨指的是一個選區的起始點(不同於 HTML 中的錨點連結)。當我們使用滑鼠框選一個區域的時候,錨點就是我們滑鼠按下瞬間的那個點。在使用者拖動滑鼠時,錨點是不會變的。

4.2.2. 焦點 (focus)

選區的焦點是該選區的終點,當你用滑鼠框選一個選區的時候,焦點是你的滑鼠鬆開瞬間所記錄的那個點。隨著使用者拖動滑鼠,焦點的位置會隨著改變。

4.2.3. 範圍 (range)

範圍指的是文件中連續的一部分。一個範圍包括整個節點,也可以包含節點的一部分,例如文字節點的一部分。使用者通常下只能選擇一個範圍,但是有的時候使用者也有可能選擇多個範圍。

4.2.4. 可編輯元素 (editing host)

一個使用者可編輯的元素(例如一個使用 contenteditableHTML 元素,或是在啟用了 designModeDocument 的子元素)。

4.3. Selection 的屬性

首先要清楚,選擇的起點稱為錨點(anchor),終點稱為焦點(focus)。

  1. anchorNode:選擇的起始節點。

  2. anchorOffset:選擇開始的 anchorNode 中的偏移量。

  3. focusNode:選擇的結束節點。

  4. focusOffset:選擇開始處 focusNode 的偏移量。

  5. isCollapsed:如果未選擇任何內容(空範圍)或不存在,則為 true

  6. rangeCount:選擇中的範圍數,之前說過,除 Firefox 外,其他瀏覽器最多為1。

  7. type:型別:NoneCaretRange

4.4. Selection 方法

  1. addRange(range): 將一個 Range 物件新增到當前選區。

  2. collapse(node, offset): 將選區摺疊到指定的節點和偏移位置。

  3. collapseToEnd(): 將選區摺疊到當前選區的末尾。

  4. collapseToStart(): 將選區摺疊到當前選區的起始位置。

  5. containsNode(node, partlyContained): 判斷選區是否包含指定的節點,可以選擇是否部分包含。

  6. deleteFromDocument(): 從文件中刪除選區內容。

  7. empty(): 從選區中移除所有範圍(同 `removeAllRanges()``,已廢棄)。

  8. extend(node, offset): 將選區的焦點節點擴充套件到指定的節點和偏移位置。

  9. getRangeAt(index): 返回選區中指定索引處的 Range 物件。

  10. removeAllRanges(): 移除所有選區中的範圍。

  11. removeRange(range): 從選區中移除指定的 Range 物件。

  12. selectAllChildren(node): 選中指定節點的所有子節點。

  13. setBaseAndExtent(anchorNode, anchorOffset, focusNode, focusOffset): 設定選區的起始和結束節點及偏移位置。

  14. setPosition(node, offset)collapse 的別名

4.5. Selection 相容性

image

  • 【詳細相容性:Can I use】

三、專案實現

1、實現思路

  1. 先獲取選擇的內容,開發 getSelectContent 函式
  2. 對獲取的內容進行判斷,是否存在 selection 例項,沒有直接返回 null
  3. 判斷 selection 例項的 isCollapsed 屬性
    • 沒有選中,對 selection 進行 toString().trim() 操作,判斷內容
      • 有內容,直接返回 text 型別
      • 無內容,返回 null
    • 有選中,則判斷內容
  4. 判斷選中的內容有沒有節點
    • 沒有節點,則和沒有選中一樣處理,進行 toString().trim() 操作,判斷內容
      • 有內容,直接返回 text 型別
      • 無內容,返回 null
    • 有節點,進行 toString().trim() 操作,判斷內容
      • 沒有內容,判斷是否有特殊節點
        • 'iframe', 'svg', 'img', 'audio', 'video' 節點,返回 html 型別
        • 'input', 'textarea', 'select',判斷 value 值,是否存在
          • 存在:返回 html 型別
          • 不存在:返回 null
        • 沒有特殊節點,返回 null
      • 有內容,返回 html 型別
  5. 對滑鼠 mousedownmouseup 事件和 selectionchangecontextmenudblclick 事件進行監聽,觸發 getSelectContent 函式
  6. 在需要的地方進行 debounce 防抖處理

2、簡易流程圖

image

2、Debounce 方法實現

2.1. JS

function debounce (fn, time = 500) {
  let timeout = null; // 建立一個標記用來存放定時器的返回值
  return function () {
    clearTimeout(timeout) // 每當觸發時,把前一個 定時器 clear 掉
    timeout = setTimeout(() => { // 建立一個新的 定時器,並賦值給 timeout
      fn.apply(this, arguments)
    }, time)
  }
}

2.2. TS

/**
 * debounce 函式型別
 */
type DebouncedFunction<F extends (...args: any[]) => any> = (...args: Parameters<F>) => void
/**
 * debounce 防抖函式
 * @param {Function} func 函式
 * @param {number} wait 等待時間
 * @param {false} immediate 是否立即執行
 * @returns {DebouncedFunction}
 */
function debounce<F extends (...args: any[]) => any>(
  func: F,
  wait = 500,
  immediate = false
): DebouncedFunction<F> {
  let timeout: ReturnType<typeof setTimeout> | null
  return function (this: ThisParameterType<F>, ...args: Parameters<F>) {
    // eslint-disable-next-line @typescript-eslint/no-this-alias
    const context = this
    const later = function () {
      timeout = null
      if (!immediate) {
        func.apply(context, args)
      }
    }
    const callNow = immediate && !timeout
    if (timeout) {
      clearTimeout(timeout)
    }
    timeout = setTimeout(later, wait)
    if (callNow) {
      func.apply(context, args)
    }
  }
}

3、獲取選擇的文字/html 元素

3.1. 獲取文字/html 元素

nterface IGetSelectContentProps {
  type: 'html' | 'text'
  content: string
}
/**
 * 獲取選擇的內容
 * @returns {null | IGetSelectContentProps} 返回選擇的內容
 */
const getSelectContent = (): null | IGetSelectContentProps => {
  const selection = window.getSelection()
  if (selection) {
    // 1. 是焦點在 input 輸入框
    // 2. 沒有選中
    // 3. 選擇的是輸入框
    if (selection.isCollapsed) {
      return selection.toString().trim().length
        ? {
            type: 'text',
            content: selection.toString().trim()
          }
        : null
    }
    // 獲取選擇範圍
    const range = selection.getRangeAt(0)
    // 獲取選擇內容
    const rangeClone = range.cloneContents()
    // 判斷選擇內容裡面有沒有節點
    if (rangeClone.childElementCount > 0) {
      // 建立 div 標籤
      const container = document.createElement('div')
      // div 標籤 append 複製節點
      container.appendChild(rangeClone)
      // 如果複製的內容長度為 0
      if (!selection.toString().trim().length) {
        // 判斷是否有選擇特殊節點
        const isSpNode = hasSpNode(container)
        return isSpNode
          ? {
              type: 'html',
              content: container.innerHTML
            }
          : null
      }
      return {
        type: 'html',
        content: container.innerHTML
      }
    } else {
      return selection.toString().trim().length
        ? {
            type: 'text',
            content: selection.toString().trim()
          }
        : null
    }
  } else {
    return null
  }
}
/**
 * 判斷是否包含特殊元素
 * @param {Element} parent 父元素
 * @returns {boolean} 是否包含特殊元素
 */
const hasSpNode = (parent: Element): boolean => {
  const nodeNameList = ['iframe', 'svg', 'img', 'audio', 'video']
  const inpList = ['input', 'textarea', 'select']
  return Array.from(parent.children).some((node) => {
    if (nodeNameList.includes(node.nodeName.toLocaleLowerCase())) return true
    if (
      inpList.includes(node.nodeName.toLocaleLowerCase()) &&
      (node as HTMLInputElement).value.trim().length
    )
      return true
    if (node.children) {
      return hasSpNode(node)
    }
    return false
  })
}

3.2. 只需要文字

/**
 * 獲取框選的文案內容
 * @returns {string} 返回框選的內容
 */
const getSelectTextContent = (): string => {
  const selection = window.getSelection()
  return selection?.toString().trim() || ''
}

4、新增事件監聽

// 是否時滑鼠點選動作
let selectionchangeMouseTrack: boolean = false
const selectionChangeFun = debounce(() => {
  const selectContent = getSelectContent()
  console.log('selectContent', selectContent)
  // todo... 處理上報
  selectionchangeMouseTrack = false
})
// 新增 mousedown 監聽事件
document.addEventListener('mousedown', () => {
  selectionchangeMouseTrack = true
})
// 新增 mouseup 監聽事件
document.addEventListener(
  'mouseup',
  debounce(() => {
    selectionChangeFun()
  }, 100)
)
// 新增 selectionchange 監聽事件
document.addEventListener(
  'selectionchange',
  debounce(() => {
    if (selectionchangeMouseTrack) return
    selectionChangeFun()
  })
)
// 新增 dblclick 監聽事件
document.addEventListener('dblclick', () => {
  selectionChangeFun()
})
// 新增 contextmenu 監聽事件
document.addEventListener(
  'contextmenu',
  debounce(() => {
    selectionChangeFun()
  })
)

也可以進行封裝

/**
 * addEventlistener function 型別
 */
export interface IEventHandlerProps {
  [eventName: string]: EventListenerOrEventListenerObject
}

let selectionchangeMouseTrack: boolean = false
const eventHandlers: IEventHandlerProps = {
  // 滑鼠 down 事件
  mousedown: () => {
    selectionchangeMouseTrack = true
  },
  // 滑鼠 up 事件
  mouseup: debounce(() => selectionChangeFun(), 100),
  // 選擇事件
  selectionchange:  debounce(() => {
    if (selectionchangeMouseTrack) return
    selectionChangeFun()
  }),
  // 雙擊事件
  dblclick: () => selectionChangeFun(),
  // 右鍵事件
  contextmenu: debounce(() => selectionChangeFun())
}
Object.keys(eventHandlers).forEach((event) => {
  document.addEventListener(event, eventHandlers[event])
})

5、返回內容

5.1. 純文字內容

image

5.2. html 格式

image

6. 完整 JS 程式碼

function debounce (fn, time = 500) {
  let timeout = null; // 建立一個標記用來存放定時器的返回值
  return function () {
    clearTimeout(timeout) // 每當觸發時,把前一個 定時器 clear 掉
    timeout = setTimeout(() => { // 建立一個新的 定時器,並賦值給 timeout
      fn.apply(this, arguments)
    }, time)
  }
}

let selectionchangeMouseTrack = false
document.addEventListener('mousedown', (e) => {
  selectionchangeMouseTrack = true
  console.log('mousedown', e)
})
document.addEventListener('mouseup', debounce((e) => {
  console.log('mouseup', e)
  selectionChangeFun()
}, 100))
document.addEventListener('selectionchange', debounce((e) => {
  console.log('selectionchange', e)
  if (selectionchangeMouseTrack) return
  selectionChangeFun()
}))
document.addEventListener('dblclick', (e) => {
  console.log('dblclick', e)
  selectionChangeFun()
})
document.addEventListener('contextmenu',debounce(() => {
  selectionChangeFun()
}))

const selectionChangeFun = debounce(() => {
  const selectContent = getSelectContent()
  selectionchangeMouseTrack = false
  console.log('selectContent', selectContent)
})

const getSelectContent = () => {
  const selection = window.getSelection();
  if (selection) {
    // 1. 是焦點在 input 輸入框
    // 2. 沒有選中
    // 3. 選擇的是輸入框
    if (selection.isCollapsed) {
      return selection.toString().trim().length ? {
        type: 'text',
        content: selection.toString().trim()
      } : null
    }
    // 獲取選擇範圍
    const range = selection.getRangeAt(0);
    // 獲取選擇內容
    const rangeClone = range.cloneContents()
    // 判斷選擇內容裡面有沒有節點
    if (rangeClone.childElementCount > 0) {
      const container = document.createElement('div');
      container.appendChild(rangeClone);
      if (!selection.toString().trim().length) {
        const hasSpNode = getSpNode(container)
        return hasSpNode ? {
          type: 'html',
          content: container.innerHTML
        } : null
      }
      return {
        type: 'html',
        content: container.innerHTML
      }
    } else {
      return selection.toString().trim().length ? {
        type: 'text',
        content: selection.toString().trim()
      } : null
    }
  } else {
    return null
  }
}

const getSpNode = (parent) => {
  const nodeNameList = ['iframe', 'svg', 'img', 'audio', 'video']
  const inpList = ['input', 'textarea', 'select']
  return Array.from(parent.children).some((node) => {
    if (nodeNameList.includes(node.nodeName.toLocaleLowerCase())) return true
    if (inpList.includes(node.nodeName.toLocaleLowerCase()) && node.value.trim().length) return true
    if (node.children) {
      return getSpNode(node)
    }
    return false
  })
}

7. 線上預覽

  • 【碼上掘金】

四、總結

  1. 滑鼠框選上報能監控使用者在頁面的行為,能為後續的資料分析等提供便利

  2. 基於 JS 中的 SelectionRange 實現的,使用原生 JS

  3. 涉及到的操作比較多,包含鍵盤、滑鼠右鍵、全選等

  4. 能對框選的內容進行分類,區別 htmltext,更方便的看出使用者選擇了哪些內容

引用

  • 【Range】
  • 【Selection】

相關文章