11import arrayMove from 'array-move'
22import React , { HTMLAttributes } from 'react'
33
4- import { findItemIndexAtPosition } from './helpers'
4+ import { findItemIndexAtPosition , getScrollableParent } from './helpers'
55import { useDrag , useDropTarget } from './hooks'
66import { Point } from './types'
77
@@ -27,6 +27,8 @@ type Props<TTag extends keyof JSX.IntrinsicElements> = HTMLAttributes<TTag> & {
2727 customHolderRef ?: React . RefObject < HTMLElement | null >
2828 /** Drop target to be used when dragging */
2929 dropTarget ?: React . ReactNode
30+ /** Scroll the window or parent element when dragging */
31+ autoScroll ?: boolean
3032}
3133
3234// this context is only used so that SortableItems can register/remove themselves
@@ -50,6 +52,7 @@ const SortableList = <TTag extends keyof JSX.IntrinsicElements = typeof DEFAULT_
5052 lockAxis,
5153 customHolderRef,
5254 dropTarget,
55+ autoScroll = false ,
5356 ...rest
5457} : Props < TTag > ) => {
5558 // this array contains the elements than can be sorted (wrapped inside SortableItem)
@@ -72,6 +75,38 @@ const SortableList = <TTag extends keyof JSX.IntrinsicElements = typeof DEFAULT_
7275 const dropTargetLogic = useDropTarget ( dropTarget )
7376 // contains the original opacity of the sorted item in order te restore it correctly
7477 const sourceOpacityRef = React . useRef < string > ( '1' )
78+ // contains the speed at which the container scrolls horizontally or vertically when auto scrolling
79+ const scrollSpeedRef = React . useRef < Point > ( { x : 0 , y : 0 } )
80+ // contains the auto scroll animation
81+ const scrollAnimationRef = React . useRef < number | null > ( null )
82+ // contains the scrollable list parent (element or window)
83+ const scrollContainerRef = React . useRef < HTMLElement | Window | null > ( null )
84+ // contains the horizontal and vertical scroll positions of the container
85+ const initialScrollRef = React . useRef < Point > ( { x : 0 , y : 0 } )
86+
87+ // auto scroll method
88+ const autoScrolling = React . useCallback ( ( ) => {
89+ const scroller = scrollContainerRef . current
90+ const scrollSpeed = scrollSpeedRef . current
91+
92+ if ( ( scrollSpeed . x === 0 && scrollSpeed . y === 0 ) || ! scroller ) {
93+ if ( scrollAnimationRef . current ) {
94+ cancelAnimationFrame ( scrollAnimationRef . current )
95+ scrollAnimationRef . current = null
96+ }
97+ return
98+ }
99+
100+ // handle both window and element scrolling
101+ if ( scroller instanceof Window ) {
102+ scroller . scrollBy ( scrollSpeed . x , scrollSpeed . y )
103+ } else if ( scroller instanceof HTMLElement ) {
104+ scroller . scrollTop += scrollSpeed . y
105+ scroller . scrollLeft += scrollSpeed . x
106+ }
107+
108+ scrollAnimationRef . current = requestAnimationFrame ( autoScrolling )
109+ } , [ ] )
75110
76111 React . useEffect ( ( ) => {
77112 const holder = customHolderRef ?. current || document . body
@@ -143,6 +178,20 @@ const SortableList = <TTag extends keyof JSX.IntrinsicElements = typeof DEFAULT_
143178 return
144179 }
145180
181+ // auto scrolling of the container
182+ if ( autoScroll ) {
183+ const scroller = getScrollableParent ( containerRef . current )
184+ scrollContainerRef . current = scroller
185+
186+ // record the starting scroll position to calculate the scroll delta later
187+ // record the starting scroll position for both axes
188+ if ( scroller instanceof HTMLElement ) {
189+ initialScrollRef . current = { y : scroller . scrollTop , x : scroller . scrollLeft }
190+ } else {
191+ initialScrollRef . current = { y : scroller . scrollY , x : scroller . scrollX }
192+ }
193+ }
194+
146195 itemsRect . current = itemsRef . current . map ( ( item ) => item . getBoundingClientRect ( ) )
147196
148197 const sourceIndex = findItemIndexAtPosition ( pointInWindow , itemsRect . current )
@@ -156,15 +205,15 @@ const SortableList = <TTag extends keyof JSX.IntrinsicElements = typeof DEFAULT_
156205
157206 // let the parent know that sort started
158207 if ( onSortStart ) {
159- onSortStart ( ) ;
208+ onSortStart ( )
160209 }
161210
162211 // the item being dragged is copied to the document body and will be used as the target
163212 copyItem ( sourceIndex )
164213
165214 // hide source during the drag gesture (and store original opacity)
166215 const source = itemsRef . current [ sourceIndex ]
167- sourceOpacityRef . current = source . style . opacity ?? '1' ;
216+ sourceOpacityRef . current = source . style . opacity ?? '1'
168217 source . style . opacity = '0'
169218 source . style . visibility = 'hidden'
170219
@@ -175,6 +224,7 @@ const SortableList = <TTag extends keyof JSX.IntrinsicElements = typeof DEFAULT_
175224 y : pointInWindow . y - sourceRect . top ,
176225 }
177226
227+ // set the initial position of the cloned item
178228 updateTargetPosition ( pointInWindow )
179229 dropTargetLogic . show ?.( sourceRect )
180230
@@ -184,18 +234,86 @@ const SortableList = <TTag extends keyof JSX.IntrinsicElements = typeof DEFAULT_
184234 }
185235 } ,
186236 onMove : ( { pointInWindow } ) => {
237+ if ( ! containerRef . current ) {
238+ return
239+ }
240+
187241 updateTargetPosition ( pointInWindow )
188242
189243 const sourceIndex = sourceIndexRef . current
190244 // if there is no source, we exit early (happened when drag gesture was started outside a SortableItem)
191- if ( sourceIndex === undefined || sourceIndexRef . current === undefined ) {
245+ if ( sourceIndex === undefined ) {
192246 return
193247 }
194248
195- const sourceRect = itemsRect . current [ sourceIndexRef . current ]
249+ if ( autoScroll ) {
250+ const scroller = scrollContainerRef . current
251+ if ( scroller ) {
252+ // auto scroll trigger logic point
253+ const SCROLL_THRESHOLD = 60
254+ const MAX_SCROLL_SPEED = 15
255+ const { x : pointerX , y : pointerY } = pointInWindow
256+
257+ // reset speed before recalculating
258+ scrollSpeedRef . current = { x : 0 , y : 0 }
259+
260+ let scrollerRect
261+ if ( scroller instanceof Window ) {
262+ scrollerRect = { top : 0 , bottom : scroller . innerHeight , left : 0 , right : scroller . innerWidth }
263+ } else {
264+ scrollerRect = scroller . getBoundingClientRect ( )
265+ }
266+
267+ // vertical scroll logic (if not locked)
268+ if ( lockAxis !== 'x' ) {
269+ if ( pointerY < scrollerRect . top + SCROLL_THRESHOLD && pointerY >= scrollerRect . top ) {
270+ const proximity = ( scrollerRect . top + SCROLL_THRESHOLD ) - pointerY
271+ scrollSpeedRef . current . y = - MAX_SCROLL_SPEED * ( proximity / SCROLL_THRESHOLD )
272+ } else if ( pointerY > scrollerRect . bottom - SCROLL_THRESHOLD && pointerY <= scrollerRect . bottom ) {
273+ const proximity = pointerY - ( scrollerRect . bottom - SCROLL_THRESHOLD )
274+ scrollSpeedRef . current . y = MAX_SCROLL_SPEED * ( proximity / SCROLL_THRESHOLD )
275+ }
276+ }
277+
278+ // horizontal scroll logic (if not locked)
279+ if ( lockAxis !== 'y' ) {
280+ if ( pointerX < scrollerRect . left + SCROLL_THRESHOLD && pointerX >= scrollerRect . left ) {
281+ const proximity = ( scrollerRect . left + SCROLL_THRESHOLD ) - pointerX
282+ scrollSpeedRef . current . x = - MAX_SCROLL_SPEED * ( proximity / SCROLL_THRESHOLD )
283+ } else if ( pointerX > scrollerRect . right - SCROLL_THRESHOLD && pointerX <= scrollerRect . right ) {
284+ const proximity = pointerX - ( scrollerRect . right - SCROLL_THRESHOLD )
285+ scrollSpeedRef . current . x = MAX_SCROLL_SPEED * ( proximity / SCROLL_THRESHOLD )
286+ }
287+ }
288+
289+ // start animation if speed is not 0
290+ if ( ( scrollSpeedRef . current . x !== 0 || scrollSpeedRef . current . y !== 0 ) && ! scrollAnimationRef . current ) {
291+ scrollAnimationRef . current = requestAnimationFrame ( autoScrolling )
292+ }
293+ }
294+ }
295+
296+ // drop-point correction for both axes
297+ let scrollDelta = { x : 0 , y : 0 }
298+ if ( autoScroll && scrollContainerRef . current ) {
299+ const scroller = scrollContainerRef . current
300+ if ( scroller instanceof HTMLElement ) {
301+ scrollDelta = {
302+ y : scroller . scrollTop - initialScrollRef . current . y ,
303+ x : scroller . scrollLeft - initialScrollRef . current . x
304+ }
305+ } else if ( scroller instanceof Window ) {
306+ scrollDelta = {
307+ y : scroller . scrollY - initialScrollRef . current . y ,
308+ x : scroller . scrollX - initialScrollRef . current . x
309+ }
310+ }
311+ }
312+
313+ const sourceRect = itemsRect . current [ sourceIndex ]
196314 const targetPoint : Point = {
197- x : lockAxis === 'y' ? sourceRect . left : pointInWindow . x ,
198- y : lockAxis === 'x' ? sourceRect . top : pointInWindow . y ,
315+ x : ( lockAxis === 'y' ? sourceRect . left : pointInWindow . x ) + scrollDelta . x ,
316+ y : ( lockAxis === 'x' ? sourceRect . top : pointInWindow . y ) + scrollDelta . y ,
199317 }
200318
201319 const targetIndex = findItemIndexAtPosition ( targetPoint , itemsRect . current , {
@@ -208,7 +326,7 @@ const SortableList = <TTag extends keyof JSX.IntrinsicElements = typeof DEFAULT_
208326
209327 // if targetIndex changed and last target index is set we can let the parent know the new position
210328 if ( onSortMove && lastTargetIndexRef . current !== undefined && lastTargetIndexRef . current !== targetIndex ) {
211- onSortMove ( targetIndex ) ;
329+ onSortMove ( targetIndex )
212330 }
213331
214332 // we keep track of the last target index (to be passed to the onSortEnd callback)
@@ -245,6 +363,19 @@ const SortableList = <TTag extends keyof JSX.IntrinsicElements = typeof DEFAULT_
245363 dropTargetLogic . setPosition ?.( lastTargetIndexRef . current , itemsRect . current , lockAxis )
246364 } ,
247365 onEnd : ( ) => {
366+ // reset auto scroll variables
367+ if ( autoScroll ) {
368+ // reset the ref that holds the scrollable container
369+ scrollContainerRef . current = null
370+ // reset the scroll speed
371+ scrollSpeedRef . current = { x : 0 , y : 0 }
372+ // cancel any ongoing animation frame loop
373+ if ( scrollAnimationRef . current ) {
374+ cancelAnimationFrame ( scrollAnimationRef . current )
375+ scrollAnimationRef . current = null
376+ }
377+ }
378+
248379 // we reset all items translations (the parent is expected to sort the items in the onSortEnd callback)
249380 for ( let index = 0 ; index < itemsRef . current . length ; index += 1 ) {
250381 const currentItem = itemsRef . current [ index ]
@@ -271,6 +402,8 @@ const SortableList = <TTag extends keyof JSX.IntrinsicElements = typeof DEFAULT_
271402 }
272403 }
273404 }
405+
406+ // reset internal state refs
274407 sourceIndexRef . current = undefined
275408 lastTargetIndexRef . current = undefined
276409 dropTargetLogic . hide ?.( )
0 commit comments