├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── public └── index.html └── src ├── App.js ├── Card.js ├── CardContent.js ├── CardDragLayer.js ├── CardsDragPreview.js ├── Container.js ├── ItemTypes.js ├── index.js └── styles.css /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-dnd-multiple-selection 2 | This repo implements multiple selection drag and drop using the [react-dnd](https://react-dnd.github.io/react-dnd/about) library and react hooks. 3 | 4 | You can try the live demo [here](https://www.zenan-wang.com/react-dnd-multiple-selection/). 5 | 6 | Play with the CodeSandBox [here](https://codesandbox.io/s/github/znwang25/react-dnd-multiple-selection). 7 | 8 | This is inspired and based on [@melvynhills](https://github.com/melvynhills)'s solution posted in this [issue](https://github.com/react-dnd/react-dnd/issues/14). But his implementation was using the legacy APIs, so I rewrote the component using new APIs as of 2020. 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "homepage": "http://znwang25.github.io/react-dnd-multiple-selection", 3 | "name": "react-dnd-multiple-selection", 4 | "version": "1.0.0", 5 | "description": "Implement multiple selection using react-dnd and hooks.", 6 | "keywords": [ 7 | "react", 8 | "multiple selection", 9 | "react hook", 10 | "react-dnd" 11 | ], 12 | "main": "src/index.js", 13 | "dependencies": { 14 | "prop-types": "^15.7.2", 15 | "react": "^17.0.0", 16 | "react-dnd": "^11.1.3", 17 | "react-dnd-html5-backend": "^11.1.3", 18 | "react-dom": "^17.0.0", 19 | "react-scripts": "^3.4.3" 20 | }, 21 | "devDependencies": { 22 | "gh-pages": "^3.1.0", 23 | "typescript": "^3.8.3" 24 | }, 25 | "eslintConfig": { 26 | "extends": [ 27 | "react-app", 28 | "react-app/jest" 29 | ] 30 | }, 31 | "scripts": { 32 | "start": "react-scripts start", 33 | "build": "react-scripts build", 34 | "test": "react-scripts test --env=jsdom", 35 | "eject": "react-scripts eject", 36 | "predeploy": "npm run build", 37 | "deploy": "gh-pages -d build" 38 | }, 39 | "browserslist": [ 40 | ">0.2%", 41 | "not dead", 42 | "not ie <= 11", 43 | "not op_mini all" 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 23 | React App 24 | 25 | 26 | 27 | 30 |
31 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { DndProvider } from "react-dnd"; 3 | import { HTML5Backend } from "react-dnd-html5-backend"; 4 | import Container from "./Container.js"; 5 | import "./styles.css"; 6 | 7 | export default function App() { 8 | return ( 9 |
10 |

Drag and drop multiple items with React DnD

11 |

Use Shift or Cmd key to multi-select

12 | 13 | 14 | 15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/Card.js: -------------------------------------------------------------------------------- 1 | import React, { useRef, useEffect } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { getEmptyImage } from "react-dnd-html5-backend"; 4 | import { useDrag, useDrop } from "react-dnd"; 5 | import ItemTypes from "./ItemTypes"; 6 | import CardContent from "./CardContent"; 7 | 8 | export default function Card(props) { 9 | const ref = useRef(null); 10 | 11 | const [{ isDragging }, drag, preview] = useDrag({ 12 | item: { type: ItemTypes.CARD }, 13 | begin: (monitor) => { 14 | const { id, order, url } = props; 15 | const draggedCard = { id, order, url }; 16 | let cards; 17 | if (props.selectedCards.find((card) => card.id === props.id)) { 18 | cards = props.selectedCards; 19 | } else { 20 | props.clearItemSelection(); 21 | cards = [draggedCard]; 22 | } 23 | const otherCards = cards.concat(); 24 | otherCards.splice( 25 | cards.findIndex((c) => c.id === props.id), 26 | 1 27 | ); 28 | const cardsDragStack = [draggedCard, ...otherCards]; 29 | const cardsIDs = cards.map((c) => c.id); 30 | return { cards, cardsDragStack, draggedCard, cardsIDs }; 31 | }, 32 | isDragging: (monitor) => { 33 | return monitor.getItem().cardsIDs.includes(props.id); 34 | }, 35 | end: (item, monitor) => { 36 | props.rearrangeCards(item); 37 | props.clearItemSelection(); 38 | }, 39 | collect: (monitor) => ({ 40 | isDragging: monitor.isDragging() 41 | }) 42 | }); 43 | 44 | const [{ hovered }, drop] = useDrop({ 45 | accept: ItemTypes.CARD, 46 | // drop: () => ({ 47 | // boxType: "Picture" 48 | // }), 49 | hover: (item, monitor) => { 50 | const dragIndex = item.draggedCard.index; 51 | const hoverIndex = props.index; 52 | 53 | // Determine rectangle on screen 54 | const hoverBoundingRect = ref.current?.getBoundingClientRect(); 55 | 56 | // Get horizontal middle 57 | const midX = 58 | hoverBoundingRect.left + 59 | (hoverBoundingRect.right - hoverBoundingRect.left) / 2; 60 | // Determine mouse position 61 | const pointerOffset = monitor.getClientOffset(); 62 | const newInsertIndex = 63 | pointerOffset.x < midX ? hoverIndex : hoverIndex + 1; 64 | props.setInsertIndex(dragIndex, hoverIndex, newInsertIndex); 65 | }, 66 | collect: (monitor) => ({ 67 | hovered: monitor.isOver() 68 | }) 69 | }); 70 | 71 | drag(drop(ref)); 72 | 73 | const onClick = (e) => { 74 | props.onSelectionChange(props.index, e.metaKey, e.shiftKey); 75 | }; 76 | 77 | useEffect(() => { 78 | // This gets called after every render, by default 79 | // (the first one, and every one after that) 80 | 81 | // Use empty image as a drag preview so browsers don't draw it 82 | // and we can draw whatever we want on the custom drag layer instead. 83 | preview(getEmptyImage(), { 84 | // IE fallback: specify that we'd rather screenshot the node 85 | // when it already knows it's being dragged so we can hide it with CSS. 86 | captureDraggingState: true 87 | }); 88 | // If you want to implement componentWillUnmount, 89 | // return a function from here, and React will call 90 | // it prior to unmounting. 91 | // return () => console.log('unmounting...'); 92 | }, []); 93 | 94 | const { url } = props; 95 | const opacity = isDragging ? 0.4 : 1; 96 | 97 | const styleClasses = []; 98 | 99 | // if (isDragging) { 100 | // styleClasses.push('card-wrapper-dragging'); 101 | // } 102 | if (props.isSelected) { 103 | styleClasses.push("card-wrapper-selected"); 104 | } 105 | 106 | return ( 107 |
108 | {props.insertLineOnLeft && hovered && ( 109 |
110 | )} 111 |
112 |
113 | 114 |
115 |
116 | {props.insertLineOnRight && hovered && ( 117 |
118 | )} 119 |
120 | ); 121 | } 122 | 123 | Card.propTypes = { 124 | selectedCards: PropTypes.array.isRequired, 125 | clearItemSelection: PropTypes.func.isRequired, 126 | rearrangeCards: PropTypes.func.isRequired, 127 | setInsertIndex: PropTypes.func.isRequired, 128 | onSelectionChange: PropTypes.func.isRequired, 129 | id: PropTypes.number.isRequired, 130 | index: PropTypes.number.isRequired, 131 | url: PropTypes.string.isRequired, 132 | insertLineOnLeft: PropTypes.bool.isRequired, 133 | insertLineOnRight: PropTypes.bool.isRequired, 134 | isSelected: PropTypes.bool.isRequired 135 | }; 136 | -------------------------------------------------------------------------------- /src/CardContent.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const CardContent = ({ url }) => ( 4 |
5 |
6 | 7 |
8 |
9 | ); 10 | 11 | export default CardContent; 12 | -------------------------------------------------------------------------------- /src/CardDragLayer.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useDragLayer } from "react-dnd"; 3 | import ItemTypes from "./ItemTypes"; 4 | import CardsDragPreview from "./CardsDragPreview"; 5 | 6 | const layerStyles = { 7 | position: "fixed", 8 | pointerEvents: "none", 9 | zIndex: 100, 10 | left: 0, 11 | top: 0, 12 | right: 0, 13 | bottom: 0 14 | }; 15 | 16 | const getItemStyles = (currentOffset) => { 17 | if (!currentOffset) { 18 | return { 19 | display: "none" 20 | }; 21 | } 22 | const { x, y } = currentOffset; 23 | return { 24 | transform: `translate(${x}px, ${y}px)`, 25 | filter: "drop-shadow(0 2px 12px rgba(0,0,0,0.45))" 26 | }; 27 | }; 28 | 29 | export default function CardDragLayer() { 30 | const { itemType, isDragging, item, currentOffset } = useDragLayer( 31 | (monitor) => ({ 32 | item: monitor.getItem(), 33 | itemType: monitor.getItemType(), 34 | currentOffset: monitor.getSourceClientOffset(), 35 | isDragging: monitor.isDragging() 36 | }) 37 | ); 38 | 39 | const renderItem = (type, item) => { 40 | switch (type) { 41 | case ItemTypes.CARD: 42 | return ; 43 | default: 44 | return null; 45 | } 46 | }; 47 | if (!isDragging) { 48 | return null; 49 | } 50 | 51 | return ( 52 |
53 |
54 | {renderItem(itemType, item)} 55 |
56 |
57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /src/CardsDragPreview.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import CardContent from "./CardContent"; 4 | 5 | const CardsDragPreview = ({ cards }) => { 6 | return ( 7 |
8 | {cards.slice(0, 3).map((card, i) => ( 9 |
17 | 18 |
19 | ))} 20 |
21 | ); 22 | }; 23 | 24 | CardsDragPreview.propTypes = { 25 | cards: PropTypes.array 26 | }; 27 | 28 | export default CardsDragPreview; 29 | -------------------------------------------------------------------------------- /src/Container.js: -------------------------------------------------------------------------------- 1 | import React, { useReducer } from "react"; 2 | import CardDragLayer from "./CardDragLayer"; 3 | import Card from "./Card"; 4 | 5 | const TOTAL_ITEMS = 50; 6 | 7 | const cardReducer = (state, action) => { 8 | switch (action.type) { 9 | case "CLEAR_SELECTION": 10 | return { 11 | ...state, 12 | selectedCards: init_state.selectedCards, 13 | lastSelectedIndex: init_state.lastSelectedIndex 14 | }; 15 | case "UPDATE_SELECTION": 16 | return { 17 | ...state, 18 | selectedCards: action.newSelectedCards, 19 | lastSelectedIndex: action.newLastSelectedIndex 20 | }; 21 | case "REARRANGE_CARDS": 22 | return { ...state, cards: action.newCards }; 23 | case "SET_INSERTINDEX": 24 | return { 25 | ...state, 26 | dragIndex: action.dragIndex, 27 | hoverIndex: action.hoverIndex, 28 | insertIndex: action.insertIndex 29 | }; 30 | default: 31 | throw new Error(); 32 | } 33 | }; 34 | 35 | const init_cards = [...Array(TOTAL_ITEMS).keys()].map((i) => ({ 36 | id: i + 1, 37 | order: i, 38 | url: "https://picsum.photos/80/45?random&" + i 39 | })); 40 | 41 | const init_state = { 42 | cards: init_cards, 43 | selectedCards: [], 44 | lastSelectedIndex: -1, 45 | dragIndex: -1, 46 | hoverIndex: -1, 47 | insertIndex: -1, 48 | isDragging: false 49 | }; 50 | 51 | export default function Container() { 52 | const [state, dispatch] = useReducer(cardReducer, init_state); 53 | 54 | const clearItemSelection = () => { 55 | dispatch({ type: "CLEAR_SELECTION" }); 56 | }; 57 | 58 | const handleItemSelection = (index, cmdKey, shiftKey) => { 59 | let newSelectedCards; 60 | const cards = state.cards; 61 | const card = index < 0 ? "" : cards[index]; 62 | const newLastSelectedIndex = index; 63 | 64 | if (!cmdKey && !shiftKey) { 65 | newSelectedCards = [card]; 66 | } else if (shiftKey) { 67 | if (state.lastSelectedIndex >= index) { 68 | newSelectedCards = [].concat.apply( 69 | state.selectedCards, 70 | cards.slice(index, state.lastSelectedIndex) 71 | ); 72 | } else { 73 | newSelectedCards = [].concat.apply( 74 | state.selectedCards, 75 | cards.slice(state.lastSelectedIndex + 1, index + 1) 76 | ); 77 | } 78 | } else if (cmdKey) { 79 | const foundIndex = state.selectedCards.findIndex((f) => f === card); 80 | // If found remove it to unselect it. 81 | if (foundIndex >= 0) { 82 | newSelectedCards = [ 83 | ...state.selectedCards.slice(0, foundIndex), 84 | ...state.selectedCards.slice(foundIndex + 1) 85 | ]; 86 | } else { 87 | newSelectedCards = [...state.selectedCards, card]; 88 | } 89 | } 90 | const finalList = cards 91 | ? cards.filter((f) => newSelectedCards.find((a) => a === f)) 92 | : []; 93 | dispatch({ 94 | type: "UPDATE_SELECTION", 95 | newSelectedCards: finalList, 96 | newLastSelectedIndex: newLastSelectedIndex 97 | }); 98 | }; 99 | 100 | const rearrangeCards = (dragItem) => { 101 | let cards = state.cards.slice(); 102 | const draggedCards = dragItem.cards; 103 | 104 | let dividerIndex; 105 | if ((state.insertIndex >= 0) & (state.insertIndex < cards.length)) { 106 | dividerIndex = state.insertIndex; 107 | } else { 108 | // If missing insert index, put the dragged cards to the end of the queue 109 | dividerIndex = cards.length; 110 | } 111 | const upperHalfRemainingCards = cards 112 | .slice(0, dividerIndex) 113 | .filter((c) => !draggedCards.find((dc) => dc.id === c.id)); 114 | const lowerHalfRemainingCards = cards 115 | .slice(dividerIndex) 116 | .filter((c) => !draggedCards.find((dc) => dc.id === c.id)); 117 | const newCards = [ 118 | ...upperHalfRemainingCards, 119 | ...draggedCards, 120 | ...lowerHalfRemainingCards 121 | ]; 122 | dispatch({ type: "REARRANGE_CARDS", newCards: newCards }); 123 | }; 124 | 125 | const setInsertIndex = (dragIndex, hoverIndex, newInsertIndex) => { 126 | if ( 127 | state.dragIndex === dragIndex && 128 | state.hoverIndex === hoverIndex && 129 | state.insertIndex === newInsertIndex 130 | ) { 131 | return; 132 | } 133 | dispatch({ 134 | type: "SET_INSERTINDEX", 135 | dragIndex: dragIndex, 136 | hoverIndex: hoverIndex, 137 | insertIndex: newInsertIndex 138 | }); 139 | }; 140 | 141 | return ( 142 |
143 | 144 |
145 | {state.cards.map((card, i) => { 146 | const insertLineOnLeft = 147 | state.hoverIndex === i && state.insertIndex === i; 148 | const insertLineOnRight = 149 | state.hoverIndex === i && state.insertIndex === i + 1; 150 | return ( 151 | 166 | ); 167 | })} 168 |
169 |
170 | ); 171 | } 172 | -------------------------------------------------------------------------------- /src/ItemTypes.js: -------------------------------------------------------------------------------- 1 | export default { 2 | CARD: "card" 3 | }; 4 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | 4 | import App from "./App"; 5 | 6 | const rootElement = document.getElementById("root"); 7 | ReactDOM.render( 8 | 9 | 10 | , 11 | rootElement 12 | ); 13 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | .App { 2 | font-family: sans-serif; 3 | text-align: center; 4 | } 5 | 6 | * { 7 | box-sizing: border-box; 8 | } 9 | 10 | body { 11 | background-color: #1d1c1f; 12 | font-family: sans-serif; 13 | font-size: 14px; 14 | } 15 | 16 | h1, h2, h4 { 17 | color: white; 18 | } 19 | 20 | .container { 21 | width: 100%; 22 | position: relative; 23 | 24 | display: flex; 25 | flex-wrap: wrap; 26 | justify-content: center; 27 | } 28 | 29 | .card-wrapper { 30 | position: relative; 31 | } 32 | 33 | .card { 34 | border: 3px solid transparent; 35 | border-radius: 5px; 36 | overflow: hidden; 37 | } 38 | 39 | .card-dragged { 40 | position: absolute; 41 | transform-origin: bottom left; 42 | -webkit-backface-visibility: hidden; 43 | } 44 | 45 | .card-wrapper-active:not(.card-wrapper-dragging) .card { 46 | border: 3px solid #4e56ff; 47 | } 48 | 49 | .card-outer { 50 | border: 2px solid transparent; 51 | border-radius: 2px; 52 | overflow: hidden; 53 | } 54 | 55 | .card-wrapper-selected:not(.card-wrapper-dragging) .card-outer { 56 | border: 2px solid orange; 57 | } 58 | 59 | .card-inner { 60 | position: relative; 61 | width: 80px; 62 | height: 45px; 63 | background-color: rgba(255, 255, 255, 0.05); 64 | color: #aaa; 65 | font-weight: bold; 66 | font-size: 24px; 67 | display: flex; 68 | text-align: center; 69 | justify-content: center; 70 | flex-direction: column; 71 | } 72 | 73 | .card-dragged .card-inner { 74 | box-shadow: 0 0px 2px rgba(0, 0, 0, 0.35); 75 | } 76 | 77 | .card-wrapper-dragging .card-inner { 78 | border: 1px solid rgba(255, 255, 255, 0.05); 79 | } 80 | 81 | .card-wrapper-dragging.card-wrapper-hovered .card-inner { 82 | border: 2px solid orange; 83 | border-radius: 2px; 84 | background-color: transparent; 85 | } 86 | 87 | .card-wrapper-dragging .card-inner img { 88 | opacity: 0; 89 | } 90 | 91 | .insert-line-left, 92 | .insert-line-right { 93 | position: absolute; 94 | top: 0; 95 | left: -1px; 96 | height: 100%; 97 | width: 2px; 98 | background-color: orange; 99 | } 100 | 101 | .insert-line-right { 102 | left: auto; 103 | right: -1px; 104 | } 105 | --------------------------------------------------------------------------------