├── .babelrc ├── .gitignore ├── LICENSE ├── README.md ├── UPGRADING.md ├── dist └── react-selectable.js ├── example.js ├── example ├── Album.js ├── App.js ├── example.js ├── index.html ├── npm-debug.log └── sample-data.js ├── package-lock.json ├── package.json ├── react-selectable.d.ts ├── src ├── createSelectable.js ├── doObjectsCollide.js ├── getBoundsForNode.js ├── index.js ├── isNodeIn.js ├── nodeInRoot.js └── selectable-group.js ├── webpack.config.example.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["react", "es2015", "stage-2"] 3 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | example/bundle.js 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 unclecheese 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Selectable items for React 2 | 3 | Allows individual or group selection of items using the mouse. 4 | 5 | ## Demo 6 | [Try it out](http://unclecheese.github.io/react-selectable/example) 7 | 8 | ## Upgrading from 0.1 to 0.2 9 | There have been significant changes in the 0.2 release. Please [read about them here](UPGRADING.md). 10 | ## Getting started 11 | ``` 12 | npm install react-selectable 13 | ``` 14 | 15 | ```js 16 | import React from 'react'; 17 | import { render } from 'react-dom'; 18 | import { SelectableGroup, createSelectable } from 'react-selectable'; 19 | import SomeComponent from './some-component'; 20 | 21 | const SelectableComponent = createSelectable(SomeComponent); 22 | 23 | class App extends React.Component { 24 | 25 | constructor (props) { 26 | super(props); 27 | this.state = { 28 | selectedKeys: [] 29 | }; 30 | } 31 | 32 | handleSelection (selectedKeys) { 33 | this.setState({ selectedKeys }); 34 | } 35 | 36 | render () { 37 | return ( 38 | 39 | {this.props.items.map((item, i) => { 40 | let selected = this.state.selectedKeys.indexOf(item.id) > -1; 41 | return ( 42 | 43 | {item.title} 44 | 45 | ); 46 | })} 47 | 48 | ); 49 | } 50 | 51 | } 52 | ``` 53 | ## Configuration 54 | 55 | The `` component accepts a few optional props: 56 | * **`onBeginSelection(event)`** (Function) Callback fired when the selection was started. 57 | * **`onSelection(items, event)`** (Function) Callback fired while the mouse is moving. Throttled to 50ms for performance in IE/Edge. 58 | * **`onEndSelection(items, event)`** (Function) Callback fired after user completes selection. 59 | * **`onNonItemClick(event)`** (Function) Callback fired when a click happens within the selectable group component, but not in a selectable item. Useful for clearing selection. 60 | * **`tolerance`** (Number) The amount of buffer to add around your `` container, in pixels. 61 | * **`component`** (String) The component to render. Defaults to `div`. 62 | * **`fixedPosition`** (Boolean) Whether the `` container is a fixed/absolute position element or the grandchild of one. Note: if you get an error that `Value must be omitted for boolean attributes` when you try ``, simply use Javascript's boolean object function: ``. 63 | * **`preventDefault`** (Boolean) Allows to enable/disable preventing the default action of the onmousedown event (with e.preventDefault). True by default. Disable if your app needs to capture this event for other functionalities. 64 | * **`enabled`** (Boolean) If false, all of the selectable features are disabled, and event handlers removed. 65 | * **`className`** (String) A CSS class to add to the containing element. 66 | * **`selectingClassName`** (String) A CSS class to add to the containing element when we select. 67 | 68 | ### Decorators 69 | 70 | Though they are optional, you can use decorators with this `react-selectable`. 71 | 72 | A side by side comparison is the best way to illustrate the difference: 73 | 74 | #### Without Decorators 75 | ```javascript 76 | class SomeComponent extends Component { 77 | 78 | } 79 | export default createSelectable(SomeComponent) 80 | ``` 81 | vs. 82 | 83 | #### With Decorators 84 | ```javascript 85 | @createSelectable 86 | export default class SomeComponent extends Component { 87 | 88 | } 89 | ``` 90 | 91 | In order to enable this functionality, you will most likely need to install a plugin (depending on your build setup). For Babel, you will need to make sure you have installed and enabled [babel-plugin-transform-decorators-legacy](https://github.com/loganfsmyth/babel-plugin-transform-decorators-legacy) by doing the following: 92 | 93 | 1. run `npm i --save-dev babel-plugin-transform-decorators-legacy` 94 | 2. Add the following line to your `.babelrc`: 95 | 96 | ```json 97 | { 98 | "plugins": ["transform-decorators-legacy"] 99 | } 100 | ``` 101 | -------------------------------------------------------------------------------- /UPGRADING.md: -------------------------------------------------------------------------------- 1 | ## Significant API changes since 0.1 2 | This module now does a lot _less_ than it used to. The sole function of `react-selectable` is now to provide mouse events that draw a box around the UI, and fire an event telling you which of components are in the group. You are responsible for wiring up everything else, and that's a good thing. 3 | 4 | The primary change in this version is that `Selectable` no longer assumes that all of its children, and only its children, are selectable. This is a false premise and precludes you from creating lists that may include non-selectable items, or lists that are grouped in other child components. You now have to explicitly compose a component as selectable by running it through the `createSelectable` higher-order component. 5 | 6 | `const MySelectableItem = createSelectable(MyItem);`. 7 | 8 | Note that this is merely sugar for wiring up `this.context.selectable.register(key, domNode)` and `this.context.selectable.unregister(key)` on lifecycle methods. 9 | 10 | To disambiguate the two, the `` component should now be referred to as `` 11 | 12 | ### In addition the following features have been removed in 0.2: 13 | 14 | * **Cmd-clicking** to concatenate items (just wire up your own `keyup` listener to toggle a multiselection state in your store(s)) 15 | 16 | * **The `distance` prop**: This assumed that you had the mouse events attached above the Selectable node (i.e. `document`), which gives this plugin too much scope. If you want the entire document to be selectable, just make the root component a ``. If you want distance padding, just place the `` at the level at which you want the selection box to be available. It will only select those items that are composed with `createSelectable`. 17 | 18 | * **The `globalMouse` prop**: For many of the same reasons as `distance`. 19 | 20 | * **Managing your `onClick` events**: You can do that on your own now. By default, a selectable item will not become selected on click. You should wire up a click handler that updates your store, similar to how you would wire up your cmd-clicking. 21 | 22 | * **You must now provide `selectableKey`**: Your selectable items have a required prop of `selectableKey`, which is the key that will be passed to the `onSelection` handler of your `SelectableGroup`. 23 | -------------------------------------------------------------------------------- /dist/react-selectable.js: -------------------------------------------------------------------------------- 1 | !function(e,t){if("object"==typeof exports&&"object"==typeof module)module.exports=t(require("react"),require("react-dom"));else if("function"==typeof define&&define.amd)define(["react","react-dom"],t);else{var n="object"==typeof exports?t(require("react"),require("react-dom")):t(e.React,e.ReactDOM);for(var r in n)("object"==typeof exports?exports:e)[r]=n[r]}}(this,function(e,t){return function(e){function t(r){if(n[r])return n[r].exports;var o=n[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,t),o.l=!0,o.exports}var n={};return t.m=e,t.c=n,t.d=function(e,n,r){t.o(e,n)||Object.defineProperty(e,n,{configurable:!1,enumerable:!0,get:r})},t.n=function(e){var n=e&&e.__esModule?function(){return e.default}:function(){return e};return t.d(n,"a",n),n},t.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},t.p="",t(t.s=12)}([function(e,t,n){"use strict";function r(){throw new Error("setTimeout has not been defined")}function o(){throw new Error("clearTimeout has not been defined")}function i(e){if(s===setTimeout)return setTimeout(e,0);if((s===r||!s)&&setTimeout)return s=setTimeout,setTimeout(e,0);try{return s(e,0)}catch(t){try{return s.call(null,e,0)}catch(t){return s.call(this,e,0)}}}function u(e){if(p===clearTimeout)return clearTimeout(e);if((p===o||!p)&&clearTimeout)return p=clearTimeout,clearTimeout(e);try{return p(e)}catch(t){try{return p.call(null,e)}catch(t){return p.call(this,e)}}}function a(){b&&y&&(b=!1,y.length?h=y.concat(h):v=-1,h.length&&c())}function c(){if(!b){var e=i(a);b=!0;for(var t=h.length;t;){for(y=h,h=[];++v1)for(var n=1;n1?t-1:0),r=1;r2?n-2:0),o=2;o1&&void 0!==arguments[1]&&arguments[1],n=this.props,r=n.tolerance,o=n.onSelection,i=n.onEndSelection,u=[],a=(0,l.findDOMNode)(this.refs.selectbox);a&&(this._registry.forEach(function(e){e.domNode&&(0,x.default)(a,e.domNode,r)&&!u.includes(e.key)&&u.push(e.key)}),t?"function"==typeof i&&i(u,e):"function"==typeof o&&o(u,e))}},{key:"render",value:function(){var e=this.props,t=e.children,n=e.enabled,r=e.fixedPosition,o=e.className,i=e.selectingClassName,u=this.state,a=u.isBoxSelecting,c=u.boxLeft,l=u.boxTop,s=u.boxWidth,p=u.boxHeight,d=this.props.component;if(!n)return f.default.createElement(d,{className:o},t);var h={left:c,top:l,width:s,height:p,zIndex:9e3,position:r?"fixed":"absolute",cursor:"default"},b={backgroundColor:"transparent",border:"1px dashed #999",width:"100%",height:"100%",float:"left"},v={position:"relative",overflow:"visible"};return f.default.createElement(d,{className:(0,y.default)(o,a?i:null),style:v},a?f.default.createElement("div",{style:h,ref:"selectbox"},f.default.createElement("span",{style:b})):null,t)}}]),t}(c.Component);j.propTypes={children:p.default.node,onBeginSelection:p.default.func,onEndSelection:p.default.func,onSelection:p.default.func,component:p.default.node,tolerance:p.default.number,fixedPosition:p.default.bool,preventDefault:p.default.bool,onNonItemClick:p.default.func,enabled:p.default.bool,className:p.default.string,selectingClassName:p.default.string},j.defaultProps={component:"div",tolerance:0,fixedPosition:!1,preventDefault:!0,enabled:!0},j.childContextTypes={selectable:p.default.object},t.default=j},function(e,t,n){"use strict";(function(t){var r="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},o=n(1),i=n(2),u=n(8),a=n(15),c=n(3),f=n(16);e.exports=function(e,n){function l(e){var t=e&&(N&&e[N]||e[P]);if("function"==typeof t)return t}function s(e,t){return e===t?0!==e||1/e==1/t:e!==e&&t!==t}function p(e){this.message=e,this.stack=""}function d(e){function r(r,f,l,s,d,y,h){if(s=s||k,y=y||l,h!==c)if(n)i(!1,"Calling PropTypes validators directly is not supported by the `prop-types` package. Use `PropTypes.checkPropTypes()` to call them. Read more at http://fb.me/use-check-prop-types");else if("production"!==t.env.NODE_ENV&&"undefined"!=typeof console){var b=s+":"+l;!o[b]&&a<3&&(u(!1,"You are manually calling a React.PropTypes validation function for the `%s` prop on `%s`. This is deprecated and will throw in the standalone `prop-types` package. You may be seeing this warning due to a third-party PropTypes library. See https://fb.me/react-warning-dont-call-proptypes for details.",y,s),o[b]=!0,a++)}return null==f[l]?r?new p(null===f[l]?"The "+d+" `"+y+"` is marked as required in `"+s+"`, but its value is `null`.":"The "+d+" `"+y+"` is marked as required in `"+s+"`, but its value is `undefined`."):null:e(f,l,s,d,y)}if("production"!==t.env.NODE_ENV)var o={},a=0;var f=r.bind(null,!1);return f.isRequired=r.bind(null,!0),f}function y(e){function t(t,n,r,o,i,u){var a=t[n];if(S(a)!==e)return new p("Invalid "+o+" `"+i+"` of type `"+j(a)+"` supplied to `"+r+"`, expected `"+e+"`.");return null}return d(t)}function h(e){function t(t,n,r,o,i){if("function"!=typeof e)return new p("Property `"+i+"` of component `"+r+"` has invalid PropType notation inside arrayOf.");var u=t[n];if(!Array.isArray(u)){return new p("Invalid "+o+" `"+i+"` of type `"+S(u)+"` supplied to `"+r+"`, expected an array.")}for(var a=0;an+a||t+o-cr+u)};t.default=function(e,t){var n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:0,r=e instanceof HTMLElement?(0,o.default)(e):e,u=t instanceof HTMLElement?(0,o.default)(t):t;return i(r.top,r.left,u.top,u.left,r.offsetWidth,r.offsetHeight,u.offsetWidth,u.offsetHeight,n)}},function(e,t,n){"use strict";(function(t){function n(e,t,n){function r(t){var n=h,r=b;return h=b=void 0,w=t,m=e.apply(r,n)}function i(e){return w=e,g=setTimeout(l,t),j?r(e):m}function u(e){var n=e-_,r=e-w,o=t-n;return E?O(o,v-r):o}function c(e){var n=e-_,r=e-w;return void 0===_||n>=t||n<0||E&&r>=v}function l(){var e=S();if(c(e))return s(e);g=setTimeout(l,u(e))}function s(e){return g=void 0,T&&h?r(e):(h=b=void 0,m)}function p(){void 0!==g&&clearTimeout(g),w=0,h=_=b=g=void 0}function d(){return void 0===g?m:s(S())}function y(){var e=S(),n=c(e);if(h=arguments,b=this,_=e,n){if(void 0===g)return i(_);if(E)return g=setTimeout(l,t),r(_)}return void 0===g&&(g=setTimeout(l,t)),m}var h,b,v,m,g,_,w=0,j=!1,E=!1,T=!0;if("function"!=typeof e)throw new TypeError(f);return t=a(t)||0,o(n)&&(j=!!n.leading,E="maxWait"in n,v=E?x(a(n.maxWait)||0,t):v,T="trailing"in n?!!n.trailing:T),y.cancel=p,y.flush=d,y}function r(e,t,r){var i=!0,u=!0;if("function"!=typeof e)throw new TypeError(f);return o(r)&&(i="leading"in r?!!r.leading:i,u="trailing"in r?!!r.trailing:u),n(e,t,{leading:i,maxWait:t,trailing:u})}function o(e){var t=void 0===e?"undefined":c(e);return!!e&&("object"==t||"function"==t)}function i(e){return!!e&&"object"==(void 0===e?"undefined":c(e))}function u(e){return"symbol"==(void 0===e?"undefined":c(e))||i(e)&&w.call(e)==s}function a(e){if("number"==typeof e)return e;if(u(e))return l;if(o(e)){var t="function"==typeof e.valueOf?e.valueOf():e;e=o(t)?t+"":t}if("string"!=typeof e)return 0===e?e:+e;e=e.replace(p,"");var n=y.test(e);return n||h.test(e)?b(e.slice(2),n?2:8):d.test(e)?l:+e}var c="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},f="Expected a function",l=NaN,s="[object Symbol]",p=/^\s+|\s+$/g,d=/^[-+]0x[0-9a-f]+$/i,y=/^0b[01]+$/i,h=/^0o[0-7]+$/i,b=parseInt,v="object"==(void 0===t?"undefined":c(t))&&t&&t.Object===Object&&t,m="object"==("undefined"==typeof self?"undefined":c(self))&&self&&self.Object===Object&&self,g=v||m||Function("return this")(),_=Object.prototype,w=_.toString,x=Math.max,O=Math.min,S=function(){return g.Date.now()};e.exports=r}).call(t,n(21))},function(e,t,n){"use strict";var r,o="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e};r=function(){return this}();try{r=r||Function("return this")()||(0,eval)("this")}catch(e){"object"===("undefined"==typeof window?"undefined":o(window))&&(r=window)}e.exports=r},function(e,t,n){"use strict";function r(e){return e&&e.__esModule?e:{default:e}}function o(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function i(e,t){if(!e)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return!t||"object"!=typeof t&&"function"!=typeof t?e:t}function u(e,t){if("function"!=typeof t&&null!==t)throw new TypeError("Super expression must either be null or a function, not "+typeof t);e.prototype=Object.create(t&&t.prototype,{constructor:{value:e,enumerable:!1,writable:!0,configurable:!0}}),t&&(Object.setPrototypeOf?Object.setPrototypeOf(e,t):e.__proto__=t)}Object.defineProperty(t,"__esModule",{value:!0});var a=function(){function e(e,t){for(var n=0;n { 9 | while (node) { 10 | if (node === root) { 11 | return true; 12 | } 13 | node = node.parentNode; 14 | } 15 | 16 | return false; 17 | }; 18 | 19 | 20 | class App extends React.Component { 21 | 22 | constructor (props) { 23 | super(props); 24 | 25 | this.state = { 26 | selectedItems: [], 27 | tolerance: 0, 28 | distance: 0, 29 | } 30 | 31 | this.handleSelection = this.handleSelection.bind(this); 32 | this.clearItems = this.clearItems.bind(this); 33 | this.handleToleranceChange = this.handleToleranceChange.bind(this); 34 | } 35 | 36 | 37 | componentDidMount () { 38 | document.addEventListener('click', this.clearItems); 39 | } 40 | 41 | 42 | componentWillUnmount () { 43 | document.removeEventListener('click', this.clearItems); 44 | } 45 | 46 | 47 | handleSelection (keys) { 48 | this.setState({ 49 | selectedItems: keys 50 | }); 51 | } 52 | 53 | 54 | clearItems (e) { 55 | if(!isNodeInRoot(e.target, this.refs.selectable)) { 56 | this.setState({ 57 | selectedItems: [] 58 | }); 59 | } 60 | } 61 | 62 | 63 | handleToleranceChange (e) { 64 | this.setState({ 65 | tolerance: e.target.value 66 | }); 67 | } 68 | 69 | 70 | render () { 71 | return ( 72 |
73 |

