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, adapter: SwipeSelectAdapter ) { const isDragging = ref(false) let dragMode: 'select' | 'deselect' = 'select' let startRowIndex = -1 let lastEndIndex = -1 let startY = 0 let initialSelectedSnapshot = new Map() 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 } }