跪拜 Guibai
← Back to the summary

Drag-and-Drop Row Reordering Inside a Virtual-Scrolling Table

Universal Virtual Scrolling Table Row Drag Sorting: From Concept to Complete Implementation

Suppose you are developing a table with 100,000 rows of data. For performance, you use virtual scrolling, rendering only the dozen or so rows in the visible area on screen. Now the user wants to drag row 9527 to before row 3.

The problem: row 3 might not even be in the DOM, and there is no "target row" under the mouse to drop onto. Worse, if the table also has filtering enabled, the row numbers themselves are dynamically changing. How do we know where the user actually wants to insert?

This article first explains the core concept, then provides complete, usable code. We do not rely on global indices, only on each row's unique identifier Idd. Using a fixed row height and the current render window, we precisely calculate the insertion position during mouse movement.


1. Mental Model: Three Data Layers and Idd

In a virtual scrolling table, there are three data views:

Full data allData:      [A, B, C, D, E, F, G, H, I, J]   (possibly filtered)
                             ↓ virtual scrolling takes a window
Window data showGridData:  [C, D, E, F, G, H]               (the DOM rows actually rendered)
                             ↓
DOM rows:                 <tr C> <tr D> <tr E> <tr F> <tr G> <tr H>

The entire drag process uses only Idd to represent "what is being dragged" and "where it is dragged to". Coordinate calculations are based on the showGridData window snapshot.

showGridData usually includes not only the rows in the visible area but also a few rows above and below as a buffer (overscan). The buffer's role will be explained in the core concept.


2. The Complete Journey of a Drag

pointerdown (press handle)
   ↓
Record fromIdd and fromNextIdd
Create ghost row
   ↓
pointermove (move)
   ↓
Update ghost row position
Calculate target Idd
Update indicator line
Trigger auto-scroll if necessary
   ↓
pointerup (release)
   ↓
Determine if target differs from original position
Call onSort(fromIdd, toIdd)
Clean up temporary state

There are two core challenges:

  1. Identity stability: Use only Idd to represent the drag source and target, not relying on volatile indices.
  2. Mapping coordinates to Idd: How to convert the mouse's screen coordinates into the Idd of "which row to insert before".

Let's first explain the solution to the second challenge, which is the core of the entire scheme.


3. Core Concept: From Mouse Coordinates to Target Idd

3.1 Unified Coordinate System

The indicator line's top is an offset relative to the scroll container's content area and scrolls with the content. Therefore, we must also convert the mouse's screen coordinates into content coordinates so the two can be compared.

Conversion formula:

mouseTopInContent = (clientY - containerRect.top) + container.scrollTop

3.2 Anchor Selection

With the mouse's content coordinates, we also need to know each row's position within the content. We use the first rendered row's offsetTop:

firstRowTopInContent = firstRowEl.offsetTop

This value is the top offset of the first rendered row within the scrolling content. It does not change with scrolling and is naturally a stable anchor. The first rendered row might be a buffer row (invisible), but this doesn't matter; as long as it's in the DOM, it provides the correct reference.

Assumption: The scroll container has position: relative set and no significant borders or padding. Most virtual scrolling component containers are clean divs that satisfy this condition.

3.3 Calculating the Offset Row Count

Both values are in the content coordinate system. Subtract them and divide by the row height to get how many rows the mouse is offset by:

exact = (mouseTopInContent - firstRowTopInContent) / rowHeight

Insertion semantics are unified as "insert before the target row":

exact round(exact) Meaning
0.2 0 Insert before row 0
0.5 1 Insert before row 1
0.7 1 Insert before row 1
1.4 1 Insert before row 1
-0.3 0 Insert before row 0 (after clamp)

Math.round(exact) perfectly implements the rule of "upper half inserts before, lower half inserts after". Then constrain within the window index range:

insertIndex = clamp(Math.round(exact), 0, totalRows)

insertIndex = totalRows means insert after the end of all rendered rows.

3.4 Index to Idd

The window index is only temporary; it must be converted to a stable Idd:

targetIdd = insertIndex < totalRows
  ? showGridData[insertIndex].Idd
  : null

null means "insert at the end of the full data".

3.5 The Value of the Buffer

