Drag-and-Drop Row Reordering Inside a Virtual-Scrolling Table
Virtual scrolling is standard for large datasets, but row reordering has been a gap — most solutions fall back to index-based logic that breaks when rows are filtered or recycled. This approach decouples drag-and-drop from the DOM entirely, making it safe for any flat virtual-scrolled list.
Dragging a row to a new position in a virtual-scrolling table breaks the usual DOM-based approach: the target row may not be rendered, and indices shift under filtering. This composable solves it by anchoring everything on a stable row ID (`Idd`) and converting mouse screen coordinates into content-space coordinates relative to the first rendered row's `offsetTop`. Dividing the offset by a fixed row height and rounding gives a window insertion index, which maps back to a target `Idd` — including `null` for "insert at end."
The overscan buffer rows that virtual scrollers already keep above and below the viewport turn out to be essential for precision: without them, dragging to the visible bottom would always resolve to "end of data" instead of "before the next off-screen row." A ghost row follows the pointer via `transform: translateY`, an indicator line reuses the same coordinate calculation, and auto-scrolling kicks in near the container edges, recalculating the target every frame since the render window shifts.
The full implementation is a single Vue 3 composable (`useVirtualDragSort`) that takes `rowHeight`, the reactive `showGridData` window, a scroll-container computed ref, and an `onSort(fromIdd, toIdd)` callback. It handles edge cases like dragging the last row, clamping out-of-bounds coordinates, and cleaning up ResizeObserver, rAF loops, and global listeners on unmount.
Virtual scrolling's overscan buffer — usually justified only for scroll smoothness — turns out to be the mechanism that makes edge-of-viewport drop targeting precise. Without it, the algorithm degrades to a coarse "end of list" guess.
The entire coordinate system collapses to a single subtraction and division because the first rendered row's `offsetTop` is a stable content-space anchor that doesn't depend on scroll position. This sidesteps the usual mess of tracking which rows are currently in the DOM.
Using `fromNextIdd` (the ID of the row after the dragged row) as the definition of "original position" handles the case where dropping on the dragged row's own upper half would otherwise look like a move. It's a small state detail that eliminates a whole class of false-positive reorderings.
The design keeps the composable's internal drag state as a plain object rather than a reactive ref, avoiding Vue's proxy overhead on high-frequency pointermove events — a practical performance choice that many composables overlook.