UNPKG

@human-tools/use-sortable

Version:

Simple implementation that allows you to reorder items easily using useSortable hook.

180 lines (171 loc) 7.87 kB
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; /** * Shift array items moving srcIndex before/after targetIndex. * TODO: Support multiple adjacent indecies being shifted. * TODO: Move this to its own package. * @param array The array you want to manipulate * @param srcIndex Index of the item that is moving. * @param targetIndex Index where the item should be placed. * @param insertBeforeTarget Wether the srcItem should be placed before or after the target index. * @returns The new array */ export const shift = (array, srcIndex, targetIndex, insertBeforeTarget = false) => { if (srcIndex < 0 || srcIndex > array.length || targetIndex < 0 || targetIndex > array.length || srcIndex === targetIndex) return array; const minIndex = Math.min(srcIndex, targetIndex); const maxIndex = Math.max(srcIndex, targetIndex); const newArr = [...array.slice(0, minIndex), ...(srcIndex < targetIndex ? array.slice(minIndex + 1, maxIndex) : []), insertBeforeTarget ? array[srcIndex] : array[targetIndex], insertBeforeTarget ? array[targetIndex] : array[srcIndex], ...(srcIndex > targetIndex ? array.slice(minIndex + 1, maxIndex) : []), ...array.slice(maxIndex + 1)]; return newArr; }; /** * TODO: Allow user to cancel a reorder while dragging an element * by hitting ESC. */ export const useSortable = (items, options) => { const settings = useMemo(() => ({ multiple: false, animate: true, animationDelayFunction: () => 0, animationDurationFunction: () => 0.3, animationTimingFunction: () => 'cubic-bezier(0, 1.28, 1, 1)', draggingClassNames: ['dragging'], dragoverClassNames: ['dragover'], ...options }), [options]); const [draggedItemIndex, setDraggedItemIndex] = useState(); const [dropTargetIndex, setDropTargetIndex] = useState(); const [shouldInsertBefore, setShouldInsertBefore] = useState(false); const [draggableNodes, setDraggableNodes] = useState([]); const [orderedItems, setOrderedItems] = useState(items); // FIXME: originalRects need to update when the layout updates or resize. // Need a ResizingObserver on the container ref probably. const [originalRects, setOriginalRects] = useState([]); const [isUpdatingRects, setIsUpdatingRects] = useState(false); const setItems = useCallback(items => { setDraggableNodes([]); setOriginalRects([]); setDropTargetIndex(undefined); setDraggedItemIndex(undefined); setOrderedItems(items); }, []); const addDraggableNodeRef = useCallback(node => { if (!node) return; setDraggableNodes(nodes => [...nodes, node]); setOriginalRects(rects => [...rects, node.getBoundingClientRect()]); }, [orderedItems]); const findItemIndexFromNode = useCallback(node => draggableNodes.findIndex(draggableNode => draggableNode === node), [draggableNodes]); const onDragStart = useCallback(e => { const el = e.target; if (!el) return; el.classList.add(...settings.draggingClassNames); const draggedIndex = findItemIndexFromNode(el); setDraggedItemIndex(draggedIndex); setDropTargetIndex(undefined); setShouldInsertBefore(false); }, [findItemIndexFromNode, setDraggedItemIndex, settings.draggingClassNames]); const updateTransforms = useCallback((srcIdx, targetIdx) => { setIsUpdatingRects(true); const newItemRects = shift(originalRects, srcIdx, targetIdx, shouldInsertBefore); const newDraggableNodes = shift(draggableNodes, srcIdx, targetIdx, shouldInsertBefore); newDraggableNodes.forEach((node, index) => { const oldRect = originalRects[index]; const newRect = newItemRects[index]; const sign = -1; const translateX = sign * (newRect.x - oldRect.x); const translateY = sign * (newRect.y - oldRect.y); node.style.transition = `transform ${settings.animationDurationFunction(index)}s ${settings.animationDelayFunction(index)}s ${settings.animationTimingFunction(index)}`; node.style.transform = `translate(${translateX}px, ${translateY}px)`; }); setIsUpdatingRects(false); }, [shouldInsertBefore, originalRects, draggableNodes, setIsUpdatingRects, settings.animationDelayFunction, settings.animationDurationFunction, settings.animationTimingFunction]); const onDragOver = useCallback(e => { e.preventDefault(); const el = e.target; if (!el || draggedItemIndex === undefined) return; const srcIdx = draggedItemIndex; const targetIdx = findItemIndexFromNode(el); const draggedNode = draggableNodes[draggedItemIndex]; if (targetIdx === -1) { return; } if (draggedNode === el || srcIdx === targetIdx) { return; } if (!isUpdatingRects) { settings.animate && updateTransforms(srcIdx, targetIdx); setDropTargetIndex(targetIdx); setShouldInsertBefore(srcIdx > targetIdx); } return false; }, [draggedItemIndex, draggableNodes, findItemIndexFromNode, updateTransforms, isUpdatingRects, setDropTargetIndex, setShouldInsertBefore, settings.animate]); const onDragEnter = useCallback(e => { e.preventDefault(); const el = e.target; if (!el) return; el.classList.add(...settings.dragoverClassNames); return false; }, [settings.dragoverClassNames]); const onDragLeave = useCallback(e => { e.preventDefault(); const el = e.target; if (!el) return; el.classList.remove(...settings.dragoverClassNames); return false; }, [settings.dragoverClassNames]); const onDrop = useCallback(e => { e.preventDefault(); const el = e.target; if (!el) return; el.classList.remove(...settings.dragoverClassNames); return false; }, [settings.dragoverClassNames]); const onDragEnd = useCallback(e => { e.preventDefault(); const el = e.target; if (!el) return; el.classList.remove(...settings.draggingClassNames); if (draggedItemIndex === undefined || dropTargetIndex === undefined) return; settings.animate && draggableNodes.forEach(node => { node.style.transition = ''; node.style.transform = ''; }); setItems(shift(orderedItems, draggedItemIndex, dropTargetIndex, shouldInsertBefore)); setDraggableNodes(shift(draggableNodes, draggedItemIndex, dropTargetIndex, shouldInsertBefore)); return false; }, [draggedItemIndex, dropTargetIndex, setItems, orderedItems, setDraggableNodes, draggableNodes, shouldInsertBefore, settings.draggingClassNames]); const containerRef = useRef(); const setContainerRef = useCallback(containerNode => { if (containerNode) containerRef.current = containerNode; }, []); useEffect(() => { const containerNode = containerRef.current; if (containerNode) { containerNode.addEventListener('dragstart', onDragStart); containerNode.addEventListener('dragover', onDragOver); containerNode.addEventListener('dragenter', onDragEnter); containerNode.addEventListener('dragleave', onDragLeave); containerNode.addEventListener('dragend', onDragEnd); containerNode.addEventListener('drop', onDrop); draggableNodes.forEach(node => { node.setAttribute('draggable', 'true'); }); } return () => { if (containerNode) { containerNode.removeEventListener('dragstart', onDragStart); containerNode.removeEventListener('dragover', onDragOver); containerNode.removeEventListener('dragenter', onDragEnter); containerNode.removeEventListener('dragleave', onDragLeave); containerNode.removeEventListener('dragend', onDragEnd); containerNode.removeEventListener('drop', onDrop); draggableNodes.forEach(node => { node.removeAttribute('draggable'); }); } }; }, [onDragStart, onDragOver, onDragEnd, onDragEnter, onDragLeave, onDrop, draggableNodes]); return { orderedItems, setItems, setContainerRef, addDraggableNodeRef }; };