React Selectable Demo

74 |
75 |
76 | Tolerance: {this.state.tolerance}
77 | The number of pixels that must be in the bounding box in order for an item to be selected. 78 |

79 | 80 | {this.state.selectedItems.length > 0 && 81 |

You have selected the following items:

82 | } 83 | {this.state.selectedItems.length === 0 && 84 |

Please select some items from the right by clicking and dragging a box around them.

85 | } 86 |
    87 | {this.state.selectedItems.map(function (key,i) { 88 | return
  • {this.props.items[key].title}
  • 89 | }.bind(this))} 90 |
91 |
92 |
93 | 100 | 101 | {this.props.items.map((item, i) => { 102 | const selected = this.state.selectedItems.indexOf(i) > -1; 103 | return ( 104 | 110 | ); 111 | })} 112 | 113 |
114 | 115 | ); 116 | } 117 | } 118 | 119 | const Item = ({ 120 | selected, 121 | title, 122 | year 123 | }) => { 124 | const classes = selected ? 'item selected' : 'item'; 125 | return ( 126 |
127 |

{title}

128 | {year} 129 |
130 | ) 131 | }; 132 | 133 | const SelectableItem = createSelectable(Item); 134 | 135 | render(, document.getElementById('app')); -------------------------------------------------------------------------------- /example/Album.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Album = ({ 4 | selected, 5 | title, 6 | year 7 | }) => { 8 | const classes = selected ? 'item selected' : 'item'; 9 | return ( 10 |
11 |

{title}

12 | {year} 13 |
14 | ) 15 | }; 16 | 17 | export default Album; 18 | -------------------------------------------------------------------------------- /example/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { SelectableGroup, createSelectable } from 'react-selectable'; 3 | import Album from './Album'; 4 | 5 | const isNodeInRoot = (node, root) => { 6 | while (node) { 7 | if (node === root) { 8 | return true; 9 | } 10 | node = node.parentNode; 11 | } 12 | 13 | return false; 14 | }; 15 | 16 | const SelectableAlbum = createSelectable(Album); 17 | 18 | class App extends React.Component { 19 | 20 | constructor (props) { 21 | super(props); 22 | 23 | this.state = { 24 | selectedItems: [], 25 | tolerance: 0, 26 | selectOnMouseMove: false, 27 | } 28 | 29 | this.handleSelection = this.handleSelection.bind(this); 30 | this.clearItems = this.clearItems.bind(this); 31 | this.handleToleranceChange = this.handleToleranceChange.bind(this); 32 | this.toggleSelectOnMouseMove = this.toggleSelectOnMouseMove.bind(this); 33 | } 34 | 35 | 36 | componentDidMount () { 37 | document.addEventListener('click', this.clearItems); 38 | } 39 | 40 | 41 | componentWillUnmount () { 42 | document.removeEventListener('click', this.clearItems); 43 | } 44 | 45 | 46 | handleSelection (keys) { 47 | this.setState({ 48 | selectedItems: keys 49 | }); 50 | } 51 | 52 | 53 | clearItems (e) { 54 | if(!isNodeInRoot(e.target, this.refs.selectable)) { 55 | this.setState({ 56 | selectedItems: [] 57 | }); 58 | } 59 | } 60 | 61 | 62 | handleToleranceChange (e) { 63 | this.setState({ 64 | tolerance: parseInt(e.target.value) 65 | }); 66 | } 67 | 68 | toggleSelectOnMouseMove () { 69 | this.setState({ 70 | selectOnMouseMove: !this.state.selectOnMouseMove 71 | }); 72 | } 73 | 74 | render () { 75 | return ( 76 |
77 |

React Selectable Demo

78 |
79 |
80 | Tolerance: {this.state.tolerance}
81 | The number of pixels that must be in the bounding box in order for an item to be selected. 82 |

83 | 84 | 88 | 89 | {this.state.selectedItems.length > 0 && 90 |

You have selected the following items:

91 | } 92 | {this.state.selectedItems.length === 0 && 93 |

Please select some items from the right by clicking and dragging a box around them.

94 | } 95 |
    96 | {this.state.selectedItems.map(function (key,i) { 97 | return
  • {this.props.items[key].title}
  • 98 | }.bind(this))} 99 |
100 |
101 |
102 | 108 | 109 | {this.props.items.map((item, i) => { 110 | const selected = this.state.selectedItems.indexOf(i) > -1; 111 | return ( 112 | 118 | ); 119 | })} 120 | 121 |
122 | 123 | ); 124 | } 125 | } 126 | 127 | export default App; -------------------------------------------------------------------------------- /example/example.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | import data from './sample-data'; 5 | 6 | ReactDOM.render(, document.getElementById('app')); -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 39 | 40 | 41 |
42 | 43 | 44 | 46 | 47 | -------------------------------------------------------------------------------- /example/npm-debug.log: -------------------------------------------------------------------------------- 1 | 0 info it worked if it ends with ok 2 | 1 verbose cli [ '/usr/local/bin/node', 3 | 1 verbose cli '/usr/local/bin/npm', 4 | 1 verbose cli 'install', 5 | 1 verbose cli 'unclecheese/react-selectable' ] 6 | 2 info using npm@3.3.6 7 | 3 info using node@v5.0.0 8 | 4 silly loadCurrentTree Starting 9 | 5 silly install loadCurrentTree 10 | 6 silly install readLocalPackageData 11 | 7 silly fetchPackageMetaData unclecheese/react-selectable 12 | 8 silly fetchOtherPackageData unclecheese/react-selectable 13 | 9 silly cache add args [ 'unclecheese/react-selectable', null ] 14 | 10 verbose cache add spec unclecheese/react-selectable 15 | 11 silly cache add parsed spec Result { 16 | 11 silly cache add raw: 'unclecheese/react-selectable', 17 | 11 silly cache add scope: null, 18 | 11 silly cache add name: null, 19 | 11 silly cache add rawSpec: 'unclecheese/react-selectable', 20 | 11 silly cache add spec: 'github:unclecheese/react-selectable', 21 | 11 silly cache add type: 'hosted', 22 | 11 silly cache add hosted: 23 | 11 silly cache add { type: 'github', 24 | 11 silly cache add ssh: 'git@github.com:unclecheese/react-selectable.git', 25 | 11 silly cache add sshUrl: 'git+ssh://git@github.com/unclecheese/react-selectable.git', 26 | 11 silly cache add httpsUrl: 'git+https://github.com/unclecheese/react-selectable.git', 27 | 11 silly cache add gitUrl: 'git://github.com/unclecheese/react-selectable.git', 28 | 11 silly cache add shortcut: 'github:unclecheese/react-selectable', 29 | 11 silly cache add directUrl: 'https://raw.githubusercontent.com/unclecheese/react-selectable/master/package.json' } } 30 | 12 verbose addRemoteGit caching unclecheese/react-selectable 31 | 13 verbose addRemoteGit unclecheese/react-selectable is a repository hosted by github 32 | 14 silly tryGitProto attempting to clone git://github.com/unclecheese/react-selectable.git 33 | 15 silly tryClone cloning unclecheese/react-selectable via git://github.com/unclecheese/react-selectable.git 34 | 16 verbose tryClone git-github-com-unclecheese-react-selectable-git-6ba2a011d3c0c4706ee6ea8f3adb5c40 not in flight; caching 35 | 17 verbose makeDirectory /Users/aaroncarlino/.npm/_git-remotes creation not in flight; initializing 36 | 18 silly makeDirectory /Users/aaroncarlino/.npm/_git-remotes uid: 501 gid: 20 37 | 19 info git [ 'clone', 38 | 19 info git '--template=/Users/aaroncarlino/.npm/_git-remotes/_templates', 39 | 19 info git '--mirror', 40 | 19 info git 'git://github.com/unclecheese/react-selectable.git', 41 | 19 info git '/Users/aaroncarlino/.npm/_git-remotes/git-github-com-unclecheese-react-selectable-git-6ba2a011d3c0c4706ee6ea8f3adb5c40' ] 42 | 20 verbose mirrorRemote unclecheese/react-selectable git clone git://github.com/unclecheese/react-selectable.git 43 | 21 verbose setPermissions unclecheese/react-selectable set permissions on /Users/aaroncarlino/.npm/_git-remotes/git-github-com-unclecheese-react-selectable-git-6ba2a011d3c0c4706ee6ea8f3adb5c40 44 | 22 verbose resolveHead unclecheese/react-selectable original treeish: master 45 | 23 info git [ 'rev-list', '-n1', 'master' ] 46 | 24 silly resolveHead unclecheese/react-selectable resolved treeish: 828a233e5468205528bb6342c03b510cbe638351 47 | 25 verbose resolveHead unclecheese/react-selectable resolved Git URL: git://github.com/unclecheese/react-selectable.git#828a233e5468205528bb6342c03b510cbe638351 48 | 26 silly resolveHead Git working directory: /var/folders/08/9c4szhh15px1hdjpfyd8061r0000gn/T/npm-8289-2b1aa411/git-cache-2e0d300d610edd35c9ccb42284f49719/828a233e5468205528bb6342c03b510cbe638351 49 | 27 info git [ 'clone', 50 | 27 info git '/Users/aaroncarlino/.npm/_git-remotes/git-github-com-unclecheese-react-selectable-git-6ba2a011d3c0c4706ee6ea8f3adb5c40', 51 | 27 info git '/var/folders/08/9c4szhh15px1hdjpfyd8061r0000gn/T/npm-8289-2b1aa411/git-cache-2e0d300d610edd35c9ccb42284f49719/828a233e5468205528bb6342c03b510cbe638351' ] 52 | 28 verbose cloneResolved unclecheese/react-selectable clone Cloning into '/var/folders/08/9c4szhh15px1hdjpfyd8061r0000gn/T/npm-8289-2b1aa411/git-cache-2e0d300d610edd35c9ccb42284f49719/828a233e5468205528bb6342c03b510cbe638351'... 53 | 28 verbose cloneResolved done. 54 | 29 info git [ 'checkout', '828a233e5468205528bb6342c03b510cbe638351' ] 55 | 30 verbose checkoutTreeish unclecheese/react-selectable checkout Note: checking out '828a233e5468205528bb6342c03b510cbe638351'. 56 | 30 verbose checkoutTreeish 57 | 30 verbose checkoutTreeish You are in 'detached HEAD' state. You can look around, make experimental 58 | 30 verbose checkoutTreeish changes and commit them, and you can discard any commits you make in this 59 | 30 verbose checkoutTreeish state without impacting any branches by performing another checkout. 60 | 30 verbose checkoutTreeish 61 | 30 verbose checkoutTreeish If you want to create a new branch to retain commits you create, you may 62 | 30 verbose checkoutTreeish do so (now or later) by using -b with the checkout command again. Example: 63 | 30 verbose checkoutTreeish 64 | 30 verbose checkoutTreeish git checkout -b 65 | 30 verbose checkoutTreeish 66 | 30 verbose checkoutTreeish HEAD is now at 828a233... Several major API changes 67 | 31 verbose addLocalDirectory /Users/aaroncarlino/.npm/react-selectable/0.2.0/package.tgz not in flight; packing 68 | 32 verbose tar pack [ '/Users/aaroncarlino/.npm/react-selectable/0.2.0/package.tgz', 69 | 32 verbose tar pack '/var/folders/08/9c4szhh15px1hdjpfyd8061r0000gn/T/npm-8289-2b1aa411/git-cache-2e0d300d610edd35c9ccb42284f49719/828a233e5468205528bb6342c03b510cbe638351' ] 70 | 33 verbose tarball /Users/aaroncarlino/.npm/react-selectable/0.2.0/package.tgz 71 | 34 verbose folder /var/folders/08/9c4szhh15px1hdjpfyd8061r0000gn/T/npm-8289-2b1aa411/git-cache-2e0d300d610edd35c9ccb42284f49719/828a233e5468205528bb6342c03b510cbe638351 72 | 35 verbose addLocalTarball adding from inside cache /Users/aaroncarlino/.npm/react-selectable/0.2.0/package.tgz 73 | 36 verbose addRemoteGit data._from: unclecheese/react-selectable 74 | 37 verbose addRemoteGit data._resolved: git://github.com/unclecheese/react-selectable.git#828a233e5468205528bb6342c03b510cbe638351 75 | 38 silly cache afterAdd react-selectable@0.2.0 76 | 39 verbose afterAdd /Users/aaroncarlino/.npm/react-selectable/0.2.0/package/package.json not in flight; writing 77 | 40 verbose afterAdd /Users/aaroncarlino/.npm/react-selectable/0.2.0/package/package.json written 78 | 41 silly install normalizeTree 79 | 42 silly loadCurrentTree Finishing 80 | 43 silly loadIdealTree Starting 81 | 44 silly install loadIdealTree 82 | 45 silly cloneCurrentTree Starting 83 | 46 silly install cloneCurrentTreeToIdealTree 84 | 47 silly cloneCurrentTree Finishing 85 | 48 silly loadShrinkwrap Starting 86 | 49 silly install loadShrinkwrap 87 | 50 silly loadShrinkwrap Finishing 88 | 51 silly loadAllDepsIntoIdealTree Starting 89 | 52 silly install loadAllDepsIntoIdealTree 90 | 53 silly rollbackFailedOptional Starting 91 | 54 silly rollbackFailedOptional Finishing 92 | 55 silly runTopLevelLifecycles Starting 93 | 56 silly runTopLevelLifecycles Finishing 94 | 57 silly install printInstalled 95 | 58 verbose stack Error: Refusing to install react-selectable as a dependency of itself 96 | 58 verbose stack at checkSelf (/usr/local/lib/node_modules/npm/lib/install/validate-args.js:40:14) 97 | 58 verbose stack at Array. (/usr/local/lib/node_modules/npm/node_modules/slide/lib/bind-actor.js:15:8) 98 | 58 verbose stack at LOOP (/usr/local/lib/node_modules/npm/node_modules/slide/lib/chain.js:15:14) 99 | 58 verbose stack at chain (/usr/local/lib/node_modules/npm/node_modules/slide/lib/chain.js:20:5) 100 | 58 verbose stack at /usr/local/lib/node_modules/npm/lib/install/validate-args.js:15:5 101 | 58 verbose stack at /usr/local/lib/node_modules/npm/node_modules/slide/lib/async-map.js:52:35 102 | 58 verbose stack at Array.forEach (native) 103 | 58 verbose stack at /usr/local/lib/node_modules/npm/node_modules/slide/lib/async-map.js:52:11 104 | 58 verbose stack at Array.forEach (native) 105 | 58 verbose stack at asyncMap (/usr/local/lib/node_modules/npm/node_modules/slide/lib/async-map.js:51:8) 106 | 59 verbose cwd /Users/aaroncarlino/Sites/react-selectable/example 107 | 60 error Darwin 15.3.0 108 | 61 error argv "/usr/local/bin/node" "/usr/local/bin/npm" "install" "unclecheese/react-selectable" 109 | 62 error node v5.0.0 110 | 63 error npm v3.3.6 111 | 64 error code ENOSELF 112 | 65 error Refusing to install react-selectable as a dependency of itself 113 | 66 error If you need help, you may report this error at: 114 | 66 error 115 | 67 verbose exit [ 1, true ] 116 | -------------------------------------------------------------------------------- /example/sample-data.js: -------------------------------------------------------------------------------- 1 | const data = [ 2 | { title: 'My Aim is True', year: '1977' }, 3 | { title: "This Year's Model", year: '1978' }, 4 | { title: 'Armed Forces', year: '1979' }, 5 | { title: 'Get Happy', year: '1980' }, 6 | { title: 'Trust', year: '1981' }, 7 | { title: 'Almost Blue', year: '1981' }, 8 | { title: 'Imperial Bedroom', year: '1982'}, 9 | { title: 'Punch the Clock', year: '1983' }, 10 | { title: 'Goodbye Cruel World', year: '1984'}, 11 | { title: 'King of America', year: '1986' }, 12 | { title: 'Blood and Chocolate', year: '1986' }, 13 | { title: 'Spike', year: '1989' }, 14 | { title: 'Mighty Like a Rose', year: '1991' }, 15 | { title: 'The Juliette Letters', year: '1993' }, 16 | { title: 'Brutal Youth', year: '1994' }, 17 | { title: 'Kojak Variety', year: '1995' }, 18 | { title: 'All This Useless Beauty', year: '1996' }, 19 | { title: 'Painted from Memory', year: '1998' }, 20 | { title: 'When I Was Cruel', year: '2002' }, 21 | { title: 'North', year: '2003' }, 22 | { title: 'The Delivery Man', year: '2004' }, 23 | { title: 'The River in Reverse', year: '2006' }, 24 | { title: 'Momofuku', year: '2008'}, 25 | { title: 'Secret, Profane & Sugarcane', year: '2009' }, 26 | { title: 'National Ransom', year: '2009' }, 27 | { title: 'Wise Up Ghost', year: '2013' } 28 | ]; 29 | 30 | export default data; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-selectable", 3 | "version": "2.1.1", 4 | "description": "", 5 | "main": "dist/react-selectable.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "git://github.com/unclecheese/react-selectable.git" 9 | }, 10 | "scripts": { 11 | "build": "NODE_ENV=production node node_modules/webpack/bin/webpack", 12 | "dev": "NODE_ENV=development node node_modules/webpack/bin/webpack", 13 | "example": "node node_modules/webpack/bin/webpack --config webpack.config.example.js", 14 | "watch": "NODE_ENV=development node node_modules/webpack/bin/webpack --watch" 15 | }, 16 | "homepage": "http://unclecheese.github.io/react-selectable", 17 | "keywords": [ 18 | "selectable", 19 | "selection", 20 | "mouse", 21 | "drag", 22 | "react" 23 | ], 24 | "author": "Uncle Cheese", 25 | "license": "MIT", 26 | "bugs": { 27 | "url": "https://github.com/unclecheese/react-selectable/issues" 28 | }, 29 | "devDependencies": { 30 | "babel-core": "^6.26.0", 31 | "babel-loader": "^7.1.2", 32 | "babel-preset-es2015": "^6.24.1", 33 | "babel-preset-react": "^6.24.1", 34 | "babel-preset-stage-2": "^6.24.1", 35 | "classnames": "^2.2.5", 36 | "lodash.throttle": "^4.1.1", 37 | "prop-types": "^15.6.0", 38 | "react": "^16.0.0", 39 | "react-dom": "^16.0.0", 40 | "webpack": "^3.8.1" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /react-selectable.d.ts: -------------------------------------------------------------------------------- 1 | 2 | declare module 'react-selectable' { 3 | interface ReactSelectableGroupProps { 4 | onSelection?: (selectedItems: Array) => void; 5 | onNonItemClick?: () => void; 6 | onBeginSelection?: () => void; 7 | onEndSelection?: () => void; 8 | selectingClassName?: string; 9 | tolerance?: number; 10 | component?: string; 11 | fixedPosition?: boolean; 12 | preventDefault?: boolean; 13 | enabled?: boolean; 14 | [key: string]: any; 15 | } 16 | 17 | interface ReactSelectableComponentProps { 18 | key?: number|string; 19 | selected?: boolean; 20 | selectableKey?: number|string; 21 | [key: string]: any; 22 | } 23 | 24 | export class SelectableGroup extends React.Component { 25 | 26 | } 27 | 28 | class SelectableComponent extends React.Component { 29 | 30 | } 31 | export const createSelectable: (component: React.ReactNode) => typeof SelectableComponent; 32 | } 33 | -------------------------------------------------------------------------------- /src/createSelectable.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {findDOMNode} from 'react-dom'; 3 | import PropTypes from 'prop-types'; 4 | 5 | const createSelectable = (WrappedComponent) => { 6 | class SelectableItem extends React.Component { 7 | 8 | componentDidMount () { 9 | this.context.selectable.register(this.props.selectableKey, findDOMNode(this)); 10 | } 11 | 12 | 13 | componentWillUnmount () { 14 | this.context.selectable.unregister(this.props.selectableKey); 15 | } 16 | 17 | 18 | render () { 19 | return
20 | 21 | {this.props.children} 22 | 23 |
24 | } 25 | } 26 | 27 | SelectableItem.contextTypes = { 28 | selectable: PropTypes.object 29 | }; 30 | 31 | SelectableItem.propTypes = { 32 | children: PropTypes.node, 33 | selectableKey: PropTypes.any.isRequired 34 | }; 35 | 36 | return SelectableItem; 37 | }; 38 | 39 | export default createSelectable; 40 | -------------------------------------------------------------------------------- /src/doObjectsCollide.js: -------------------------------------------------------------------------------- 1 | import getBoundsForNode from './getBoundsForNode'; 2 | 3 | /** 4 | * Given offsets, widths, and heights of two objects, determine if they collide (overlap). 5 | * @param {int} aTop The top position of the first object 6 | * @param {int} aLeft The left position of the first object 7 | * @param {int} bTop The top position of the second object 8 | * @param {int} bLeft The left position of the second object 9 | * @param {int} aWidth The width of the first object 10 | * @param {int} aHeight The height of the first object 11 | * @param {int} bWidth The width of the second object 12 | * @param {int} bHeight The height of the second object 13 | * @param {int} tolerance Amount of forgiveness an item will offer to the selectbox before registering a selection 14 | * @return {bool} 15 | */ 16 | const coordsCollide = (aTop, aLeft, bTop, bLeft, aWidth, aHeight, bWidth, bHeight, tolerance) => { 17 | return !( 18 | // 'a' bottom doesn't touch 'b' top 19 | ( (aTop + aHeight - tolerance) < bTop ) || 20 | // 'a' top doesn't touch 'b' bottom 21 | ( (aTop + tolerance) > (bTop + bHeight) ) || 22 | // 'a' right doesn't touch 'b' left 23 | ( (aLeft + aWidth - tolerance) < bLeft ) || 24 | // 'a' left doesn't touch 'b' right 25 | ( (aLeft + tolerance) > (bLeft + bWidth) ) 26 | ); 27 | }; 28 | 29 | /** 30 | * Given two objects containing "top", "left", "offsetWidth" and "offsetHeight" 31 | * properties, determine if they collide. 32 | * @param {Object|HTMLElement} a 33 | * @param {Object|HTMLElement} b 34 | * @param {int} tolerance 35 | * @return {bool} 36 | */ 37 | export default (a, b, tolerance = 0) => { 38 | const aObj = (a instanceof HTMLElement) ? getBoundsForNode(a) : a; 39 | const bObj = (b instanceof HTMLElement) ? getBoundsForNode(b) : b; 40 | 41 | return coordsCollide( 42 | aObj.top, 43 | aObj.left, 44 | bObj.top, 45 | bObj.left, 46 | aObj.offsetWidth, 47 | aObj.offsetHeight, 48 | bObj.offsetWidth, 49 | bObj.offsetHeight, 50 | tolerance 51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /src/getBoundsForNode.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Given a node, get everything needed to calculate its boundaries 3 | * @param {HTMLElement} node 4 | * @return {Object} 5 | */ 6 | export default node => { 7 | const rect = node.getBoundingClientRect(); 8 | 9 | return { 10 | top: rect.top+document.body.scrollTop, 11 | left: rect.left+document.body.scrollLeft, 12 | offsetWidth: node.offsetWidth, 13 | offsetHeight: node.offsetHeight 14 | }; 15 | }; -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import SelectableGroup from './selectable-group'; 2 | import createSelectable from './createSelectable'; 3 | import isNodeIn from './isNodeIn'; 4 | import nodeInRoot from './nodeInRoot'; 5 | 6 | export { 7 | SelectableGroup, 8 | createSelectable, 9 | isNodeIn, 10 | nodeInRoot, 11 | }; -------------------------------------------------------------------------------- /src/isNodeIn.js: -------------------------------------------------------------------------------- 1 | const isNodeIn = (node, predicate) => { 2 | if (typeof predicate !== 'function') { 3 | throw new Error('isNodeIn second parameter must be a function'); 4 | } 5 | 6 | let currentNode = node; 7 | while (currentNode) { 8 | if (predicate(currentNode)) { 9 | return true; 10 | } 11 | currentNode = currentNode.parentNode; 12 | } 13 | 14 | return false; 15 | }; 16 | 17 | export default isNodeIn; 18 | -------------------------------------------------------------------------------- /src/nodeInRoot.js: -------------------------------------------------------------------------------- 1 | import isNodeIn from './isNodeIn'; 2 | 3 | const isNodeInRoot = (node, root) => ( 4 | isNodeIn(node, currentNode => currentNode === root) 5 | ); 6 | 7 | export default isNodeInRoot; 8 | -------------------------------------------------------------------------------- /src/selectable-group.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import {findDOMNode} from 'react-dom'; 3 | import PropTypes from 'prop-types'; 4 | import cx from 'classnames'; 5 | import isNodeInRoot from './nodeInRoot'; 6 | import isNodeIn from './isNodeIn'; 7 | import getBoundsForNode from './getBoundsForNode'; 8 | import doObjectsCollide from './doObjectsCollide'; 9 | import throttle from 'lodash.throttle'; 10 | 11 | class SelectableGroup extends Component { 12 | 13 | constructor (props) { 14 | super(props); 15 | 16 | this.state = { 17 | isBoxSelecting: false, 18 | boxWidth: 0, 19 | boxHeight: 0 20 | }; 21 | 22 | this._mouseDownData = null; 23 | this._rect = null; 24 | this._registry = []; 25 | 26 | this._openSelector = this._openSelector.bind(this); 27 | this._mouseDown = this._mouseDown.bind(this); 28 | this._mouseUp = this._mouseUp.bind(this); 29 | this._selectElements = this._selectElements.bind(this); 30 | this._registerSelectable = this._registerSelectable.bind(this); 31 | this._unregisterSelectable = this._unregisterSelectable.bind(this); 32 | 33 | this._throttledSelect = throttle(this._selectElements, 50); 34 | } 35 | 36 | 37 | getChildContext () { 38 | return { 39 | selectable: { 40 | register: this._registerSelectable, 41 | unregister: this._unregisterSelectable 42 | } 43 | }; 44 | } 45 | 46 | 47 | componentDidMount () { 48 | this._applyMousedown(this.props.enabled); 49 | this._rect = this._getInitialCoordinates(); 50 | } 51 | 52 | 53 | /** 54 | * Remove global event listeners 55 | */ 56 | componentWillUnmount () { 57 | this._applyMousedown(false); 58 | } 59 | 60 | componentWillReceiveProps (nextProps) { 61 | if (nextProps.enabled !== this.props.enabled) { 62 | this._applyMousedown(nextProps.enabled); 63 | } 64 | } 65 | 66 | _registerSelectable (key, domNode) { 67 | this._registry.push({key, domNode}); 68 | } 69 | 70 | 71 | _unregisterSelectable (key) { 72 | this._registry = this._registry.filter(data => data.key !== key); 73 | } 74 | 75 | _applyMousedown (apply) { 76 | const funcName = apply ? 'addEventListener' : 'removeEventListener'; 77 | findDOMNode(this)[funcName]('mousedown', this._mouseDown); 78 | } 79 | 80 | /** 81 | * Called while moving the mouse with the button down. Changes the boundaries 82 | * of the selection box 83 | */ 84 | _openSelector (e) { 85 | const w = Math.abs(this._mouseDownData.initialW - e.pageX + this._rect.x); 86 | const h = Math.abs(this._mouseDownData.initialH - e.pageY + this._rect.y); 87 | 88 | this.setState({ 89 | isBoxSelecting: true, 90 | boxWidth: w, 91 | boxHeight: h, 92 | boxLeft: Math.min(e.pageX - this._rect.x, this._mouseDownData.initialW), 93 | boxTop: Math.min(e.pageY - this._rect.y, this._mouseDownData.initialH) 94 | }); 95 | 96 | this._throttledSelect(e); 97 | } 98 | 99 | _getInitialCoordinates() { 100 | if (this.props.fixedPosition) { 101 | return { x: 0, y: 0 } 102 | } 103 | 104 | const style = window.getComputedStyle(document.body); 105 | const t = style.getPropertyValue('margin-top'); 106 | const l = style.getPropertyValue('margin-left'); 107 | const mLeft = parseInt(l.slice(0, l.length - 2), 10); 108 | const mTop = parseInt(t.slice(0, t.length - 2), 10); 109 | 110 | const bodyRect = document.body.getBoundingClientRect(); 111 | const elemRect = findDOMNode(this).getBoundingClientRect(); 112 | return { 113 | x: Math.round(elemRect.left - bodyRect.left + mLeft), 114 | y: Math.round(elemRect.top - bodyRect.top + mTop) 115 | }; 116 | } 117 | 118 | 119 | /** 120 | * Called when a user presses the mouse button. Determines if a select box should 121 | * be added, and if so, attach event listeners 122 | */ 123 | _mouseDown (e) { 124 | const {onBeginSelection, preventDefault} = this.props; 125 | 126 | // Disable if target is control by react-dnd 127 | if (isNodeIn(e.target, node => !!node.draggable)) return; 128 | 129 | // Allow onBeginSelection to cancel selection by return an explicit false 130 | if (typeof onBeginSelection === 'function' && onBeginSelection(e) === false) { 131 | return; 132 | } 133 | 134 | const node = findDOMNode(this); 135 | let collides, offsetData; 136 | window.addEventListener('mouseup', this._mouseUp); 137 | 138 | // Right clicks 139 | if (e.which === 3 || e.button === 2) return; 140 | 141 | if (!isNodeInRoot(e.target, node)) { 142 | offsetData = getBoundsForNode(node); 143 | collides = doObjectsCollide( 144 | { 145 | top: offsetData.top, 146 | left: offsetData.left, 147 | bottom: offsetData.offsetHeight, 148 | right: offsetData.offsetWidth 149 | }, 150 | { 151 | top: e.pageY - this._rect.y, 152 | left: e.pageX - this._rect.x, 153 | offsetWidth: 0, 154 | offsetHeight: 0 155 | } 156 | ); 157 | if (!collides) return; 158 | } 159 | this._rect = this._getInitialCoordinates(); 160 | 161 | this._mouseDownData = { 162 | boxLeft: e.pageX - this._rect.x, 163 | boxTop: e.pageY - this._rect.y, 164 | initialW: e.pageX - this._rect.x, 165 | initialH: e.pageY - this._rect.y 166 | }; 167 | 168 | if (preventDefault) e.preventDefault(); 169 | 170 | window.addEventListener('mousemove', this._openSelector); 171 | } 172 | 173 | 174 | /** 175 | * Called when the user has completed selection 176 | */ 177 | _mouseUp (e) { 178 | const {onNonItemClick} = this.props; 179 | const {isBoxSelecting} = this.state; 180 | 181 | e.stopPropagation(); 182 | 183 | window.removeEventListener('mousemove', this._openSelector); 184 | window.removeEventListener('mouseup', this._mouseUp); 185 | 186 | if (!this._mouseDownData) return; 187 | 188 | // Mouse up when not box selecting is a heuristic for a "click" 189 | if (onNonItemClick && !isBoxSelecting) { 190 | if (!this._registry.some(({domNode}) => isNodeInRoot(e.target, domNode))) { 191 | onNonItemClick(e); 192 | } 193 | } 194 | 195 | this._selectElements(e, true); 196 | 197 | this._mouseDownData = null; 198 | this.setState({ 199 | isBoxSelecting: false, 200 | boxWidth: 0, 201 | boxHeight: 0 202 | }); 203 | } 204 | 205 | 206 | /** 207 | * Selects multiple children given x/y coords of the mouse 208 | */ 209 | _selectElements (e, isEnd = false) { 210 | const {tolerance, onSelection, onEndSelection} = this.props; 211 | 212 | const currentItems = []; 213 | const _selectbox = findDOMNode(this.refs.selectbox); 214 | 215 | if (!_selectbox) return; 216 | 217 | this._registry.forEach(itemData => { 218 | if ( 219 | itemData.domNode 220 | && doObjectsCollide(_selectbox, itemData.domNode, tolerance) 221 | && !currentItems.includes(itemData.key) 222 | ) { 223 | currentItems.push(itemData.key); 224 | } 225 | }); 226 | 227 | if (isEnd) { 228 | if (typeof onEndSelection === 'function') onEndSelection(currentItems, e); 229 | } else { 230 | if (typeof onSelection === 'function') onSelection(currentItems, e); 231 | } 232 | } 233 | 234 | 235 | /** 236 | * Renders the component 237 | * @return {ReactComponent} 238 | */ 239 | render () { 240 | const {children, enabled, fixedPosition, className, selectingClassName} = this.props; 241 | const {isBoxSelecting, boxLeft, boxTop, boxWidth, boxHeight} = this.state; 242 | const Component = this.props.component; 243 | 244 | if (!enabled) { 245 | return ( 246 | 247 | {children} 248 | 249 | ); 250 | } 251 | 252 | const boxStyle = { 253 | left: boxLeft, 254 | top: boxTop, 255 | width: boxWidth, 256 | height: boxHeight, 257 | zIndex: 9000, 258 | position: fixedPosition ? 'fixed' : 'absolute', 259 | cursor: 'default' 260 | }; 261 | 262 | const spanStyle = { 263 | backgroundColor: 'transparent', 264 | border: '1px dashed #999', 265 | width: '100%', 266 | height: '100%', 267 | float: 'left' 268 | }; 269 | 270 | const wrapperStyle = { 271 | position: 'relative', 272 | overflow: 'visible' 273 | }; 274 | 275 | return ( 276 | 283 | { 284 | isBoxSelecting ? 285 |
289 | 292 |
293 | : null 294 | } 295 | {children} 296 |
297 | ); 298 | } 299 | } 300 | 301 | SelectableGroup.propTypes = { 302 | /** 303 | * @typedef {Object} MouseEvent 304 | * @typedef {Object} HTMLElement 305 | */ 306 | 307 | /** 308 | * @type {HTMLElement} node 309 | */ 310 | children: PropTypes.node, 311 | 312 | /** 313 | * Event that will fire when selection was started 314 | * 315 | * @type {Function} 316 | * @param {MouseEvent} event - MouseEvent 317 | */ 318 | onBeginSelection: PropTypes.func, 319 | 320 | /** 321 | * Event that will fire when selection was finished. Passes an array of keys 322 | * 323 | * @type {Function} 324 | * @param {Array} items - The array of selected items 325 | * @param {MouseEvent} event - MouseEvent 326 | */ 327 | onEndSelection: PropTypes.func, 328 | 329 | /** 330 | * Event that will fire when items are selected. Passes an array of keys 331 | * 332 | * @type {Function} 333 | * @param {Array} items - The array of selected items 334 | * @param {MouseEvent} event - MouseEvent 335 | */ 336 | onSelection: PropTypes.func, 337 | 338 | /** 339 | * The component that will represent the Selectable DOM node 340 | * 341 | * @type {HTMLElement} node 342 | */ 343 | component: PropTypes.node, 344 | 345 | /** 346 | * Amount of forgiveness an item will offer to the selectbox before registering 347 | * a selection, i.e. if only 1px of the item is in the selection, it shouldn't be 348 | * included. 349 | * 350 | * @type {Number} 351 | */ 352 | tolerance: PropTypes.number, 353 | 354 | /** 355 | * In some cases, it the bounding box may need fixed positioning, if your layout 356 | * is relying on fixed positioned elements, for instance. 357 | * 358 | * @type {Boolean} 359 | */ 360 | fixedPosition: PropTypes.bool, 361 | 362 | /** 363 | * Allows to enable/disable preventing the default action of the onmousedown event (with e.preventDefault). 364 | * True by default. Disable if your app needs to capture this event for other functionalities. 365 | * 366 | * @type {Boolean} 367 | */ 368 | preventDefault: PropTypes.bool, 369 | 370 | /** 371 | * Triggered when the user clicks in the component, but not on an item, e.g. whitespace 372 | * 373 | * @type {Function} 374 | */ 375 | onNonItemClick: PropTypes.func, 376 | 377 | /** 378 | * If false, all of the selectble features are turned off. 379 | * @type {[type]} 380 | */ 381 | enabled: PropTypes.bool, 382 | 383 | /** 384 | * A CSS class to add to the containing element 385 | * @type {string} 386 | */ 387 | className: PropTypes.string, 388 | 389 | /** 390 | * A CSS class to add to the containing element when we select 391 | * @type {string} 392 | */ 393 | selectingClassName: PropTypes.string 394 | 395 | }; 396 | 397 | SelectableGroup.defaultProps = { 398 | component: 'div', 399 | tolerance: 0, 400 | fixedPosition: false, 401 | preventDefault: true, 402 | enabled: true 403 | }; 404 | 405 | SelectableGroup.childContextTypes = { 406 | selectable: PropTypes.object 407 | }; 408 | 409 | export default SelectableGroup; 410 | -------------------------------------------------------------------------------- /webpack.config.example.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | module.exports = { 3 | entry: './example/example.js', 4 | output: { 5 | path: path.resolve(__dirname,'example'), // This is where images AND js will go 6 | publicPath: '', // This is used to generate URLs to e.g. images 7 | filename: 'bundle.js' 8 | }, 9 | 10 | module: { 11 | rules: [ 12 | { 13 | test: /\.js$/, 14 | loader: 'babel-loader' 15 | } 16 | ] 17 | }, 18 | resolve: { 19 | modules: [path.resolve(__dirname),"node_modules","dist"] 20 | } 21 | 22 | }; 23 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'); 2 | const path = require('path'); 3 | 4 | module.exports = { 5 | entry: './src/index.js', 6 | output: { 7 | path: path.resolve(__dirname,'dist'), // This is where images AND js will go 8 | publicPath: '', // This is used to generate URLs to e.g. images 9 | filename: 'react-selectable.js', 10 | libraryTarget: 'umd' 11 | }, 12 | externals: { 13 | 'react': { 14 | root: 'React', 15 | commonjs2: 'react', 16 | commonjs: 'react', 17 | amd: 'react', 18 | }, 19 | 'react-dom': { 20 | root: 'ReactDOM', 21 | commonjs2: 'react-dom', 22 | commonjs: 'react-dom', 23 | amd: 'react-dom' 24 | } 25 | }, 26 | module: { 27 | rules: [ 28 | { 29 | test: /\.js$/, 30 | loader: 'babel-loader' 31 | } 32 | ] 33 | }, 34 | resolve: { 35 | modules: ["node_modules"], 36 | }, 37 | plugins: process.env.NODE_ENV === 'production' ? [ 38 | new webpack.optimize.ModuleConcatenationPlugin(), 39 | new webpack.optimize.UglifyJsPlugin({ 40 | compress: { 41 | warnings: false, 42 | screw_ie8: true, 43 | conditionals: true, 44 | unused: true, 45 | comparisons: true, 46 | sequences: true, 47 | dead_code: true, 48 | evaluate: true, 49 | if_return: true, 50 | join_vars: true 51 | }, 52 | output: { 53 | comments: false 54 | } 55 | }), 56 | ] : [] 57 | }; 58 | --------------------------------------------------------------------------------