totalRows is the length of showGridData, and showGridData includes the visible area plus upper and lower buffers. This buffer is crucial for drag sorting:

Suppose the last row in the visible area is E, but showGridData also contains F, G, H (buffer). When the user drags a row to the visible bottom, we can still get F's Idd and precisely tell the business side "insert before F".

Without a buffer, totalRows would equal the number of visible rows. When the mouse is dragged to the visible bottom, insertIndex would directly equal totalRows, the target would become null (end of data), and precision would be greatly reduced. So the buffer not only improves the scrolling experience but also makes drag sorting more precise at the edges.


4. Indicator Line Positioning: Reusing the Same Calculation Result

The insertion point's Y coordinate within the scrolling content is simply:

indicatorTopInContent = firstRowTopInContent + insertIndex * rowHeight

When insertIndex = totalRows, the indicator line lands exactly at the bottom of the window content.

The indicator line's top directly uses content coordinates, requiring no extra conversion. Like the row elements, it is inside the scroll container and scrolls with the content, perfectly matching the semantics of "insert at a certain position in the content".

For performance, if the indicator line falls outside the visible area (with a 2px tolerance above and below), simply hide it.


5. Press and Release: Identity Judgment

5.1 What to Record on Press

When pressing the handle, record:

Why record fromNextIdd? Because the definition of "original position" is "the dragged row is still before its original next row". If toIdd still equals fromNextIdd (or equals fromIdd itself) on release, no effective move occurred.

5.2 How to Judge on Release

Suppose window data is [A, B, C], dragging B:

So we need to exclude both cases simultaneously:

toIdd !== fromIdd && toIdd !== fromNextIdd

Only when the target is neither itself nor its original next row is it considered a real move, at which point onSort(fromIdd, toIdd) is called.


6. Ghost Row and Auto-Scrolling

6.1 Ghost Row

The ghost row is a clone of the dragged row, position: fixed following the mouse. Key points:

6.2 Auto-Scrolling

When the mouse approaches the top or bottom edge of the container, start auto-scrolling. Each frame does three things:

  1. Determine if the mouse is still in the edge zone; if not, stop.
  2. Determine if scrolling can still continue (canScrollUp / canScrollDown); if not, stop.
  3. Modify scrollTop, then recalculate the target Idd.

The last step is critical: after scrolling, showGridData will update, and the first rendered row element may be replaced, so the anchor must be re-fetched every frame and cannot be cached.


7. Complete Code

import {onBeforeUnmount, ref, toValue} from 'vue'
import {clamp} from '@/tools/Tool.js'
​
/**
 * Virtual scrolling table row drag sorting composable
 *
 * @param {Object}                    options
 * @param {number}                    options.rowHeight         Row height (px), must match the table's actual row height
 * @param {import('vue').Ref<[]>}     options.showGridData     All data actually displayed by virtual scrolling
 * @param {Function}                  options.onSort           Drag end callback (fromGlobalIdx, toGlobalIdx)
 * @param {import('vue').ComputedRef<HTMLElement | null>} options.scrollContainer
 *                                                             Computed property for the scroll container
 * @param {number}                    [options.edgeZone=20]    Height of the edge zone that triggers auto-scroll (px)
 * @param {number}                    [options.scrollSpeed=8]  Auto-scroll speed (px/frame)
 * @param {string}                    [options.rowSelector='tbody tr'] Row element selector
 * @returns {Object} Contains onPointerDown, getDragRowClass
 */
