Skip to content

Commit 4e143c0

Browse files
committed
sortable library
1 parent d24c118 commit 4e143c0

File tree

2 files changed

+271
-0
lines changed

2 files changed

+271
-0
lines changed
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
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+
}

src/components/sortable/index.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import React from 'react';
2+
import Sortable, { useSortableElement, useSortable } from './Sortable';
3+
4+
function Item(props) {
5+
// The library needs DOM nodes of every single item we want to reorder
6+
// This custom hook will add React ref to the element and then push respective DOM node to the array on nodes on mount
7+
return <div
8+
{...useSortableElement()}
9+
style={{ padding: '10px', background: '#ddd' }}
10+
>{props.children}</div>
11+
}
12+
13+
function List() {
14+
// We pass array of items to custom hook which will 1) reorder them after every 'dragEnd' 2) automatically update our list
15+
// There is no "onSortingEnded" callback. We are basically saying:
16+
// "Hey, I don't want to think about this, just do the necessary stuff and update my component"
17+
const items = useSortable(['Item 1', 'Item 2', 'Item 3', 'Item 4', 'Item 5']);
18+
19+
return items.map((item, i) => <Item key={i}>{item}</Item>);
20+
}
21+
22+
function Test() {
23+
// This wrapper will provide all child components with Sortable context
24+
// Hooks do not share any state between components, so this is important
25+
return <Sortable>
26+
<List/>
27+
</Sortable>;
28+
}
29+
30+
export default Test;

0 commit comments

Comments
 (0)