├── .babelrc ├── .editorconfig ├── .eslintrc ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .travis.yml ├── README.md ├── _config.yml ├── example ├── README.md ├── package-lock.json ├── package.json ├── public │ ├── index.html │ └── manifest.json └── src │ ├── App.js │ ├── images │ ├── arrow-down.svg │ └── profile_m_0.png │ ├── index.css │ └── index.js ├── images ├── forecast_logo_okta.png └── logo-v2.svg ├── package-lock.json ├── package.json ├── rollup.config.js └── src ├── .eslintrc ├── components ├── drag_drop_context.js ├── drag_scroll_bar.js ├── drag_section.js ├── draggable.js ├── droppable.js ├── dynamic-virtualized-scrollbar.js └── virtualized-scrollbar.js ├── examples ├── example-board.js ├── example-dynamic.js └── example-multiple-droppables.js ├── index.js ├── styles.css ├── test.js └── util ├── event_manager.js └── util.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["env", { 4 | "modules": false 5 | }], 6 | "stage-0", 7 | "react" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": [ 4 | "standard", 5 | "standard-react" 6 | ], 7 | "env": { 8 | "es6": true 9 | }, 10 | "plugins": [ 11 | "react" 12 | ], 13 | "parserOptions": { 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | // don't force es6 functions to include space before paren 18 | "space-before-function-paren": 0, 19 | 20 | // allow specifying true explicitly for boolean props 21 | "react/jsx-boolean-value": 0, 22 | 23 | "no-tabs":"off", 24 | 25 | "semi":"off", 26 | 27 | "indent":"off", 28 | 29 | "react/jsx-indent":"off", 30 | "react/jsx-indent-props":"off", 31 | 32 | "jsx-quotes":"off", 33 | "react/jsx-no-bind": "off" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # See https://help.github.com/ignore-files/ for more about ignoring files. 3 | 4 | # dependencies 5 | node_modules 6 | 7 | # builds 8 | build 9 | dist 10 | .rpt2_cache 11 | 12 | # misc 13 | .DS_Store 14 | .env 15 | .env.local 16 | .env.development.local 17 | .env.test.local 18 | .env.production.local 19 | 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | src/translations/lang/* 2 | src/translations/whitelists/* 3 | package-lock.json 4 | package.json -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 200, 3 | "tabWidth": 4, 4 | "singleQuote": true, 5 | "bracketSpacing": false, 6 | "useTabs": true 7 | } 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 9 4 | - 8 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # About us 2 | 3 |

4 | 5 |