export function useVirtualDragSort(options) {
    const {
        rowHeight,
        showGridData,
        onSort,
        scrollContainer,
        edgeZone = 20,
        scrollSpeed = 8,
        rowSelector = 'tbody tr'
    } = options
​
    // ==================== Internal State ====================
​
    /** Drag state: plain object, not exposed to template, avoids reactive overhead */
    let dragState = null
​
    /** Insert indicator line DOM element (lazily created) */
    let indicatorEl = null
​
    /** Auto-scroll requestAnimationFrame handle */
    let autoScrollFrame = null
​
    /** Scroll container DOM reference (lazily fetched then cached) */
    let containerEl = null
​
    /** Scroll container height cache, updated by ResizeObserver */
    let containerHeight = 0
​
    /** ResizeObserver instance, monitors container height changes */
    let resizeObserver = null
​
    /**
     * Idd of the row currently being dragged (reactive)
     * Used to dynamically bind row class in the template, solving the problem of inline style loss after virtual scrolling DOM recycling
     */
    const draggingIdd = ref(null)
​
​
    // ==================== Container and Monitoring ====================
​
    /**
     * Get scroll container (lazy initialization: fetched and cached only on first drag)
     * @returns {HTMLElement | null}
     */
    function getContainer() {
        if (!containerEl) {
            const el = toValue(scrollContainer)
            if (el) {
                containerEl = el
                containerHeight = el.clientHeight
                setupResizeObserver(el)
            }
        }
        return containerEl
    }
​
    /**
     * Set up ResizeObserver to monitor container height changes
     * @param {HTMLElement} el Scroll container
     */
    function setupResizeObserver(el) {
        cleanupResizeObserver()
​
        resizeObserver = new ResizeObserver(() => {
            // Only update cached value on height change, no repeated reads
            if (containerEl) {
                containerHeight = containerEl.clientHeight
                // Container size change may affect scrollWidth, refresh indicator line width
                updateIndicatorWidth()
            }
        })
        resizeObserver.observe(el)
    }
​
    /**
     * Clean up ResizeObserver instance
     */
    function cleanupResizeObserver() {
        if (resizeObserver) {
            resizeObserver.disconnect()
            resizeObserver = null
        }
    }
​
​
    // ==================== DOM Utilities ====================
​
    /**
     * Create ghost row (fixed positioning, follows mouse)
     * @param {HTMLElement} sourceEl The original row element being dragged
     * @returns {HTMLElement} Ghost row element
     */
    function createGhost(sourceEl) {
        const ghost = sourceEl.cloneNode(true)
        const rect = sourceEl.getBoundingClientRect()
​
        // Basic styles: fixed positioning, dimensions matching original row
        ghost.style.position = 'fixed'
        ghost.style.top = `${rect.top}px`
        ghost.style.left = `${rect.left}px`
        ghost.style.width = `${rect.width}px`
        ghost.style.height = `${rect.height}px`
        ghost.style.zIndex = '9999'
        ghost.style.pointerEvents = 'none'   // Let mouse events pass through
        ghost.style.opacity = '0.85'
        ghost.style.boxShadow = '0 4px 12px rgba(0,0,0,0.15)'
        ghost.style.margin = '0'
        ghost.style.border = '1px solid #409eff'
        ghost.style.boxSizing = 'border-box'
​
        // If source row has drag highlight class, clone will carry it; ghost row doesn't need this class
        ghost.classList.remove('is-dragging-source')
​
        document.body.appendChild(ghost)
        return ghost
    }
​
    /** Update indicator line width to equal the scroll container's full content width (scrollWidth) */
    function updateIndicatorWidth() {
        if (!indicatorEl || !containerEl) return
        indicatorEl.style.width = `${containerEl.scrollWidth}px`
    }
​
    /**
     * Create insert indicator line (absolutely positioned inside scroll container)
     * @param {HTMLElement} container Scroll container
     * @returns {HTMLElement} Indicator line element
     */
    function createIndicator(container) {
        // Ensure container can serve as reference point for absolute positioning
        if (!container.style.position || container.style.position === 'static') {
            container.style.position = 'relative'
        }
​
        const el = document.createElement('div')
        el.style.position = 'absolute'
        el.style.height = '2px'
        el.style.backgroundColor = '#409eff'
        el.style.zIndex = '1000'
        el.style.pointerEvents = 'none'
        el.style.display = 'none'
​
        container.appendChild(el)
        return el
    }
​
    /**
     * Get the first rendered row element inside the scroll container (queried in real-time; DOM changes dynamically under virtual scrolling)
     * @param {HTMLElement} container Scroll container
     * @returns {Element | null}
     */
    function getFirstRowEl(container) {
        return container.querySelector(rowSelector)
    }
​
​
    // ==================== Indicator Line Positioning ====================
​
    /**
     * Update insert indicator line position
     * Indicator line is always at the top of the target row (i.e., insert before that row)
     * When targetIndex is null, it means insert at the end; indicator line is at the content bottom (total rows * row height)
     * @param {number|null} targetIndex          Insertion point index (based on current render window), null means end
     * @param {number}      firstRowTopInContent Y coordinate of the first rendered row's top within the scrolling content
     * @param {HTMLElement} container            Scroll container
     */
    function updateIndicator(
        targetIndex,
        firstRowTopInContent,
        container
    ) {
        if (!container || !indicatorEl) return
​
        // Calculate indicator line's Y coordinate within the scrolling content
        // If targetIndex is null, use current window total row count as index (i.e., content bottom)
        const totalRows = toValue(showGridData).length
        const effectiveIndex = targetIndex === null ? totalRows : targetIndex
        const indicatorTopInContent = firstRowTopInContent + effectiveIndex * rowHeight
​
        // Viewport clipping to avoid flickering near scrollbar
        const scrollTop = container.scrollTop
        const viewportTop = scrollTop
        const viewportBottom = scrollTop + containerHeight
        if (indicatorTopInContent < viewportTop - 2 || indicatorTopInContent > viewportBottom + 2) {
            indicatorEl.style.display = 'none'
            return
        }
​
        indicatorEl.style.top = `${indicatorTopInContent}px`
        indicatorEl.style.display = 'block'
    }
​
    // ==================== Core Calculation ====================
​
    /**
     * Update target insertion row and indicator line based on mouse Y coordinate
     * Target row Idd means "insert before this row"; if null, means insert at the end
     * @param {number} clientY Mouse's clientY coordinate
     */
    function updateTargetFromPoint(clientY) {
        if (!dragState) return
​
        const container = getContainer()
        if (!container) return
​
        const firstRowEl = getFirstRowEl(container)
        if (!firstRowEl) return
​
        const containerRect = container.getBoundingClientRect()
        const firstRowRect = firstRowEl.getBoundingClientRect()
​
        // Y coordinate of the first rendered row's top within the scrolling content
        const firstRowTopInContent =
            firstRowRect.top - containerRect.top - container.clientTop + container.scrollTop
​
        // Mouse's Y coordinate within the scrolling content
        const mouseTopInContent =
            clientY - containerRect.top - container.clientTop + container.scrollTop
​
        // Mouse offset relative to the first rendered row (in row height units)
        const exact = (mouseTopInContent - firstRowTopInContent) / rowHeight
​
        // Current window total row count (length of showGridData)
        const totalRows = toValue(showGridData).length
​
        // Insertion point index: round to nearest integer row position, range [0, totalRows]
        const insertIndex = clamp(Math.round(exact), 0, totalRows)
​
        // Target row Idd: null when insertion point is at the end, otherwise take corresponding row data
        dragState.toIdd = insertIndex < totalRows
            ? toValue(showGridData)[insertIndex].Idd
            : null
​
        // Pass targetIndex (null means end) to updateIndicator
        const targetIndex = insertIndex < totalRows ? insertIndex : null
        updateIndicator(targetIndex, firstRowTopInContent, container)
    }
​
    // ==================== Auto-Scrolling ====================
​
    /**
     * Get auto-scroll edge thresholds
     * @param {HTMLElement} container Scroll container
     * @returns {{top: number, bottom: number}} Upper and lower thresholds (clientY coordinates)
     */
    function getScrollThresholds(container) {
        const rect = container.getBoundingClientRect()
        return {
            top: rect.top + edgeZone,
            bottom: rect.top + containerHeight - edgeZone,
        }
    }
​
    /**
     * Determine if mouse is in the auto-scroll edge zone
     * Uses module-level dragState.mouseY as current mouse Y coordinate
     * @returns {boolean}
     */
    function isInAutoScrollZone() {
        if (!dragState) return false
​
        const container = getContainer()
        if (!container) return false
​
        const {top, bottom} = getScrollThresholds(container)
        return dragState.mouseY < top || dragState.mouseY > bottom
    }
​
    /**
     * Determine if container can still scroll up
     * @param {HTMLElement} container Scroll container
     * @returns {boolean} Returns true if can scroll up, otherwise false
     */
    function canScrollUp(container) {
        return container.scrollTop > 0
    }
​
    /**
     * Determine if container can still scroll down
     * @param {HTMLElement} container Scroll container
     * @returns {boolean} Returns true if can scroll down, otherwise false
     */
    function canScrollDown(container) {
        const maxScrollTop = container.scrollHeight - container.clientHeight
        return container.scrollTop < maxScrollTop
    }
​
    /**
     * Start auto-scrolling (continuously scrolls while mouse is in container edge zone)
     */
    function startAutoScroll() {
        if (autoScrollFrame) return
​
        const step = () => {
            const container = getContainer()
            if (!container) return stopAutoScroll()
​
            // Mouse not in edge zone, stop auto-scrolling
            if (!isInAutoScrollZone()) return stopAutoScroll()
​
            // Decide scroll direction based on mouse position
            const {top} = getScrollThresholds(container)
            if (dragState.mouseY < top) {
                // Scroll up
                if (!canScrollUp(container)) return stopAutoScroll()
                container.scrollTop -= scrollSpeed
            } else {
                // Scroll down
                if (!canScrollDown(container)) return stopAutoScroll()
                container.scrollTop += scrollSpeed
            }
​
            // After scrolling, mouse position unchanged but content position changed; must recalculate insertion position
            updateTargetFromPoint(dragState.mouseY)
​
            // Continue next frame
            autoScrollFrame = requestAnimationFrame(step)
        }
​
        autoScrollFrame = requestAnimationFrame(step)
    }
​
    /**
     * Stop auto-scrolling
     */
    function stopAutoScroll() {
        if (autoScrollFrame) {
            cancelAnimationFrame(autoScrollFrame)
            autoScrollFrame = null
        }
    }
​
​
    // ==================== Event Handling ====================
​
    /**
     * Global pointermove event handler
     * @param {PointerEvent} e
     */
    function onPointerMove(e) {
        if (!dragState) return
​
        // Update mouse coordinates
        dragState.mouseY = e.clientY
​
        // Update ghost row position (offset relative to start position)
        const deltaY = e.clientY - dragState.startY
        dragState.ghostEl.style.transform = `translateY(${deltaY}px)`
​
        // Update insertion position and indicator line
        updateTargetFromPoint(e.clientY)
​
        // Determine if auto-scroll is needed based on current mouse position
        if (isInAutoScrollZone()) startAutoScroll()
        else stopAutoScroll()
    }
​
    /**
     * Global pointerup event handler: end drag and trigger sorting
     */
    function onPointerUp() {
        if (!dragState) return
​
        // Remove global event listeners
        document.removeEventListener('pointermove', onPointerMove)
        document.removeEventListener('pointerup', onPointerUp)
​
        // Stop auto-scrolling
        stopAutoScroll()
​
        // Remove ghost row
        dragState.ghostEl?.remove()
​
        // Hide indicator line
        if (indicatorEl) indicatorEl.style.display = 'none'
​
        // Clear dragging row ID; template will automatically remove source row highlight class
        draggingIdd.value = null
​
        // Trigger sorting (only when target position differs from start position)
        if (
            dragState.toIdd !== dragState.fromIdd &&
            dragState.toIdd !== dragState.fromNextIdd
        ) onSort(dragState.fromIdd, dragState.toIdd)
​
        // Reset drag state
        dragState = null
    }
​
    /**
     * Drag handle pointerdown event: start drag
     * @param {PointerEvent} e
     * @param {Object} row Current row data (must include primary key Idd)
     */
    function onPointerDown(e, row) {
        // Only respond to left mouse button
        if (e.button !== undefined && e.button !== 0) return
        e.preventDefault()
​
        // Get the row element being dragged (compatible with el-table internal structure)
        const rowEl = e.target.closest(rowSelector)
        if (!rowEl) return
​
        // Get scroll container (lazy initialization)
        const container = getContainer()
        if (!container) return
​
        // Create ghost row
        const ghostEl = createGhost(rowEl)
​
        // Set dragging row ID for template dynamic highlight class binding
        draggingIdd.value = row.Idd
​
        // Create indicator line (if not already)
        if (!indicatorEl) {
            indicatorEl = createIndicator(container)
            // Initial width setting
            updateIndicatorWidth()
        }
​
        // Index of dragged element in showGridData
        const totalRows = toValue(showGridData)
        const fromIndex = totalRows?.findIndex(r => r?.Idd === row?.Idd)
        // Get Idd of the next row after dragged row; if last row, null, meaning insert at end
        const fromNextIdd = totalRows?.[fromIndex + 1]?.Idd ?? null
​
        // Initialize drag state
        dragState = {
            fromIdd: row.Idd,
            fromNextIdd,
            toIdd: fromNextIdd,
            startY: e.clientY,
            mouseY: e.clientY,
            ghostEl,
        }
​
        // Register global events
        document.addEventListener('pointermove', onPointerMove)
        document.addEventListener('pointerup', onPointerUp)
    }
​
​
    // ==================== Row Class Calculation ====================
​
    /**
     * Calculate row class based on current drag state (for merging with other row class logic)
     * @param {Object} params
     * @param {Object} params.row - Row object
     * @returns {string} Returns class string, e.g., 'is-dragging-source' or ''
     */
    function getDragRowClass({row}) {
        return row.Idd === draggingIdd.value ? 'is-dragging-source' : ''
    }
​
​
    // ==================== Cleanup ====================
​
    /**
     * Clean up all resources (called on component unmount)
     */
    function destroy() {
        // Remove global event listeners
        document.removeEventListener('pointermove', onPointerMove)
        document.removeEventListener('pointerup', onPointerUp)
​
        // Stop auto-scrolling
        stopAutoScroll()
​
        // Clean up drag state
        if (dragState) {
            dragState.ghostEl?.remove()
            dragState = null
        }
​
        // Remove indicator line
        if (indicatorEl) {
            indicatorEl.remove()
            indicatorEl = null
        }
​
        // Clear dragging row ID
        draggingIdd.value = null
​
        // Clean up ResizeObserver and container reference
        cleanupResizeObserver()
        containerEl = null
        containerHeight = 0
    }
​
    // Auto-cleanup on component unmount
    onBeforeUnmount(destroy)
​
    return {
        onPointerDown,
        getDragRowClass,
    }
}
​
export default useVirtualDragSort

