From 2475d4a20596b047315f30e0a59176149bf0f7d0 Mon Sep 17 00:00:00 2001 From: erio Date: Sun, 8 Mar 2026 01:45:22 +0800 Subject: [PATCH] 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 --- frontend/src/components/common/DataTable.vue | 1 + frontend/src/composables/useSwipeSelect.ts | 224 +++++++++++++++++++ frontend/src/views/admin/AccountsView.vue | 9 + frontend/src/views/admin/ProxiesView.vue | 9 + 4 files changed, 243 insertions(+) create mode 100644 frontend/src/composables/useSwipeSelect.ts diff --git a/frontend/src/components/common/DataTable.vue b/frontend/src/components/common/DataTable.vue index 43755301..16aea107 100644 --- a/frontend/src/components/common/DataTable.vue +++ b/frontend/src/components/common/DataTable.vue @@ -152,6 +152,7 @@ v-else v-for="(row, index) in sortedData" :key="resolveRowKey(row, index)" + :data-row-id="resolveRowKey(row, index)" class="hover:bg-gray-50 dark:hover:bg-dark-800" > 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 } +} diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index 0173ea0a..b608aa97 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -132,6 +132,7 @@ + @@ -285,6 +287,7 @@ import { useAppStore } from '@/stores/app' import { useAuthStore } from '@/stores/auth' import { adminAPI } from '@/api/admin' import { useTableLoader } from '@/composables/useTableLoader' +import { useSwipeSelect } from '@/composables/useSwipeSelect' import AppLayout from '@/components/layout/AppLayout.vue' import TablePageLayout from '@/components/layout/TablePageLayout.vue' import DataTable from '@/components/common/DataTable.vue' @@ -319,6 +322,12 @@ const authStore = useAuthStore() const proxies = ref([]) const groups = ref([]) const selIds = ref([]) +const accountTableRef = ref(null) +useSwipeSelect(accountTableRef, { + isSelected: (id) => selIds.value.includes(id), + select: (id) => { if (!selIds.value.includes(id)) selIds.value.push(id) }, + deselect: (id) => { selIds.value = selIds.value.filter(x => x !== id) } +}) const selPlatforms = computed(() => { const platforms = new Set( accounts.value diff --git a/frontend/src/views/admin/ProxiesView.vue b/frontend/src/views/admin/ProxiesView.vue index 147b3205..c26aa233 100644 --- a/frontend/src/views/admin/ProxiesView.vue +++ b/frontend/src/views/admin/ProxiesView.vue @@ -88,6 +88,7 @@