@@ -12,6 +12,8 @@ type CellPosition = {
1212}
1313
1414const CELL_PREVIEW_TEXT_LIMIT = 150
15+ const DRAG_SCROLL_THRESHOLD = 48
16+ const DRAG_SCROLL_STEP = 20
1517
1618const props = withDefaults (
1719 defineProps <{
@@ -38,6 +40,9 @@ const editingCell = ref<CellPosition | null>(null)
3840const editingValue = ref <unknown >(' ' )
3941const isDragging = ref (false )
4042const contextCell = ref <CellPosition | null >(null )
43+ const dragPointer = ref <{ x: number ; y: number } | null >(null )
44+
45+ let dragScrollFrame = 0
4146
4247const createRowId = () =>
4348 typeof crypto !== ' undefined' && ' randomUUID' in crypto
@@ -143,6 +148,21 @@ const focusGrid = () => {
143148 gridContainer .value ?.focus ()
144149}
145150
151+ const getCellElement = (cell : CellPosition ) =>
152+ gridContainer .value ?.querySelector <HTMLTableCellElement >(
153+ ` td[data-cell-row="${cell .row }"][data-cell-column="${cell .column }"] ` ,
154+ ) ?? null
155+
156+ const ensureCellVisible = (cell : CellPosition ) => {
157+ nextTick (() => {
158+ const targetCell = getCellElement (cell )
159+ targetCell ?.scrollIntoView ({
160+ block: ' nearest' ,
161+ inline: ' nearest' ,
162+ })
163+ })
164+ }
165+
146166const restoreGridFocusAfterEdit = () => {
147167 nextTick (() => {
148168 requestAnimationFrame (() => {
@@ -165,7 +185,10 @@ const finishPendingEdit = (nextCell?: CellPosition) => {
165185 commitEditing ()
166186}
167187
168- const setSelection = (cell : CellPosition , options ? : { extend? : boolean }) => {
188+ const setSelection = (
189+ cell : CellPosition ,
190+ options ? : { extend? : boolean ; ensureVisible? : boolean },
191+ ) => {
169192 if (! hasRows .value ) return
170193
171194 const nextCell = clampCell (cell )
@@ -179,6 +202,10 @@ const setSelection = (cell: CellPosition, options?: { extend?: boolean }) => {
179202 }
180203
181204 focusGrid ()
205+
206+ if (options ?.ensureVisible !== false ) {
207+ ensureCellVisible (nextCell )
208+ }
182209}
183210
184211const selectAll = () => {
@@ -543,10 +570,94 @@ const moveSelection = (deltaRow: number, deltaColumn: number, extend: boolean) =
543570 row: baseCell .row + deltaRow ,
544571 column: baseCell .column + deltaColumn ,
545572 },
546- { extend },
573+ { extend , ensureVisible: true },
547574 )
548575}
549576
577+ const updateDragSelectionFromPointer = (clientX : number , clientY : number ) => {
578+ if (! isDragging .value || ! anchorCell .value || ! gridContainer .value ) {
579+ return
580+ }
581+
582+ const containerRect = gridContainer .value .getBoundingClientRect ()
583+ const probeX = Math .min (Math .max (clientX , containerRect .left + 2 ), containerRect .right - 2 )
584+ const probeY = Math .min (Math .max (clientY , containerRect .top + 2 ), containerRect .bottom - 2 )
585+ const element = document .elementFromPoint (probeX , probeY )
586+ const cell = element ?.closest ?.(' td[data-cell-row][data-cell-column]' ) as HTMLTableCellElement | null
587+
588+ if (! cell ) {
589+ return
590+ }
591+
592+ const row = Number (cell .dataset .cellRow )
593+ const column = Number (cell .dataset .cellColumn )
594+
595+ if (Number .isNaN (row ) || Number .isNaN (column )) {
596+ return
597+ }
598+
599+ focusCell .value = clampCell ({ row , column })
600+ }
601+
602+ const stopDragAutoScroll = () => {
603+ if (dragScrollFrame ) {
604+ cancelAnimationFrame (dragScrollFrame )
605+ dragScrollFrame = 0
606+ }
607+ }
608+
609+ const runDragAutoScroll = () => {
610+ if (! isDragging .value || ! dragPointer .value || ! gridContainer .value ) {
611+ dragScrollFrame = 0
612+ return
613+ }
614+
615+ const container = gridContainer .value
616+ const rect = container .getBoundingClientRect ()
617+ let deltaX = 0
618+ let deltaY = 0
619+
620+ if (dragPointer .value .x < rect .left + DRAG_SCROLL_THRESHOLD ) {
621+ deltaX = - Math .ceil (
622+ ((rect .left + DRAG_SCROLL_THRESHOLD - dragPointer .value .x ) / DRAG_SCROLL_THRESHOLD ) *
623+ DRAG_SCROLL_STEP ,
624+ )
625+ } else if (dragPointer .value .x > rect .right - DRAG_SCROLL_THRESHOLD ) {
626+ deltaX = Math .ceil (
627+ ((dragPointer .value .x - (rect .right - DRAG_SCROLL_THRESHOLD )) / DRAG_SCROLL_THRESHOLD ) *
628+ DRAG_SCROLL_STEP ,
629+ )
630+ }
631+
632+ if (dragPointer .value .y < rect .top + DRAG_SCROLL_THRESHOLD ) {
633+ deltaY = - Math .ceil (
634+ ((rect .top + DRAG_SCROLL_THRESHOLD - dragPointer .value .y ) / DRAG_SCROLL_THRESHOLD ) *
635+ DRAG_SCROLL_STEP ,
636+ )
637+ } else if (dragPointer .value .y > rect .bottom - DRAG_SCROLL_THRESHOLD ) {
638+ deltaY = Math .ceil (
639+ ((dragPointer .value .y - (rect .bottom - DRAG_SCROLL_THRESHOLD )) / DRAG_SCROLL_THRESHOLD ) *
640+ DRAG_SCROLL_STEP ,
641+ )
642+ }
643+
644+ if (deltaX || deltaY ) {
645+ container .scrollLeft += deltaX
646+ container .scrollTop += deltaY
647+ updateDragSelectionFromPointer (dragPointer .value .x , dragPointer .value .y )
648+ }
649+
650+ dragScrollFrame = requestAnimationFrame (runDragAutoScroll )
651+ }
652+
653+ const startDragAutoScroll = () => {
654+ if (dragScrollFrame ) {
655+ return
656+ }
657+
658+ dragScrollFrame = requestAnimationFrame (runDragAutoScroll )
659+ }
660+
550661const onGridKeyDown = (event : KeyboardEvent ) => {
551662 if (event .target instanceof HTMLInputElement || event .target instanceof HTMLTextAreaElement )
552663 return
@@ -628,7 +739,12 @@ const onCellMouseDown = (event: MouseEvent, rowIndex: number, columnIndex: numbe
628739
629740 event .preventDefault ()
630741 isDragging .value = true
631- setSelection ({ row: rowIndex , column: columnIndex }, { extend: event .shiftKey })
742+ dragPointer .value = { x: event .clientX , y: event .clientY }
743+ setSelection (
744+ { row: rowIndex , column: columnIndex },
745+ { extend: event .shiftKey , ensureVisible: true },
746+ )
747+ startDragAutoScroll ()
632748}
633749
634750const onCellMouseEnter = (_event : MouseEvent , rowIndex : number , columnIndex : number ) => {
@@ -638,8 +754,20 @@ const onCellMouseEnter = (_event: MouseEvent, rowIndex: number, columnIndex: num
638754 focusCell .value = clampCell ({ row: rowIndex , column: columnIndex })
639755}
640756
757+ const onDocumentMouseMove = (event : MouseEvent ) => {
758+ if (! isDragging .value ) {
759+ return
760+ }
761+
762+ dragPointer .value = { x: event .clientX , y: event .clientY }
763+ updateDragSelectionFromPointer (event .clientX , event .clientY )
764+ startDragAutoScroll ()
765+ }
766+
641767const onDocumentMouseUp = () => {
642768 isDragging .value = false
769+ dragPointer .value = null
770+ stopDragAutoScroll ()
643771}
644772
645773const showContextMenu = (event : MouseEvent , rowIndex ? : number , columnIndex ? : number ) => {
@@ -712,10 +840,13 @@ const contextMenuItems = computed(() => [
712840])
713841
714842onMounted (() => {
843+ window .addEventListener (' mousemove' , onDocumentMouseMove )
715844 window .addEventListener (' mouseup' , onDocumentMouseUp )
716845})
717846
718847onUnmounted (() => {
848+ stopDragAutoScroll ()
849+ window .removeEventListener (' mousemove' , onDocumentMouseMove )
719850 window .removeEventListener (' mouseup' , onDocumentMouseUp )
720851})
721852
@@ -814,6 +945,8 @@ defineExpose({
814945 <td
815946 v-for =" (column, columnIndex) of columns"
816947 :key =" column.field"
948+ :data-cell-row =" rowIndex"
949+ :data-cell-column =" columnIndex"
817950 class =" border border-neutral-200 dark:border-neutral-800 mono text-xs align-top"
818951 :class =" {
819952 'bg-primary-500/18 ring-inset ring-1 ring-primary-500/30': isCellSelected(
0 commit comments