├── .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 |
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 |
--------------------------------------------------------------------------------