如何实现Element Table的拖拽框选行数据。一个可以即用的mixins拖拽工具。
最近项目上,产品有一个需求,用户想拖拽去勾选一行或多行数据。比起常见的点击勾选,拖拽勾选的灵活性、操作性更好,对用户更友好。
那我们现在来实现一个,大体思路:
-
首先梳理实现思路。我们要实现第一步拖动,需要监听鼠标的mousedown事件和mouseup事件,以及mouseover事件;
-
在mousedown事件开始时,我们要获取点击的位置信息,作为框选起点;同样的把mouseup事件结束时的位置作为结束位置;
-
有了这两个位置,我们就可以得到一个框选的坐标范围,通过对框选范围与表格checkbox元素的相交和包含判断,来计算得到框选了哪几行的数据;
-
但是对于用户来说,框选的位置需要实时显示,这样用户才能知道自己的操作。这时候我们可以通过mouseover事件实时计算每个时刻点鼠标所在位置,结合起始位置,就可以得到框选的范围,然后我们将范围画出来。
有了思路,我们就可以动手编写代码了,这里我采用canvas来画出矩形。
import { vwToPx } from '@/utils/index'
/**
* 表格拖拽勾选mixins
* 注意事项:
* 1、表格的data属性为tableData
* 2、表格的ref为tableRef,如果没有固定的可以设置 currentTableRef
* 3、页面根节点的ref为pageRef(非必选,可省略),pageRef必须作用在原生标签上
*
* 4、页面/组件的data属性不能覆盖mixins中除noReverseSelect以外的其他字段 -- 防止出现不可预知的问题(特别重要)
*/
export default {
data() {
return {
selecting: false,
startX: 0,
startY: 0,
endX: 0,
endY: 0,
canvas: null,
ctx: null,
selectionColumnRect: null,
canvasOffsetTop: 0,
canvasOffsetLeft: 0,
tableElDom: null,
isCheckboxFixed: false, // 是否fixed布局
clearSelectionTimer: void 0, // 定时器,解决点击勾选和拖拽勾选的问题
// 是否不能反选,可以在页面自行替换,默认true(不能反选)
noReverseSelect: true
}
},
mounted() {
window.addEventListener('mousedown', this.onMouseDown)
window.addEventListener('mousemove', this.onMouseMove)
window.addEventListener('mouseup', this.onMouseUp)
window.addEventListener('resize', this.updateCanvasSize)
},
methods: {
createCanvas() {
this.canvas = document.createElement('canvas')
this.canvas.style.position = 'absolute'
this.canvas.style.zIndex = 100
document.body.appendChild(this.canvas)
this.ctx = this.canvas.getContext('2d')
this.updateCanvasSize()
},
removeCanvas() {
if (this.canvas) {
document.body.removeChild(this.canvas)
this.canvas = null
this.ctx = null
}
},
updateCanvasSize() {
if (!this.canvas) return
// 如果表格勾选列时fixed时
let app = this.$refs.pageRef
if (!app) {
app = document.querySelector('.app-main').firstChild || {}
}
this.canvas.width = app.offsetWidth
this.canvas.height = app.offsetHeight
if (this.isCheckboxFixed) {
const fixedCheckedColumn = this.tableElDom.querySelector('.el-table__fixed')
const rect = fixedCheckedColumn.getBoundingClientRect()
this.canvas.style.top = `${rect.top}px`
this.canvas.style.left = `${rect.left}px`
this.canvasOffsetTop = rect.top
this.canvasOffsetLeft = rect.left
} else {
// this.canvas.width = app.offsetWidth
// this.canvas.height = app.offsetHeight
this.canvas.style.top = `${app.offsetTop}px`
this.canvas.style.left = `${app.offsetLeft}px`
this.canvasOffsetTop = app.offsetTop
this.canvasOffsetLeft = app.offsetLeft
}
// }
},
/**
* 判断鼠标点击区域是否在第一列,且为selection列
*/
getSelectionColumnRect() {
let table = null
if (this.isCheckboxFixed) {
table = this.tableElDom.querySelector('.el-table__fixed-body-wrapper tbody')
} else {
table = this.tableElDom.querySelector('.el-table__body-wrapper tbody')
}
const firstRow = table.querySelector('tr')
if (firstRow) {
const selectionCell = firstRow.querySelector('.el-table-column--selection')
if (selectionCell) {
this.selectionColumnRect = selectionCell.getBoundingClientRect()
}
}
},
/**
* 开始拖动
* @param {Event} event
*/
startTableDrag(event) {
this.selecting = true
this.startX = event.clientX
this.startY = event.clientY
this.endX = this.startX
this.endY = this.startY
this.getSelectionColumnRect()
this.createCanvas()
},
onMouseDown(event) {
// 限制必须是左键点击
if (event.button !== 0) {
// event.preventDefault()
return
}
const _tableRef = this.$refs.tableRef || this.$refs[this.currentTableRef]
if (!_tableRef) return
this.tableElDom = _tableRef.$el
// 判断是否checkbox是fixed布局
this.isCheckboxFixed = !!(this.tableElDom && this.tableElDom.querySelector('.el-table__fixed .el-checkbox'))
// 限制在selection列,但是不能覆盖el-checkbox,因为el-checkbox需要能手动点击
// if (event.target.closest('.el-table') && event.target.closest('.el-table-column--selection') && !event.target.closest('.el-checkbox')) {
// 解决框选和点击的区分,并限制为tbody里面的节点
if (event.target.closest('.el-table') && event.target.closest('tbody .el-table-column--selection')) {
if (event.target.closest('.el-checkbox')) {
this.clearSelectionTimer = setTimeout(() => {
this.startTableDrag(event)
}, 200)
} else {
this.startTableDrag(event)
}
}
},
onMouseMove(event) {
if (!this.selecting) return
// 限制框选在selection列
// this.endX = Math.min(Math.max(event.clientX, this.selectionColumnRect?.left), this.selectionColumnRect?.right)
this.endX = event.clientX
// this.endY = event.clientY
const tableBody = this.tableElDom.querySelector('.el-table__body-wrapper')
// // 限制在表格内
const tableRect = tableBody ? tableBody.getBoundingClientRect() : {}
// // 判断是否有滚动条
const hasScrollbar = tableBody && tableBody.scrollHeight > tableBody.clientHeight
let bar_width = 0
if (hasScrollbar) {
// 去掉滚动条高度的常量
const BAR_WIDTH_VW = (10 / 1920) * 100 // 以1920宽度设置默认值
bar_width = vwToPx(BAR_WIDTH_VW) // 根据屏幕实际宽度,计算滚动条宽度
}
// 添加BAR_WIDTH,为了解决fixed布局时框选数量不一致的问题
this.endY = Math.min(Math.max(event.clientY, tableRect.top), tableRect.bottom) - bar_width
this.drawSelection()
},
onMouseUp() {
if (this.clearSelectionTimer) {
clearTimeout(this.clearSelectionTimer)
this.clearSelectionTimer = void 0
}
if (!this.selecting) return
this.selecting = false
this.selectTableDragRows()
this.clearSelection()
this.removeCanvas()
},
drawSelection() {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
this.ctx.fillStyle = 'rgba(28, 122, 244, 0.3)'
const rectWidth = this.endX - this.startX
const rectHeight = this.endY - this.startY
this.ctx.fillRect(this.startX - this.canvasOffsetLeft, this.startY - this.canvasOffsetTop, rectWidth, rectHeight)
// 添加边框,并设置边框颜色
this.ctx.lineWidth = 1
this.ctx.strokeStyle = 'rgba(28, 122, 244, 1)'
this.ctx.strokeRect(this.startX - this.canvasOffsetLeft, this.startY - this.canvasOffsetTop, rectWidth, rectHeight)
},
clearSelection() {
if (this.ctx) {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
}
},
/**
* 拖拽完成,计算并触发checkbox勾选
*/
selectTableDragRows() {
let table = null
if (this.isCheckboxFixed) {
table = this.tableElDom.querySelector('.el-table__fixed-body-wrapper tbody')
} else {
table = this.tableElDom.querySelector('.el-table__body-wrapper tbody')
}
// 是否交界,限制在div.cell,如需为行,则可以只保留tr
const rows = table.querySelectorAll('tr .el-table-column--selection .cell')
const startX = Math.min(this.startX, this.endX)
const startY = Math.min(this.startY, this.endY)
const endX = Math.max(this.startX, this.endX)
const endY = Math.max(this.startY, this.endY)
rows.forEach((row, index) => {
const rect = row.getBoundingClientRect()
const withinSelection = rect.top < endY && rect.bottom > startY && rect.left < endX && rect.right > startX
if (withinSelection) {
const checkbox = row.querySelector('.el-checkbox .el-checkbox__input')
// 不能反选
if (this.noReverseSelect) {
// 1、触发手动勾选checkbox,且没有勾选的
if (checkbox && !checkbox.getAttribute('class').includes('is-checked')) {
checkbox.click()
}
// 2、使用表格来勾选
// const _tableRef = this.$refs.tableRef || this.$refs[this.currentTableRef]
// _tableRef.toggleRowSelection(this.tableData[index], true)
} else if (checkbox) {
// 可以反选
checkbox.click()
}
}
})
}
},
beforeDestroy() {
window.removeEventListener('mousedown', this.onMouseDown)
window.removeEventListener('mousemove', this.onMouseMove)
window.removeEventListener('mouseup', this.onMouseUp)
window.removeEventListener('resize', this.updateCanvasSize)
this.removeCanvas()
}
}
这样,就可以实现表格拖拽效果。效果如下:
最后这里页面使用了vw来做多端适配,引用了vwToPx工具方法
/**
* 将vw单位转换为px单位
* @param {number} vw 需要转换的vw值
* @return {number} 转换后的px值
*/
export function vwToPx(vw) {
var viewportWidth = window.innerWidth // 获取视口宽度
return Math.ceil((vw / 100) * viewportWidth)
}