Commit 2475d4a2 authored by erio's avatar erio
Browse files

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: default avatarClaude Opus 4.6 <noreply@anthropic.com>
parent be75fc34
......@@ -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"
>
<td
......
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 }
}
......@@ -132,6 +132,7 @@
</template>
<template #table>
<AccountBulkActionsBar :selected-ids="selIds" @delete="handleBulkDelete" @edit="showBulkEdit = true" @clear="selIds = []" @select-page="selectPage" @toggle-schedulable="handleBulkToggleSchedulable" />
<div ref="accountTableRef">
<DataTable
:columns="cols"
:data="accounts"
......@@ -252,6 +253,7 @@
</div>
</template>
</DataTable>
</div>
</template>
<template #pagination><Pagination v-if="pagination.total > 0" :page="pagination.page" :total="pagination.total" :page-size="pagination.page_size" @update:page="handlePageChange" @update:pageSize="handlePageSizeChange" /></template>
</TablePageLayout>
......@@ -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<Proxy[]>([])
const groups = ref<AdminGroup[]>([])
const selIds = ref<number[]>([])
const accountTableRef = ref<HTMLElement | null>(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<AccountPlatform[]>(() => {
const platforms = new Set(
accounts.value
......
......@@ -88,6 +88,7 @@
</template>
<template #table>
<div ref="proxyTableRef">
<DataTable :columns="columns" :data="proxies" :loading="loading">
<template #header-select>
<input
......@@ -325,6 +326,7 @@
/>
</template>
</DataTable>
</div>
</template>
<template #pagination>
......@@ -880,6 +882,7 @@ import Select from '@/components/common/Select.vue'
import Icon from '@/components/icons/Icon.vue'
import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
import { useClipboard } from '@/composables/useClipboard'
import { useSwipeSelect } from '@/composables/useSwipeSelect'
const { t } = useI18n()
const appStore = useAppStore()
......@@ -959,6 +962,12 @@ const qualityCheckingProxyIds = ref<Set<number>>(new Set())
const batchTesting = ref(false)
const batchQualityChecking = ref(false)
const selectedProxyIds = ref<Set<number>>(new Set())
const proxyTableRef = ref<HTMLElement | null>(null)
useSwipeSelect(proxyTableRef, {
isSelected: (id) => selectedProxyIds.value.has(id),
select: (id) => { const next = new Set(selectedProxyIds.value); next.add(id); selectedProxyIds.value = next },
deselect: (id) => { const next = new Set(selectedProxyIds.value); next.delete(id); selectedProxyIds.value = next }
})
const accountsProxy = ref<Proxy | null>(null)
const proxyAccounts = ref<ProxyAccountSummary[]>([])
const accountsLoading = ref(false)
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment