Files
BMS/bms-front/src/directive/common/textDragCopy.js
T
2026-10-10 15:54:53 +08:00

309 lines
9.6 KiB
JavaScript

const HIGHLIGHT_NAME = 'bms-table-text-copy-highlight'
const EDITABLE_SELECTOR = [
'button',
'a',
'input',
'textarea',
'select',
'[contenteditable="true"]',
'[role="button"]',
'.el-button',
'.el-checkbox',
'.el-radio',
'.el-switch',
'.el-input',
'.el-select',
'.el-date-editor',
'.el-cascader',
'.el-autocomplete',
'.el-input-number'
].join(',')
const contexts = new Map()
let activeSelection = null
let dragging = false
let hasDragged = false
let suppressClickRoot = null
let listenersInstalled = false
function supportsHighlights() {
return Boolean(window.CSS?.highlights && typeof window.Highlight === 'function')
}
function clearHighlight() {
window.CSS?.highlights?.delete(HIGHLIGHT_NAME)
}
function clearSelection() {
activeSelection = null
dragging = false
hasDragged = false
clearHighlight()
}
function getContextForCell(cell) {
for (let element = cell; element; element = element.parentElement) {
const context = contexts.get(element)
if (context?.enabled) return context
}
return null
}
function getRows(bodyWrapper) {
return Array.from(bodyWrapper.querySelectorAll('tbody > tr.el-table__row'))
.filter(row => row.getClientRects().length > 0)
}
function resolveCell(target) {
const element = target instanceof Element ? target : target?.parentElement
const cell = element?.closest('td')
const bodyWrapper = cell?.closest('.el-table__body-wrapper')
const row = cell?.closest('tbody > tr.el-table__row')
if (!cell || !bodyWrapper || !row || cell.matches('.el-table-column--selection, .el-table__expand-column')) return null
if (element?.closest(EDITABLE_SELECTOR)) return null
const context = getContextForCell(cell)
if (!context || !context.el.contains(bodyWrapper)) return null
const rows = getRows(bodyWrapper)
const rowIndex = rows.indexOf(row)
const colIndex = Array.from(row.children).indexOf(cell)
if (rowIndex < 0 || colIndex < 0) return null
return { context, bodyWrapper, rowIndex, colIndex, cell }
}
function cellTextRoot(cell) {
return cell.querySelector('.cell') || cell
}
function cellTextNodes(cell) {
const walker = document.createTreeWalker(cellTextRoot(cell), NodeFilter.SHOW_TEXT)
const nodes = []
let node
while ((node = walker.nextNode())) {
if (node.nodeValue) nodes.push(node)
}
return nodes
}
function cellText(cell) {
return cellTextNodes(cell).map(node => node.nodeValue).join('')
}
function caretOffsetInCell(cell, clientX, clientY) {
const root = cellTextRoot(cell)
let point = null
if (document.caretRangeFromPoint) {
const range = document.caretRangeFromPoint(clientX, clientY)
if (range) point = { node: range.startContainer, offset: range.startOffset }
} else if (document.caretPositionFromPoint) {
const position = document.caretPositionFromPoint(clientX, clientY)
if (position) point = { node: position.offsetNode, offset: position.offset }
}
if (point && (point.node === root || root.contains(point.node))) {
const range = document.createRange()
range.selectNodeContents(root)
range.setEnd(point.node, point.offset)
return Math.min(range.toString().length, cellText(cell).length)
}
const rect = root.getBoundingClientRect()
return clientX <= rect.left + rect.width / 2 ? 0 : cellText(cell).length
}
function textRangesInCell(cell, startOffset, endOffset) {
const ranges = []
let cursor = 0
cellTextNodes(cell).forEach(node => {
const length = node.nodeValue.length
const start = Math.max(startOffset, cursor)
const end = Math.min(endOffset, cursor + length)
if (end > start) {
const range = document.createRange()
range.setStart(node, start - cursor)
range.setEnd(node, end - cursor)
ranges.push(range)
}
cursor += length
})
return ranges
}
function selectedTextSegments() {
if (!activeSelection) return []
const firstRow = Math.min(activeSelection.anchorRow, activeSelection.focusRow)
const lastRow = Math.max(activeSelection.anchorRow, activeSelection.focusRow)
const rows = getRows(activeSelection.bodyWrapper)
const segments = []
for (let rowIndex = firstRow; rowIndex <= lastRow; rowIndex += 1) {
const cell = rows[rowIndex]?.children[activeSelection.colIndex]
if (!cell) continue
const value = cellText(cell)
let start = 0
let end = value.length
if (activeSelection.anchorRow === activeSelection.focusRow) {
start = Math.min(activeSelection.anchorOffset, activeSelection.focusOffset)
end = Math.max(activeSelection.anchorOffset, activeSelection.focusOffset)
} else if (rowIndex === activeSelection.anchorRow) {
if (activeSelection.anchorRow < activeSelection.focusRow) start = activeSelection.anchorOffset
else end = activeSelection.anchorOffset
} else if (rowIndex === activeSelection.focusRow) {
if (activeSelection.focusRow > activeSelection.anchorRow) end = activeSelection.focusOffset
else start = activeSelection.focusOffset
}
start = Math.max(0, Math.min(start, value.length))
end = Math.max(start, Math.min(end, value.length))
segments.push({ cell, start, end, text: value.slice(start, end) })
}
return segments
}
function renderHighlight() {
clearHighlight()
if (!activeSelection || !supportsHighlights()) return
const ranges = selectedTextSegments().flatMap(segment => textRangesInCell(segment.cell, segment.start, segment.end))
if (ranges.length) window.CSS.highlights.set(HIGHLIGHT_NAME, new window.Highlight(...ranges))
}
function handleMouseDown(event) {
suppressClickRoot = null
clearSelection()
if (event.button !== 0 || !supportsHighlights()) return
const cell = resolveCell(event.target)
if (!cell) return
const offset = caretOffsetInCell(cell.cell, event.clientX, event.clientY)
activeSelection = {
context: cell.context,
bodyWrapper: cell.bodyWrapper,
colIndex: cell.colIndex,
anchorRow: cell.rowIndex,
focusRow: cell.rowIndex,
anchorOffset: offset,
focusOffset: offset,
startX: event.clientX,
startY: event.clientY
}
dragging = true
event.preventDefault()
}
function handleMouseMove(event) {
if (!dragging || !activeSelection) return
if (Math.abs(event.clientX - activeSelection.startX) + Math.abs(event.clientY - activeSelection.startY) > 2) {
hasDragged = true
}
const cell = resolveCell(document.elementFromPoint(event.clientX, event.clientY))
if (!cell || cell.context !== activeSelection.context || cell.bodyWrapper !== activeSelection.bodyWrapper || cell.colIndex !== activeSelection.colIndex) return
const offset = caretOffsetInCell(cell.cell, event.clientX, event.clientY)
if (activeSelection.focusRow === cell.rowIndex && activeSelection.focusOffset === offset) return
activeSelection.focusRow = cell.rowIndex
activeSelection.focusOffset = offset
renderHighlight()
event.preventDefault()
}
function handleMouseUp() {
if (!dragging) return
dragging = false
if (hasDragged && activeSelection) {
suppressClickRoot = activeSelection.context.el
requestAnimationFrame(() => { suppressClickRoot = null })
}
}
function handleDoubleClick(event) {
const cell = resolveCell(event.target)
if (!cell) return
const textLength = cellText(cell.cell).length
if (!textLength) return
activeSelection = {
context: cell.context,
bodyWrapper: cell.bodyWrapper,
colIndex: cell.colIndex,
anchorRow: cell.rowIndex,
focusRow: cell.rowIndex,
anchorOffset: 0,
focusOffset: textLength
}
dragging = false
hasDragged = false
renderHighlight()
event.preventDefault()
}
function handleClick(event) {
const root = suppressClickRoot
if (!root || !root.contains(event.target)) return
event.preventDefault()
event.stopImmediatePropagation()
suppressClickRoot = null
}
function handleCopy(event) {
if (!activeSelection || dragging || !event.clipboardData) return
const target = event.target instanceof Element ? event.target : event.target?.parentElement
if (target?.closest(EDITABLE_SELECTOR)) return
const text = selectedTextSegments().map(segment => segment.text).join('\n')
if (!text) return
event.clipboardData.setData('text/plain', text)
event.preventDefault()
}
function installListeners() {
if (listenersInstalled) return
document.addEventListener('mousedown', handleMouseDown, true)
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
document.addEventListener('dblclick', handleDoubleClick)
document.addEventListener('click', handleClick, true)
document.addEventListener('copy', handleCopy, true)
listenersInstalled = true
}
function removeListeners() {
if (!listenersInstalled) return
document.removeEventListener('mousedown', handleMouseDown, true)
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
document.removeEventListener('dblclick', handleDoubleClick)
document.removeEventListener('click', handleClick, true)
document.removeEventListener('copy', handleCopy, true)
listenersInstalled = false
}
function isEnabled(value) {
if (value && typeof value === 'object') return value.enabled !== false
return value !== false
}
export default {
mounted(el, binding) {
contexts.set(el, { el, enabled: isEnabled(binding.value) })
installListeners()
},
updated(el, binding) {
const context = contexts.get(el)
if (!context) return
context.enabled = isEnabled(binding.value)
if (!context.enabled || activeSelection?.context === context) clearSelection()
},
unmounted(el) {
const context = contexts.get(el)
if (activeSelection?.context === context) clearSelection()
contexts.delete(el)
if (contexts.size === 0) removeListeners()
}
}