Files
sub2api/frontend/src/composables/useSwipeSelect.ts
erio 2475d4a205 feat: add marquee selection box overlay during drag-to-select
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>
2026-03-08 01:45:22 +08:00

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 }
}