6 | 7 | This library was made by Forecast - powered by AI, Forecast is supporting your work process with a complete Resource & Project Management platform. Connect your work, fill your pipeline, & meet your deadlines at [www.forecast.app](https://www.forecast.app) 8 | 9 | # react-virtualized-dnd 10 | 11 | react-virtualized-dnd is a React-based, fully virtualized drag-and-drop framework, enabling the the cross over of great user interaction and great performance. 12 | This project was made in response to the large amount of issues experienced trying to use virtualization libraries together with drag and drop libraries - react-virtualized-dnd does it all for you! 13 | 14 | [Check it out!](https://forecast-it.github.io/react-virtualized-dnd/) 15 | 16 | [![NPM](https://img.shields.io/npm/v/react-virtualized-dnd.svg)](https://www.npmjs.com/package/react-virtualized-dnd) [![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com) 17 | 18 | ## Install 19 | 20 | ```bash 21 | npm install --save react-virtualized-dnd 22 | ``` 23 | 24 | ## Usage 25 | 26 | React-Virtualized-DnD utilizes a three part abstraction for drag and drop: 27 | 28 | - A _DragDropContext_, which controls the overall flow and events of the drag and drop. 29 | - _Draggables_, which are wrappers for the elements you want to drag around. 30 | - _Droppables_, which indicate a drop zone that _Draggables_ can be dropped on, and create the virtualizing container. 31 | _Draggables_ and _Droppables_ can be organized in groups. 32 | 33 | _Droppables_ use an internal scrollbar to virtualize its children, and the _DragDropContext_ offers the option to include an outer scrollbar that can be scrolled while dragging. 34 | Addtionally, a DragScrollBar component is also available, which is a simple scroll container that reacts to drags and scroll near its edges. 35 | 36 | React-virtualized-dnd places a placeholder in droppables during drag, which is placed after the draggable element hovered over during drag. The placeholderId represents the id of the element it was placed after. 37 | On drag end, the _DragDropContext_ returns the placeholderId. 38 | 39 | Example code can be seen below. A live example can be found at: https://forecast-it.github.io/react-virtualized-dnd/ 40 | 41 | ```jsx 42 | import React, {Component} from 'react'; 43 | import ExampleBoard from 'react-virtualized-dnd'; 44 | 45 | class Example extends Component { 46 | render() { 47 | const name = 'my-group'; 48 | const elemsToRender = [... your data here ...]; 49 | return ( 50 | 51 |
52 | {elemsToRender.map((elem, index) => ( 53 |
54 | 55 | {elem.items.map(item => ( 56 | 57 |
58 |

59 | {item.name} 60 |

61 |
62 |
63 | ))} 64 |
65 |
66 | ))} 67 |
68 |
69 | ); 70 | } 71 | } 72 | ``` 73 | 74 | ## Documentation & API 75 | 76 | ### DragDropContext 77 | 78 | #### Props 79 | 80 | | **Prop** | **Type** | **Required** | **Description** | 81 | | ------------------------ | -------- | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | 82 | | dragAndDropGroup | string | yes | Unique identifier for the drag and drop group the context uses | 83 | | outerScrollBar | boolean | no | Enables or disables global outer scrolling of the context (triggered by dragging) | 84 | | scrollYSpeed | number | no | Custom scroll speed for global page scrolling (y-axis) | 85 | | scrollXSpeed | number | no | Custom scroll speed for global page scrolling (x-axis) | 86 | | autoScrollThreshold | number | no | Distance (in px) from the edges of the context where auto scroll is triggered during drag | 87 | | scrollContainerHeight | number | no, yes with outerScrollBar | Height of the outer scrollable container | 88 | | scrollContainerMinHeight | number | no | Minimum height of the outer scrollable container | 89 | | onScroll | func | no | Function fired when the DragDropContext's outer scrollbar scrolls. Returns {scrollX, scrollY} | 90 | | onDragEnd | function | no | Function fired on drag end with the source object, the droppableId of the destination, and the ID of the placeholder dropped on as params | 91 | | onDragCancel | function | no | Function fired on drag end if the drop did not occur inside a droppable with the draggableId of the dragged element as params | 92 | | onDragStart | function | no | Function fired on drag start with the draggableId of the dragged element as params | 93 | 94 | The placeholder ID can be used to determine where to place the dragged element on drag end. The placeholderID returns the string "END_OF_LIST" if dragged below the last element of a droppable. 95 | 96 | ### Draggable 97 | 98 | #### Props 99 | 100 | | **Prop** | **Type** | **Required** | **Description** | 101 | | ------------------------ | -------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 102 | | dragAndDropGroup | string | yes | Unique identifier for the drag and drop group the context uses | 103 | | draggableId | string | yes | Unique identifier for the draggable | 104 | | dragActiveClass | string | no | CSS class applied to a draggable element during an active drag | 105 | | disabled | bool | no | Flag to disabled dragging of element | 106 | | minDragDistanceThreshold | number | no | Minimum pixels a drag should move, before registering new elements dragged over (for updating placeholder). Defaults to 5px, increase for better performance, but fewer scans | 107 | | usePointerEvents | bool | no | Flag to enable pointer-event based implementation. Experimental for now. | 108 | 109 | Draggables will ignore drags started by clicking on any element with the "no-drag" css class. This can be used to control drag interactions with interactive elements, such as inputs or buttons. 110 | 111 | ### Droppable 112 | 113 | #### Props 114 | 115 | | **Prop** | **Type** | **Required** | **Description** | 116 | | ------------------------- | ------------ | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | 117 | | dragAndDropGroup | string | yes | Unique identifier for the drag and drop group the context uses | 118 | | droppableId | string | yes | Unique identifier for the droppable | 119 | | containerHeight | Number | yes | Height of the virtualizing scroll container | 120 | | placeholderStyle | Object | no | CSS style object to style the placeholder during drag | 121 | | enforceContainerMinHeight | boolean | no | Force height of the droppable to always minimally match the containerHeight | 122 | | rowHeight | Number | no | Height of each row _with_ borders. Default is 50px. | 123 | | disableScroll | boolean | no | Flag to disable scrollbars. This disables virtualization as well | 124 | | listHeader | HTML element | no | Element to use as header for a droppable list, to react to drops on top of the list. | 125 | | listHeaderHeight | Number | no (yes with listHeader) | Height of the header element, necessary for calculations. | 126 | | activeHeaderClass | string | no | CSS class added to the header when an active drag is hovering over the list header | 127 | | hideList | boolean | no | hides all droppable elements in the list | 128 | | dynamicElemHeight | boolean | no | Flag to indicate differing/dynamicly changing heights of children elements.\* | 129 | | virtualizationThreshold | Number | no | Minimum number of elements in the list, before the virtualization kicks in. Useful if virtualizing very small lists produces flickers. | 130 | | minElemHeight | Number | no (yes with dynamicElemHeight) | Minimum height of children elements. Necessary for calulating scrolling space. | 131 | | customScrollbars | component | no | Component that uses forwardRef to generate scrollbars using react-custom-scrollbars | 132 | | initialElemsToRender | Number | no | Number of elements to initially render. Defaults to an optimistic guess about the number of elements that can fit in the viewport. | 133 | 134 | \*Enabling dynamic element height fundamentally changes how the scrolling works, and requries some more complex logic that is completely separate from the normal virtualization. If you experience issues with it, I recommend using the static element height approach, or trying to make the rendered children more similar in size. 135 | 136 | ### Droppable 137 | 138 | #### Props 139 | 140 | | **Prop** | **Type** | **Required** | **Description** | 141 | | ------------------- | -------- | ------------ | -------------------------------------------------------------------------------------------------- | 142 | | minHeight | Number | no | Minimum Height of the Scroll Container | 143 | | maxHeight | Number | no | Maximum Height of the Scroll Container | 144 | | autoScrollThreshold | Number | no | Distance (in px) from the edges of the scroll container where auto scroll is triggered during drag | 145 | | onScroll | Function | no | Function fired when the Scrollbar scrolls. Returns {scrollX, scrollY} | 146 | 147 | #### Example Custom Scroll Bar 148 | 149 | This component requires the usage of React's forwardRef to pass along the parent reference to the Scrollbars Element. 150 | Please see [react-custom-scrollbars](https://github.com/malte-wessel/react-custom-scrollbars) for more information on how to customize a scrollbar. 151 | 152 | ##### Usage: 153 | 154 | ```jsx 155 | import {Scrollbars} from 'react-custom-scrollbars'; 156 | 157 | const CustomScrollBars = React.forwardRef((props, ref) => { 158 | const {children, ...rest} = props; 159 | return ( 160 |
} {...rest}> 161 | {children} 162 | 163 | ); 164 | }); 165 | ``` 166 | 167 | ## Author 168 | 169 | [Mikkel Agerlin](https://github.com/MagerlinC), Full Stack Developer at Forecast. 170 | 171 | ## License 172 | 173 | MIT © 174 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-cayman -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-virtualized-dnd-example", 3 | "homepage": "https://MagerlinC.github.io/react-virtualized-dnd", 4 | "version": "0.0.0", 5 | "license": "MIT", 6 | "private": true, 7 | "dependencies": { 8 | "prop-types": "^15.6.2", 9 | "react": "^16.12.0", 10 | "react-dom": "^16.12.0", 11 | "react-scripts": "3.3.0", 12 | "react-virtualized-dnd": "file:.." 13 | }, 14 | "scripts": { 15 | "start": "react-scripts start", 16 | "build": "react-scripts build", 17 | "test": "react-scripts test --env=jsdom", 18 | "eject": "react-scripts eject" 19 | }, 20 | "browserslist": { 21 | "production": [ 22 | ">0.2%", 23 | "not dead", 24 | "not op_mini all" 25 | ], 26 | "development": [ 27 | "last 1 chrome version", 28 | "last 1 firefox version", 29 | "last 1 safari version" 30 | ] 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /example/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | react-virtualized-dnd 11 | 12 | 13 | 14 | 17 | 18 |
19 | 20 | 21 | -------------------------------------------------------------------------------- /example/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "react-virtualized-dnd", 3 | "name": "react-virtualized-dnd", 4 | "start_url": "./index.html", 5 | "display": "standalone", 6 | "theme_color": "#000000", 7 | "background_color": "#ffffff" 8 | } 9 | -------------------------------------------------------------------------------- /example/src/App.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | 3 | import {ExampleBoard, DynamicHeightExample, ExampleMultipleDroppables} from 'react-virtualized-dnd'; 4 | 5 | export default class App extends Component { 6 | constructor(props) { 7 | super(props); 8 | this.state = {page: 'static'}; 9 | this.setShowDynamic = this.setPage.bind(this); 10 | } 11 | 12 | setPage(pageName) { 13 | this.setState({page: pageName}); 14 | } 15 | 16 | render() { 17 | let page; 18 | switch (this.state.page) { 19 | default: 20 | case 'static': 21 | page = ; 22 | break; 23 | case 'dynamic': 24 | page = ; 25 | break; 26 | case 'multiple': 27 | page = ; 28 | break; 29 | } 30 | return ( 31 |
32 |
33 |
this.setPage('static')} className={'tab' + (this.state.page === 'static' ? ' active' : '')}> 34 | Fixed Height 35 |
36 |
this.setPage('dynamic')} className={'tab' + (this.state.page === 'dynamic' ? ' active' : '')}> 37 | Variable Height 38 |
39 |
this.setPage('multiple')} className={'tab' + (this.state.page === 'multiple' ? ' active' : '')}> 40 | Grouped Droppables 41 |
42 |
43 | {page} 44 |
45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /example/src/images/arrow-down.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Path 6 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /example/src/images/profile_m_0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Forecast-it/react-virtualized-dnd/c4fee64465751ea0abb66a9de4bc699d0fc50499/example/src/images/profile_m_0.png -------------------------------------------------------------------------------- /example/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: 'Roboto'; 5 | } 6 | .test-container { 7 | margin-bottom: 10px; 8 | display: flex; 9 | flex-direction: row; 10 | position: relative; 11 | } 12 | .page-margin { 13 | margin: 0 66px; 14 | } 15 | .last { 16 | margin-bottom: 66px; 17 | } 18 | .top-margin { 19 | margin-top: 66px; 20 | } 21 | .droppable-section-header { 22 | margin-left: 66px; 23 | } 24 | .with-space { 25 | margin: 26px 26px 0 26px; 26 | } 27 | 28 | .with-border { 29 | border: 1px solid #dbdbdb; 30 | } 31 | 32 | .col { 33 | display: flex; 34 | flex-direction: column; 35 | } 36 | 37 | .dropGlow { 38 | animation-duration: 1s; 39 | animation-name: dropGlow; 40 | } 41 | @keyframes dropGlow { 42 | from { 43 | background: rgba(23, 23, 230, 0.2); 44 | } 45 | to { 46 | background: white; 47 | } 48 | } 49 | .tabs { 50 | z-index: 9; 51 | position: fixed; 52 | right: 0; 53 | top: 0; 54 | display: flex; 55 | flex-direction: row; 56 | } 57 | .tab { 58 | width: 150px; 59 | background: white; 60 | text-align: center; 61 | cursor: pointer; 62 | height: 30px; 63 | font-size: 16px; 64 | font-family: 'Roboto'; 65 | font-weight: 500; 66 | margin-top: 4px; 67 | margin-right: 4px; 68 | padding-top: 8px; 69 | } 70 | .tab.active { 71 | background: #6e0fea; 72 | } 73 | .example-board { 74 | margin: 10px 0 0 10px; 75 | } 76 | input { 77 | font-family: 'Roboto'; 78 | } 79 | .row-splitter { 80 | margin-top: 10px; 81 | margin-left: 10px; 82 | } 83 | .scroll-button, 84 | .row-split-button { 85 | font-family: 'Roboto'; 86 | flex-grow: 0; 87 | width: 120px; 88 | height: 30px; 89 | text-transform: uppercase; 90 | border: 1px solid #e6e6e6; 91 | background: white; 92 | color: grey; 93 | margin-top: 15px; 94 | margin-right: 10px; 95 | cursor: pointer; 96 | outline: none; 97 | } 98 | .row-split-button { 99 | margin-right: 0; 100 | } 101 | .indicator-button { 102 | margin-left: 16px; 103 | font-family: 'Roboto'; 104 | flex-grow: 0; 105 | height: 30px; 106 | text-transform: uppercase; 107 | border: 1px solid #e6e6e6; 108 | background: white; 109 | color: grey; 110 | margin-top: 15px; 111 | margin-right: 10px; 112 | cursor: pointer; 113 | outline: none; 114 | } 115 | .first-button { 116 | border-right: none; 117 | } 118 | .second-button { 119 | border-left: none; 120 | } 121 | .active { 122 | background-color: #6e0fea; 123 | color: white; 124 | } 125 | .scroll-button:hover { 126 | border-color: grey; 127 | color: black; 128 | } 129 | .title-and-controls, 130 | .controls { 131 | position: relative; 132 | display: flex; 133 | flex-direction: row; 134 | } 135 | .backwards, 136 | .forwards { 137 | margin-top: 30px; 138 | margin-left: 24px; 139 | cursor: pointer; 140 | } 141 | .backwards { 142 | width: 20px; 143 | height: 20px; 144 | background: url('images/arrow-down.svg'); 145 | background-size: 18px; 146 | background-position: center; 147 | background-repeat: no-repeat; 148 | transform: rotate(90deg); 149 | } 150 | .forwards { 151 | width: 20px; 152 | height: 20px; 153 | background: url('images/arrow-down.svg'); 154 | background-size: 18px; 155 | background-position: center; 156 | background-repeat: no-repeat; 157 | transform: rotate(270deg); 158 | } 159 | .input-section { 160 | display: flex; 161 | } 162 | .row { 163 | display: flex; 164 | flex-direction: row; 165 | } 166 | .person-image { 167 | margin-right: 18px; 168 | width: 28px; 169 | height: 28px; 170 | background: url('images/profile_m_0.png'); 171 | background-size: 26px; 172 | background-position: center; 173 | background-repeat: no-repeat; 174 | } 175 | .list-header { 176 | display: flex; 177 | flex-direction: row; 178 | position: relative; 179 | border-right: 1px solid white; 180 | } 181 | .list-header-text { 182 | flex-grow: 1; 183 | color: white; 184 | font-weight: 600; 185 | font-size: 16px; 186 | text-align: left; 187 | margin-top: 22px; 188 | margin-left: 16px; 189 | } 190 | .header-wrapper { 191 | background: #6e0fea; 192 | } 193 | .header-active { 194 | transform: scale(1.05); 195 | } 196 | -------------------------------------------------------------------------------- /example/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | 4 | import './index.css' 5 | import App from './App' 6 | 7 | ReactDOM.render(, document.getElementById('root')) 8 | -------------------------------------------------------------------------------- /images/forecast_logo_okta.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Forecast-it/react-virtualized-dnd/c4fee64465751ea0abb66a9de4bc699d0fc50499/images/forecast_logo_okta.png -------------------------------------------------------------------------------- /images/logo-v2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 9 | 10 | 11 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@forecasthq/react-virtualized-dnd", 3 | "version": "3.0.4", 4 | "description": "A React-based, virtualized drag-and-drop framework.", 5 | "author": "MagerlinC", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/Forecast-it/react-virtualized-dnd" 10 | }, 11 | "homepage": "https://forecast-it.github.io/react-virtualized-dnd/", 12 | "main": "dist/index.js", 13 | "module": "dist/index.es.js", 14 | "jsnext:main": "dist/index.es.js", 15 | "engines": { 16 | "node": ">=8", 17 | "npm": ">=5" 18 | }, 19 | "scripts": { 20 | "test": "cross-env CI=1 react-scripts test --env=jsdom", 21 | "test:watch": "react-scripts test --env=jsdom", 22 | "build": "rollup -c", 23 | "start": "rollup -c -w", 24 | "prepare": "npm run build", 25 | "predeploy": "cd example && npm install && npm run build", 26 | "deploy": "gh-pages -d example/build", 27 | "clean": "if exist dist ( rd /S /Q dist)", 28 | "updateLink": "npm run clean && npm rm react-virtualized-dnd -g && npm link" 29 | }, 30 | "peerDependencies": { 31 | "prop-types": "^15.5.4", 32 | "react": "^15.0.0 || ^16.0.0", 33 | "react-dom": "^15.0.0 || ^16.0.0" 34 | }, 35 | "devDependencies": { 36 | "@svgr/rollup": "^2.4.1", 37 | "babel-core": "^6.26.3", 38 | "babel-plugin-external-helpers": "^6.22.0", 39 | "babel-preset-env": "^1.7.0", 40 | "babel-preset-react": "^6.24.1", 41 | "babel-preset-stage-0": "^6.24.1", 42 | "cross-env": "^5.2.1", 43 | "eslint-config-standard": "^11.0.0", 44 | "eslint-config-standard-react": "^6.0.0", 45 | "eslint-plugin-import": "^2.19.1", 46 | "eslint-plugin-node": "^7.0.1", 47 | "eslint-plugin-promise": "^4.2.1", 48 | "eslint-plugin-react": "^7.17.0", 49 | "eslint-plugin-standard": "^3.1.0", 50 | "gh-pages": "^1.2.0", 51 | "react": "^16.12.0", 52 | "react-dom": "^16.12.0", 53 | "react-scripts": "3.3.0", 54 | "rollup": "^0.64.1", 55 | "rollup-plugin-babel": "^3.0.7", 56 | "rollup-plugin-commonjs": "^9.3.4", 57 | "rollup-plugin-node-resolve": "^3.3.0", 58 | "rollup-plugin-peer-deps-external": "^2.2.0", 59 | "rollup-plugin-postcss": "^1.6.2", 60 | "rollup-plugin-url": "^1.4.0" 61 | }, 62 | "files": [ 63 | "dist" 64 | ], 65 | "dependencies": { 66 | "js-yaml": "^3.13.1", 67 | "react-custom-scrollbars": "^4.2.1", 68 | "rebound": "^0.1.0" 69 | }, 70 | "keywords": [ 71 | "React", 72 | "JS", 73 | "dnd", 74 | "virtualized", 75 | "virtualization", 76 | "drag-and-drop", 77 | "drag and drop" 78 | ], 79 | "style": "styles.css" 80 | } 81 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from 'rollup-plugin-babel' 2 | import commonjs from 'rollup-plugin-commonjs' 3 | import external from 'rollup-plugin-peer-deps-external' 4 | import postcss from 'rollup-plugin-postcss' 5 | import resolve from 'rollup-plugin-node-resolve' 6 | import url from 'rollup-plugin-url' 7 | import svgr from '@svgr/rollup' 8 | 9 | import pkg from './package.json' 10 | 11 | export default { 12 | input: 'src/index.js', 13 | output: [ 14 | { 15 | file: pkg.main, 16 | format: 'cjs', 17 | sourcemap: true 18 | }, 19 | { 20 | file: pkg.module, 21 | format: 'es', 22 | sourcemap: true 23 | } 24 | ], 25 | plugins: [ 26 | external(), 27 | postcss({ 28 | modules: true 29 | }), 30 | url(), 31 | svgr(), 32 | babel({ 33 | exclude: 'node_modules/**', 34 | plugins: [ 'external-helpers' ] 35 | }), 36 | resolve(), 37 | commonjs() 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /src/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "jest": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/components/drag_drop_context.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import {dispatch, subscribe, unsubscribe} from '../util/event_manager'; 3 | import {Scrollbars} from 'react-custom-scrollbars'; 4 | import Util from './../util/util'; 5 | 6 | class DragDropContext extends Component { 7 | constructor(props) { 8 | super(props); 9 | this.state = { 10 | placeholder: null, 11 | dragStarted: false, 12 | dragActive: false, 13 | draggedElem: null, 14 | droppableActive: null, 15 | targetSection: null, 16 | dragAndDropGroup: Util.getDragEvents(this.props.dragAndDropGroup) 17 | }; 18 | this.onDragMove = this.onDragMove.bind(this); 19 | this.resetPlaceholderIndex = this.resetPlaceholderIndex.bind(this); 20 | this.onDragEnd = this.onDragEnd.bind(this); 21 | this.onDragStart = this.onDragStart.bind(this); 22 | this.dispatchPlaceholder = this.dispatchPlaceholder.bind(this); 23 | } 24 | 25 | componentDidMount() { 26 | subscribe(this.state.dragAndDropGroup.endEvent, this.onDragEnd); 27 | subscribe(this.state.dragAndDropGroup.startEvent, this.onDragStart); 28 | subscribe(this.state.dragAndDropGroup.moveEvent, this.onDragMove); 29 | subscribe(this.state.dragAndDropGroup.resetEvent, this.resetPlaceholderIndex); 30 | } 31 | 32 | componentWillUnmount() { 33 | unsubscribe(this.state.dragAndDropGroup.endEvent, this.onDragEnd); 34 | unsubscribe(this.state.dragAndDropGroup.startEvent, this.onDragStart); 35 | unsubscribe(this.state.dragAndDropGroup.moveEvent, this.onDragMove); 36 | unsubscribe(this.state.dragAndDropGroup.resetEvent, this.resetPlaceholderIndex); 37 | } 38 | 39 | componentDidUpdate(prevProps, prevState) { 40 | // If our placeholder has changed, notify droppables 41 | if (this.state.placeholder !== prevState.placeholder || this.state.droppableActive !== prevState.droppableActive) { 42 | this.dispatchPlaceholder(); 43 | } 44 | } 45 | 46 | dispatchPlaceholder() { 47 | if (this.state.draggedElem && this.state.dragActive && this.state.droppableActive) { 48 | dispatch(this.state.dragAndDropGroup.placeholderEvent, this.state.placeholder, this.state.droppableActive, this.state.draggedElem); 49 | } else { 50 | dispatch(this.state.dragAndDropGroup.placeholderEvent, null, null); 51 | } 52 | } 53 | 54 | onDragStart(draggable, x, y) { 55 | if (!this.state.dragActive) { 56 | this.setState({dragActive: true, draggedElem: draggable}); 57 | } 58 | if (this.props.onDragStart) { 59 | this.props.onDragStart(draggable, x, y); 60 | } 61 | } 62 | 63 | onDragEnd() { 64 | if (this.state.draggedElem && this.state.droppableActive) { 65 | let placeholder = this.state.placeholder != null ? this.state.placeholder : 'END_OF_LIST'; 66 | if (this.props.onDragEnd) { 67 | if (this.state.targetSection && this.state.targetSection === placeholder) { 68 | // Send null and placeholderSection, not both 69 | placeholder = null; 70 | } 71 | this.props.onDragEnd(this.state.draggedElem, this.state.droppableActive, placeholder, this.state.targetSection); 72 | } 73 | } else { 74 | if (this.props.onDragCancel) { 75 | this.props.onDragCancel(this.state.draggedElem); 76 | } 77 | } 78 | this.setState({ 79 | draggedElem: null, 80 | placeholder: null, 81 | dragActive: false, 82 | droppableActive: null, 83 | dragStarted: false, 84 | globalScroll: null, 85 | globalScrollXDirection: null, 86 | globalScrollYDirection: null 87 | }); 88 | } 89 | // Check if global scroll is at appropriate edge already 90 | getCanScrollDirection(dir) { 91 | if (!this.outerScrollBar) { 92 | return false; 93 | } 94 | switch (dir) { 95 | case 'down': 96 | return this.outerScrollBar.getScrollTop() < this.outerScrollBar.getScrollHeight() - this.props.scrollContainerHeight; 97 | case 'up': 98 | return this.outerScrollBar.getScrollTop() > 0; 99 | case 'left': 100 | return this.outerScrollBar.getScrollLeft() > 0; 101 | case 'right': 102 | return this.outerScrollBar.getScrollLeft() < this.outerScrollBar.getScrollWidth() - window.innerWidth; 103 | } 104 | } 105 | 106 | // When a card is moved, check for autoScroll 107 | onMoveScroll(x, y, droppable) { 108 | //var h = this.container.getBoundingClientRect().bottom - this.container.getBoundingClientRect().top; 109 | if (this.state.dragActive && this.state.draggedElem) { 110 | const screenWidth = window.innerWidth; 111 | // Scroll when within 5% or 50px of edge, depending on which one is larger. 112 | // This gives nice big areas for computer screens, without scrolling everywhere on phones 113 | const scrollThreshold = this.props.autoScrollThreshold || Math.max(50, Math.round(screenWidth * 0.05)); 114 | const scrollContainerPos = this.container.getBoundingClientRect(); 115 | 116 | const isNearPageLeft = Math.abs(x - scrollContainerPos.left) <= scrollThreshold; 117 | const isNearPageRight = Math.abs(x - scrollContainerPos.right) <= scrollThreshold; 118 | const isNearPageTop = Math.abs(y - scrollContainerPos.top) <= scrollThreshold; 119 | const isNearPageBottom = Math.abs(y - scrollContainerPos.bottom) <= scrollThreshold; 120 | 121 | const shouldScrollGlobally = isNearPageBottom || isNearPageTop || isNearPageLeft || isNearPageRight; 122 | const canScrollGlobally = this.getCanScrollDirection(isNearPageBottom ? 'down' : isNearPageTop ? 'up' : isNearPageLeft ? 'left' : isNearPageRight ? 'right' : ''); 123 | // BEGIN GLOBAL SCROLLING // 124 | if (shouldScrollGlobally && canScrollGlobally) { 125 | if (this.outerScrollBar) { 126 | if (isNearPageRight) { 127 | // Scroll right 128 | this.setState({ 129 | globalScroll: true, 130 | globalScrollXDirection: 'right' 131 | }); 132 | } else if (isNearPageLeft) { 133 | // Scroll left 134 | this.setState({ 135 | globalScroll: true, 136 | globalScrollXDirection: 'left' 137 | }); 138 | } else { 139 | this.setState({ 140 | globalScrollXDirection: null 141 | }); 142 | } 143 | if (isNearPageBottom) { 144 | this.setState({ 145 | globalScroll: true, 146 | globalScrollYDirection: 'down' 147 | }); 148 | // can only scroll down if the current scroll is less than height 149 | } else if (isNearPageTop) { 150 | this.setState({ 151 | globalScroll: true, 152 | globalScrollYDirection: 'up' 153 | }); 154 | // can only scroll up if current scroll is larger than 0 155 | } else { 156 | this.setState({globalScrollYDirection: null}); 157 | } 158 | if (!this.frame) { 159 | this.frame = requestAnimationFrame(() => this.autoScroll(x, y)); 160 | } 161 | } 162 | // END GLOBAL SCROLLING // 163 | } else if (droppable) { 164 | // Clear global scroll 165 | this.setState({globalScroll: null}); 166 | const containerBoundaries = { 167 | left: droppable.getBoundingClientRect().left, 168 | right: droppable.getBoundingClientRect().right, 169 | top: droppable.getBoundingClientRect().top, 170 | bottom: droppable.getBoundingClientRect().bottom 171 | }; 172 | 173 | const isNearYBottom = containerBoundaries.bottom - y <= scrollThreshold; 174 | const isNearYTop = y - containerBoundaries.top <= scrollThreshold; 175 | 176 | if (isNearYBottom) { 177 | //Scroll down the page, increase y values 178 | this.setState({ 179 | shouldScrollY: true, 180 | increaseYScroll: true 181 | }); 182 | } else if (isNearYTop) { 183 | //Scroll up 184 | this.setState({shouldScrollY: true, increaseYScroll: false}); 185 | } else { 186 | this.setState({shouldScrollY: false}); 187 | } 188 | if (!this.frame) { 189 | this.frame = requestAnimationFrame(() => this.autoScroll(x, y)); 190 | } 191 | } 192 | } else { 193 | this.frame = null; 194 | this.clearScrolling(); 195 | } 196 | } 197 | clearScrolling() { 198 | const mutationObject = {}; 199 | if (this.state.globalScroll) { 200 | mutationObject.globalScroll = false; 201 | } 202 | this.setState({mutationObject}); 203 | } 204 | 205 | onDragMove(draggable, droppable, draggableHoveredOverId, x, y, sectionId) { 206 | if (draggable && droppable) { 207 | const shouldUpdateDraggable = this.state.draggedElem != null ? this.state.draggedElem.id !== draggable.id : draggable != null; 208 | const shouldUpdateDroppable = this.state.droppableActive != null ? this.state.droppableActive !== droppable : droppable != null; 209 | const shouldUpdatePlaceholder = this.state.placeholder != null ? this.state.placeholder !== draggableHoveredOverId : draggableHoveredOverId != null; 210 | const shouldUpdateSectionId = this.state.targetSection != null ? this.state.targetSection !== sectionId : sectionId != null; 211 | const mutationObject = {}; 212 | let shouldUpdate = false; 213 | // Update if field is currently not set, and it is in nextstate, or if the two IDs differ. 214 | if (shouldUpdateDraggable) { 215 | mutationObject.draggedElem = draggable; 216 | shouldUpdate = true; 217 | } 218 | if (shouldUpdateDroppable) { 219 | mutationObject.droppableActive = droppable.getAttribute('droppableid'); 220 | shouldUpdate = true; 221 | } 222 | if (shouldUpdatePlaceholder) { 223 | mutationObject.placeholder = draggableHoveredOverId; 224 | shouldUpdate = true; 225 | } 226 | if (shouldUpdateSectionId) { 227 | mutationObject.targetSection = sectionId; 228 | shouldUpdate = true; 229 | } 230 | if (shouldUpdate) { 231 | this.setState(mutationObject); 232 | } 233 | } 234 | // Register move no matter what (even if draggable/droppably wasnt updated here) 235 | this.onMoveScroll(x, y, droppable); 236 | } 237 | 238 | resetPlaceholderIndex() { 239 | if (this.state.placeholder != null || this.state.droppableActive != null) { 240 | this.setState({placeholder: null, droppableActive: null}); 241 | } 242 | } 243 | 244 | sideScroll(val) { 245 | if (this.outerScrollBar) { 246 | this.outerScrollBar.scrollLeft(val); 247 | } 248 | } 249 | 250 | getSideScroll() { 251 | if (this.outerScrollBar) { 252 | return this.outerScrollBar.getScrollLeft(); 253 | } 254 | } 255 | 256 | autoScroll(x, y) { 257 | if (this.state.dragActive && this.state.draggedElem) { 258 | if (this.state.globalScroll && (this.state.globalScrollXDirection || this.state.globalScrollYDirection) && this.outerScrollBar) { 259 | switch (this.state.globalScrollYDirection) { 260 | case 'down': 261 | if (this.outerScrollBar.getScrollTop() < this.outerScrollBar.getScrollHeight()) { 262 | this.outerScrollBar.scrollTop(this.outerScrollBar.getScrollTop() + (this.props.scrollYSpeed ? this.props.scrollYSpeed : 10)); 263 | } 264 | break; 265 | case 'up': 266 | if (this.outerScrollBar.getScrollTop() > 0) { 267 | this.outerScrollBar.scrollTop(this.outerScrollBar.getScrollTop() - (this.props.scrollYSpeed ? this.props.scrollYSpeed : 10)); 268 | } 269 | break; 270 | default: 271 | break; 272 | } 273 | switch (this.state.globalScrollXDirection) { 274 | case 'right': 275 | if (this.outerScrollBar.getScrollLeft() < this.outerScrollBar.getScrollWidth()) { 276 | this.outerScrollBar.scrollLeft(this.outerScrollBar.getScrollLeft() + (this.props.scrollXSpeed ? this.props.scrollXSpeed : 10)); 277 | } 278 | break; 279 | case 'left': 280 | if (this.outerScrollBar.getScrollLeft() > 0) { 281 | this.outerScrollBar.scrollLeft(this.outerScrollBar.getScrollLeft() - (this.props.scrollXSpeed ? this.props.scrollXSpeed : 10)); 282 | } 283 | break; 284 | default: 285 | break; 286 | } 287 | requestAnimationFrame(() => this.autoScroll(x, y)); 288 | } else if (this.state.droppableActive && this.state.shouldScrollY) { 289 | if (this.state.increaseYScroll) { 290 | dispatch(this.state.dragAndDropGroup.scrollEvent, this.state.droppableActive, 15); 291 | } else { 292 | dispatch(this.state.dragAndDropGroup.scrollEvent, this.state.droppableActive, -15); 293 | } 294 | requestAnimationFrame(() => this.autoScroll(x, y)); 295 | } else { 296 | this.frame = null; 297 | return; 298 | } 299 | } else { 300 | this.frame = null; 301 | return; 302 | } 303 | } 304 | 305 | handleScroll(e) { 306 | if (this.props.onScroll) { 307 | const scrollOffsetY = this.outerScrollBar ? this.outerScrollBar.getScrollTop() : 0; 308 | const scrollOffsetX = this.outerScrollBar ? this.outerScrollBar.getScrollLeft() : 0; 309 | this.props.onScroll({scrollX: scrollOffsetX, scrollY: scrollOffsetY}); 310 | } 311 | } 312 | 313 | render() { 314 | return this.props.outerScrollBar ? ( 315 |
(this.container = div)} className={'drag-drop-context'} style={{display: 'flex', flexDirection: 'column'}}> 316 | (this.outerScrollBar = scrollDiv)} 319 | autoHeight={true} 320 | autoHeightMin={this.props.scrollContainerMinHeight != null ? this.props.scrollContainerMinHeight : 1} 321 | autoHeightMax={this.props.scrollContainerHeight} 322 | {...this.props.scrollProps} 323 | > 324 | {this.props.children} 325 | 326 |
327 | ) : ( 328 |
(this.container = div)} className={'drag-drop-context'}> 329 | {this.props.children} 330 |
331 | ); 332 | } 333 | } 334 | export default DragDropContext; 335 | -------------------------------------------------------------------------------- /src/components/drag_scroll_bar.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import {dispatch, subscribe, unsubscribe} from '../util/event_manager'; 3 | import {Scrollbars} from 'react-custom-scrollbars'; 4 | import Util from './../util/util'; 5 | import PropTypes from 'prop-types'; 6 | 7 | class DragScrollBar extends Component { 8 | constructor(props) { 9 | super(props); 10 | this.state = { 11 | placeholder: null, 12 | dragStarted: false, 13 | dragActive: false, 14 | draggedElem: null, 15 | droppableActive: null, 16 | targetSection: null, 17 | dragAndDropGroup: Util.getDragEvents(this.props.dragAndDropGroup) 18 | }; 19 | this.onDragMove = this.onDragMove.bind(this); 20 | this.onDragStart = this.onDragStart.bind(this); 21 | this.onDragEnd = this.onDragEnd.bind(this); 22 | } 23 | 24 | componentDidMount() { 25 | subscribe(this.state.dragAndDropGroup.startEvent, this.onDragStart); 26 | subscribe(this.state.dragAndDropGroup.endEvent, this.onDragEnd); 27 | subscribe(this.state.dragAndDropGroup.moveEvent, this.onDragMove); 28 | } 29 | 30 | componentWillUnmount() { 31 | unsubscribe(this.state.dragAndDropGroup.startEvent, this.onDragStart); 32 | unsubscribe(this.state.dragAndDropGroup.endEvent, this.onDragEnd); 33 | unsubscribe(this.state.dragAndDropGroup.moveEvent, this.onDragMove); 34 | } 35 | 36 | onDragEnd() { 37 | // Todo, performance 38 | this.setState({ 39 | draggedElem: null, 40 | dragActive: false, 41 | droppableActive: null, 42 | dragStarted: false, 43 | globalScroll: null, 44 | globalScrollXDirection: null, 45 | globalScrollYDirection: null 46 | }); 47 | } 48 | 49 | onDragStart(draggable, x, y) { 50 | if (!this.state.dragActive) { 51 | this.setState({dragActive: true, draggedElem: draggable}); 52 | } 53 | if (this.props.onDragStart) { 54 | this.props.onDragStart(draggable, x, y); 55 | } 56 | } 57 | 58 | // Check if global scroll is at appropriate edge already 59 | getCanScrollDirection(dir) { 60 | if (!this.outerScrollBar) { 61 | return false; 62 | } 63 | const containerHeight = this.container ? this.container.clientHeight : this.props.maxHeight ? this.props.maxHeight : window.innerHeight; 64 | const scrollHeight = this.outerScrollBar.getScrollHeight(); 65 | switch (dir) { 66 | case 'down': 67 | return scrollHeight > this.props.maxHeight && this.outerScrollBar.getScrollTop() < scrollHeight - containerHeight; 68 | case 'up': 69 | return scrollHeight > this.props.maxHeight && this.outerScrollBar.getScrollTop() > 0; 70 | case 'left': 71 | return this.outerScrollBar.getScrollLeft() > 0; 72 | case 'right': 73 | return this.outerScrollBar.getScrollLeft() < this.outerScrollBar.getScrollWidth() - window.innerWidth; 74 | } 75 | } 76 | 77 | // When a card is moved, check for autoScroll 78 | onMoveScroll(x, y, droppable) { 79 | //var h = this.container.getBoundingClientRect().bottom - this.container.getBoundingClientRect().top; 80 | // Scroll when within 60px of edge 81 | const shouldScroll = this.state.dragActive && this.state.draggedElem; 82 | if (shouldScroll) { 83 | const scrollThreshold = this.props.autoScrollThreshold || 60; 84 | const scrollContainerPos = this.container.getBoundingClientRect(); 85 | 86 | const isNearPageLeft = Math.abs(x - scrollContainerPos.left) <= scrollThreshold; 87 | const isNearPageRight = Math.abs(x - scrollContainerPos.right) <= scrollThreshold; 88 | const isNearPageTop = Math.abs(y - scrollContainerPos.top) <= scrollThreshold; 89 | const isNearPageBottom = Math.abs(y - scrollContainerPos.bottom) <= scrollThreshold; 90 | 91 | const shouldScrollGlobally = isNearPageBottom || isNearPageTop || isNearPageLeft || isNearPageRight; 92 | const canScrollGlobally = this.getCanScrollDirection(isNearPageBottom ? 'down' : isNearPageTop ? 'up' : isNearPageLeft ? 'left' : isNearPageRight ? 'right' : ''); 93 | // BEGIN GLOBAL SCROLLING // 94 | if (shouldScrollGlobally && canScrollGlobally) { 95 | if (this.outerScrollBar) { 96 | if (isNearPageRight) { 97 | // Scroll right 98 | this.setState({ 99 | globalScroll: true, 100 | globalScrollXDirection: 'right' 101 | }); 102 | } else if (isNearPageLeft) { 103 | // Scroll left 104 | this.setState({ 105 | globalScroll: true, 106 | globalScrollXDirection: 'left' 107 | }); 108 | } else { 109 | this.setState({ 110 | globalScrollXDirection: null 111 | }); 112 | } 113 | if (isNearPageBottom) { 114 | this.setState({ 115 | globalScroll: true, 116 | globalScrollYDirection: 'down' 117 | }); 118 | // can only scroll down if the current scroll is less than height 119 | } else if (isNearPageTop) { 120 | this.setState({ 121 | globalScroll: true, 122 | globalScrollYDirection: 'up' 123 | }); 124 | // can only scroll up if current scroll is larger than 0 125 | } else { 126 | this.setState({globalScrollYDirection: null}); 127 | } 128 | if (!this.frame && shouldScroll) { 129 | this.frame = requestAnimationFrame(() => this.autoScroll(x, y)); 130 | } 131 | } 132 | // END GLOBAL SCROLLING // 133 | } else if (droppable) { 134 | // Clear global scroll 135 | this.setState({globalScroll: null}); 136 | const containerBoundaries = { 137 | left: droppable.getBoundingClientRect().left, 138 | right: droppable.getBoundingClientRect().right, 139 | top: droppable.getBoundingClientRect().top, 140 | bottom: droppable.getBoundingClientRect().bottom 141 | }; 142 | 143 | const isNearYBottom = containerBoundaries.bottom - y <= scrollThreshold; 144 | const isNearYTop = y - containerBoundaries.top <= scrollThreshold; 145 | 146 | if (isNearYBottom) { 147 | //Scroll down the page, increase y values 148 | this.setState({ 149 | shouldScrollY: true, 150 | increaseYScroll: true 151 | }); 152 | } else if (isNearYTop) { 153 | //Scroll up 154 | this.setState({shouldScrollY: true, increaseYScroll: false}); 155 | } else { 156 | this.setState({shouldScrollY: false}); 157 | } 158 | if (!this.frame && shouldScroll) { 159 | this.frame = requestAnimationFrame(() => this.autoScroll(x, y)); 160 | } 161 | } 162 | } else { 163 | this.frame = null; 164 | if (this.state.globalScroll) { 165 | this.setState({globalScroll: false}); 166 | } 167 | } 168 | } 169 | 170 | onDragMove(draggable, droppable, draggableHoveredOverId, x, y, sectionId) { 171 | if (draggable && droppable) { 172 | const shouldUpdateDraggable = this.state.draggedElem != null ? this.state.draggedElem.id !== draggable.id : draggable != null; 173 | const shouldUpdateDroppable = this.state.droppableActive != null ? this.state.droppableActive !== droppable : droppable != null; 174 | const shouldUpdatePlaceholder = this.state.placeholder != null ? this.state.placeholder !== draggableHoveredOverId : draggableHoveredOverId != null; 175 | const shouldUpdateSectionId = this.state.targetSection != null ? this.state.targetSection !== sectionId : sectionId != null; 176 | const mutationObject = {}; 177 | let shouldUpdate = false; 178 | // Update if field is currently not set, and it is in nextstate, or if the two IDs differ. 179 | if (shouldUpdateDraggable) { 180 | mutationObject.draggedElem = draggable; 181 | shouldUpdate = true; 182 | } 183 | if (shouldUpdateDroppable) { 184 | mutationObject.droppableActive = droppable.getAttribute('droppableid'); 185 | shouldUpdate = true; 186 | } 187 | if (shouldUpdatePlaceholder) { 188 | mutationObject.placeholder = draggableHoveredOverId; 189 | shouldUpdate = true; 190 | } 191 | if (shouldUpdateSectionId) { 192 | mutationObject.targetSection = sectionId; 193 | shouldUpdate = true; 194 | } 195 | if (shouldUpdate) { 196 | this.setState(mutationObject); 197 | } 198 | } 199 | // Register move no matter what (even if draggable/droppably wasnt updated here) 200 | this.onMoveScroll(x, y, droppable); 201 | } 202 | 203 | sideScroll(val) { 204 | if (this.outerScrollBar) { 205 | this.outerScrollBar.scrollLeft(val); 206 | } 207 | } 208 | 209 | getSideScroll() { 210 | if (this.outerScrollBar) { 211 | return this.outerScrollBar.getScrollLeft(); 212 | } 213 | } 214 | 215 | autoScroll(x, y) { 216 | const shouldScroll = this.state.dragActive && this.state.draggedElem; 217 | if (shouldScroll) { 218 | if (this.state.globalScroll && (this.state.globalScrollXDirection || this.state.globalScrollYDirection) && this.outerScrollBar) { 219 | switch (this.state.globalScrollYDirection) { 220 | case 'down': 221 | if (this.outerScrollBar.getScrollTop() < this.outerScrollBar.getScrollHeight()) { 222 | this.outerScrollBar.scrollTop(this.outerScrollBar.getScrollTop() + (this.props.scrollYSpeed ? this.props.scrollYSpeed : 10)); 223 | } 224 | break; 225 | case 'up': 226 | if (this.outerScrollBar.getScrollTop() > 0) { 227 | this.outerScrollBar.scrollTop(this.outerScrollBar.getScrollTop() - (this.props.scrollYSpeed ? this.props.scrollYSpeed : 10)); 228 | } 229 | break; 230 | default: 231 | break; 232 | } 233 | switch (this.state.globalScrollXDirection) { 234 | case 'right': 235 | if (this.outerScrollBar.getScrollLeft() < this.outerScrollBar.getScrollWidth()) { 236 | this.outerScrollBar.scrollLeft(this.outerScrollBar.getScrollLeft() + (this.props.scrollXSpeed ? this.props.scrollXSpeed : 10)); 237 | } 238 | break; 239 | case 'left': 240 | if (this.outerScrollBar.getScrollLeft() > 0) { 241 | this.outerScrollBar.scrollLeft(this.outerScrollBar.getScrollLeft() - (this.props.scrollXSpeed ? this.props.scrollXSpeed : 10)); 242 | } 243 | break; 244 | default: 245 | break; 246 | } 247 | if (shouldScroll) { 248 | requestAnimationFrame(() => this.autoScroll(x, y)); 249 | } 250 | } else if (this.state.droppableActive && this.state.shouldScrollY) { 251 | if (this.state.increaseYScroll) { 252 | dispatch(this.state.dragAndDropGroup.scrollEvent, this.state.droppableActive, 15); 253 | } else { 254 | dispatch(this.state.dragAndDropGroup.scrollEvent, this.state.droppableActive, -15); 255 | } 256 | if (shouldScroll) { 257 | requestAnimationFrame(() => this.autoScroll(x, y)); 258 | } 259 | } else { 260 | this.frame = null; 261 | return; 262 | } 263 | } else { 264 | this.frame = null; 265 | if (this.state.globalScroll || this.state.shouldScrollY) { 266 | this.setState({globalScroll: false, shouldScrollY: false}); 267 | } 268 | return; 269 | } 270 | } 271 | 272 | getScrollTop() { 273 | if (this.outerScrollBar) { 274 | return this.outerScrollBar.getScrollTop(); 275 | } 276 | } 277 | 278 | scrollTop(val) { 279 | if (this.outerScrollBar) { 280 | this.outerScrollBar.scrollTop(val); 281 | } 282 | } 283 | 284 | getScrollHeight() { 285 | if (this.outerScrollBar) { 286 | return this.outerScrollBar.getScrollHeight(); 287 | } 288 | } 289 | 290 | handleScroll(e) { 291 | const scrollOffsetY = this.outerScrollBar ? this.outerScrollBar.getScrollTop() : 0; 292 | const scrollOffsetX = this.outerScrollBar ? this.outerScrollBar.getScrollLeft() : 0; 293 | if (this.props.onScroll) { 294 | this.props.onScroll({scrollX: scrollOffsetX, scrollY: scrollOffsetY}); 295 | } 296 | } 297 | 298 | render() { 299 | const {customScrollbars, children} = this.props; 300 | const UseScrollbars = customScrollbars || Scrollbars; 301 | return ( 302 |
(this.container = div)} className={'drag-drop-context'} style={{display: 'flex', flexDirection: 'column'}}> 303 | (this.outerScrollBar = scrollDiv)} 306 | autoHeight={true} 307 | autoHeightMin={this.props.minHeight != null ? this.props.minHeight : 1} 308 | autoHeightMax={this.props.maxHeight != null ? this.props.maxHeight : window.innerHeight} 309 | {...this.props.scrollProps} 310 | > 311 | {children} 312 | 313 |
314 | ); 315 | } 316 | } 317 | DragScrollBar.propTypes = { 318 | dragAndDropGroup: PropTypes.string.isRequired, 319 | onScroll: PropTypes.func, 320 | autoScrollThreshold: PropTypes.number, 321 | tagName: PropTypes.string 322 | }; 323 | export default DragScrollBar; 324 | -------------------------------------------------------------------------------- /src/components/drag_section.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import PropTypes from 'prop-types'; 3 | class DragSection extends Component { 4 | render() { 5 | const propsObject = { 6 | 'data-cy': 'drag-section-' + this.props.sectionId, 7 | className: 'draggable', 8 | key: this.props.sectionId, 9 | draggableid: this.props.sectionId, 10 | tabIndex: '0', 11 | 'aria-grabbed': true, 12 | 'aria-dropeffect': 'move' 13 | }; 14 | const CustomTag = this.props.tagName ? this.props.tagName : 'div'; 15 | 16 | return {this.props.children}; 17 | } 18 | } 19 | DragSection.propTypes = { 20 | sectionId: PropTypes.string.isRequired, 21 | customTag: PropTypes.string 22 | }; 23 | export default DragSection; 24 | -------------------------------------------------------------------------------- /src/components/draggable.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import {dispatch} from '../util/event_manager'; 4 | import Util from './../util/util'; 5 | 6 | class Draggable extends Component { 7 | constructor(props) { 8 | super(props); 9 | this.state = { 10 | startX: null, 11 | startY: null, 12 | isDragging: false, 13 | wasClicked: false, 14 | isTouch: false, 15 | xClickOffset: 0, 16 | yClickOffset: 0, 17 | didMoveMinDistanceDuringDrag: false, 18 | dragSensitivityX: 15, 19 | dragSensitivityY: 15 20 | }; 21 | this.handleDragShortcuts = this.handleDragShortcuts.bind(this); 22 | this.onPointerMove = this.onPointerMove.bind(this); 23 | this.onPointerUp = this.onPointerUp.bind(this); 24 | this.dragAndDropGroup = Util.getDragEvents(this.props.dragAndDropGroup); 25 | this.elFromPointCounter = 0; 26 | this.latestUpdateX = null; 27 | this.latestUpdateY = null; 28 | // Minimum pixels moved before looking for new cards etc. 29 | this.minDragDistanceThreshold = this.props.minDragDistanceThreshold ? this.props.minDragDistanceThreshold : 5; 30 | this.draggableHoveringOver = null; 31 | this.droppableDraggedOver = null; 32 | } 33 | 34 | componentDidMount() { 35 | document.addEventListener('keydown', this.handleDragShortcuts); 36 | } 37 | 38 | componentWillUnmount() { 39 | document.removeEventListener('keydown', this.handleDragShortcuts); 40 | cancelAnimationFrame(this.frame); 41 | this.frame = null; 42 | } 43 | 44 | handleDragShortcuts(e) { 45 | if (e.key === 'Escape') { 46 | this.cancelDrag(); 47 | } 48 | } 49 | 50 | /*setPointerCapture(pointerId) { 51 | if (!this.state.capturing && pointerId) { 52 | this.draggable.setPointerCapture(pointerId); 53 | this.setState({capturing: true, pointerId: pointerId}); 54 | } 55 | } 56 | 57 | releasePointerCapture() { 58 | if (this.state.isTouch && this.state.pointerId && this.draggable) { 59 | this.draggable.releasePointerCapture(this.state.pointerId); 60 | } 61 | }*/ 62 | 63 | getBoundingClientRect() { 64 | if (this.draggable) { 65 | return this.draggable.getBoundingClientRect(); 66 | } 67 | } 68 | 69 | removeDragEventListeners() { 70 | if (!this.state.isTouch) { 71 | document.removeEventListener('mousemove', this.onPointerMove); 72 | document.removeEventListener('mouseup', this.onPointerUp); 73 | // Remove the click blocker after ended drag (on next available frame) 74 | requestAnimationFrame(() => document.removeEventListener('click', this.clickBlocker, true)); 75 | } 76 | } 77 | 78 | clickBlocker(e) { 79 | e.stopPropagation(); 80 | e.preventDefault(); 81 | } 82 | 83 | onPointerDown(e, isTouch) { 84 | const isMouse = e.buttons === 1; 85 | if ((!isTouch && !isMouse) || (e.target.className && typeof e.target.className === 'string' && e.target.className.includes('no-drag')) || this.props.disabled || this.props.isSectionHeader) { 86 | return; 87 | } 88 | if (e) { 89 | if (!isTouch) e.preventDefault(); 90 | e.stopPropagation(); 91 | } 92 | if (!isTouch) { 93 | document.addEventListener('mousemove', this.onPointerMove); 94 | document.addEventListener('mouseup', this.onPointerUp); 95 | } 96 | const dragObject = {draggableId: this.props.draggableId, droppableId: this.props.droppableId}; 97 | dispatch(this.dragAndDropGroup.moveEvent, dragObject, null, null, null, null); 98 | if (this.droppableDraggedOver !== null || this.draggableHoveringOver !== null) { 99 | this.droppableDraggedOver = null; 100 | this.draggableHoveringOver = null; 101 | } 102 | const x = isTouch ? e.changedTouches[e.changedTouches.length - 1].clientX : e.clientX; 103 | const y = isTouch ? e.changedTouches[e.changedTouches.length - 1].clientY : e.clientY; 104 | let cardWidth = this.draggable.offsetWidth; 105 | const cardTop = this.draggable.getBoundingClientRect().top; 106 | const cardLeft = this.draggable.getBoundingClientRect().left; 107 | this.setState({ 108 | width: cardWidth, 109 | didMoveMinDistanceDuringDrag: false, 110 | minDragDistanceMoved: false, 111 | startX: x, 112 | startY: y, 113 | wasClicked: true, 114 | isTouch: isTouch, 115 | isDragging: false, 116 | // +8 for margin for error 117 | xClickOffset: Math.abs(x - cardLeft) + 8, 118 | yClickOffset: Math.abs(y - cardTop) + 8 119 | }); 120 | } 121 | onPointerUp(e) { 122 | e.preventDefault(); 123 | e.stopPropagation(); 124 | if (this.props.disabled || this.props.isSectionHeader) { 125 | return; 126 | } 127 | if (this.state.isTouch && this.state.pointerId) { 128 | //this.releasePointerCapture(); 129 | } 130 | if (this.state.didMoveMinDistanceDuringDrag) { 131 | dispatch(this.dragAndDropGroup.endEvent); 132 | } 133 | this.cancelDrag(); 134 | } 135 | cancelDrag() { 136 | dispatch(this.dragAndDropGroup.resetEvent); 137 | this.draggableHoveringOver = null; 138 | this.setState({ 139 | isDragging: false, 140 | capturing: false, 141 | didMoveMinDistanceDuringDrag: false, 142 | minDragDistanceMoved: false, 143 | left: null, 144 | top: null, 145 | cardLeft: 0, 146 | cardTop: 0, 147 | wasClicked: false 148 | }); 149 | this.removeDragEventListeners(); 150 | //this.releasePointerCapture(); 151 | } 152 | // Don't update what we're dragging over on every single drag 153 | shouldRefindDragElems(x, y) { 154 | if (!this.latestUpdateX || !this.latestUpdateY) { 155 | this.latestUpdateX = x; 156 | this.latestUpdateY = y; 157 | return true; 158 | } else { 159 | // Only update if we've moved some x + y distance that is larger than threshold 160 | const shouldUpdate = Math.abs(this.latestUpdateX - x) + Math.abs(this.latestUpdateY - y) >= this.minDragDistanceThreshold; 161 | if (shouldUpdate) { 162 | this.latestUpdateX = x; 163 | this.latestUpdateY = y; 164 | return true; 165 | } 166 | return false; 167 | } 168 | } 169 | 170 | moveElement(x, y) { 171 | let hasDispatched = false; 172 | const shouldRefindDragElems = this.shouldRefindDragElems(x, y); 173 | let droppableDraggedOver = shouldRefindDragElems || this.droppableDraggedOver == null ? this.getDroppableElemUnderDrag(x, y) : this.droppableDraggedOver; 174 | let draggableHoveringOver = shouldRefindDragElems || this.draggableHoveringOver == null ? this.getDraggableElemUnderDrag(x, y) : this.draggableHoveringOver; 175 | const newLeft = x - this.state.xClickOffset; 176 | const newTop = y - this.state.yClickOffset; 177 | const minDistanceMoved = Math.abs(this.state.startX - x) > this.state.dragSensitivityX || Math.abs(this.state.startY - y) > this.state.dragSensitivityY; 178 | if (minDistanceMoved && !this.state.didMoveMinDistanceDuringDrag) { 179 | this.setState({didMoveMinDistanceDuringDrag: true}); 180 | } 181 | if (!minDistanceMoved && !this.state.didMoveMinDistanceDuringDrag) { 182 | const dragObject = {draggableId: this.props.draggableId, droppableId: this.props.droppableId}; 183 | dispatch(this.dragAndDropGroup.moveEvent, dragObject, null, null, null, null); 184 | hasDispatched = true; 185 | return; 186 | } 187 | if (!droppableDraggedOver) { 188 | dispatch(this.dragAndDropGroup.resetEvent); 189 | this.droppableDraggedOver = null; 190 | this.draggableHoveringOver = null; 191 | } 192 | const shouldRegisterAsDrag = this.state.didMoveMinDistanceDuringDrag || minDistanceMoved; 193 | if (shouldRegisterAsDrag && this.state.wasClicked && !this.state.isDragging) { 194 | const dragObject = { 195 | draggableId: this.props.draggableId, 196 | droppableId: this.props.droppableId, 197 | height: this.draggable ? this.draggable.clientHeight : null 198 | }; 199 | dispatch(this.dragAndDropGroup.startEvent, dragObject, x, y); 200 | hasDispatched = true; 201 | } 202 | // We're hovering over a droppable and a draggable 203 | if (droppableDraggedOver && draggableHoveringOver && shouldRegisterAsDrag) { 204 | const draggableHoveredOverId = draggableHoveringOver.getAttribute('draggableid'); 205 | if (!draggableHoveredOverId.includes('placeholder')) { 206 | if (this.droppableDraggedOver !== droppableDraggedOver || this.draggableHoveringOver !== draggableHoveringOver) { 207 | const dragObject = {draggableId: this.props.draggableId, droppableId: this.props.droppableId}; 208 | dispatch(this.dragAndDropGroup.moveEvent, dragObject, droppableDraggedOver, draggableHoveredOverId, x, y); 209 | hasDispatched = true; 210 | this.droppableDraggedOver = droppableDraggedOver; 211 | this.draggableHoveringOver = draggableHoveringOver; 212 | } 213 | } 214 | } else if (droppableDraggedOver && shouldRegisterAsDrag) { 215 | // We're hovering over a droppable, but no draggable 216 | this.droppableDraggedOver = droppableDraggedOver; 217 | this.draggableHoveringOver = null; 218 | const dragObject = {draggableId: this.props.draggableId, droppableId: this.props.droppableId}; 219 | dispatch(this.dragAndDropGroup.moveEvent, dragObject, droppableDraggedOver, null, x, y, null); 220 | hasDispatched = true; 221 | } 222 | if (!hasDispatched) { 223 | // If nothing changed, we still wanna notify move for scrolling 224 | dispatch(this.dragAndDropGroup.moveEvent, null, null, null, x, y, null); 225 | hasDispatched = true; 226 | } 227 | this.setState({ 228 | isDragging: shouldRegisterAsDrag, 229 | // We need to move more than the drag sensitivity before we consider it an intended drag 230 | minDragDistanceMoved: minDistanceMoved, 231 | left: newLeft, 232 | top: newTop 233 | }); 234 | } 235 | 236 | onPointerMove(e) { 237 | if (this.props.disabled || !this.state.wasClicked || this.props.isSectionHeader) { 238 | return; 239 | } 240 | const x = this.state.isTouch ? e.changedTouches[e.changedTouches.length - 1].clientX : e.clientX; 241 | const y = this.state.isTouch ? e.changedTouches[e.changedTouches.length - 1].clientY : e.clientY; 242 | if (this.state.isTouch) { 243 | // This seems unneccesary for touch events 244 | //this.setPointerCapture(e.pointerId); 245 | } 246 | const minDistanceMoved = Math.abs(this.state.startX - x) > this.state.dragSensitivityX || Math.abs(this.state.startY - y) > this.state.dragSensitivityY; 247 | if (!minDistanceMoved && !this.state.didMoveMinDistanceDuringDrag) { 248 | return; 249 | } 250 | document.addEventListener('click', this.clickBlocker, true); 251 | if (!this.state.isTouch) e.preventDefault(); 252 | else e.nativeEvent.preventDefault(); 253 | 254 | e.stopPropagation(); 255 | if (e.buttons === 1 || this.state.isTouch) { 256 | requestAnimationFrame(() => this.moveElement(x, y)); 257 | } else { 258 | if (!this.props.noCancelOnMove) { 259 | // This call can cause mouseUp calls to be cancelled instead when using mice with low polling rates. 260 | // The noCancelOnMove prop is a workaround for this, disabling the cancelDrag invocation. 261 | // It is currently unknown if noCancelOnMove has side effects, use at your own risk. 262 | this.cancelDrag(); 263 | } 264 | } 265 | } 266 | 267 | getDroppableElemUnderDrag(x, y) { 268 | let colUnder = null; 269 | let draggingElement = this.draggable; 270 | if (draggingElement) { 271 | // Disable pointer events to look through element 272 | draggingElement.style.pointerEvents = 'none'; 273 | // Get element under dragged (look through) 274 | let elementUnder = document.elementFromPoint(x, y); 275 | // Reset dragged element's pointers 276 | draggingElement.style.pointerEvents = 'all'; 277 | colUnder = Util.getDroppableParentElement(elementUnder, this.props.dragAndDropGroup); 278 | } 279 | return colUnder; 280 | } 281 | 282 | getDraggableElemUnderDrag(x, y) { 283 | if (!this.state.wasClicked || !this.state.didMoveMinDistanceDuringDrag) { 284 | return; 285 | } 286 | let cardUnder = null; 287 | // The Element we're dragging 288 | let draggingElement = this.draggable; 289 | if (draggingElement) { 290 | // Disable pointer events to look through element 291 | draggingElement.style.pointerEvents = 'none'; 292 | // Get element under dragged tasks (look through) 293 | let elementUnder = document.elementFromPoint(x, y); 294 | // Reset dragged element's pointers 295 | cardUnder = Util.getDraggableParentElement(elementUnder); 296 | draggingElement.style.pointerEvents = 'all'; 297 | } 298 | return cardUnder; 299 | } 300 | 301 | /*handlePointerCaptureLoss(e) { 302 | if (this.state.wasClicked && e.pointerId != null) { 303 | this.draggable.setPointerCapture(e.pointerId); 304 | } 305 | }*/ 306 | 307 | render() { 308 | const active = this.state.isDragging && this.state.wasClicked; 309 | const draggingStyle = { 310 | cursor: 'move', 311 | position: this.state.didMoveMinDistanceDuringDrag || this.state.minDragDistanceMoved ? 'fixed' : '', 312 | width: this.state.width, 313 | transition: 'none', 314 | animation: 'none', 315 | zIndex: 500, 316 | top: this.state.minDragDistanceMoved || this.state.didMoveMinDistanceDuringDrag ? this.state.top + 'px' : 0, 317 | left: this.state.minDragDistanceMoved || this.state.didMoveMinDistanceDuringDrag ? this.state.left + 'px' : 0 318 | }; 319 | 320 | const propsObject = { 321 | 'data-cy': 'draggable-' + this.props.draggableId, 322 | className: 'draggable' + (active ? this.props.dragActiveClass : ''), 323 | style: active ? {...draggingStyle} : {transform: 'none', top: 0, left: 0, cursor: this.props.disabled || this.props.isSectionHeader ? 'arrow' : this.state.wasClicked ? 'move' : 'grab'}, 324 | key: this.props.draggableId, 325 | draggableid: this.props.isSectionHeader ? 'SECTION_HEADER_' + this.props.draggableId + (this.props.disableMove ? '_DISABLE_MOVE' : '') : this.props.draggableId, 326 | index: this.props.innerIndex, 327 | tabIndex: '0', 328 | ref: div => (this.draggable = div), 329 | 'aria-grabbed': true, 330 | 'aria-dropeffect': 'move' 331 | }; 332 | const useTouchEvents = false; 333 | if (useTouchEvents && this.state.isTouch) { 334 | propsObject.onTouchMove = e => this.onPointerMove(e); 335 | propsObject.onTouchEnd = e => this.onPointerUp(e); 336 | propsObject.onTouchCancel = () => this.cancelDrag(); 337 | } 338 | if (useTouchEvents) propsObject.onTouchStart = e => this.onPointerDown(e, true); 339 | propsObject.onMouseDown = e => this.onPointerDown(e, false); 340 | 341 | const CustomTag = this.props.tagName ? this.props.tagName : 'div'; 342 | return {this.props.children}; 343 | } 344 | } 345 | 346 | Draggable.propTypes = { 347 | dragAndDropGroup: PropTypes.string.isRequired, 348 | draggableId: PropTypes.string.isRequired, 349 | dragDisabled: PropTypes.bool, 350 | section: PropTypes.string, 351 | noCancelOnMove: PropTypes.bool 352 | }; 353 | 354 | export default Draggable; 355 | -------------------------------------------------------------------------------- /src/components/droppable.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import {Scrollbars} from 'react-custom-scrollbars'; 4 | import {subscribe, unsubscribe} from '../util/event_manager'; 5 | import VirtualizedScrollBar from './virtualized-scrollbar'; 6 | import Util from './../util/util'; 7 | import DynamicVirtualizedScrollbar from './dynamic-virtualized-scrollbar'; 8 | 9 | class Droppable extends Component { 10 | constructor(props) { 11 | super(props); 12 | this.state = { 13 | placeholder: null, 14 | scrollOffset: 0, 15 | topSpacerHeight: 0, 16 | unrenderedBelow: 0, 17 | unrenderedAbove: 0, 18 | dragAndDropGroup: Util.getDragEvents(this.props.dragAndDropGroup), 19 | currentlyActiveDraggable: null 20 | }; 21 | this.onPlaceholderChange = this.onPlaceholderChange.bind(this); 22 | this.onScrollChange = this.onScrollChange.bind(this); 23 | this.onDragEnd = this.onDragEnd.bind(this); 24 | this.onDragStart = this.onDragStart.bind(this); 25 | this.getDraggedElemHeight = this.getDraggedElemHeight.bind(this); 26 | this.defaultElemHeight = 50; 27 | //this.getShouldAlwaysRender = this.getShouldAlwaysRender.bind(this); 28 | } 29 | 30 | componentDidMount() { 31 | subscribe(this.state.dragAndDropGroup.placeholderEvent, this.onPlaceholderChange); 32 | subscribe(this.state.dragAndDropGroup.scrollEvent, this.onScrollChange); 33 | subscribe(this.state.dragAndDropGroup.endEvent, this.onDragEnd); 34 | subscribe(this.state.dragAndDropGroup.startEvent, this.onDragStart); 35 | this.setState({mounted: true}); 36 | } 37 | 38 | componentWillUnmount() { 39 | unsubscribe(this.state.dragAndDropGroup.endEvent, this.onDragEnd); 40 | unsubscribe(this.state.dragAndDropGroup.startEvent, this.onDragStart); 41 | unsubscribe(this.state.dragAndDropGroup.placeholderEvent, this.onPlaceholderChange); 42 | unsubscribe(this.state.dragAndDropGroup.scrollEvent, this.onScrollChange); 43 | } 44 | 45 | getScrollTop() { 46 | if (this.scrollBars) { 47 | return this.scrollBars.getScrollTop(); 48 | } 49 | } 50 | 51 | animateScrollTop(val) { 52 | if (this.scrollBars) { 53 | this.scrollBars.animateScrollTop(val); 54 | } 55 | } 56 | 57 | scrollTop(val) { 58 | if (this.scrollBars) { 59 | this.scrollBars.scrollTop(val); 60 | } 61 | } 62 | 63 | getScrollHeight() { 64 | if (this.scrollBars) { 65 | return this.scrollBars.getScrollHeight(); 66 | } 67 | } 68 | 69 | onDragEnd(draggedElem) { 70 | this.setState({currentlyActiveDraggable: null}, () => this.forceUpdate()); 71 | } 72 | 73 | onDragStart(draggedElem) { 74 | this.setState({currentlyActiveDraggable: draggedElem}); 75 | } 76 | 77 | // Receives notification about placeholder from context. If we're not the active droppable, don't show placeholder. 78 | onPlaceholderChange(placeholder, droppableActive) { 79 | const isTargetingMe = droppableActive === this.props.droppableId; 80 | if (isTargetingMe) { 81 | this.setState({placeholder: placeholder, droppableActive: droppableActive}); 82 | } else if (this.state.placeholder != null || this.state.droppableActive !== null) { 83 | this.setState({placeholder: null, droppableActive: null}); 84 | } 85 | } 86 | 87 | shouldComponentUpdate(nextProps, nextState) { 88 | // If we're not in a drag, and one is not coming up, always update 89 | if (this.state.currentlyActiveDraggable == null && this.state.droppableActive == null && nextState.droppableActive == null && nextState.currentlyActiveDraggable == null) { 90 | return true; 91 | } 92 | if (this.state.mounted !== nextState.mounted) { 93 | return true; 94 | } 95 | if (this.state.scrollOffset !== nextState.scrollOffset) { 96 | return true; 97 | } 98 | if (this.props.children && nextProps.children && this.props.children.length !== nextProps.children.length) { 99 | return true; 100 | } 101 | const isTargetingMe = nextState.droppableActive === this.props.droppableId; 102 | if (isTargetingMe) { 103 | if (this.state.droppableActive === nextState.droppableActive && this.state.placeholder === nextState.placeholder) { 104 | return false; 105 | } 106 | } else if (this.state.placeholder == null && this.state.droppableActive == null) { 107 | //If we're not being targeted, we dont' want a placeholder update. 108 | return false; 109 | } 110 | return true; 111 | } 112 | 113 | getDraggedElemHeight() { 114 | if (this.state.currentlyActiveDraggable) { 115 | return this.state.currentlyActiveDraggable.height; 116 | } 117 | return this.props.elemHeight ? this.props.elemHeight : this.defaultElemHeight; 118 | } 119 | 120 | pushPlaceholder(children) { 121 | let pushedPlaceholder = false; 122 | const listToRender = [...children]; 123 | const placeholderHeight = this.props.dynamicElemHeight ? this.getDraggedElemHeight() : this.props.elemHeight ? this.props.elemHeight : this.defaultElemHeight; 124 | let style; 125 | 126 | if (this.props.placeholderStyle) { 127 | style = {...this.props.placeholderStyle}; 128 | style.height = placeholderHeight; 129 | } else { 130 | style = { 131 | border: '1px dashed grey', 132 | height: placeholderHeight, 133 | backgroundColor: 'transparent' 134 | }; 135 | } 136 | 137 | if (this.state.placeholder) { 138 | listToRender.forEach((elem, index) => { 139 | if (elem && elem.props && elem.props.draggableId === this.state.placeholder && !pushedPlaceholder) { 140 | listToRender.splice( 141 | index, 142 | 0, 143 |
144 |

145 |

146 | ); 147 | pushedPlaceholder = true; 148 | } 149 | }); 150 | } else if (!pushedPlaceholder) { 151 | listToRender.push( 152 |
153 |

154 |

155 | ); 156 | } 157 | return listToRender; 158 | } 159 | 160 | onScrollChange(droppableActive, scrollOffset) { 161 | const goingDown = scrollOffset > 0; 162 | if (droppableActive != null && droppableActive === this.state.droppableActive && this.scrollBars) { 163 | if ((goingDown && this.scrollBars.getScrollHeight() <= this.scrollBars.getScrollTop()) || (!goingDown && this.scrollBars.getScrollTop() <= 0)) { 164 | return; 165 | } 166 | this.scrollBars.scrollTop(this.scrollBars.getScrollTop() + scrollOffset); 167 | } 168 | } 169 | 170 | render() { 171 | const {children, customScrollbars} = this.props; 172 | // Objects we want to render 173 | let listToRender = []; 174 | const propsObject = { 175 | key: this.props.droppableId, 176 | droppableid: this.props.droppableId, 177 | droppablegroup: this.props.dragAndDropGroup 178 | }; 179 | 180 | if (children && children.length > 0) { 181 | // Pass my droppableId to all children to give a source for DnD 182 | let childrenWithProps = React.Children.map(children, child => 183 | React.cloneElement(child, { 184 | droppableId: this.props.droppableId 185 | //alwaysRender: this.getShouldAlwaysRender 186 | }) 187 | ); 188 | listToRender = childrenWithProps; 189 | } 190 | const optimism = 25; 191 | let elemHeight = 0; 192 | let rowsTotalHeight = 0; 193 | let shouldScroll = true; 194 | let calculatedRowMinHeight = 0; 195 | const listHeaderHeight = this.props.listHeader != null ? this.props.listHeaderHeight : 0; 196 | let outerContainerHeight = this.props.containerHeight; 197 | elemHeight = this.props.hideList ? 0 : this.props.dynamicElemHeight ? this.props.minElemHeight : this.props.elemHeight; 198 | rowsTotalHeight = listToRender.length * elemHeight; 199 | // Container smaller than calculated height of rows? 200 | shouldScroll = this.props.dynamicElemHeight || this.props.containerHeight <= rowsTotalHeight + listHeaderHeight + optimism; 201 | 202 | // Total rows + height of one row (required for DnD to empty lists/dropping below list) 203 | calculatedRowMinHeight = rowsTotalHeight + (this.props.hideList ? 0 : elemHeight); 204 | 205 | // The minimum height of the container is the # of elements + 1 (same reason as above), unless a minimum height is specificied that is larger than this. 206 | // If the minimum height exceeds the containerHeight, we limit it to containerHeight and enable scroll instead 207 | outerContainerHeight = this.props.enforceContainerMinHeight 208 | ? this.props.containerHeight 209 | : shouldScroll 210 | ? this.props.containerHeight 211 | : this.props.containerMinHeight && this.props.containerMinHeight >= calculatedRowMinHeight 212 | ? this.props.containerMinHeight 213 | : Math.min(calculatedRowMinHeight + listHeaderHeight, this.props.containerHeight); 214 | 215 | const draggedElemId = this.state.currentlyActiveDraggable ? this.state.currentlyActiveDraggable.draggableId : null; 216 | const CustomTag = this.props.tagName ? this.props.tagName : 'div'; 217 | const headerWithProps = 218 | this.props.listHeader != null && this.props.listHeaderHeight != null 219 | ? React.cloneElement(this.props.listHeader, { 220 | draggableid: this.props.droppableId + '-header' 221 | }) 222 | : null; 223 | const isActive = this.state.droppableActive && this.state.droppableActive === this.props.droppableId; 224 | const headerActive = isActive && this.state.placeholder && this.state.placeholder.includes('header'); 225 | 226 | return ( 227 | 228 |
{headerWithProps}
229 | {this.props.hideList ? null : shouldScroll && !this.props.disableScroll ? ( 230 | this.props.externalVirtualization ? ( 231 | (this.scrollBars = div)} 235 | {...this.props.scrollProps} 236 | autoHeight={true} 237 | autoHeightMax={this.props.containerHeight - listHeaderHeight} 238 | autoHeightMin={this.props.containerHeight - listHeaderHeight} 239 | > 240 | {isActive ? this.pushPlaceholder(listToRender) : listToRender} 241 | 242 | ) : this.props.dynamicElemHeight ? ( 243 | (this.scrollBars = scrollDiv)} 251 | containerHeight={this.props.containerHeight - listHeaderHeight} 252 | showIndicators={this.props.showIndicators} 253 | scrollProps={this.props.scrollProps} 254 | onScroll={this.props.onScroll} 255 | > 256 | {isActive ? this.pushPlaceholder(listToRender) : listToRender} 257 | 258 | ) : ( 259 | (this.scrollBars = scrollDiv)} 264 | customScrollbars={customScrollbars} 265 | containerHeight={this.props.containerHeight - listHeaderHeight} 266 | onScroll={this.props.onScroll} 267 | > 268 | {isActive ? this.pushPlaceholder(listToRender) : listToRender} 269 | 270 | ) 271 | ) : ( 272 |
{isActive ? this.pushPlaceholder(listToRender) : listToRender}
273 | )} 274 |
275 | ); 276 | } 277 | } 278 | 279 | Droppable.propTypes = { 280 | droppableId: PropTypes.string.isRequired, 281 | dragAndDropGroup: PropTypes.string.isRequired, 282 | containerHeight: PropTypes.number.isRequired, 283 | placeholderStyle: PropTypes.object, 284 | elemHeight: PropTypes.number, 285 | dynamicElemHeight: PropTypes.bool, 286 | disableScroll: PropTypes.bool 287 | }; 288 | export default Droppable; 289 | -------------------------------------------------------------------------------- /src/components/dynamic-virtualized-scrollbar.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import {Scrollbars} from 'react-custom-scrollbars'; 3 | import PropTypes from 'prop-types'; 4 | // import {SpringSystem} from 'rebound'; 5 | import Rebound from 'rebound'; 6 | 7 | class DynamicVirtualizedScrollbar extends Component { 8 | constructor(props) { 9 | super(props); 10 | // Set initial elements to render - either specific amount, or the amount that can be in the viewPort + some optimistic amount to account for number of elements that deviate from min 11 | this.optimisticCount = 4; 12 | // Threshold at which to start virtualizing. Virtualizing small lists can produce jumping, and adds uneccesary overhead 13 | this.virtualizationThreshold = props.virtualizationThreshold != null ? props.virtualizationThreshold : 40; 14 | const initialElemsToRender = this.getInitialRenderAmount(props); 15 | this.state = { 16 | // Update this when dynamic row height becomes a thing 17 | scrollOffset: 0, 18 | firstRenderedItemIndex: 0, 19 | lastRenderedItemIndex: initialElemsToRender, 20 | aboveSpacerHeight: 0, 21 | // Initially guess that all elems are min height 22 | belowSpacerHeight: initialElemsToRender === this.props.listLength - 1 ? 0 : (Math.floor(props.listLength * 0.75) - 1) * props.minElemHeight, 23 | numElemsSized: 0, 24 | totalElemsSizedSize: 0, 25 | renderPart: null 26 | }; 27 | this.elemOverScan = this.props.elemOverScan != null ? this.props.elemOverScan : this.props.simplified ? 0 : 10; 28 | this.childRefs = []; 29 | this.stickyElems = null; 30 | this.lastElemBounds = null; 31 | this.firstElemBounds = null; 32 | this.lastSectionChangeAt = 0; 33 | this.updateRemainingSpace = this.updateRemainingSpace.bind(this); 34 | this.handleScroll = this.handleScroll.bind(this); 35 | this.setFullRender = this.setFullRender.bind(this); 36 | this.clearTimer = this.clearTimer.bind(this); 37 | } 38 | 39 | componentDidMount() { 40 | this.springSystem = new Rebound.SpringSystem(); 41 | this.spring = this.springSystem.createSpring(); 42 | this.spring.setOvershootClampingEnabled(true); 43 | this.spring.addListener({onSpringUpdate: this.handleSpringUpdate.bind(this)}); 44 | if (this.inner != null) { 45 | this.setState({containerTop: this.inner.getBoundingClientRect().top}); 46 | } 47 | // Set initial bounds for first and last rendered elems 48 | if (this.itemsContainer && this.itemsContainer.children && !this.props.simplified) { 49 | const lastElem = this.itemsContainer.lastElementChild; 50 | const firstElem = this.itemsContainer.firstElementChild; 51 | const lastElemBounds = lastElem && lastElem.firstElementChild ? lastElem.firstElementChild.getBoundingClientRect() : {}; 52 | const firstElemBounds = firstElem ? firstElem.getBoundingClientRect() : {}; 53 | this.firstElemBounds = { 54 | top: firstElemBounds.top, 55 | bottom: firstElemBounds.bottom, 56 | left: firstElemBounds.left, 57 | right: firstElemBounds.right 58 | }; 59 | this.lastElemBounds = { 60 | top: lastElemBounds.top, 61 | bottom: lastElemBounds.bottom, 62 | left: lastElemBounds.left, 63 | right: lastElemBounds.right 64 | }; 65 | } 66 | if (this.scrollBars) { 67 | this.scrollHeight = this.scrollBars.getScrollHeight(); 68 | } 69 | this.updateAverageSizing(); 70 | } 71 | 72 | componentDidUpdate(prevProps, prevState) { 73 | if (prevState.firstRenderedItemIndex !== this.state.firstRenderedItemIndex) { 74 | this.firstElemBounds = null; 75 | } 76 | if (prevState.lastRenderedItemIndex !== this.state.lastRenderedItemIndex) { 77 | this.lastElemBounds = null; 78 | } 79 | if (this.props.listLength !== prevProps.listLength) { 80 | this.updateRemainingSpace(); 81 | if (this.scrollBars) { 82 | this.scrollHeight = this.scrollBars.getScrollHeight(); 83 | } 84 | } 85 | if (this.state.renderPart !== prevState.renderPart) { 86 | // We rendered a new section - start timer to not bounce back and forth if on the edge between two renderParts 87 | this.renderPartTimeout = setTimeout(() => this.clearTimer(), 200); 88 | } 89 | } 90 | 91 | // Clear timeout, allowing changes to renderpart 92 | clearTimer() { 93 | clearTimeout(this.renderPartTimeout); 94 | this.renderPartTimeout = null; 95 | } 96 | 97 | componentWillUnmount() { 98 | this.springSystem.deregisterSpring(this.spring); 99 | this.springSystem.removeAllListeners(); 100 | this.springSystem = undefined; 101 | this.spring.destroy(); 102 | this.spring = undefined; 103 | } 104 | 105 | shouldComponentUpdate(nextProps, nextState) { 106 | // only render when visible items change -> smooth scroll 107 | return ( 108 | (this.props.stickyElems && this.props.stickyElems.length > 0) || 109 | this.props.listLength !== nextProps.listLength || 110 | nextState.aboveSpacerHeight !== this.state.aboveSpacerHeight || 111 | nextState.belowSpacerHeight !== this.state.belowSpacerHeight || 112 | nextState.firstRenderedItemIndex !== this.state.firstRenderedItemIndex || 113 | nextState.lastRenderedItemIndex !== this.state.lastRenderedItemIndex || 114 | this.propsDidChange(this.props, nextProps) 115 | ); 116 | } 117 | 118 | getInitialRenderAmount(props) { 119 | // Return full list if list is small, else return first quarter 120 | if (props.listLength <= this.virtualizationThreshold) { 121 | return props.listLength - 1; 122 | } 123 | return Math.round((props.listLength - 1) / 4) + this.optimisticCount; 124 | } 125 | 126 | propsDidChange(props, nextProps) { 127 | const newProps = Object.entries(nextProps); 128 | return newProps.filter(([key, val]) => props[key] !== val).length > 0; 129 | } 130 | 131 | // Calculate remaining space below list, given the current rendering (first to last + overscan below and above) 132 | updateRemainingSpace() { 133 | const lastRenderedItemIndex = Math.min(this.props.listLength - 1, this.state.lastRenderedItemIndex + this.elemOverScan); 134 | const remainingElemsBelow = Math.max(this.props.listLength - (lastRenderedItemIndex + 1), 0); 135 | const averageItemSize = this.getElemSizeAvg(); 136 | const belowSpacerHeight = remainingElemsBelow * averageItemSize; 137 | if (belowSpacerHeight !== this.state.belowSpacerHeight) { 138 | this.setState({belowSpacerHeight: belowSpacerHeight, renderPart: null}, () => this.setSpacingValidationTimer()); 139 | } 140 | } 141 | 142 | setSpacingValidationTimer() { 143 | if (this.autoCalcTimeout) { 144 | clearTimeout(this.autoCalcTimeout); 145 | } 146 | this.autoCalcTimeout = setTimeout(() => this.autoCalculateSpacing(), 500); 147 | } 148 | 149 | autoCalculateSpacing() { 150 | let shouldCalc = false; 151 | // Only re-calculate if we're more than 10 pixels past triggers 152 | const triggerOffset = 10; 153 | if (this.belowSpacer && this.aboveSpacer) { 154 | const belowSpacerBounds = this.belowSpacer.getBoundingClientRect(); 155 | const aboveSpacerBounds = this.aboveSpacer.getBoundingClientRect(); 156 | // Below spacer is in viewport 157 | if (this.state.containerTop + this.props.containerHeight - triggerOffset > belowSpacerBounds.top) { 158 | shouldCalc = true; 159 | } 160 | // Above spacer is in viewport 161 | if (this.state.containerTop < aboveSpacerBounds.bottom - triggerOffset) { 162 | shouldCalc = true; 163 | } 164 | } 165 | if (shouldCalc) { 166 | const scrollOffset = this.scrollBars.getScrollTop(); 167 | const averageItemSize = this.getElemSizeAvg(); 168 | const elemsAbove = Math.round(scrollOffset / averageItemSize); 169 | const elemsToRender = Math.round(this.props.containerHeight / averageItemSize); 170 | this.setState({ 171 | aboveSpacerHeight: elemsAbove * averageItemSize, 172 | belowSpacerHeight: (this.props.listLength - (elemsAbove + elemsToRender)) * averageItemSize + averageItemSize, 173 | firstRenderedItemIndex: Math.max(0, elemsAbove), 174 | lastRenderedItemIndex: Math.min(this.props.listLength - 1, elemsAbove + elemsToRender) 175 | }); 176 | } 177 | } 178 | 179 | handleSpringUpdate(spring) { 180 | const val = spring.getCurrentValue(); 181 | this.scrollBars.scrollTop(val); 182 | } 183 | 184 | getListToRender(list) { 185 | this.stickyElems = []; 186 | const lastRenderedItemIndex = this.state.lastRenderedItemIndex; 187 | const firstRenderedItemIndex = this.state.firstRenderedItemIndex; 188 | let start = 0; 189 | let end = list.length - 1; 190 | // Only virtualize if we have more elements than our combined overscan 191 | if (list.length > this.elemOverScan * 2) { 192 | // Render elemOverscan amount of elements above and below the indices 193 | start = Math.max(firstRenderedItemIndex - this.elemOverScan, 0); 194 | end = Math.min(lastRenderedItemIndex + this.elemOverScan, this.props.listLength - 1); 195 | if (this.props.stickyElems) { 196 | end += this.props.stickyElems.length; 197 | } 198 | } 199 | 200 | let items = []; 201 | // Add sticky (dragged) elems and render other visible items 202 | list.forEach((child, index) => { 203 | // Maintain elements that have the alwaysRender flag set. This is used to keep a dragged element rendered, even if its scroll parent would normally unmount it. 204 | if (this.props.stickyElems.find(id => id === child.props.draggableId)) { 205 | this.stickyElems.push(child); 206 | } else if (index >= start && index <= end) { 207 | items.push(child); 208 | } 209 | }); 210 | 211 | return items; 212 | } 213 | 214 | setScrollSection(scrollOffset, isScrollingDown) { 215 | const avgElemSize = this.getElemSizeAvg(); 216 | // The number of elements you can see in the container at a time (size of chunks we virtualize) 217 | const elemsPerSection = Math.ceil(this.props.containerHeight / avgElemSize); 218 | // Optimisticly render more than neccesary, to avoid removing elements at borders 219 | // Scale optimism with amount of elements, up to a max of 10. Mostly for small lists to avoid rendering the entirety of an upcoming section along with the prior section 220 | this.optimisticCount = Math.min(Math.round(elemsPerSection * 0.8), 4); 221 | let numSections = Math.floor(this.props.listLength / elemsPerSection); 222 | 223 | // The sector we've scrolled to. Height of each section (virtualizable chunk) should be roughly the container size 224 | const sectionScrolledTo = Math.round(scrollOffset / this.props.containerHeight); 225 | const isScrolledToLastSection = sectionScrolledTo >= numSections - 1; 226 | 227 | // Only update if changed 228 | if (this.state.renderPart !== sectionScrolledTo) { 229 | // Optimism used above at start. 230 | const usedOptimismAbove = sectionScrolledTo === 0 ? 0 : this.optimisticCount; 231 | const optimismAboveHeight = usedOptimismAbove * avgElemSize; 232 | 233 | // Optimism used below to end of list 234 | const usedOptimismBelow = isScrolledToLastSection ? 0 : this.optimisticCount; 235 | const optimismBelowHeight = usedOptimismBelow * avgElemSize; 236 | 237 | const firstIndex = 238 | sectionScrolledTo == 0 239 | ? 0 240 | : isScrolledToLastSection 241 | ? Math.max(0, this.props.listLength - 1 - elemsPerSection - this.optimisticCount) 242 | : sectionScrolledTo * elemsPerSection - this.optimisticCount; 243 | const lastIndex = Math.min(this.props.listLength - 1, firstIndex + elemsPerSection + this.optimisticCount); 244 | // Sometimes, scroll bounces weirdly, causing a scroll down to actually render something pushes elements so far down, that the result is a lower scroll value. 245 | // In this case, we don't want to update anything. 246 | const didBounce = isScrollingDown ? sectionScrolledTo < this.state.renderPart : sectionScrolledTo > this.state.renderPart; 247 | if (didBounce) { 248 | return; 249 | } 250 | const aboveSpacerHeight = sectionScrolledTo === 0 ? 0 : sectionScrolledTo * this.props.containerHeight - optimismAboveHeight; 251 | const belowSpacerHeight = isScrolledToLastSection ? 0 : (numSections - sectionScrolledTo) * this.props.containerHeight - optimismBelowHeight; 252 | this.setState( 253 | { 254 | renderPart: sectionScrolledTo, 255 | // If we're at section 1, we have scrolled past section 0, and the above height will be 1 sections height 256 | aboveSpacerHeight: aboveSpacerHeight, 257 | belowSpacerHeight: belowSpacerHeight, 258 | firstRenderedItemIndex: firstIndex, 259 | lastRenderedItemIndex: lastIndex 260 | }, 261 | () => this.updateAverageSizing() 262 | ); 263 | } 264 | } 265 | // Render entire list, if we aren't already viewing all of it 266 | setFullRender() { 267 | const stateUpdate = {}; 268 | if (this.state.firstRenderedItemIndex !== 0) { 269 | stateUpdate.firstRenderedItemIndex = 0; 270 | stateUpdate.aboveSpacerHeight = 0; 271 | } 272 | if (this.state.lastRenderedItemIndex !== this.props.listLength - 1) { 273 | stateUpdate.lastRenderedItemIndex = this.props.listLength - 1; 274 | stateUpdate.belowSpacerHeight = 0; 275 | } 276 | if (Object.entries(stateUpdate).length > 0) { 277 | this.setState(stateUpdate); 278 | } 279 | } 280 | 281 | handleScroll(e) { 282 | // Don't start rendering new things more than once every 200ms. 283 | if (this.renderPartTimeout != null) { 284 | return; 285 | } 286 | const avgElemSize = this.getElemSizeAvg(); 287 | const scrollOffset = e.scrollTop; 288 | 289 | const scrollHeight = this.scrollHeight; 290 | // Scrolling difference in px since last time we rendered a new section 291 | const scrollDiff = scrollOffset - this.lastSectionChangeAt; 292 | 293 | // Update only if difference isn't minimal 294 | if (Math.abs(scrollDiff) < Math.max(5, avgElemSize * 0.1)) { 295 | return; 296 | } else { 297 | this.lastSectionChangeAt = scrollOffset; 298 | } 299 | // If list contains fewer elements in total than some small number, or the list's scroll area isn't at least 2x bigger than the container, don't virtualize 300 | if (this.props.listLength <= this.virtualizationThreshold || Math.round(scrollHeight / this.props.containerHeight) <= 2) { 301 | this.setFullRender(); // Just render entire list 302 | return; 303 | } else { 304 | // Set section to render based on the current scroll 305 | this.setScrollSection(scrollOffset, scrollDiff > 0); 306 | } 307 | if (this.props.onScroll) { 308 | this.props.onScroll(e); 309 | } 310 | } 311 | updateAverageSizing() { 312 | let numSized = 0; 313 | let totalSize = 0; 314 | if (this.itemsContainer && this.itemsContainer.children) { 315 | for (let i = 0; i < this.itemsContainer.children.length; i++) { 316 | const child = this.itemsContainer.children[i]; 317 | numSized++; 318 | totalSize += child.clientHeight; 319 | } 320 | } 321 | if (numSized !== this.state.numElemsSized) { 322 | const scrollHeight = this.scrollBars ? this.scrollBars.getScrollHeight() : window.innerHeight; 323 | this.scrollHeight = scrollHeight; 324 | this.setState({numElemsSized: numSized, totalElemsSizedSize: totalSize}); 325 | } 326 | } 327 | 328 | // Animated scroll to top 329 | animateScrollTop(top) { 330 | const scrollTop = this.scrollBars.getScrollTop(); 331 | this.spring.setCurrentValue(scrollTop).setAtRest(); 332 | this.spring.setEndValue(top); 333 | } 334 | 335 | // Get height of virtualized scroll container 336 | getScrollHeight() { 337 | return this.scrollBars.getScrollHeight(); 338 | } 339 | // Set scroll offset of virtualized scroll container 340 | scrollTop(val) { 341 | this.scrollBars.scrollTop(val); 342 | } 343 | // Get scroll offset of virtualized scroll container 344 | getScrollTop() { 345 | return this.scrollBars.getScrollTop(); 346 | } 347 | 348 | getElemSizeAvg() { 349 | return Math.ceil(this.state.numElemsSized > 0 ? this.state.totalElemsSizedSize / this.state.numElemsSized : this.props.minElemHeight); 350 | } 351 | 352 | getOverScanUsed() { 353 | const overscan = { 354 | above: this.state.firstRenderedItemIndex > this.elemOverScan ? Math.min(this.elemOverScan, this.state.firstRenderedItemIndex - this.elemOverScan) : 0, 355 | below: Math.min(this.elemOverScan, this.props.listLength - 1 - this.state.lastRenderedItemIndex) 356 | }; 357 | return overscan; 358 | } 359 | 360 | onScrollStop() { 361 | this.shouldScroll = false; 362 | this.setScrollStopTimer(); 363 | } 364 | 365 | setScrollStopTimer() { 366 | if (this.scrollStopTimer) { 367 | clearTimeout(this.scrollStopTimer); 368 | } 369 | // Don't allow scroll updates more than once every 5ms 370 | this.scrollStopTimer = setTimeout(() => (this.shouldScroll = true), 5); 371 | } 372 | 373 | render() { 374 | const {children} = this.props; 375 | const overscanUsed = this.getOverScanUsed(); 376 | let childrenWithProps = React.Children.map(children, (child, index) => React.cloneElement(child, {originalindex: index, ref: node => (this.childRefs[index] = node)})); 377 | const overScanHeightBelow = overscanUsed.below * this.getElemSizeAvg(); 378 | const overScanHeightAbove = overscanUsed.above * this.getElemSizeAvg(); 379 | 380 | const listToRender = this.getListToRender(childrenWithProps); 381 | // Always add one empty space below 382 | const belowSpacerStyle = { 383 | border: this.props.showIndicators ? 'solid 3px yellow' : 'none', 384 | width: '100%', 385 | height: this.state.belowSpacerHeight + (this.props.stickyElems && this.props.stickyElems.length > 0 ? this.props.stickyElems.length * this.getElemSizeAvg() : 0) - overScanHeightBelow 386 | }; 387 | const aboveSpacerStyle = {border: this.props.showIndicators ? 'solid 3px purple' : 'none', width: '100%', height: Math.max(this.state.aboveSpacerHeight - overScanHeightAbove, 0)}; 388 | if (this.stickyElems && this.stickyElems.length > 0) { 389 | // Insert element that is being dragged (stickyElems - currentl represented as arrays, in case we want more than 1, but multidrag only sticks 1 in the current design). 390 | // This is done to avoid virtualizing the element we're dragging away, when its parent container is virtuaized away (drag + scroll) 391 | listToRender.push(this.stickyElems[0]); 392 | } 393 | 394 | const innerStyle = { 395 | width: '100%', 396 | display: 'flex', 397 | flexDirection: 'column', 398 | flexGrow: '1', 399 | minHeight: this.props.containerHeight 400 | }; 401 | 402 | const listItemsStyle = { 403 | width: '100%', 404 | height: '100%', 405 | display: 'flex', 406 | flexDirection: 'column', 407 | flexGrow: '1' 408 | }; 409 | 410 | let firstIndicatorStyle; 411 | let lastIndicatorStyle; 412 | if (this.firstElemBounds && this.lastElemBounds && !this.props.simplified) { 413 | firstIndicatorStyle = { 414 | top: this.firstElemBounds.top, 415 | left: this.firstElemBounds.left, 416 | width: this.firstElemBounds.right - this.firstElemBounds.left - 6, 417 | height: this.firstElemBounds.bottom - this.firstElemBounds.top - 6, 418 | boxSizing: 'border-box', 419 | background: 'transparent', 420 | border: 'solid 3px green', 421 | position: 'fixed' 422 | }; 423 | lastIndicatorStyle = { 424 | top: this.lastElemBounds.top, 425 | left: this.lastElemBounds.left, 426 | width: this.lastElemBounds.right - this.lastElemBounds.left - 6, 427 | height: this.lastElemBounds.bottom - this.lastElemBounds.top - 6, 428 | boxSizing: 'border-box', 429 | background: 'transparent', 430 | border: 'solid 3px blue', 431 | position: 'fixed' 432 | }; 433 | } 434 | return ( 435 | (this.scrollBars = div)} 439 | {...this.props.scrollProps} 440 | autoHeight={true} 441 | autoHeightMax={this.props.containerHeight} 442 | autoHeightMin={this.props.containerHeight} 443 | > 444 | {this.props.showIndicators ?
: null} 445 | {this.props.showIndicators ?
: null} 446 | 447 |
(this.inner = div)}> 448 |
(this.aboveSpacer = div)} style={aboveSpacerStyle} className={'above-spacer'} /> 449 |
(this.itemsContainer = div)}> 450 | {listToRender} 451 |
452 |
(this.belowSpacer = div)} style={belowSpacerStyle} className={'below-spacer'} /> 453 |
454 | 455 | ); 456 | } 457 | } 458 | DynamicVirtualizedScrollbar.propTypes = { 459 | minElemHeight: PropTypes.number.isRequired 460 | }; 461 | export default DynamicVirtualizedScrollbar; 462 | -------------------------------------------------------------------------------- /src/components/virtualized-scrollbar.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import {Scrollbars} from 'react-custom-scrollbars'; 3 | //import {SpringSystem} from 'rebound'; 4 | import Rebound from 'rebound'; 5 | 6 | class VirtualizedScrollBar extends Component { 7 | constructor(props) { 8 | super(props); 9 | this.state = { 10 | elemHeight: this.props.staticElemHeight ? this.props.staticElemHeight : 50, 11 | scrollOffset: 0, 12 | elemOverScan: this.props.overScan ? this.props.overScan : 3, 13 | topSpacerHeight: 0, 14 | unrenderedBelow: 0, 15 | unrenderedAbove: 0 16 | }; 17 | this.stickyElems = null; 18 | } 19 | 20 | componentDidMount() { 21 | this.springSystem = new Rebound.SpringSystem(); 22 | this.spring = this.springSystem.createSpring(); 23 | this.spring.setOvershootClampingEnabled(true); 24 | this.spring.addListener({onSpringUpdate: this.handleSpringUpdate.bind(this)}); 25 | } 26 | 27 | componentWillUnmount() { 28 | this.springSystem.deregisterSpring(this.spring); 29 | this.springSystem.removeAllListeners(); 30 | this.springSystem = undefined; 31 | this.spring.destroy(); 32 | this.spring = undefined; 33 | } 34 | 35 | handleSpringUpdate(spring) { 36 | const val = spring.getCurrentValue(); 37 | this.scrollBars.scrollTop(val); 38 | } 39 | 40 | // Find the first element to render, and render (containersize + overScan / index * height) elems after the first. 41 | getListToRender(list) { 42 | let listToRender = []; 43 | this.stickyElems = []; 44 | const elemHeight = this.state.elemHeight; 45 | const containerHeight = this.props.containerHeight; 46 | const maxVisibleElems = Math.floor(containerHeight / elemHeight); 47 | if (!containerHeight || this.state.scrollOffset == null) { 48 | return list; 49 | } 50 | 51 | let smallestIndexVisible = null; 52 | if (this.state.scrollOffset === 0 && (this.props.stickyElems && this.props.stickyElems.length === 0)) { 53 | smallestIndexVisible = 0; 54 | } else { 55 | for (let index = 0; index < list.length; index++) { 56 | const child = list[index]; 57 | // Maintain elements that have the alwaysRender flag set. This is used to keep a dragged element rendered, even if its scroll parent would normally unmount it. 58 | if (this.props.stickyElems.find(id => id === child.props.draggableId)) { 59 | this.stickyElems.push(child); 60 | } else { 61 | const ySmallerThanList = (index + 1) * elemHeight < this.state.scrollOffset; 62 | 63 | if (ySmallerThanList) { 64 | // Keep overwriting to obtain the last element that is not smaller 65 | smallestIndexVisible = index; 66 | } 67 | } 68 | } 69 | } 70 | const start = Math.max(0, (smallestIndexVisible != null ? smallestIndexVisible : 0) - this.state.elemOverScan); 71 | // start plus number of visible elements plus overscan 72 | const end = smallestIndexVisible + maxVisibleElems + this.state.elemOverScan; 73 | // +1 because Array.slice isn't inclusive 74 | listToRender = list.slice(start, end + 1); 75 | // Remove any element from the list, if it was included in the stickied list 76 | if (this.stickyElems && this.stickyElems.length > 0) { 77 | listToRender = listToRender.filter(elem => !this.stickyElems.find(e => e.props.draggableId === elem.props.draggableId)); 78 | } 79 | return listToRender; 80 | } 81 | 82 | // Save scroll position in state for virtualization 83 | handleScroll(e) { 84 | const scrollOffset = this.scrollBars ? this.scrollBars.getScrollTop() : 0; 85 | const scrollDiff = Math.abs(scrollOffset - this.state.scrollOffset); 86 | const leniency = Math.max(5, this.state.elemHeight * 0.1); // As to not update exactly on breakpoint, but instead 5px or 10% within an element being scrolled past 87 | if (!this.state.scrollOffset || scrollDiff >= this.state.elemHeight - leniency) { 88 | this.setState({scrollOffset: scrollOffset}); 89 | } 90 | if (this.props.onScroll) { 91 | this.props.onScroll(e); 92 | } 93 | } 94 | 95 | // Animated scroll to top 96 | animateScrollTop(top) { 97 | const scrollTop = this.scrollBars.getScrollTop(); 98 | this.spring.setCurrentValue(scrollTop).setAtRest(); 99 | this.spring.setEndValue(top); 100 | } 101 | 102 | // Get height of virtualized scroll container 103 | getScrollHeight() { 104 | return this.scrollBars.getScrollHeight(); 105 | } 106 | // Set scroll offset of virtualized scroll container 107 | scrollTop(val) { 108 | this.scrollBars.scrollTop(val); 109 | } 110 | // Get scroll offset of virtualized scroll container 111 | getScrollTop() { 112 | return this.scrollBars.getScrollTop(); 113 | } 114 | 115 | render() { 116 | const {customScrollbars, children} = this.props; 117 | const UseScrollbars = customScrollbars || Scrollbars; 118 | const rowCount = children.length; 119 | const elemHeight = this.state.elemHeight; 120 | 121 | const height = rowCount * this.state.elemHeight; 122 | let childrenWithProps = React.Children.map(children, (child, index) => React.cloneElement(child, {originalindex: index})); 123 | this.numChildren = childrenWithProps.length; 124 | 125 | const hasScrolled = this.state.scrollOffset > 0; 126 | 127 | const listToRender = this.getListToRender(childrenWithProps); 128 | 129 | const unrenderedBelow = hasScrolled ? (listToRender && listToRender.length > 0 ? listToRender[0].props.originalindex : 0) - (this.stickyElems ? this.stickyElems.length : 0) : 0; 130 | const unrenderedAbove = listToRender && listToRender.length > 0 ? childrenWithProps.length - (listToRender[listToRender.length - 1].props.originalindex + 1) : 0; 131 | const belowSpacerStyle = this.props.disableVirtualization ? {width: '100%', height: 0} : {width: '100%', height: unrenderedBelow ? unrenderedBelow * elemHeight : 0}; 132 | 133 | const aboveSpacerStyle = this.props.disableVirtualization ? {width: '100%', height: 0} : {width: '100%', height: unrenderedAbove ? unrenderedAbove * elemHeight : 0}; 134 | 135 | if (this.stickyElems && this.stickyElems.length > 0) { 136 | listToRender.push(this.stickyElems[0]); 137 | } 138 | 139 | const innerStyle = { 140 | width: '100%', 141 | display: 'flex', 142 | flexDirection: 'column', 143 | flexGrow: '1' 144 | }; 145 | if (!this.props.disableVirtualization) { 146 | innerStyle.minHeight = height; 147 | innerStyle.height = height; 148 | innerStyle.maxHeight = height; 149 | } 150 | 151 | return ( 152 | (this.scrollBars = div)} 155 | autoHeight={true} 156 | autoHeightMax={this.props.containerHeight} 157 | autoHeightMin={this.props.containerHeight} 158 | {...this.props.scrollProps} 159 | > 160 |
(this._test = div)}> 161 |
162 | {listToRender} 163 |
164 |
165 | 166 | ); 167 | } 168 | } 169 | VirtualizedScrollBar.propTypes = {}; 170 | export default VirtualizedScrollBar; 171 | -------------------------------------------------------------------------------- /src/examples/example-board.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import Droppable from '../components/droppable'; 3 | import Draggable from '../components/draggable'; 4 | import DragDropContext from '../components/drag_drop_context'; 5 | 6 | class ExampleBoard extends Component { 7 | constructor(props) { 8 | super(props); 9 | this.state = { 10 | listData: [], 11 | numItems: 100, 12 | numColumns: 6 13 | }; 14 | this.dragAndDropGroupName = 'exampleboard'; 15 | this.droppables = []; 16 | } 17 | 18 | componentDidMount() { 19 | this.getListData(); 20 | } 21 | 22 | getListData() { 23 | const numLists = this.state.numColumns; 24 | const newItemLists = []; 25 | for (let i = 0; i < numLists; i++) { 26 | newItemLists.push(this.generateTestList(i, this.state.numItems)); 27 | } 28 | this.setState({listData: newItemLists}); 29 | } 30 | 31 | generateTestList(num, numItems) { 32 | let entry = {name: 'droppable' + num + 'Items', items: [], index: num}; 33 | for (let i = 0; i < numItems; i++) { 34 | entry.items.push({id: num + '-' + i, name: 'Item ' + num + '-' + i}); 35 | } 36 | return entry; 37 | } 38 | 39 | getElemsToRender(list) { 40 | let dataToRender = []; 41 | 42 | list.forEach((entry, index) => { 43 | const list = []; 44 | entry.items.forEach(item => { 45 | list.push( 46 | 47 |
alert('A click is not a drag')} className={'draggable-test'} style={{border: 'solid 1px black', height: '48px', backgroundColor: 'white', flexGrow: 1}}> 48 |

49 | {item.name} 50 |

51 |
52 |
53 | ); 54 | }); 55 | dataToRender.push({droppableId: 'droppable' + index, items: list}); 56 | }); 57 | return dataToRender; 58 | } 59 | 60 | componentDidUpdate(prevProps, prevState) { 61 | if (prevState.numItems !== this.state.numItems || prevState.numColumns !== this.state.numColumns) { 62 | this.getListData(); 63 | } 64 | } 65 | 66 | handleInputChange(e) { 67 | if (Number(e.target.value) > 5000) { 68 | alert('Please, calm down.'); 69 | return; 70 | } 71 | if (e.target.value !== this.state.numItems && e.target.value) { 72 | this.setState({numItems: Number(e.target.value)}); 73 | } 74 | } 75 | 76 | handleColumnInputChange(e) { 77 | if (Number(e.target.value) > 100) { 78 | alert('Please, calm down.'); 79 | return; 80 | } 81 | if (e.target.value !== this.state.numColumns && e.target.value) { 82 | this.setState({numColumns: Number(e.target.value)}); 83 | } 84 | } 85 | 86 | scroll(ref) { 87 | if (ref) { 88 | ref.animateScrollTop(ref.getScrollTop() + 200); 89 | } 90 | } 91 | 92 | sideScroll(val) { 93 | this.dragDropContext.sideScroll(this.dragDropContext.getSideScroll() + val); 94 | } 95 | 96 | onDragEnd(source, destinationId, placeholderId) { 97 | const listToRemoveFrom = this.state.listData.find(list => list.name.includes(source.droppableId)); 98 | const listToAddTo = this.state.listData.find(list => list.name.includes(destinationId)); 99 | const elemToAdd = listToRemoveFrom.items.find(entry => entry.id === source.draggableId); 100 | let indexToRemove = listToRemoveFrom.items.findIndex(item => item.id === source.draggableId); 101 | let indexToInsert = placeholderId === 'END_OF_LIST' ? listToAddTo.items.length : placeholderId.includes('header') ? 0 : listToAddTo.items.findIndex(item => item.id === placeholderId); 102 | // Re-arrange within the same list 103 | if (listToRemoveFrom.name === listToAddTo.name) { 104 | if (indexToRemove === indexToInsert) { 105 | return; 106 | } 107 | // If we're moving an element below the insertion point, indexes will change. 108 | const direction = indexToRemove < indexToInsert ? 1 : 0; 109 | listToRemoveFrom.items.splice(indexToRemove, 1); 110 | listToAddTo.items.splice(indexToInsert - direction, 0, elemToAdd); 111 | } else { 112 | listToRemoveFrom.items.splice(indexToRemove, 1); 113 | listToAddTo.items.splice(indexToInsert, 0, elemToAdd); 114 | } 115 | 116 | const newData = this.state.listData; 117 | newData[listToRemoveFrom.index] = listToRemoveFrom; 118 | newData[listToAddTo.index] = listToAddTo; 119 | this.setState({testData: newData}); 120 | } 121 | 122 | toggleSplit() { 123 | this.setState(prevState => { 124 | return {split: !prevState.split}; 125 | }); 126 | } 127 | 128 | render() { 129 | const elemsToRender = this.getElemsToRender(this.state.listData); 130 | const getListHeader = index => ( 131 |
132 |
List {index}
133 | 136 |
137 | ); 138 | 139 | return ( 140 |
141 | (this.dragDropContext = div)} 143 | // 10px margin around page 144 | scrollContainerHeight={window.innerHeight - 10} 145 | dragAndDropGroup={this.dragAndDropGroupName} 146 | onDragEnd={this.onDragEnd.bind(this)} 147 | outerScrollBar={true} 148 | > 149 |
150 |
151 |

Example Board

152 |
153 |
154 |
155 |
156 |
157 |
158 | 161 | 164 |
165 |
166 |
167 |

Items per column

168 | (e.key === 'Enter' ? this.handleInputChange(e) : void 0)} 172 | onBlur={this.handleInputChange.bind(this)} 173 | /> 174 |
175 |
176 |

Number of columns

177 | 178 | (e.key === 'Enter' ? this.handleColumnInputChange(e) : void 0)} 182 | onBlur={this.handleColumnInputChange.bind(this)} 183 | /> 184 |
185 |
186 | {elemsToRender.map((elem, index) => 187 | !this.state.split || index < elemsToRender.length / 2 ? ( 188 |
189 | this.droppables.push(div)} 195 | containerHeight={620} 196 | elemHeight={50} 197 | dragAndDropGroup={this.dragAndDropGroupName} 198 | droppableId={elem.droppableId} 199 | key={elem.droppableId} 200 | > 201 | {elem.items} 202 | 203 |
204 | ) : null 205 | )} 206 |
207 | {this.state.split ? ( 208 |
209 | {elemsToRender.map((elem, index) => 210 | index >= elemsToRender.length / 2 ? ( 211 |
212 | this.droppables.push(div)} 217 | containerHeight={500} 218 | dragAndDropGroup={this.dragAndDropGroupName} 219 | droppableId={elem.droppableId} 220 | key={elem.droppableId} 221 | > 222 | {elem.items} 223 | 224 |
225 | ) : null 226 | )} 227 |
228 | ) : null} 229 | 230 |
231 | ); 232 | } 233 | } 234 | ExampleBoard.propTypes = {}; 235 | export default ExampleBoard; 236 | -------------------------------------------------------------------------------- /src/examples/example-dynamic.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import Droppable from '../components/droppable'; 3 | import Draggable from '../components/draggable'; 4 | import DragDropContext from '../components/drag_drop_context'; 5 | 6 | class DynamicHeightExample extends Component { 7 | constructor(props) { 8 | super(props); 9 | this.state = { 10 | listData: [], 11 | numItems: 50, 12 | numColumns: 6, 13 | showIndicators: false, 14 | useSections: false, 15 | lazyLoad: false 16 | }; 17 | this.dragAndDropGroupName = 'exampleboard'; 18 | this.droppables = []; 19 | this.TEST_ENV = window.location.href.includes('localhost'); 20 | } 21 | 22 | componentDidMount() { 23 | this.getListData(); 24 | } 25 | 26 | addMoreElements(e) { 27 | if (this.state.lazyLoad && e.scrollHeight - e.scrollTop < e.clientHeight + 1000) { 28 | this.setState({numItems: 100}); 29 | } 30 | } 31 | 32 | toggleIndicators() { 33 | this.setState(prevState => { 34 | return {showIndicators: !prevState.showIndicators}; 35 | }); 36 | } 37 | 38 | toggleUseSections() { 39 | this.setState(prevState => { 40 | return {useSections: !prevState.useSections}; 41 | }); 42 | } 43 | 44 | getListData() { 45 | const numLists = this.state.numColumns; 46 | const newItemLists = []; 47 | for (let i = 0; i < numLists; i++) { 48 | newItemLists.push(this.generateTestList(i, this.state.numItems)); 49 | } 50 | this.setState({listData: newItemLists}); 51 | } 52 | 53 | generateTestList(num, numItems) { 54 | let entry = {name: 'droppable' + num + 'Items', items: [], index: num}; 55 | const randomSize = () => 50 + Math.floor(Math.random() * Math.floor(250)); 56 | const pseudoRandomSize = i => 57 | 50 + (((i + 1) * (num + 1)) % 5 === 0 ? 200 : ((i + 1) * (num + 1)) % 4 === 0 ? 150 : ((i + 1) * (num + 1)) % 3 === 0 ? 100 : ((i + 1) * (num + 1)) % 2 === 0 ? 50 : 0); 58 | let sectionId = 0; 59 | for (let i = 0; i < numItems; i++) { 60 | if (i % 3 === 0) { 61 | sectionId = i; 62 | } 63 | entry.items.push({id: num + '-' + i, name: 'Item ' + num + '-' + i, height: this.state.lazyLoad ? pseudoRandomSize(i) : randomSize(), sectionId: 'Person ' + sectionId / 3}); 64 | } 65 | return entry; 66 | } 67 | 68 | getElemsToRender(list) { 69 | let dataToRender = []; 70 | const seenSections = []; 71 | list.forEach((entry, index) => { 72 | const list = []; 73 | entry.items.forEach((item, idx) => { 74 | if (this.state.useSections && !seenSections.includes(entry.index + '-' + item.sectionId)) { 75 | list.push( 76 | 86 |
87 |
88 |
89 | {item.sectionId} 90 |
91 |
92 | 93 | ); 94 | seenSections.push(entry.index + '-' + item.sectionId); 95 | } 96 | list.push( 97 | 98 |
alert('A click is not a drag')} 100 | className={'draggable-test' + (this.state.recentlyMovedItem === item.id ? ' dropGlow' : '')} 101 | style={{border: 'solid 1px black', height: item.height, backgroundColor: 'white', flexGrow: 1, marginBottom: '2.5px', marginTop: '2.5px'}} 102 | > 103 |

104 | {item.name} 105 |

106 |
107 |
108 | ); 109 | }); 110 | dataToRender.push({droppableId: 'droppable' + index, items: list}); 111 | }); 112 | return dataToRender; 113 | } 114 | 115 | componentDidUpdate(prevProps, prevState) { 116 | if (prevState.numItems !== this.state.numItems || prevState.numColumns !== this.state.numColumns || prevState.lazyLoad !== this.state.lazyLoad) { 117 | this.getListData(); 118 | } 119 | } 120 | 121 | handleInputChange(e) { 122 | if (Number(e.target.value) > 5000) { 123 | alert('Please, calm down.'); 124 | return; 125 | } 126 | this.setState({numItems: Number(e.target.value)}); 127 | } 128 | 129 | handleColumnInputChange(e) { 130 | if (Number(e.target.value) > 100) { 131 | alert('Please, calm down.'); 132 | return; 133 | } 134 | this.setState({numColumns: Number(e.target.value)}); 135 | } 136 | 137 | handleLazyLoadChange(e) { 138 | this.setState({lazyLoad: !this.state.lazyLoad}); 139 | } 140 | 141 | scroll(ref) { 142 | if (ref) { 143 | ref.animateScrollTop(ref.getScrollTop() + 200); 144 | } 145 | } 146 | 147 | sideScroll(val) { 148 | this.dragDropContext.sideScroll(this.dragDropContext.getSideScroll() + val); 149 | } 150 | 151 | onDragCancel() { 152 | this.setState({recentlyMovedItem: null}); 153 | } 154 | 155 | onDragEnd(source, destinationId, placeholderId, sectionId) { 156 | const listToRemoveFrom = this.state.listData.find(list => list.name.includes(source.droppableId)); 157 | const listToAddTo = this.state.listData.find(list => list.name.includes(destinationId)); 158 | const elemToAdd = listToRemoveFrom.items.find(entry => entry.id === source.draggableId); 159 | let indexToRemove = listToRemoveFrom.items.findIndex(item => item.id === source.draggableId); 160 | let indexToInsert = 161 | placeholderId != null 162 | ? placeholderId === 'END_OF_LIST' 163 | ? listToAddTo.items.length 164 | : placeholderId.includes('header') 165 | ? 0 166 | : listToAddTo.items.findIndex(item => item.id === placeholderId) 167 | : sectionId != null 168 | ? listToAddTo.items.findIndex(item => item.sectionId === sectionId) // Add at the first occurence of the section when dropping on top of a section 169 | : -1; 170 | const targetElem = listToAddTo.items[indexToInsert - 1]; 171 | const isSameSection = targetElem && targetElem.sectionId && source.sectionId && targetElem.sectionId === source.sectionId; 172 | if (!isSameSection) { 173 | //indexToInsert += 1; // move into next section //TODO NOPE 174 | } 175 | // Re-arrange within the same list 176 | if (listToRemoveFrom.name === listToAddTo.name) { 177 | if (indexToRemove === indexToInsert) { 178 | return; 179 | } 180 | // If we're moving an element below the insertion point, indexes will change. 181 | const direction = indexToRemove < indexToInsert ? 1 : 0; 182 | listToRemoveFrom.items.splice(indexToRemove, 1); 183 | listToAddTo.items.splice(indexToInsert - direction, 0, elemToAdd); 184 | } else { 185 | listToRemoveFrom.items.splice(indexToRemove, 1); 186 | listToAddTo.items.splice(indexToInsert, 0, elemToAdd); 187 | } 188 | 189 | const newData = this.state.listData; 190 | newData[listToRemoveFrom.index] = listToRemoveFrom; 191 | newData[listToAddTo.index] = listToAddTo; 192 | this.setState({testData: newData, recentlyMovedItem: source.draggableId}); 193 | } 194 | 195 | toggleSplit() { 196 | this.setState(prevState => { 197 | return {split: !prevState.split}; 198 | }); 199 | } 200 | 201 | render() { 202 | const elemsToRender = this.getElemsToRender(this.state.listData); 203 | const getListHeader = index => ( 204 |
205 |
List {index}
206 | 209 |
210 | ); 211 | 212 | const scrollProps = { 213 | autoHide: true, 214 | hideTracksWhenNotNeeded: true 215 | }; 216 | 217 | return ( 218 |
219 | (this.dragDropContext = div)} 221 | // 10px margin around page 222 | scrollContainerHeight={window.innerHeight - 10} 223 | dragAndDropGroup={this.dragAndDropGroupName} 224 | onDragEnd={this.onDragEnd.bind(this)} 225 | onDragCancel={this.onDragCancel.bind(this)} 226 | outerScrollBar={true} 227 | > 228 |
229 |
230 |

Dynamic Height Example

231 |
232 |
233 |
234 |
235 |
236 |
237 | 240 | 243 | 246 | {this.TEST_ENV ? ( 247 | 250 | ) : null} 251 |
252 |
253 |
254 |

Items per column

255 | (e.key === 'Enter' ? this.handleInputChange(e) : void 0)} 259 | onBlur={this.handleInputChange.bind(this)} 260 | /> 261 |
262 |
263 |

Number of columns

264 | (e.key === 'Enter' ? this.handleColumnInputChange(e) : void 0)} 268 | onBlur={this.handleColumnInputChange.bind(this)} 269 | /> 270 |
271 |
272 |
Lazy loading example
273 | 274 |
275 |
276 | {elemsToRender.map((elem, index) => 277 | !this.state.split || index < elemsToRender.length / 2 ? ( 278 |
279 | this.droppables.push(div)} 288 | containerHeight={800} 289 | dragAndDropGroup={this.dragAndDropGroupName} 290 | droppableId={elem.droppableId} 291 | key={elem.droppableId} 292 | onScroll={this.addMoreElements.bind(this)} 293 | > 294 | {elem.items} 295 | 296 |
297 | ) : null 298 | )} 299 |
300 | {this.state.split ? ( 301 |
302 | {elemsToRender.map((elem, index) => 303 | index >= elemsToRender.length / 2 ? ( 304 |
305 | this.droppables.push(div)} 313 | containerHeight={620} 314 | dragAndDropGroup={this.dragAndDropGroupName} 315 | droppableId={elem.droppableId} 316 | key={elem.droppableId} 317 | > 318 | {elem.items} 319 | 320 |
321 | ) : null 322 | )} 323 |
324 | ) : null} 325 | 326 |
327 | ); 328 | } 329 | } 330 | DynamicHeightExample.propTypes = {}; 331 | export default DynamicHeightExample; 332 | -------------------------------------------------------------------------------- /src/examples/example-multiple-droppables.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import Droppable from '../components/droppable'; 3 | import Draggable from '../components/draggable'; 4 | import DragDropContext from '../components/drag_drop_context'; 5 | import DragScrollBar from '../components/drag_scroll_bar'; 6 | 7 | class ExampleMultipleDroppables extends Component { 8 | constructor(props) { 9 | super(props); 10 | this.state = { 11 | listData: [], 12 | numItems: 100, 13 | numColumns: 6 14 | }; 15 | this.dragAndDropGroupName = 'exampleboard'; 16 | this.droppables = []; 17 | } 18 | 19 | componentDidMount() { 20 | this.getListData(); 21 | } 22 | 23 | getListData() { 24 | const numLists = this.state.numColumns; 25 | const newItemLists = []; 26 | for (let i = 0; i < numLists; i++) { 27 | newItemLists.push(this.generateTestList(i, this.state.numItems)); 28 | } 29 | this.setState({listData: newItemLists}); 30 | } 31 | 32 | generateTestList(num, numItems) { 33 | let entry = {name: 'droppable' + num + 'Items', items: [], index: num}; 34 | for (let i = 0; i < numItems; i++) { 35 | entry.items.push({id: num + '-' + i, name: 'Item ' + num + '-' + i}); 36 | } 37 | return entry; 38 | } 39 | 40 | getElemsToRender(list) { 41 | let dataToRender = []; 42 | 43 | list.forEach((entry, index) => { 44 | const list = []; 45 | entry.items.forEach(item => { 46 | list.push( 47 | 48 |
alert('A click is not a drag')} className={'draggable-test'} style={{border: 'solid 1px black', height: '48px', backgroundColor: 'white', flexGrow: 1}}> 49 |

50 | {item.name} 51 |

52 |
53 |
54 | ); 55 | }); 56 | dataToRender.push({droppableId: 'droppable' + index, items: list}); 57 | }); 58 | return dataToRender; 59 | } 60 | 61 | componentDidUpdate(prevProps, prevState) { 62 | if (prevState.numItems !== this.state.numItems || prevState.numColumns !== this.state.numColumns) { 63 | this.getListData(); 64 | } 65 | } 66 | 67 | handleInputChange(e) { 68 | if (Number(e.target.value) > 5000) { 69 | alert('Please, calm down.'); 70 | return; 71 | } 72 | if (e.target.value !== this.state.numItems && e.target.value) { 73 | this.setState({numItems: Number(e.target.value)}); 74 | } 75 | } 76 | 77 | handleColumnInputChange(e) { 78 | if (Number(e.target.value) > 100) { 79 | alert('Please, calm down.'); 80 | return; 81 | } 82 | if (e.target.value !== this.state.numColumns && e.target.value) { 83 | this.setState({numColumns: Number(e.target.value)}); 84 | } 85 | } 86 | 87 | scroll(ref) { 88 | if (ref) { 89 | ref.animateScrollTop(ref.getScrollTop() + 200); 90 | } 91 | } 92 | 93 | sideScroll(val) { 94 | this.dragDropContext.sideScroll(this.dragDropContext.getSideScroll() + val); 95 | } 96 | 97 | onDragEnd(source, destinationId, placeholderId) { 98 | const listToRemoveFrom = this.state.listData.find(list => list.name.includes(source.droppableId)); 99 | const listToAddTo = this.state.listData.find(list => list.name.includes(destinationId)); 100 | const elemToAdd = listToRemoveFrom.items.find(entry => entry.id === source.draggableId); 101 | let indexToRemove = listToRemoveFrom.items.findIndex(item => item.id === source.draggableId); 102 | let indexToInsert = placeholderId === 'END_OF_LIST' ? listToAddTo.items.length : placeholderId.includes('header') ? 0 : listToAddTo.items.findIndex(item => item.id === placeholderId); 103 | // Re-arrange within the same list 104 | if (listToRemoveFrom.name === listToAddTo.name) { 105 | if (indexToRemove === indexToInsert) { 106 | return; 107 | } 108 | // If we're moving an element below the insertion point, indexes will change. 109 | const direction = indexToRemove < indexToInsert ? 1 : 0; 110 | listToRemoveFrom.items.splice(indexToRemove, 1); 111 | listToAddTo.items.splice(indexToInsert - direction, 0, elemToAdd); 112 | } else { 113 | listToRemoveFrom.items.splice(indexToRemove, 1); 114 | listToAddTo.items.splice(indexToInsert, 0, elemToAdd); 115 | } 116 | 117 | const newData = this.state.listData; 118 | newData[listToRemoveFrom.index] = listToRemoveFrom; 119 | newData[listToAddTo.index] = listToAddTo; 120 | this.setState({testData: newData}); 121 | } 122 | 123 | toggleSplit() { 124 | this.setState(prevState => { 125 | return {split: !prevState.split}; 126 | }); 127 | } 128 | 129 | render() { 130 | const elemsToRender = this.getElemsToRender(this.state.listData); 131 | const getListHeader = index => ( 132 |
133 |
List {index}
134 | 137 |
138 | ); 139 | 140 | return ( 141 |
142 | (this.dragDropContext = div)} 144 | // 10px margin around page 145 | scrollContainerHeight={window.innerHeight - 10} 146 | dragAndDropGroup={this.dragAndDropGroupName} 147 | onDragEnd={this.onDragEnd.bind(this)} 148 | outerScrollBar={true} 149 | > 150 |
151 |
152 |

Example Board

153 |
154 |
155 |
156 |
157 |
158 |
159 |
160 |

Items per column

161 | (e.key === 'Enter' ? this.handleInputChange(e) : void 0)} 165 | onBlur={this.handleInputChange.bind(this)} 166 | /> 167 |
168 |
169 |

Number of columns

170 | (e.key === 'Enter' ? this.handleColumnInputChange(e) : void 0)} 174 | onBlur={this.handleColumnInputChange.bind(this)} 175 | /> 176 |
177 |

Droppable Group 1

178 |
179 | 180 | {elemsToRender.map((elem, index) => 181 | !this.state.split || index < elemsToRender.length / 2 ? ( 182 |
183 | this.droppables.push(div)} 189 | containerHeight={350} 190 | elemHeight={50} 191 | dragAndDropGroup={this.dragAndDropGroupName} 192 | droppableId={elem.droppableId} 193 | key={elem.droppableId} 194 | > 195 | {elem.items} 196 | 197 |
198 | ) : null 199 | )} 200 |
201 |
202 | 203 |

Droppable Group 2

204 |
205 | 206 | {elemsToRender.map((elem, index) => 207 | !this.state.split || index < elemsToRender.length / 2 ? ( 208 |
209 | this.droppables.push(div)} 215 | containerHeight={350} 216 | elemHeight={50} 217 | dragAndDropGroup={this.dragAndDropGroupName} 218 | droppableId={elem.droppableId} 219 | key={elem.droppableId} 220 | > 221 | {elem.items} 222 | 223 |
224 | ) : null 225 | )} 226 |
227 |
228 | 229 |
230 | ); 231 | } 232 | } 233 | ExampleMultipleDroppables.propTypes = {}; 234 | export default ExampleMultipleDroppables; 235 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Draggable from './components/draggable'; 2 | import Droppable from './components/droppable'; 3 | import DragDropContext from './components/drag_drop_context'; 4 | import DragScrollBar from './components/drag_scroll_bar'; 5 | import VirtualizedScrollBar from './components/virtualized-scrollbar'; 6 | import ExampleBoard from './examples/example-board'; 7 | import DynamicHeightExample from './examples/example-dynamic'; 8 | import ExampleMultipleDroppables from './examples/example-multiple-droppables'; 9 | 10 | export {Draggable, Droppable, DragDropContext, DragScrollBar, VirtualizedScrollBar, ExampleBoard, DynamicHeightExample, ExampleMultipleDroppables}; 11 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | /* add css styles here (optional) */ 2 | -------------------------------------------------------------------------------- /src/test.js: -------------------------------------------------------------------------------- 1 | import ExampleComponent from './' 2 | 3 | describe('ExampleComponent', () => { 4 | it('is truthy', () => { 5 | expect(ExampleComponent).toBeTruthy() 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /src/util/event_manager.js: -------------------------------------------------------------------------------- 1 | const eventMap = new Map(); 2 | 3 | const subscribe = (eventId, callback) => { 4 | if (!eventMap.has(eventId)) { 5 | eventMap.set(eventId, []); 6 | } 7 | eventMap.get(eventId).push(callback); 8 | }; 9 | 10 | const unsubscribe = (eventId, callback) => { 11 | if (eventMap.has(eventId)) { 12 | const handlerArray = eventMap.get(eventId); 13 | const callbackIndex = handlerArray.indexOf(callback); 14 | if (callbackIndex >= 0) { 15 | handlerArray.splice(callbackIndex, 1); 16 | } else { 17 | console.warn('Unsubscription unsuccessful - callback not found.'); 18 | } 19 | } else { 20 | console.warn('Unsubscription unsuccessful - eventId not found.'); 21 | } 22 | }; 23 | 24 | const dispatch = (eventId, ...args) => { 25 | if (!eventMap.has(eventId)) return; 26 | eventMap.get(eventId).forEach(callback => callback.call(this, ...args)); 27 | }; 28 | 29 | const EVENT_ID = { 30 | SCHEDULING_MODAL_MUTATION_SUCCESS: 0, 31 | WORKFLOW_DRAG_DESTINATION_PLACERHOLDER: 1, 32 | WORKFLOW_SAVE_DRAG: 2, 33 | WORKFLOW_MULTISELECT: 3, 34 | CANVAS_TIMELINE_FORCE_REDRAW: 4, 35 | SOCKET_NOTIFY: 5, 36 | DND_REGISTER_DRAG_MOVE: 6, 37 | DND_RESET_PLACEHOLDER: 7 38 | }; 39 | 40 | module.exports = { 41 | EVENT_ID, 42 | subscribe, 43 | unsubscribe, 44 | dispatch 45 | }; 46 | -------------------------------------------------------------------------------- /src/util/util.js: -------------------------------------------------------------------------------- 1 | export default class Util { 2 | static getDragEvents(group) { 3 | return { 4 | id: group, 5 | moveEvent: group + '-MOVE', 6 | resetEvent: group + '-RESET', 7 | startEvent: group + '-START', 8 | endEvent: group + '-END', 9 | scrollEvent: group + '-SCROLL', 10 | placeholderEvent: group + '-PLACEHOLDER' 11 | }; 12 | } 13 | 14 | static getDroppableParentElement(element, dragAndDropGroup) { 15 | let count = 0; 16 | let maxTries = 15; 17 | let droppableParentElem = null; 18 | while (element && element.parentNode && !droppableParentElem && element.tagName !== 'body' && count <= maxTries) { 19 | const foundDragAndDropGroup = element.getAttribute('droppablegroup'); 20 | if (foundDragAndDropGroup && foundDragAndDropGroup === dragAndDropGroup) { 21 | droppableParentElem = element; 22 | } 23 | element = element.parentNode; 24 | count++; 25 | } 26 | return droppableParentElem; 27 | } 28 | static getDraggableParentElement(element) { 29 | let count = 0; 30 | let maxTries = 10; 31 | let draggableParentElem = null; 32 | while (element && element.parentNode && !draggableParentElem && element.tagName !== 'body' && count <= maxTries) { 33 | if (element.getAttribute('draggableid')) { 34 | draggableParentElem = element; 35 | break; 36 | } 37 | element = element.parentNode; 38 | count++; 39 | } 40 | return draggableParentElem; 41 | } 42 | static logUpdateReason(props, state, prevProps, prevState) { 43 | Object.entries(props).forEach(([key, val]) => prevProps[key] !== val && console.log(`Prop '${key}' changed`)); 44 | Object.entries(state).forEach(([key, val]) => prevState[key] !== val && console.log(`State '${key}' changed`)); 45 | } 46 | } 47 | --------------------------------------------------------------------------------