mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-03 06:52:13 +08:00
Show a semi-transparent blue rectangle overlay while dragging to select rows, matching the project's primary color theme with dark mode support. The box spans the full table width from drag start to current mouse position. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
225 lines
6.2 KiB
TypeScript
225 lines
6.2 KiB
TypeScript
import { ref, onMounted, onUnmounted, type Ref } from 'vue'
|
|
|
|
export interface SwipeSelectAdapter {
|
|
isSelected: (id: number) => boolean
|
|
select: (id: number) => void
|
|
deselect: (id: number) => void
|
|
}
|
|
|
|
export function useSwipeSelect(
|
|
containerRef: Ref<HTMLElement | null>,
|
|
adapter: SwipeSelectAdapter
|
|
) {
|
|
const isDragging = ref(false)
|
|
|
|
let dragMode: 'select' | 'deselect' = 'select'
|
|
let startRowIndex = -1
|
|
let lastEndIndex = -1
|
|
let startY = 0
|
|
let initialSelectedSnapshot = new Map<number, boolean>()
|
|
let cachedRows: HTMLElement[] = []
|
|
let marqueeEl: HTMLDivElement | null = null
|
|
|
|
function getDataRows(): HTMLElement[] {
|
|
const container = containerRef.value
|
|
if (!container) return []
|
|
return Array.from(container.querySelectorAll('tbody tr[data-row-id]'))
|
|
}
|
|
|
|
function getRowId(el: HTMLElement): number | null {
|
|
const raw = el.getAttribute('data-row-id')
|
|
if (raw === null) return null
|
|
const id = Number(raw)
|
|
return Number.isFinite(id) ? id : null
|
|
}
|
|
|
|
// --- Marquee overlay ---
|
|
function createMarquee() {
|
|
marqueeEl = document.createElement('div')
|
|
const isDark = document.documentElement.classList.contains('dark')
|
|
Object.assign(marqueeEl.style, {
|
|
position: 'fixed',
|
|
background: isDark ? 'rgba(96, 165, 250, 0.15)' : 'rgba(59, 130, 246, 0.12)',
|
|
border: isDark ? '1.5px solid rgba(96, 165, 250, 0.5)' : '1.5px solid rgba(59, 130, 246, 0.4)',
|
|
borderRadius: '4px',
|
|
pointerEvents: 'none',
|
|
zIndex: '9999',
|
|
transition: 'none'
|
|
})
|
|
document.body.appendChild(marqueeEl)
|
|
}
|
|
|
|
function updateMarquee(currentY: number) {
|
|
if (!marqueeEl || !containerRef.value) return
|
|
const containerRect = containerRef.value.getBoundingClientRect()
|
|
|
|
const top = Math.min(startY, currentY)
|
|
const bottom = Math.max(startY, currentY)
|
|
|
|
// Clamp to container horizontal bounds, extend full width
|
|
marqueeEl.style.left = containerRect.left + 'px'
|
|
marqueeEl.style.width = containerRect.width + 'px'
|
|
marqueeEl.style.top = top + 'px'
|
|
marqueeEl.style.height = (bottom - top) + 'px'
|
|
}
|
|
|
|
function removeMarquee() {
|
|
if (marqueeEl) {
|
|
marqueeEl.remove()
|
|
marqueeEl = null
|
|
}
|
|
}
|
|
|
|
// --- Row selection logic ---
|
|
function applyRange(endIndex: number) {
|
|
const rangeMin = Math.min(startRowIndex, endIndex)
|
|
const rangeMax = Math.max(startRowIndex, endIndex)
|
|
const prevMin = lastEndIndex >= 0 ? Math.min(startRowIndex, lastEndIndex) : rangeMin
|
|
const prevMax = lastEndIndex >= 0 ? Math.max(startRowIndex, lastEndIndex) : rangeMax
|
|
|
|
const lo = Math.min(rangeMin, prevMin)
|
|
const hi = Math.max(rangeMax, prevMax)
|
|
|
|
for (let i = lo; i <= hi && i < cachedRows.length; i++) {
|
|
const id = getRowId(cachedRows[i])
|
|
if (id === null) continue
|
|
|
|
if (i >= rangeMin && i <= rangeMax) {
|
|
if (dragMode === 'select') {
|
|
adapter.select(id)
|
|
} else {
|
|
adapter.deselect(id)
|
|
}
|
|
} else {
|
|
const wasSelected = initialSelectedSnapshot.get(id) ?? false
|
|
if (wasSelected) {
|
|
adapter.select(id)
|
|
} else {
|
|
adapter.deselect(id)
|
|
}
|
|
}
|
|
}
|
|
|
|
lastEndIndex = endIndex
|
|
}
|
|
|
|
function onMouseDown(e: MouseEvent) {
|
|
if (e.button !== 0) return
|
|
|
|
const target = e.target as HTMLElement
|
|
if (target.closest('button, a, input, select, textarea, [role="button"], [role="menuitem"]')) return
|
|
if (!target.closest('tbody')) return
|
|
|
|
cachedRows = getDataRows()
|
|
const tr = target.closest('tr[data-row-id]') as HTMLElement | null
|
|
if (!tr) return
|
|
const rowIndex = cachedRows.indexOf(tr)
|
|
if (rowIndex < 0) return
|
|
|
|
const rowId = getRowId(tr)
|
|
if (rowId === null) return
|
|
|
|
initialSelectedSnapshot = new Map()
|
|
for (const row of cachedRows) {
|
|
const id = getRowId(row)
|
|
if (id !== null) {
|
|
initialSelectedSnapshot.set(id, adapter.isSelected(id))
|
|
}
|
|
}
|
|
|
|
isDragging.value = true
|
|
startRowIndex = rowIndex
|
|
lastEndIndex = -1
|
|
startY = e.clientY
|
|
dragMode = adapter.isSelected(rowId) ? 'deselect' : 'select'
|
|
|
|
applyRange(rowIndex)
|
|
|
|
// Create visual marquee
|
|
createMarquee()
|
|
updateMarquee(e.clientY)
|
|
|
|
e.preventDefault()
|
|
document.body.style.userSelect = 'none'
|
|
document.addEventListener('mousemove', onMouseMove)
|
|
document.addEventListener('mouseup', onMouseUp)
|
|
}
|
|
|
|
function onMouseMove(e: MouseEvent) {
|
|
if (!isDragging.value) return
|
|
|
|
// Update marquee box
|
|
updateMarquee(e.clientY)
|
|
|
|
const el = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null
|
|
if (!el) return
|
|
|
|
const tr = el.closest('tr[data-row-id]') as HTMLElement | null
|
|
if (!tr) return
|
|
const rowIndex = cachedRows.indexOf(tr)
|
|
if (rowIndex < 0) return
|
|
|
|
applyRange(rowIndex)
|
|
autoScroll(e)
|
|
}
|
|
|
|
function onMouseUp() {
|
|
isDragging.value = false
|
|
startRowIndex = -1
|
|
lastEndIndex = -1
|
|
cachedRows = []
|
|
initialSelectedSnapshot.clear()
|
|
stopAutoScroll()
|
|
removeMarquee()
|
|
document.body.style.userSelect = ''
|
|
|
|
document.removeEventListener('mousemove', onMouseMove)
|
|
document.removeEventListener('mouseup', onMouseUp)
|
|
}
|
|
|
|
// --- Auto-scroll ---
|
|
let scrollRAF = 0
|
|
const SCROLL_ZONE = 40
|
|
const SCROLL_SPEED = 8
|
|
|
|
function autoScroll(e: MouseEvent) {
|
|
cancelAnimationFrame(scrollRAF)
|
|
const container = containerRef.value
|
|
if (!container) return
|
|
|
|
const rect = container.getBoundingClientRect()
|
|
let dy = 0
|
|
if (e.clientY < rect.top + SCROLL_ZONE) {
|
|
dy = -SCROLL_SPEED
|
|
} else if (e.clientY > rect.bottom - SCROLL_ZONE) {
|
|
dy = SCROLL_SPEED
|
|
}
|
|
|
|
if (dy !== 0) {
|
|
const step = () => {
|
|
container.scrollTop += dy
|
|
scrollRAF = requestAnimationFrame(step)
|
|
}
|
|
scrollRAF = requestAnimationFrame(step)
|
|
}
|
|
}
|
|
|
|
function stopAutoScroll() {
|
|
cancelAnimationFrame(scrollRAF)
|
|
}
|
|
|
|
onMounted(() => {
|
|
containerRef.value?.addEventListener('mousedown', onMouseDown)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
containerRef.value?.removeEventListener('mousedown', onMouseDown)
|
|
document.removeEventListener('mousemove', onMouseMove)
|
|
document.removeEventListener('mouseup', onMouseUp)
|
|
stopAutoScroll()
|
|
removeMarquee()
|
|
})
|
|
|
|
return { isDragging }
|
|
}
|