1
+ import React , {
2
+ createContext ,
3
+ useContext ,
4
+ useEffect ,
5
+ useReducer ,
6
+ useRef , useState
7
+ } from 'react' ;
8
+
9
+ // Tired of immutable update patterns? Take a look at immer, it's awesome!
10
+ import produce from 'immer' ;
11
+
12
+ // Hooks do not share any state between components, but we have context for it
13
+ const SortableContext = createContext ( ) ;
14
+
15
+ // Our library needs DOM nodes of every single item we want to reorder
16
+ // This custom hook will add React ref to the element and then push respective DOM node to the array on nodes on mount
17
+ function useSortableElement ( ) {
18
+ const ref = useRef ( null ) ;
19
+ const { addNode } = useContext ( SortableContext ) ;
20
+
21
+ useEffect ( ( ) => {
22
+ addNode ( ref . current ) ;
23
+ } , [ ] ) ;
24
+
25
+ return {
26
+ ref,
27
+ style : {
28
+ userSelect : 'none'
29
+ }
30
+ } ;
31
+ }
32
+
33
+ // We pass initial array of items to this hook which will reorder them after every 'dragEnd'
34
+ // Our component will then be automatically notified about it and rerender
35
+ function useSortable ( initialItems ) {
36
+ const [ items , setItems ] = useState ( initialItems ) ;
37
+ const { isDragging, oldIndex, newIndex } = useContext ( SortableContext ) ;
38
+
39
+ useEffect ( ( ) => {
40
+ // When dragging has ended and had results
41
+ if ( isDragging === false && oldIndex !== newIndex ) {
42
+ // Move item from old index to new index in array
43
+ setItems ( produce ( items , draft => {
44
+ draft . splice ( newIndex , 0 , draft . splice ( oldIndex , 1 ) [ 0 ] ) ;
45
+ } ) ) ;
46
+ }
47
+ } , [ isDragging ] ) ;
48
+
49
+ return items ;
50
+ }
51
+
52
+ // This is the whole state of the library is managed
53
+ // this "produce" stuff comes from immer and makes updating immutable data less painful
54
+ function reducer ( state , action ) {
55
+ switch ( action . type ) {
56
+ // When sortable components are mounted they are added to "elements" array
57
+ case 'ADD_NODE' :
58
+ return produce ( state , draft => {
59
+ draft . nodes . push ( action . payload ) ;
60
+ } ) ;
61
+ case 'DRAG_START' :
62
+ return produce ( state , draft => {
63
+ draft . isDragging = true ;
64
+ draft . initialY = action . payload . initialY ;
65
+ draft . draggedElement = {
66
+ node : action . payload . node ,
67
+ rect : action . payload . node . getBoundingClientRect ( )
68
+ } ;
69
+ draft . draggedElementIndex = state . nodes . findIndex ( node => node === action . payload . node ) ;
70
+ // We won't actually manipulate the original draggable node, that's why we create a duplicate
71
+ draft . duplicateNode = action . payload . node . cloneNode ( true ) ;
72
+ } ) ;
73
+ case 'DRAG_END' :
74
+ return produce ( state , draft => {
75
+ draft . isDragging = false ;
76
+ } ) ;
77
+ case 'SET_CURRENT_Y' :
78
+ return produce ( state , draft => {
79
+ draft . currentY = action . payload
80
+ } ) ;
81
+ case 'SET_NEW_INDEX' :
82
+ return produce ( state , draft => {
83
+ draft . draggedElementNewIndex = action . payload
84
+ } ) ;
85
+ default :
86
+ return state ;
87
+ }
88
+ }
89
+
90
+ function Sortable ( props ) {
91
+ const initialState = {
92
+ nodes : [ ]
93
+ } ;
94
+
95
+ const [ state , dispatch ] = useReducer ( reducer , initialState ) ;
96
+
97
+ const containerRef = useRef ( ) ;
98
+
99
+ function addNode ( node ) {
100
+ dispatch ( { type : 'ADD_NODE' , payload : node } ) ;
101
+ }
102
+
103
+ // Event handlers
104
+ function dragStart ( e ) {
105
+ const clientY = e . type === 'touchstart' ? e . touches [ 0 ] . clientY : e . clientY ;
106
+ if ( state . nodes . some ( node => node === e . target ) ) {
107
+ dispatch ( { type : 'DRAG_START' , payload : { initialY : clientY , node : e . target } } ) ;
108
+ }
109
+ }
110
+
111
+ function dragEnd ( ) {
112
+ dispatch ( { type : 'DRAG_END' } ) ;
113
+ }
114
+
115
+ function drag ( e ) {
116
+ if ( state . isDragging ) {
117
+ e . preventDefault ( ) ;
118
+
119
+ const clientY = e . type === 'touchmove' ? e . touches [ 0 ] . clientY : e . clientY ;
120
+ dispatch ( { type : 'SET_CURRENT_Y' , payload : clientY - state . initialY } ) ;
121
+ }
122
+ }
123
+
124
+ // Assignment of event handlers
125
+ useEffect ( ( ) => {
126
+ containerRef . current . addEventListener ( 'mousedown' , dragStart , false ) ;
127
+ window . addEventListener ( 'mousemove' , drag , false ) ;
128
+ window . addEventListener ( 'mouseup' , dragEnd , false ) ;
129
+ containerRef . current . addEventListener ( "touchstart" , dragStart , false ) ;
130
+ window . addEventListener ( "touchend" , dragEnd , false ) ;
131
+ window . addEventListener ( "touchmove" , drag , false ) ;
132
+ // If your effect returns a function React will run it when it is time to clean up
133
+ // This has similar logic to componentWillUnmount
134
+ return ( ) => {
135
+ containerRef . current . removeEventListener ( 'mousedown' , dragStart , false ) ;
136
+ window . removeEventListener ( 'mousemove' , drag , false ) ;
137
+ window . removeEventListener ( 'mouseup' , dragEnd , false ) ;
138
+ containerRef . current . removeEventListener ( "touchstart" , dragStart , false ) ;
139
+ window . removeEventListener ( "touchend" , dragEnd , false ) ;
140
+ window . removeEventListener ( "touchmove" , drag , false ) ;
141
+ }
142
+ // So this part here is confusing
143
+ // Theoretically all of our event listeners should only be initialized on mount (useEffect(..., []))
144
+ // But our event handlers depend on some variables which won't be updated unless we specify them here
145
+ // But it also means that our event handlers will be reassigned every time these variables change
146
+ // https://github.com/facebook/react/issues/14092#issuecomment-435907249
147
+ // "This is a known limitation. We want to provide a better solution"
148
+ } , [ state . nodes , state . isDragging ] ) ;
149
+
150
+ // Stuff we do when dragging started/ended
151
+ useEffect ( ( ) => {
152
+ if ( state . isDragging ) {
153
+ const originalRect = state . draggedElement . node . getBoundingClientRect ( ) ;
154
+
155
+ // We don't manipulate the original draggable node, but the duplicate
156
+ // We append it to body, position exactly above original, move it around the page and remove after dragging has ended
157
+ state . duplicateNode . style . position = 'absolute' ;
158
+ state . duplicateNode . style . left = `${ originalRect . left } px` ;
159
+ state . duplicateNode . style . top = `${ originalRect . top } px` ;
160
+ state . duplicateNode . style . height = `${ originalRect . height } px` ;
161
+ state . duplicateNode . style . width = `${ originalRect . width } px` ;
162
+
163
+ document . body . appendChild ( state . duplicateNode ) ;
164
+
165
+ // Hide original node
166
+ state . draggedElement . node . style . visibility = 'hidden' ;
167
+
168
+ // We want to animate our nodes
169
+ state . nodes . forEach ( ( node , i ) => {
170
+ if ( i !== state . draggedElement . draggedElement ) {
171
+ node . style . webkitTransition = 'transform 0.3s' ;
172
+ }
173
+ } ) ;
174
+ } else {
175
+ if ( state . draggedElement ) {
176
+ // Cleanup after dragging has ended
177
+ // Remove duplicate, show original node, remove transformations and transitions
178
+ document . body . removeChild ( state . duplicateNode ) ;
179
+ state . draggedElement . node . style . visibility = 'visible' ;
180
+ state . nodes . forEach ( node => {
181
+ node . style . webkitTransition = '' ;
182
+ node . style . transform = '' ;
183
+ } ) ;
184
+ }
185
+ }
186
+ } , [ state . isDragging ] ) ;
187
+
188
+ useEffect ( ( ) => {
189
+ if ( state . duplicateNode ) {
190
+ // Move duplicate node across the screen
191
+ // We don't use HTML5 drag and drop API and do the whole dragging animation manually
192
+ state . duplicateNode . style . transform = `translate3d(0, ${ state . currentY } px, 0)` ;
193
+
194
+ const draggedRect = state . draggedElement . rect ;
195
+ const offset = state . currentY + draggedRect . y + draggedRect . height / 2 ;
196
+
197
+ const draggedElementIndex = state . draggedElementIndex ;
198
+
199
+ // Computer science baby! This is where reordering animation happens
200
+ // Every time cursor position changes we decide which node to move up/down using translate3D
201
+ // Algorithm is crappy, but the purpose of this library is not to become better at algorithms
202
+ state . nodes . forEach ( ( node , index ) => {
203
+ const rect = node . getBoundingClientRect ( ) ;
204
+ // We do nothing with the node that is currently dragged
205
+ if ( index !== draggedElementIndex ) {
206
+ if ( offset > rect . y && draggedElementIndex < index ) {
207
+ state . nodes [ index ] . style . transform = `translate3d(0, -${ draggedRect . height } px, 0)` ;
208
+ } else if ( offset < rect . y + rect . height && draggedElementIndex > index ) {
209
+ state . nodes [ index ] . style . transform = `translate3d(0, ${ draggedRect . height } px, 0)` ;
210
+ } else {
211
+ state . nodes [ index ] . style . transform = `translate3d(0, 0, 0)` ;
212
+ }
213
+ }
214
+
215
+ // Update newIndex in state, we will expose this variable to children
216
+ if ( offset > rect . y && offset < rect . y + rect . height ) {
217
+ dispatch ( { type : 'SET_NEW_INDEX' , payload : index } ) ;
218
+ }
219
+ } ) ;
220
+ }
221
+ } , [ state . currentY ] ) ;
222
+
223
+ // We decide what to expose to child components through context
224
+ const ctx = {
225
+ addNode,
226
+ isDragging : state . isDragging ,
227
+ oldIndex : state . draggedElementIndex ,
228
+ newIndex : state . draggedElementNewIndex
229
+ } ;
230
+
231
+ return < SortableContext . Provider value = { ctx } >
232
+ < div ref = { containerRef } style = { { touchAction : 'none' } } > { props . children } </ div >
233
+ </ SortableContext . Provider > ;
234
+ }
235
+
236
+ export default Sortable ;
237
+
238
+ export {
239
+ useSortable ,
240
+ useSortableElement
241
+ }
0 commit comments