mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-03 06:52:13 +08:00
perf(frontend): add virtual scrolling to DataTable
Replace direct row rendering with @tanstack/vue-virtual. The table now only renders visible rows (~20) via padding <tr> placeholders, eliminating the rendering bottleneck when displaying 100+ rows with heavy cell components. Key changes: - DataTable.vue: integrate useVirtualizer (always-on), virtual row template with measureElement for variable row heights, defineExpose virtualizer/sortedData for external access, overflow-y/flex CSS - useSwipeSelect.ts: dual-mode support via optional SwipeSelectVirtualContext — data-driven row index lookup and selection range when virtualizer is present, original DOM-based path preserved for callers that don't pass virtualContext
This commit is contained in:
@@ -16,6 +16,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lobehub/icons": "^4.0.2",
|
"@lobehub/icons": "^4.0.2",
|
||||||
|
"@tanstack/vue-virtual": "^3.13.23",
|
||||||
"@vueuse/core": "^10.7.0",
|
"@vueuse/core": "^10.7.0",
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
"chart.js": "^4.4.1",
|
"chart.js": "^4.4.1",
|
||||||
|
|||||||
18
frontend/pnpm-lock.yaml
generated
18
frontend/pnpm-lock.yaml
generated
@@ -11,6 +11,9 @@ importers:
|
|||||||
'@lobehub/icons':
|
'@lobehub/icons':
|
||||||
specifier: ^4.0.2
|
specifier: ^4.0.2
|
||||||
version: 4.0.2(@lobehub/ui@4.9.2)(@types/react@19.2.7)(antd@6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
version: 4.0.2(@lobehub/ui@4.9.2)(@types/react@19.2.7)(antd@6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@tanstack/vue-virtual':
|
||||||
|
specifier: ^3.13.23
|
||||||
|
version: 3.13.23(vue@3.5.26(typescript@5.6.3))
|
||||||
'@vueuse/core':
|
'@vueuse/core':
|
||||||
specifier: ^10.7.0
|
specifier: ^10.7.0
|
||||||
version: 10.11.1(vue@3.5.26(typescript@5.6.3))
|
version: 10.11.1(vue@3.5.26(typescript@5.6.3))
|
||||||
@@ -1376,6 +1379,14 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: '>= 16.3.0'
|
react: '>= 16.3.0'
|
||||||
|
|
||||||
|
'@tanstack/virtual-core@3.13.23':
|
||||||
|
resolution: {integrity: sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==}
|
||||||
|
|
||||||
|
'@tanstack/vue-virtual@3.13.23':
|
||||||
|
resolution: {integrity: sha512-b5jPluAR6U3eOq6GWAYSpj3ugnAIZgGR0e6aGAgyRse0Yu6MVQQ0ZWm9SArSXWtageogn6bkVD8D//c4IjW3xQ==}
|
||||||
|
peerDependencies:
|
||||||
|
vue: ^2.7.0 || ^3.0.0
|
||||||
|
|
||||||
'@types/d3-array@3.2.2':
|
'@types/d3-array@3.2.2':
|
||||||
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
|
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
|
||||||
|
|
||||||
@@ -5808,6 +5819,13 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.3
|
react: 19.2.3
|
||||||
|
|
||||||
|
'@tanstack/virtual-core@3.13.23': {}
|
||||||
|
|
||||||
|
'@tanstack/vue-virtual@3.13.23(vue@3.5.26(typescript@5.6.3))':
|
||||||
|
dependencies:
|
||||||
|
'@tanstack/virtual-core': 3.13.23
|
||||||
|
vue: 3.5.26(typescript@5.6.3)
|
||||||
|
|
||||||
'@types/d3-array@3.2.2': {}
|
'@types/d3-array@3.2.2': {}
|
||||||
|
|
||||||
'@types/d3-axis@3.0.6':
|
'@types/d3-axis@3.0.6':
|
||||||
|
|||||||
@@ -147,28 +147,46 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<!-- Data rows -->
|
<!-- Data rows (virtual scroll) -->
|
||||||
<tr
|
<template v-else>
|
||||||
v-else
|
<tr v-if="virtualPaddingTop > 0" aria-hidden="true">
|
||||||
v-for="(row, index) in sortedData"
|
<td :colspan="columns.length"
|
||||||
:key="resolveRowKey(row, index)"
|
:style="{ height: virtualPaddingTop + 'px', padding: 0, border: 'none' }">
|
||||||
:data-row-id="resolveRowKey(row, index)"
|
</td>
|
||||||
class="hover:bg-gray-50 dark:hover:bg-dark-800"
|
</tr>
|
||||||
>
|
<tr
|
||||||
<td
|
v-for="virtualRow in virtualItems"
|
||||||
v-for="(column, colIndex) in columns"
|
:key="resolveRowKey(sortedData[virtualRow.index], virtualRow.index)"
|
||||||
:key="column.key"
|
:data-row-id="resolveRowKey(sortedData[virtualRow.index], virtualRow.index)"
|
||||||
:class="[
|
:data-index="virtualRow.index"
|
||||||
'whitespace-nowrap py-4 text-sm text-gray-900 dark:text-gray-100',
|
:ref="measureElement"
|
||||||
getAdaptivePaddingClass(),
|
class="hover:bg-gray-50 dark:hover:bg-dark-800"
|
||||||
getStickyColumnClass(column, colIndex)
|
|
||||||
]"
|
|
||||||
>
|
>
|
||||||
<slot :name="`cell-${column.key}`" :row="row" :value="row[column.key]" :expanded="actionsExpanded">
|
<td
|
||||||
{{ column.formatter ? column.formatter(row[column.key], row) : row[column.key] }}
|
v-for="(column, colIndex) in columns"
|
||||||
</slot>
|
:key="column.key"
|
||||||
</td>
|
:class="[
|
||||||
</tr>
|
'whitespace-nowrap py-4 text-sm text-gray-900 dark:text-gray-100',
|
||||||
|
getAdaptivePaddingClass(),
|
||||||
|
getStickyColumnClass(column, colIndex)
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<slot :name="`cell-${column.key}`"
|
||||||
|
:row="sortedData[virtualRow.index]"
|
||||||
|
:value="sortedData[virtualRow.index][column.key]"
|
||||||
|
:expanded="actionsExpanded">
|
||||||
|
{{ column.formatter
|
||||||
|
? column.formatter(sortedData[virtualRow.index][column.key], sortedData[virtualRow.index])
|
||||||
|
: sortedData[virtualRow.index][column.key] }}
|
||||||
|
</slot>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="virtualPaddingBottom > 0" aria-hidden="true">
|
||||||
|
<td :colspan="columns.length"
|
||||||
|
:style="{ height: virtualPaddingBottom + 'px', padding: 0, border: 'none' }">
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -176,6 +194,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
import { computed, ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||||
|
import { useVirtualizer } from '@tanstack/vue-virtual'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import type { Column } from './types'
|
import type { Column } from './types'
|
||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
@@ -299,6 +318,10 @@ interface Props {
|
|||||||
* will emit 'sort' events instead of performing client-side sorting.
|
* will emit 'sort' events instead of performing client-side sorting.
|
||||||
*/
|
*/
|
||||||
serverSideSort?: boolean
|
serverSideSort?: boolean
|
||||||
|
/** Estimated row height in px for the virtualizer (default 56) */
|
||||||
|
estimateRowHeight?: number
|
||||||
|
/** Number of rows to render beyond the visible area (default 5) */
|
||||||
|
overscan?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
@@ -499,6 +522,33 @@ const sortedData = computed(() => {
|
|||||||
.map(item => item.row)
|
.map(item => item.row)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// --- Virtual scrolling ---
|
||||||
|
const rowVirtualizer = useVirtualizer(computed(() => ({
|
||||||
|
count: sortedData.value?.length ?? 0,
|
||||||
|
getScrollElement: () => tableWrapperRef.value,
|
||||||
|
estimateSize: () => props.estimateRowHeight ?? 56,
|
||||||
|
overscan: props.overscan ?? 5,
|
||||||
|
})))
|
||||||
|
|
||||||
|
const virtualItems = computed(() => rowVirtualizer.value.getVirtualItems())
|
||||||
|
|
||||||
|
const virtualPaddingTop = computed(() => {
|
||||||
|
const items = virtualItems.value
|
||||||
|
return items.length > 0 ? items[0].start : 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const virtualPaddingBottom = computed(() => {
|
||||||
|
const items = virtualItems.value
|
||||||
|
if (items.length === 0) return 0
|
||||||
|
return rowVirtualizer.value.getTotalSize() - items[items.length - 1].end
|
||||||
|
})
|
||||||
|
|
||||||
|
const measureElement = (el: any) => {
|
||||||
|
if (el) {
|
||||||
|
rowVirtualizer.value.measureElement(el as Element)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const hasActionsColumn = computed(() => {
|
const hasActionsColumn = computed(() => {
|
||||||
return props.columns.some(column => column.key === 'actions')
|
return props.columns.some(column => column.key === 'actions')
|
||||||
})
|
})
|
||||||
@@ -595,6 +645,13 @@ watch(
|
|||||||
},
|
},
|
||||||
{ flush: 'post' }
|
{ flush: 'post' }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
virtualizer: rowVirtualizer,
|
||||||
|
sortedData,
|
||||||
|
resolveRowKey,
|
||||||
|
tableWrapperEl: tableWrapperRef,
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -603,6 +660,9 @@ watch(
|
|||||||
--select-col-width: 52px; /* 勾选列宽度:px-6 (24px*2) + checkbox (16px) */
|
--select-col-width: 52px; /* 勾选列宽度:px-6 (24px*2) + checkbox (16px) */
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ref, onMounted, onUnmounted, type Ref } from 'vue'
|
import { ref, onMounted, onUnmounted, type Ref } from 'vue'
|
||||||
|
import type { Virtualizer } from '@tanstack/vue-virtual'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WeChat-style swipe/drag to select rows in a DataTable,
|
* WeChat-style swipe/drag to select rows in a DataTable,
|
||||||
@@ -25,11 +26,22 @@ export interface SwipeSelectAdapter {
|
|||||||
isSelected: (id: number) => boolean
|
isSelected: (id: number) => boolean
|
||||||
select: (id: number) => void
|
select: (id: number) => void
|
||||||
deselect: (id: number) => void
|
deselect: (id: number) => void
|
||||||
|
batchUpdate?: (updater: (draft: Set<number>) => void) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SwipeSelectVirtualContext {
|
||||||
|
/** Get the virtualizer instance */
|
||||||
|
getVirtualizer: () => Virtualizer<HTMLElement, Element> | null
|
||||||
|
/** Get all sorted data */
|
||||||
|
getSortedData: () => any[]
|
||||||
|
/** Get row ID from data row */
|
||||||
|
getRowId: (row: any, index: number) => number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSwipeSelect(
|
export function useSwipeSelect(
|
||||||
containerRef: Ref<HTMLElement | null>,
|
containerRef: Ref<HTMLElement | null>,
|
||||||
adapter: SwipeSelectAdapter
|
adapter: SwipeSelectAdapter,
|
||||||
|
virtualContext?: SwipeSelectVirtualContext
|
||||||
) {
|
) {
|
||||||
const isDragging = ref(false)
|
const isDragging = ref(false)
|
||||||
|
|
||||||
@@ -95,6 +107,32 @@ export function useSwipeSelect(
|
|||||||
return (clientY - rHi.bottom < rLo.top - clientY) ? hi : lo
|
return (clientY - rHi.bottom < rLo.top - clientY) ? hi : lo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Virtual mode: find row index from Y coordinate using virtualizer data */
|
||||||
|
function findRowIndexAtYVirtual(clientY: number): number {
|
||||||
|
const virt = virtualContext!.getVirtualizer()
|
||||||
|
if (!virt) return -1
|
||||||
|
const scrollEl = virt.scrollElement
|
||||||
|
if (!scrollEl) return -1
|
||||||
|
|
||||||
|
const scrollRect = scrollEl.getBoundingClientRect()
|
||||||
|
const thead = scrollEl.querySelector('thead')
|
||||||
|
const theadHeight = thead ? thead.getBoundingClientRect().height : 0
|
||||||
|
const contentY = clientY - scrollRect.top - theadHeight + scrollEl.scrollTop
|
||||||
|
|
||||||
|
// Search in rendered virtualItems first
|
||||||
|
const items = virt.getVirtualItems()
|
||||||
|
for (const item of items) {
|
||||||
|
if (contentY >= item.start && contentY < item.end) return item.index
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outside visible range: estimate
|
||||||
|
const totalCount = virtualContext!.getSortedData().length
|
||||||
|
if (totalCount === 0) return -1
|
||||||
|
const est = virt.options.estimateSize(0)
|
||||||
|
const guess = Math.floor(contentY / est)
|
||||||
|
return Math.max(0, Math.min(totalCount - 1, guess))
|
||||||
|
}
|
||||||
|
|
||||||
// --- Prevent text selection via selectstart (no body style mutation) ---
|
// --- Prevent text selection via selectstart (no body style mutation) ---
|
||||||
function onSelectStart(e: Event) { e.preventDefault() }
|
function onSelectStart(e: Event) { e.preventDefault() }
|
||||||
|
|
||||||
@@ -140,16 +178,68 @@ export function useSwipeSelect(
|
|||||||
const lo = Math.min(rangeMin, prevMin)
|
const lo = Math.min(rangeMin, prevMin)
|
||||||
const hi = Math.max(rangeMax, prevMax)
|
const hi = Math.max(rangeMax, prevMax)
|
||||||
|
|
||||||
for (let i = lo; i <= hi && i < cachedRows.length; i++) {
|
if (adapter.batchUpdate) {
|
||||||
const id = getRowId(cachedRows[i])
|
adapter.batchUpdate((draft) => {
|
||||||
if (id === null) continue
|
for (let i = lo; i <= hi && i < cachedRows.length; i++) {
|
||||||
if (i >= rangeMin && i <= rangeMax) {
|
const id = getRowId(cachedRows[i])
|
||||||
if (dragMode === 'select') adapter.select(id)
|
if (id === null) continue
|
||||||
else adapter.deselect(id)
|
const shouldBeSelected = (i >= rangeMin && i <= rangeMax)
|
||||||
} else {
|
? (dragMode === 'select')
|
||||||
const wasSelected = initialSelectedSnapshot.get(id) ?? false
|
: (initialSelectedSnapshot.get(id) ?? false)
|
||||||
if (wasSelected) adapter.select(id)
|
if (shouldBeSelected) draft.add(id)
|
||||||
else adapter.deselect(id)
|
else draft.delete(id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Virtual mode: apply selection range using data array instead of DOM */
|
||||||
|
function applyRangeVirtual(endIndex: number) {
|
||||||
|
if (startRowIndex < 0 || endIndex < 0) return
|
||||||
|
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)
|
||||||
|
const data = virtualContext!.getSortedData()
|
||||||
|
|
||||||
|
if (adapter.batchUpdate) {
|
||||||
|
adapter.batchUpdate((draft) => {
|
||||||
|
for (let i = lo; i <= hi && i < data.length; i++) {
|
||||||
|
const id = virtualContext!.getRowId(data[i], i)
|
||||||
|
const shouldBeSelected = (i >= rangeMin && i <= rangeMax)
|
||||||
|
? (dragMode === 'select')
|
||||||
|
: (initialSelectedSnapshot.get(id) ?? false)
|
||||||
|
if (shouldBeSelected) draft.add(id)
|
||||||
|
else draft.delete(id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
for (let i = lo; i <= hi && i < data.length; i++) {
|
||||||
|
const id = virtualContext!.getRowId(data[i], i)
|
||||||
|
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
|
lastEndIndex = endIndex
|
||||||
@@ -234,8 +324,14 @@ export function useSwipeSelect(
|
|||||||
if (shouldPreferNativeTextSelection(target)) return
|
if (shouldPreferNativeTextSelection(target)) return
|
||||||
if (shouldPreferNativeSelectionOutsideRows(target)) return
|
if (shouldPreferNativeSelectionOutsideRows(target)) return
|
||||||
|
|
||||||
cachedRows = getDataRows()
|
if (virtualContext) {
|
||||||
if (cachedRows.length === 0) return
|
// Virtual mode: check data availability instead of DOM rows
|
||||||
|
const data = virtualContext.getSortedData()
|
||||||
|
if (data.length === 0) return
|
||||||
|
} else {
|
||||||
|
cachedRows = getDataRows()
|
||||||
|
if (cachedRows.length === 0) return
|
||||||
|
}
|
||||||
|
|
||||||
pendingStartY = e.clientY
|
pendingStartY = e.clientY
|
||||||
// Prevent text selection as soon as the mouse is down,
|
// Prevent text selection as soon as the mouse is down,
|
||||||
@@ -253,13 +349,19 @@ export function useSwipeSelect(
|
|||||||
document.removeEventListener('mousemove', onThresholdMove)
|
document.removeEventListener('mousemove', onThresholdMove)
|
||||||
document.removeEventListener('mouseup', onThresholdUp)
|
document.removeEventListener('mouseup', onThresholdUp)
|
||||||
|
|
||||||
beginDrag(pendingStartY)
|
if (virtualContext) {
|
||||||
|
beginDragVirtual(pendingStartY)
|
||||||
|
} else {
|
||||||
|
beginDrag(pendingStartY)
|
||||||
|
}
|
||||||
|
|
||||||
// Process the move that crossed the threshold
|
// Process the move that crossed the threshold
|
||||||
lastMouseY = e.clientY
|
lastMouseY = e.clientY
|
||||||
updateMarquee(e.clientY)
|
updateMarquee(e.clientY)
|
||||||
const rowIdx = findRowIndexAtY(e.clientY)
|
const findIdx = virtualContext ? findRowIndexAtYVirtual : findRowIndexAtY
|
||||||
if (rowIdx >= 0) applyRange(rowIdx)
|
const apply = virtualContext ? applyRangeVirtual : applyRange
|
||||||
|
const rowIdx = findIdx(e.clientY)
|
||||||
|
if (rowIdx >= 0) apply(rowIdx)
|
||||||
autoScroll(e)
|
autoScroll(e)
|
||||||
|
|
||||||
document.addEventListener('mousemove', onMouseMove)
|
document.addEventListener('mousemove', onMouseMove)
|
||||||
@@ -306,22 +408,62 @@ export function useSwipeSelect(
|
|||||||
window.getSelection()?.removeAllRanges()
|
window.getSelection()?.removeAllRanges()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Virtual mode: begin drag using data array */
|
||||||
|
function beginDragVirtual(clientY: number) {
|
||||||
|
startRowIndex = findRowIndexAtYVirtual(clientY)
|
||||||
|
const data = virtualContext!.getSortedData()
|
||||||
|
const startRowId = startRowIndex >= 0 && startRowIndex < data.length
|
||||||
|
? virtualContext!.getRowId(data[startRowIndex], startRowIndex)
|
||||||
|
: null
|
||||||
|
dragMode = (startRowId !== null && adapter.isSelected(startRowId)) ? 'deselect' : 'select'
|
||||||
|
|
||||||
|
// Build full snapshot from all data rows
|
||||||
|
initialSelectedSnapshot = new Map()
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
const id = virtualContext!.getRowId(data[i], i)
|
||||||
|
initialSelectedSnapshot.set(id, adapter.isSelected(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
isDragging.value = true
|
||||||
|
startY = clientY
|
||||||
|
lastMouseY = clientY
|
||||||
|
lastEndIndex = -1
|
||||||
|
|
||||||
|
// In virtual mode, scroll parent is the virtualizer's scroll element
|
||||||
|
const virt = virtualContext!.getVirtualizer()
|
||||||
|
cachedScrollParent = virt?.scrollElement ?? (containerRef.value ? getScrollParent(containerRef.value) : null)
|
||||||
|
|
||||||
|
createMarquee()
|
||||||
|
updateMarquee(clientY)
|
||||||
|
applyRangeVirtual(startRowIndex)
|
||||||
|
window.getSelection()?.removeAllRanges()
|
||||||
|
}
|
||||||
|
|
||||||
|
let moveRAF = 0
|
||||||
|
|
||||||
function onMouseMove(e: MouseEvent) {
|
function onMouseMove(e: MouseEvent) {
|
||||||
if (!isDragging.value) return
|
if (!isDragging.value) return
|
||||||
lastMouseY = e.clientY
|
lastMouseY = e.clientY
|
||||||
updateMarquee(e.clientY)
|
const findIdx = virtualContext ? findRowIndexAtYVirtual : findRowIndexAtY
|
||||||
const rowIdx = findRowIndexAtY(e.clientY)
|
const apply = virtualContext ? applyRangeVirtual : applyRange
|
||||||
if (rowIdx >= 0 && rowIdx !== lastEndIndex) applyRange(rowIdx)
|
cancelAnimationFrame(moveRAF)
|
||||||
|
moveRAF = requestAnimationFrame(() => {
|
||||||
|
updateMarquee(lastMouseY)
|
||||||
|
const rowIdx = findIdx(lastMouseY)
|
||||||
|
if (rowIdx >= 0 && rowIdx !== lastEndIndex) apply(rowIdx)
|
||||||
|
})
|
||||||
autoScroll(e)
|
autoScroll(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onWheel() {
|
function onWheel() {
|
||||||
if (!isDragging.value) return
|
if (!isDragging.value) return
|
||||||
|
const findIdx = virtualContext ? findRowIndexAtYVirtual : findRowIndexAtY
|
||||||
|
const apply = virtualContext ? applyRangeVirtual : applyRange
|
||||||
// After wheel scroll, rows shift in viewport — re-check selection
|
// After wheel scroll, rows shift in viewport — re-check selection
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (!isDragging.value) return // guard: drag may have ended before this frame
|
if (!isDragging.value) return // guard: drag may have ended before this frame
|
||||||
const rowIdx = findRowIndexAtY(lastMouseY)
|
const rowIdx = findIdx(lastMouseY)
|
||||||
if (rowIdx >= 0) applyRange(rowIdx)
|
if (rowIdx >= 0) apply(rowIdx)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,6 +474,7 @@ export function useSwipeSelect(
|
|||||||
cachedRows = []
|
cachedRows = []
|
||||||
initialSelectedSnapshot.clear()
|
initialSelectedSnapshot.clear()
|
||||||
cachedScrollParent = null
|
cachedScrollParent = null
|
||||||
|
cancelAnimationFrame(moveRAF)
|
||||||
stopAutoScroll()
|
stopAutoScroll()
|
||||||
removeMarquee()
|
removeMarquee()
|
||||||
document.removeEventListener('selectstart', onSelectStart)
|
document.removeEventListener('selectstart', onSelectStart)
|
||||||
@@ -372,13 +515,15 @@ export function useSwipeSelect(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (dy !== 0) {
|
if (dy !== 0) {
|
||||||
|
const findIdx = virtualContext ? findRowIndexAtYVirtual : findRowIndexAtY
|
||||||
|
const apply = virtualContext ? applyRangeVirtual : applyRange
|
||||||
const step = () => {
|
const step = () => {
|
||||||
const prevScrollTop = scrollEl.scrollTop
|
const prevScrollTop = scrollEl.scrollTop
|
||||||
scrollEl.scrollTop += dy
|
scrollEl.scrollTop += dy
|
||||||
// Only re-check selection if scroll actually moved
|
// Only re-check selection if scroll actually moved
|
||||||
if (scrollEl.scrollTop !== prevScrollTop) {
|
if (scrollEl.scrollTop !== prevScrollTop) {
|
||||||
const rowIdx = findRowIndexAtY(lastMouseY)
|
const rowIdx = findIdx(lastMouseY)
|
||||||
if (rowIdx >= 0 && rowIdx !== lastEndIndex) applyRange(rowIdx)
|
if (rowIdx >= 0 && rowIdx !== lastEndIndex) apply(rowIdx)
|
||||||
}
|
}
|
||||||
scrollRAF = requestAnimationFrame(step)
|
scrollRAF = requestAnimationFrame(step)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user