8. Usage Example

import { useVirtualDragSort } from './useVirtualDragSort'
​
const { onPointerDown, getDragRowClass } = useVirtualDragSort({
  rowHeight: 25,
  showGridData,       // Ref, virtual scrolling window data (includes buffer)
  scrollContainer,    // ComputedRef<HTMLElement | null>
  onSort: (fromIdd, toIdd) => {
    const data = allData.value
    const fromIndex = data.findIndex(r => r.Idd === fromIdd)
    if (fromIndex === -1) return
​
    const [moved] = data.splice(fromIndex, 1)
​
    if (toIdd === null) {
      data.push(moved)
    } else {
      const toIndex = data.findIndex(r => r.Idd === toIdd)
      if (toIndex === -1) {
        data.splice(fromIndex, 0, moved) // fallback
        return
      }
      data.splice(toIndex, 0, moved)
    }
  },
})

Template:

<tr
  v-for="row in showGridData"
  :key="row.Idd"
  :class="getDragRowClass({ row })"
  @pointerdown="onPointerDown($event, row)"
>
  <td>{{ row.name }}</td>
  <!-- other columns -->
</tr>

CSS:

.is-dragging-source {
  opacity: 0.5;
}

9. Edge Cases and Limitations


10. Summary

The core of this scheme can be condensed into four sentences:

  1. Anchor on stable Idd: The entire drag process uses only Idd to represent identity, completely摆脱 index dependency.
  2. Simplify content coordinate calculation with offsetTop: The first rendered row's offsetTop directly gives the content offset. Subtract from the mouse's converted content coordinate, divide by row height, and round to get the window insertion index.
  3. Indicator line directly reuses the same calculation result: firstRowTopInContent + insertIndex * rowHeight is the indicator line's top, requiring no additional query of the target row DOM.
  4. Leverage buffer for precise insertion: showGridData naturally provides the Idd of "the next item after the window end", enabling precise positioning even when dragging to the edge of the visible area.

This scheme not only solves the coordinate mapping problem under virtual scrolling but also considers interaction experience (ghost row, indicator line, auto-scrolling) and performance (rAF, transform, ResizeObserver). The business side only needs to operate the full data array in the onSort callback, without worrying about the complexity of virtual scrolling.