Repository: aurbano/react-ds Branch: master Commit: 80c92e3d1497 Files: 28 Total size: 74.4 KB Directory structure: gitextract_m4l_eywj/ ├── .babelrc ├── .eslintignore ├── .eslintrc.json ├── .flowconfig ├── .gitignore ├── .travis.yml ├── .vscode/ │ └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── dist/ │ ├── index.js │ └── index.js.flow ├── example/ │ └── app/ │ ├── .gitignore │ ├── README.md │ ├── package.json │ ├── public/ │ │ ├── index.html │ │ └── manifest.json │ └── src/ │ ├── Example.js │ ├── Examples.js │ ├── example.css │ ├── example.less │ ├── index.js │ ├── react-ds.css │ └── registerServiceWorker.js ├── package.json ├── src/ │ └── index.js └── test/ ├── index.test.js └── util/ └── test-bundler.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .babelrc ================================================ { "presets": [ [ "env", { "modules": "commonjs", "targets": { "browsers": ["last 2 versions", "safari >= 7"] } } ], "flow", "react", "stage-0" ], "plugins": [ "transform-es2015-destructuring", ["transform-object-rest-spread", { "useBuiltIns": true } ] ], "env": { "development": { "plugins": [ "flow-react-proptypes", "transform-react-jsx-source" ] }, "production": { "plugins": [ "transform-react-remove-prop-types", "transform-react-constant-elements" ] }, "test": { "plugins": [ "transform-es2015-modules-commonjs", "dynamic-import-node" ] } } } ================================================ FILE: .eslintignore ================================================ .gitignore dist/**/* example/**/* test/**/* gulpfile.js rollup.config.js ================================================ FILE: .eslintrc.json ================================================ { "parser": "babel-eslint", "extends": [ "airbnb", "plugin:flowtype/recommended" ], "globals": { "API_URL": false, "ENVIRONMENT": false, "VERSION": false }, "env": { "browser": true, "node": true, "jest": true, "es6": true }, "plugins": [ "react", "jsx-a11y", "flowtype" ], "parserOptions": { "ecmaVersion": 6, "sourceType": "module", "ecmaFeatures": { "jsx": true } }, "rules": { "eol-last": 0, "jsx-quotes": [ "error", "prefer-single" ], "no-plusplus": 0, "no-nested-ternary": 1, "arrow-parens": 0, "object-curly-spacing": [ 2, "always", { "arraysInObjects": false, "objectsInObjects": false } ], "arrow-body-style": [ 2, "as-needed" ], "comma-dangle": [ 2, "always-multiline" ], "import/imports-first": 0, "import/newline-after-import": 0, "import/no-dynamic-require": 0, "import/no-extraneous-dependencies": 0, "import/no-named-as-default": 0, "import/no-unresolved": 2, "import/prefer-default-export": 0, "indent": [ 2, 2, { "SwitchCase": 1 } ], "jsx-a11y/aria-props": 2, "jsx-a11y/heading-has-content": 0, "jsx-a11y/href-no-hash": "off", "jsx-a11y/anchor-is-valid": [ "error", { "components": [ "Link" ], "specialLink": [ "hrefLeft", "hrefRight" ], "aspects": [ "noHref", "invalidHref", "preferButton" ] }], "jsx-a11y/label-has-for": [ "error", { "required": { "every": [ "nesting", "id" ] }, "allowChildren": true }], "jsx-a11y/mouse-events-have-key-events": 2, "jsx-a11y/role-has-required-aria-props": 2, "jsx-a11y/role-supports-aria-props": 2, "jsx-a11y/no-static-element-interactions": 1, "jsx-a11y/img-has-alt": 0, "max-len": 0, "newline-per-chained-call": 0, "no-confusing-arrow": 0, "no-console": 1, "no-use-before-define": 0, "prefer-template": 2, "class-methods-use-this": 0, "react/sort-comp": [ 2, { "order": [ "type-annotations", "static-methods", "lifecycle", "everything-else", "render" ] } ], "react/jsx-wrap-multilines": 2, "react/forbid-prop-types": 0, "react/jsx-first-prop-new-line": [ 2, "multiline" ], "react/jsx-filename-extension": 0, "react/jsx-no-bind": 1, "react/jsx-curly-spacing": [ 2, "always" ], "react/jsx-no-target-blank": 0, "react/no-multi-comp": 1, "react/require-extension": 0, "react/self-closing-comp": 0, "require-yield": 0, "import/no-webpack-loader-syntax": 0, "react/require-default-props": 0, "react/no-array-index-key": 0, "flowtype/object-type-delimiter": [ 2, "comma" ] }, "settings": { "flowtype": { "onlyFilesWithFlowAnnotation": true } } } ================================================ FILE: .flowconfig ================================================ [ignore] /node_modules/babel-plugin-transform-react-remove-prop-types/.* /node_modules/babel-plugin-flow-runtime/.* /node_modules/eslint-plugin-jsx-a11y/.* /node_modules/flow-runtime/.* /node_modules/.staging/.* /node_modules/kefir/.* /example/.* /dist/.* [include] [libs] [lints] [options] # Support ES7 classes esproposal.class_static_fields=enable esproposal.class_instance_fields=enable esproposal.export_star_as=enable # Warn on @decorators esproposal.decorators=warn # Experimental settings experimental.const_params=true [strict] ================================================ FILE: .gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* # Runtime data pids *.pid *.seed *.pid.lock # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage test-report # nyc test coverage .nyc_output # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) .grunt # Bower dependency directory (https://bower.io/) bower_components # node-waf configuration .lock-wscript # Compiled binary addons (http://nodejs.org/api/addons.html) build/Release # Dependency directories node_modules/ jspm_packages/ # Typescript v1 declaration files typings/ # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Optional REPL history .node_repl_history # Output of 'npm pack' *.tgz # Yarn Integrity file .yarn-integrity # dotenv environment variables file .env .idea *.iml ================================================ FILE: .travis.yml ================================================ sudo: false language: node_js node_js: - stable script: - npm test after_success: - cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js - cat ./coverage/lcov.info | ./node_modules/.bin/codacy-coverage env: global: secure: o+QC0UCfqDo+Py3i6Wa0eOMGfPgqrXXMCrINxzvS8WNe5Lh9bIGbFfE4BctPu6YJfFFv8h1krHlA+oHFaFLzsWUqe70pEKu9+hFWWpR4WMdlRW5oHMjwmO3M5cKzcPYsvJ9YzjoEEU9wsLx/pt3i+8VRl0E+0F2B4Zo3PfcGny3WYhEO3r/B8shU81EE1sajhhesPj5dGWHRTfQhcQlyvtBlWqy+Atp+RFGDyB7N0KJhIjw15vLvPqCI0UsaWrpCIC8c3uV3um92ChKTaxbxBJS7cdG9wIMoardBY1UxxMahn9ohgO5/6obr9h6FTpfotblGpFQ6qKVySd2wic8eODPbplP+pq0TaMhw37GuE+ZEL3Yg7pwfeav2nKf8xwvGA5yxZLq8WKnTZZdOUMRT9DX3MFTKNkUusINv0ehWoVZSRsFqFrsXF0C9cx9/HjEpG33j3I2bH9fo0CPcgfwM9frGHcTfqirq86bwESIYfGXX7+e6qkV9yozEtpDDueaQK0hHWrEAMsNQgYX63r8lGF1oYCdeQ14I2xQjs0Yufi8TTcpDPy3977A68HkASj0mNLH24u4fK6zQYkDLCc7Q4BXIO1lVKeDjw7/obvjQ/+QM0BT1F757OWak5YjhAy9SAttZnsCYcBa9VVIlEQYrrHKzwR3RQDGhmE2g85dzWOc= ================================================ FILE: .vscode/settings.json ================================================ { "flow.useNPMPackagedFlow": true, "javascript.validate.enable": false, "files.associations": { "*.overrides": "less", "*.variables": "less", "theme.config": "less", }, "flow.showUncovered": true, "flow.logLevel": "warn" } ================================================ FILE: CHANGELOG.md ================================================ # Change Log > I haven't been maintaining this changelog. > > Check out the [releases](https://github.com/aurbano/react-ds/releases) section instead. ------ # Old Changelog ## [v1.7.0](https://github.com/aurbano/react-ds/tree/v1.7.0) (2017-07-27) [Full Changelog](https://github.com/aurbano/react-ds/compare/v1.6.0...v1.7.0) **Closed issues:** - Add option to ignore certain targets when initiating selects [\#7](https://github.com/aurbano/react-ds/issues/7) ## [v1.6.0](https://github.com/aurbano/react-ds/tree/v1.6.0) (2017-07-25) [Full Changelog](https://github.com/aurbano/react-ds/compare/v1.5.0...v1.6.0) ## [v1.5.0](https://github.com/aurbano/react-ds/tree/v1.5.0) (2017-07-13) [Full Changelog](https://github.com/aurbano/react-ds/compare/v1.4.1...v1.5.0) ## [v1.4.1](https://github.com/aurbano/react-ds/tree/v1.4.1) (2017-07-13) [Full Changelog](https://github.com/aurbano/react-ds/compare/v1.4.0...v1.4.1) **Closed issues:** - Move react to peerDependencies [\#5](https://github.com/aurbano/react-ds/issues/5) ## [v1.4.0](https://github.com/aurbano/react-ds/tree/v1.4.0) (2017-07-12) [Full Changelog](https://github.com/aurbano/react-ds/compare/v1.3.0...v1.4.0) **Fixed bugs:** - Items not properly selected when scrolled [\#4](https://github.com/aurbano/react-ds/issues/4) ## [v1.3.0](https://github.com/aurbano/react-ds/tree/v1.3.0) (2017-07-12) [Full Changelog](https://github.com/aurbano/react-ds/compare/v1.2.0...v1.3.0) ## [v1.2.0](https://github.com/aurbano/react-ds/tree/v1.2.0) (2017-07-12) [Full Changelog](https://github.com/aurbano/react-ds/compare/v1.1.0...v1.2.0) **Implemented enhancements:** - Add examples [\#1](https://github.com/aurbano/react-ds/issues/1) ## [v1.1.0](https://github.com/aurbano/react-ds/tree/v1.1.0) (2017-07-12) [Full Changelog](https://github.com/aurbano/react-ds/compare/v1.0.3...v1.1.0) ## [v1.0.3](https://github.com/aurbano/react-ds/tree/v1.0.3) (2017-07-12) [Full Changelog](https://github.com/aurbano/react-ds/compare/v1.0.2...v1.0.3) **Implemented enhancements:** - Setup CI [\#3](https://github.com/aurbano/react-ds/issues/3) ## [v1.0.2](https://github.com/aurbano/react-ds/tree/v1.0.2) (2017-07-12) [Full Changelog](https://github.com/aurbano/react-ds/compare/v1.0.1...v1.0.2) **Implemented enhancements:** - Generate dist folder with babel without flow [\#2](https://github.com/aurbano/react-ds/issues/2) ## [v1.0.1](https://github.com/aurbano/react-ds/tree/v1.0.1) (2017-07-12) \* *This Change Log was automatically generated by [github_changelog_generator](https://github.com/skywinder/Github-Changelog-Generator)* ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2017 Alejandro U. Alvarez Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # React DS > Tiny (7KB) React Drag-to-Select component (with no dependencies! with support for touch devices! [![Travis](https://img.shields.io/travis/aurbano/react-ds.svg)](https://travis-ci.org/aurbano/react-ds) [![npm](https://img.shields.io/npm/v/react-ds.svg)](https://www.npmjs.com/package/react-ds) [![Coverage Status](https://coveralls.io/repos/github/aurbano/react-ds/badge.svg?branch=master)](https://coveralls.io/github/aurbano/react-ds?branch=master) [![npm](https://img.shields.io/npm/dm/react-ds.svg)](https://www.npmjs.com/package/react-ds) [![npm](https://img.shields.io/npm/l/react-ds.svg)](https://www.npmjs.com/package/react-ds) [![Codacy grade](https://img.shields.io/codacy/grade/e2589a609bdc4c56bd49c232a65dab4e.svg)](https://www.codacy.com/app/aurbano/react-ds) [![react-ds gif](https://thumbs.gfycat.com/FatYellowKid-size_restricted.gif)](https://gfycat.com/gifs/detail/fatyellowkid) I wrote this library because I couldn't find any existing one to do selections without having to wrap the items in their component. In some cases you really need an unobtrusive way to make items selectable, this will do just that. ## Installation ```console $ npm i react-ds ``` Or if you prefer yarn ```console $ yarn add react-ds ``` ## Usage ```jsx import Selection from 'react-ds'; // target (ref) is the parent component (so that selects only happen when clicking and dragging on it) // elements (refs[]) is an array of refs to the components that are selectable ``` ### Props #### `target` Element where the selection should be applied to. This is to scope the mouse/touch event handlers and make sure that it doesn't affect your whole web app. It must be a React `ref`, it should also exist, so you may want to check if it's already initialized before rendering the `Selection` component. #### `elements` Array of refs to the elements that are selectable. The `Selection` component will use this to get their location and sizes to determine whether they are within the selection area. The should exist before rendering the `Selection` component. #### `onSelectionChange` Function that will be executed when the selection changes. An array of element indexes will be passed (with the same indexes as the `elements` prop). This is where you want to update your state, to highlight them as selected for example. #### `onHighlightChange` While dragging, `onHighlightChange` is called only when the highlighted elements have changed. When the mouse is released, it will be called with an empty array. ![onHighlightChange Example](https://user-images.githubusercontent.com/1640952/61724315-0269b280-ad6e-11e9-899c-4466e090cb13.gif) #### `offset` *(Optional)* This is used to calculate the coordinates of the mouse when drawing the Selection box, since the mouse events gives coordinates relative to the document, but the Selection box may have a different parent. Essentially you need to pass the offset of the parent element where the Selection is being rendered. If it's rendered in the same component as the items to be selected then the default value will work fine. If passing your own offset keep in mind that `getBoundingClientRect()` depends on the scroll, so you may want to do something like this: ```js const boundingBox = target.getBoundingClientRect(); const offset = { top: boundingBox.top + window.scrollY, left: boundingBox.left + window.scrollX, }; ``` #### `style` *(Optional)* If you want to override the styles for the selection area, you can either pass any styles here, or use css and declare any styles on the `.react-ds-border` class. The styles are merged, so you can override just one property if you need (typically the `zIndex`). The default styles are: ```js const style = { position: 'absolute', background: 'rgba(159, 217, 255, 0.3)', border: 'solid 1px rgba(123, 123, 123, 0.61)', zIndex: 9, cursor: 'crosshair', } ``` #### `ignoreTargets` *(Optional)* Specify an array of CSS3 selectors for DOM targets that should be ignored when initiating a selection. i.e. `['div', 'div > p', '#someId']` >This is specially useful because `react-ds` uses native browser events that bypass React's event queue, so you won't be able to `stopPropagation` as usual. ```jsx ``` ## Example This example was taken from [`example/app/src/Example.js`](https://github.com/aurbano/react-ds/blob/master/example/app/src/Example.js) which you can see running at https://aurbano.eu/react-ds/ ```jsx import React from 'react'; import PropTypes from 'prop-types'; import Selection from 'react-ds'; export default class Example extends React.PureComponent { constructor() { super(); this.state = { ref: null, elRefs: [], selectedElements: [], // track the elements that are selected }; } handleSelection = (indexes) => { this.setState({ selectedElements: indexes, }); }; getStyle = (index) => { if (this.state.selectedElements.indexOf(index) > -1) { // Selected state return { background: '#2185d0', borderColor: '#2185d0', color: 'white', }; } return {}; }; addElementRef = (ref) => { const elRefs = this.state.elRefs; elRefs.push(ref); this.setState({ elRefs, }); }; renderSelection() { if (!this.state.ref || !this.state.elRefs) { return null; } return ( ); } render() { const selectableElements = [ 'one', 'another', 'hey there', 'item', 'two', 'three', 'something longer?', 'last' ]; return (
{ this.setState({ ref }); } } className='item-container'> { selectableElements.map((el, index) => (
{ el }
)) } { this.renderSelection() }
); } } Example.PropTypes = { style: PropTypes.object, }; ``` ## Contributing Only edit the files in the `src` folder. I'll update `dist` manually before publishing new versions to npm. To run the tests simply run `npm test`. Add tests as you see fit to the `test` folder, they must be called `{string}.test.js`. ## Meta Copyright © [Alejandro U. Alvarez](https:/aurbano.eu) 2017. MIT Licensed. ================================================ FILE: dist/index.js ================================================ "use strict";Object.defineProperty(exports,"__esModule",{value:true});var _jsx=function(){var REACT_ELEMENT_TYPE=typeof Symbol==="function"&&Symbol.for&&Symbol.for("react.element")||60103;return function createRawReactElement(type,props,key,children){var defaultProps=type&&type.defaultProps;var childrenLength=arguments.length-3;if(!props&&childrenLength!==0){props={}}if(props&&defaultProps){for(var propName in defaultProps){if(props[propName]===void 0){props[propName]=defaultProps[propName]}}}else if(!props){props=defaultProps||{}}if(childrenLength===1){props.children=children}else if(childrenLength>1){var childArray=Array(childrenLength);for(var i=0;i1){return}if(_this.init(e,e.touches[0].pageX,e.touches[0].pageY)){window.document.addEventListener("touchmove",_this.onTouchMove);window.document.addEventListener("touchend",_this.onMouseUp)}};_this.onMouseUp=function(){window.document.removeEventListener("touchmove",_this.onTouchMove);window.document.removeEventListener("mousemove",_this.onMouseMove);window.document.removeEventListener("mouseup",_this.onMouseUp);window.document.removeEventListener("touchend",_this.onMouseUp);_this.setState({mouseDown:false,startPoint:null,endPoint:null,selectionBox:null});if(_this.props.onSelectionChange){_this.props.onSelectionChange(_this.selectedChildren)}if(_this.props.onHighlightChange){_this.highlightedChildren=[];_this.props.onHighlightChange(_this.highlightedChildren)}_this.selectedChildren=[]};_this.onMouseMove=function(e){e.preventDefault();if(_this.state.mouseDown){var _endPoint={x:(e.pageX-_this.state.offset.left)/_this.state.zoom,y:(e.pageY-_this.state.offset.top)/_this.state.zoom};_this.setState({endPoint:_endPoint,selectionBox:_this.calculateSelectionBox(_this.state.startPoint,_endPoint)})}};_this.onTouchMove=function(e){e.preventDefault();if(_this.state.mouseDown){var _endPoint2={x:(e.touches[0].pageX-_this.state.offset.left)/_this.state.zoom,y:(e.touches[0].pageY-_this.state.offset.top)/_this.state.zoom};_this.setState({endPoint:_endPoint2,selectionBox:_this.calculateSelectionBox(_this.state.startPoint,_endPoint2)})}};_this.lineIntersects=function(lineA,lineB){return lineA[1]>=lineB[0]&&lineB[1]>=lineA[0]};_this.boxIntersects=function(boxA,boxB){var boxAProjection={x:[boxA.left,boxA.left+boxA.width],y:[boxA.top,boxA.top+boxA.height]};var boxBProjection={x:[boxB.left,boxB.left+boxB.width],y:[boxB.top,boxB.top+boxB.height]};return _this.lineIntersects(boxAProjection.x,boxBProjection.x)&&_this.lineIntersects(boxAProjection.y,boxBProjection.y)};_this.updateCollidingChildren=function(selectionBox){_this.selectedChildren=[];if(_this.props.elements){_this.props.elements.forEach(function(ref,$index){if(ref){var refBox=ref.getBoundingClientRect();var tmpBox={top:(refBox.top-_this.state.offset.top+window.scrollY)/_this.state.zoom,left:(refBox.left-_this.state.offset.left+window.scrollX)/_this.state.zoom,width:ref.clientWidth,height:ref.clientHeight};if(_this.boxIntersects(selectionBox,tmpBox)){_this.selectedChildren.push($index)}}})}if(_this.props.onHighlightChange&&JSON.stringify(_this.highlightedChildren)!==JSON.stringify(_this.selectedChildren)){var _onHighlightChange=_this.props.onHighlightChange;_this.highlightedChildren=[].concat(_toConsumableArray(_this.selectedChildren));if(window.requestAnimationFrame){window.requestAnimationFrame(function(){_onHighlightChange(_this.highlightedChildren)})}else{_onHighlightChange(_this.highlightedChildren)}}};_this.calculateSelectionBox=function(startPoint,endPoint){if(!_this.state.mouseDown||!startPoint||!endPoint){return null}var left=Math.min(startPoint.x,endPoint.x)-1;var top=Math.min(startPoint.y,endPoint.y)-1;var width=Math.abs(startPoint.x-endPoint.x)+1;var height=Math.abs(startPoint.y-endPoint.y)+1;return{left:left,top:top,width:width,height:height}};_this.state={mouseDown:false,startPoint:null,endPoint:null,selectionBox:null,offset:getOffset(props),zoom:props.zoom||1};_this.selectedChildren=[];_this.highlightedChildren=[];return _this}_createClass(Selection,[{key:"componentDidMount",value:function componentDidMount(){this.reset();this.bind()}},{key:"componentWillReceiveProps",value:function componentWillReceiveProps(nextProps){this.setState({offset:getOffset(nextProps)})}},{key:"componentDidUpdate",value:function componentDidUpdate(){this.reset();this.bind();if(this.state.mouseDown&&this.state.selectionBox){this.updateCollidingChildren(this.state.selectionBox)}}},{key:"componentWillUnmount",value:function componentWillUnmount(){this.reset();window.document.removeEventListener("mousemove",this.onMouseMove);window.document.removeEventListener("mouseup",this.onMouseUp)}},{key:"render",value:function render(){var style=Object.assign({position:"absolute",background:"rgba(159, 217, 255, 0.3)",border:"solid 1px rgba(123, 123, 123, 0.61)",zIndex:9,cursor:"crosshair"},this.props.style);if(this.state.selectionBox){style=Object.assign({},style,this.state.selectionBox)}if(!this.state.mouseDown||!this.state.endPoint||!this.state.startPoint){return null}return _jsx("div",{className:"react-ds-border",style:style})}}]);return Selection}(_react2.default.PureComponent);exports.default=Selection; ================================================ FILE: dist/index.js.flow ================================================ // @flow import React from 'react'; import PropTypes from 'prop-types'; export type Point = { x: number, y: number, } export type Box = { left: number, top: number, width: number, height: number, } type Props = { disabled?: boolean, target: HTMLElement, onSelectionChange?: (elements: Array) => void, onHighlightChange?: (elements: Array) => void, elements: Array, // eslint-disable-next-line react/no-unused-prop-types offset?: { // eslint-disable-next-line react/no-unused-prop-types top: number, // eslint-disable-next-line react/no-unused-prop-types left: number, }, style?: any, zoom?: number, ignoreTargets?: Array, }; type State = { mouseDown: boolean, startPoint: ?Point, endPoint: ?Point, selectionBox: ?Box, offset: { top: number, left: number, }, zoom: number, }; function getOffset(props: Props) { let offset = { top: 0, left: 0, }; if (props.offset) { offset = { ...props.offset, }; } else if (props.target) { const boundingBox = props.target.getBoundingClientRect(); offset.top = boundingBox.top + window.scrollY; offset.left = boundingBox.left + window.scrollX; } return offset; } export default class Selection extends React.PureComponent { // eslint-disable-line react/prefer-stateless-function props: Props; state: State; selectedChildren: Array; highlightedChildren: Array; constructor(props: Props) { super(props); this.state = { mouseDown: false, startPoint: null, endPoint: null, selectionBox: null, offset: getOffset(props), zoom: props.zoom || 1, }; this.selectedChildren = []; this.highlightedChildren = []; } componentDidMount() { this.reset(); this.bind(); } componentWillReceiveProps(nextProps: Props) { this.setState({ offset: getOffset(nextProps), }); } componentDidUpdate() { this.reset(); this.bind(); if (this.state.mouseDown && this.state.selectionBox) { this.updateCollidingChildren(this.state.selectionBox); } } componentWillUnmount() { this.reset(); window.document.removeEventListener('mousemove', this.onMouseMove); window.document.removeEventListener('mouseup', this.onMouseUp); } bind = () => { this.props.target.addEventListener('mousedown', this.onMouseDown); this.props.target.addEventListener('touchstart', this.onTouchStart); }; reset = () => { if (this.props.target) { this.props.target.removeEventListener('mousedown', this.onMouseDown); } }; init = (e: Event, x: number, y: number): boolean => { if (this.props.ignoreTargets) { const Target = (e.target: any); if (!Target.matches) { // polyfill matches const defaultMatches = (s: string) => ( [].indexOf.call(window.document.querySelectorAll(s), this) !== -1 ); Target.matches = Target.matchesSelector || Target.mozMatchesSelector || Target.msMatchesSelector || Target.oMatchesSelector || Target.webkitMatchesSelector || defaultMatches; } if (Target.matches && Target.matches(this.props.ignoreTargets.join(','))) { return false; } } const nextState = {}; nextState.mouseDown = true; nextState.startPoint = { x: (x - this.state.offset.left) / this.state.zoom, y: (y - this.state.offset.top) / this.state.zoom, }; this.setState(nextState); return true; }; /** * On root element mouse down * The event should be a MouseEvent | TouchEvent, but flow won't get it... * @private */ onMouseDown = (e: MouseEvent | any) => { if (this.props.disabled || e.button === 2 || (e.nativeEvent && e.nativeEvent.which === 2)) { return; } if (this.init(e, e.pageX, e.pageY)) { window.document.addEventListener('mousemove', this.onMouseMove); window.document.addEventListener('mouseup', this.onMouseUp); } }; onTouchStart = (e: TouchEvent) => { if (this.props.disabled || !e.touches || !e.touches[0] || e.touches.length > 1) { return; } if (this.init(e, e.touches[0].pageX, e.touches[0].pageY)) { window.document.addEventListener('touchmove', this.onTouchMove); window.document.addEventListener('touchend', this.onMouseUp); } }; /** * On document element mouse up * @private */ onMouseUp = () => { window.document.removeEventListener('touchmove', this.onTouchMove); window.document.removeEventListener('mousemove', this.onMouseMove); window.document.removeEventListener('mouseup', this.onMouseUp); window.document.removeEventListener('touchend', this.onMouseUp); this.setState({ mouseDown: false, startPoint: null, endPoint: null, selectionBox: null, }); if (this.props.onSelectionChange) { this.props.onSelectionChange(this.selectedChildren); } if (this.props.onHighlightChange) { this.highlightedChildren = []; this.props.onHighlightChange(this.highlightedChildren); } this.selectedChildren = []; }; /** * On document element mouse move * @private */ onMouseMove = (e: MouseEvent) => { e.preventDefault(); if (this.state.mouseDown) { const endPoint: Point = { x: (e.pageX - this.state.offset.left) / this.state.zoom, y: (e.pageY - this.state.offset.top) / this.state.zoom, }; this.setState({ endPoint, selectionBox: this.calculateSelectionBox( this.state.startPoint, endPoint ), }); } }; onTouchMove = (e: TouchEvent) => { e.preventDefault(); if (this.state.mouseDown) { const endPoint: Point = { x: (e.touches[0].pageX - this.state.offset.left) / this.state.zoom, y: (e.touches[0].pageY - this.state.offset.top) / this.state.zoom, }; this.setState({ endPoint, selectionBox: this.calculateSelectionBox( this.state.startPoint, endPoint ), }); } }; /** * Calculate if two segments overlap in 1D * @param lineA [min, max] * @param lineB [min, max] */ lineIntersects = (lineA: [number, number], lineB: [number, number]): boolean => ( lineA[1] >= lineB[0] && lineB[1] >= lineA[0] ); /** * Detect 2D box intersection - the two boxes will intersect * if their projections to both axis overlap * @private */ boxIntersects = (boxA: Box, boxB: Box): boolean => { // calculate coordinates of all points const boxAProjection = { x: [boxA.left, boxA.left + boxA.width], y: [boxA.top, boxA.top + boxA.height], }; const boxBProjection = { x: [boxB.left, boxB.left + boxB.width], y: [boxB.top, boxB.top + boxB.height], }; return this.lineIntersects(boxAProjection.x, boxBProjection.x) && this.lineIntersects(boxAProjection.y, boxBProjection.y); }; /** * Updates the selected items based on the * collisions with selectionBox, * also updates the highlighted items if they have changed * @private */ updateCollidingChildren = (selectionBox: Box) => { this.selectedChildren = []; if (this.props.elements) { this.props.elements.forEach((ref, $index) => { if (ref) { const refBox = ref.getBoundingClientRect(); const tmpBox = { top: ((refBox.top - this.state.offset.top) + window.scrollY) / this.state.zoom, left: ((refBox.left - this.state.offset.left) + window.scrollX) / this.state.zoom, width: ref.clientWidth, height: ref.clientHeight, }; if (this.boxIntersects(selectionBox, tmpBox)) { this.selectedChildren.push($index); } } }); } if (this.props.onHighlightChange && JSON.stringify(this.highlightedChildren) !== JSON.stringify(this.selectedChildren)) { const { onHighlightChange } = this.props; this.highlightedChildren = [...this.selectedChildren]; if (window.requestAnimationFrame) { window.requestAnimationFrame(() => { onHighlightChange(this.highlightedChildren); }); } else { onHighlightChange(this.highlightedChildren); } } }; /** * Calculate selection box dimensions * @private */ calculateSelectionBox = (startPoint: ?Point, endPoint: ?Point) => { if (!this.state.mouseDown || !startPoint || !endPoint) { return null; } // The extra 1 pixel is to ensure that the mouse is on top // of the selection box and avoids triggering clicks on the target. const left = Math.min(startPoint.x, endPoint.x) - 1; const top = Math.min(startPoint.y, endPoint.y) - 1; const width = Math.abs(startPoint.x - endPoint.x) + 1; const height = Math.abs(startPoint.y - endPoint.y) + 1; return { left, top, width, height, }; }; /** * Render */ render() { let style: any = { position: 'absolute', background: 'rgba(159, 217, 255, 0.3)', border: 'solid 1px rgba(123, 123, 123, 0.61)', zIndex: 9, cursor: 'crosshair', ...this.props.style, }; if (this.state.selectionBox) { style = { ...style, ...this.state.selectionBox, }; } if (!this.state.mouseDown || !this.state.endPoint || !this.state.startPoint) { return null; } return (
); } } Selection.propTypes = { target: PropTypes.object, disabled: PropTypes.bool, onSelectionChange: PropTypes.func.isRequired, onHighlightChange: PropTypes.func, elements: PropTypes.array.isRequired, // eslint-disable-next-line react/no-unused-prop-types offset: PropTypes.object, zoom: PropTypes.number, style: PropTypes.object, ignoreTargets: PropTypes.array, }; ================================================ FILE: example/app/.gitignore ================================================ # See https://help.github.com/ignore-files/ for more about ignoring files. # dependencies /node_modules # testing /coverage # production /build # misc .DS_Store .env.local .env.development.local .env.test.local .env.production.local npm-debug.log* yarn-debug.log* yarn-error.log* ================================================ FILE: example/app/README.md ================================================ Example application using `create-react-app`. ## NPM Commands * `npm start` - to run locally * `npm build` - to generate a build Unless you're developing the component here you probably shouldn't be editing this. ================================================ FILE: example/app/package.json ================================================ { "name": "app", "version": "0.1.0", "private": true, "homepage": "https://aurbano.eu/react-ds/", "dependencies": { "react": "^16.0.0", "react-dom": "^16.0.0", "react-ds": "file:../../", "react-scripts": "1.0.14" }, "scripts": { "build:less": "./node_modules/.bin/lessc src/example.less src/example.css", "start": "npm run build:less && react-scripts start", "build": "npm run build:less && react-scripts build", "test": "react-scripts test --env=jsdom", "eject": "react-scripts eject", "postinstall": "linklocal" }, "devDependencies": { "less": "^2.7.2" } } ================================================ FILE: example/app/public/index.html ================================================ React DS Example
================================================ FILE: example/app/public/manifest.json ================================================ { "short_name": "React DS", "name": "React DS Example", "icons": [ { "src": "favicon.ico", "sizes": "192x192", "type": "image/png" } ], "start_url": "./index.html", "display": "standalone", "theme_color": "#000000", "background_color": "#ffffff" } ================================================ FILE: example/app/src/Example.js ================================================ import React from 'react'; import PropTypes from 'prop-types'; import Selection from 'react-ds'; export default class Example extends React.PureComponent { constructor() { super(); this.state = { ref: null, elRefs: [], selectedElements: [], // track the elements that are selected }; } handleSelection = (indexes) => { // eslint-disable-line no-undef this.setState({ selectedElements: indexes, }); }; getStyle = (index) => { // eslint-disable-line no-undef if (this.state.selectedElements.indexOf(index) > -1) { // Selected state return { background: '#2185d0', borderColor: '#2185d0', color: 'white', }; } return {}; }; addElementRef = (ref) => { // eslint-disable-line no-undef const elRefs = this.state.elRefs; elRefs.push(ref); this.setState({ elRefs, }); }; renderSelection() { if (!this.state.ref || !this.state.elRefs) { return null; } return ( ); } render() { const selectableElements = [ 'one', 'another', 'hey there', 'item', 'two', 'three', 'something longer?', 'last' ]; return (
{ this.setState({ ref }); } } className='item-container'> { selectableElements.map((el, index) => (
{ el }
)) } { this.renderSelection() }
); } } Example.PropTypes = { style: PropTypes.object, ignoreTargets: PropTypes.array, }; ================================================ FILE: example/app/src/Examples.js ================================================ import React from 'react'; import Example from './Example'; export default class Examples extends React.PureComponent { render() { return (

<Source> Example

The box below is the target for the Selection component. You'll see that you can't initiate selections outside of it.

Custom styles

Here's an example using custom styles for the selection box. These are simply passed on the style prop of the Selection component.

Ignore certain targets

You can also ignore certain targets from initiating selects. This is specially useful because react-ds uses native events which bypass React's synthetic event queue.

In this example, events initiated over the item "three" are ignored.

); } } ================================================ FILE: example/app/src/example.css ================================================ body { color: #333; font-family: Helvetica Neue, Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.4; margin: 0; padding: 0; } a { color: #007eff; text-decoration: none; } a:hover { text-decoration: underline; } code { padding: 0.2em 0.5em; margin: 0; font-size: 85%; background-color: rgba(27, 31, 35, 0.05); border-radius: 3px; font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; } .container { margin-left: auto; margin-right: auto; max-width: 600px; padding: 0 30px; } h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 { color: black; font-weight: 500; line-height: 1; margin-bottom: .66em; margin-top: 0; } h1 .right, h2 .right, h3 .right, h4 .right, h5 .right, h6 .right, .h1 .right, .h2 .right, .h3 .right, .h4 .right, .h5 .right, .h6 .right { font-size: 0.6em; } h1, .h1 { font-size: 3em; } h2, .h2 { font-size: 2em; font-weight: 300; } h3, .h3 { font-size: 1.25em; } h4, .h4 { font-size: 1em; } h5, .h5 { font-size: .85em; } h6, .h6 { font-size: .75em; } .page-body, .page-footer, .page-header { padding: 30px 0; } .page-header { background: linear-gradient(135deg, #F97794 0%, #623AA2 100%); color: #d8cee8; } .page-header h1, .page-header h2, .page-header h3 { color: white; } .page-header p { font-size: 1.2em; margin: 0; } .page-header a { border-bottom: 1px solid rgba(255, 255, 255, 0.3); color: white; text-decoration: none; } .page-header a:hover, .page-header a:focus { border-bottom-color: white; outline: none; text-decoration: none; } .right { float: right; position: relative; bottom: -0.2em; } .page-subheader { background-color: #e6f2ff; line-height: 20px; padding: 30px 0; } .page-subheader__button { float: right; } .page-subheader__link { border-bottom: 1px solid rgba(0, 126, 255, 0.3); outline: none; text-decoration: none; } .page-subheader__link:hover, .page-subheader__link:focus { border-bottom-color: #007eff; outline: none; text-decoration: none; } .page-footer { background-color: #fafafa; color: #999; padding: 30px 0; text-align: center; } .page-footer a { color: black; } @media (min-width: 480px) { .page-body, .page-header { padding: 60px 0; } .page-header { font-size: 1.4em; } .page-subheader { font-size: 1.125em; line-height: 28px; } } .switcher { color: #999; cursor: default; font-size: 12px; margin: 10px 0; text-transform: uppercase; } .switcher .link { color: #007eff; cursor: pointer; font-weight: bold; margin-left: 10px; } .switcher .link:hover { text-decoration: underline; } .switcher .active { color: #666; font-weight: bold; margin-left: 10px; } .section { margin-bottom: 40px; } .hint { font-size: .85em; margin: 15px 0; color: #666; } .virtual-scroll { z-index: 1; } ================================================ FILE: example/app/src/example.less ================================================ // // Common Example Styles // ------------------------------ // Constants // ------------------------------ // example site @heading-color: black; @text-color: #333; @link-color: #007eff; @gutter: 30px; @headerStart: #F97794; @headerEnd: #623AA2; @headerBackground: linear-gradient( 135deg, @headerStart 0%, @headerEnd 100%); // Base // ------------------------------ body { color: @text-color; font-family: Helvetica Neue, Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.4; margin: 0; padding: 0; } a { color: @link-color; text-decoration: none; &:hover { text-decoration: underline; } } code { padding: 0.2em 0.5em; margin: 0; font-size: 85%; background-color: rgba(27,31,35,0.05); border-radius: 3px; font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; } .container { margin-left: auto; margin-right: auto; max-width: 600px; padding: 0 @gutter; } // Headings // ------------------------------ h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 { color: @heading-color; font-weight: 500; line-height: 1; margin-bottom: .66em; margin-top: 0; .right { font-size: 0.6em; } } h1, .h1 { font-size: 3em; } h2, .h2 { font-size: 2em; font-weight: 300; } h3, .h3 { font-size: 1.25em; // text-transform: uppercase; } h4, .h4 { font-size: 1em; } h5, .h5 { font-size: .85em; } h6, .h6 { font-size: .75em; } // Layout // ------------------------------ // common .page-body, .page-footer, .page-header { padding: @gutter 0; } // header .page-header { background: @headerBackground; color: mix(white, @headerEnd, 75%); h1, h2, h3 { color: white; } p { font-size: 1.2em; margin: 0; } a { border-bottom: 1px solid fade(white, 30%); color: white; text-decoration: none; &:hover, &:focus { border-bottom-color: white; outline: none; text-decoration: none; } } } .right { float: right; position: relative; bottom: -0.2em; } // subheader .page-subheader { background-color: mix(@link-color, white, 10%); line-height: 20px; padding: @gutter 0; } .page-subheader__button { float: right; } .page-subheader__link { border-bottom: 1px solid fade(@link-color, 30%); outline: none; text-decoration: none; &:hover, &:focus { border-bottom-color: @link-color; outline: none; text-decoration: none; } } // footer .page-footer { background-color: #fafafa; // border-top: 1px solid #eee; color: #999; padding: @gutter 0; text-align: center; a { color: black; } } // layout changes based on screen dimensions @media (min-width: 480px) { .page-body, .page-header { padding: (@gutter * 2) 0; } .page-header { font-size: 1.4em; } .page-subheader { font-size: 1.125em; line-height: 28px; } } // Switcher // ------------------------------ .switcher { color: #999; cursor: default; font-size: 12px; margin: 10px 0; text-transform: uppercase; .link { color: @link-color; cursor: pointer; font-weight: bold; margin-left: 10px; &:hover { text-decoration: underline; } } .active { color: #666; font-weight: bold; margin-left: 10px; } } // Miscellaneous // ------------------------------ .section { margin-bottom: 40px; } .hint { font-size: .85em; margin: 15px 0; color: #666; } .virtual-scroll { z-index: 1; } ================================================ FILE: example/app/src/index.js ================================================ import React from 'react'; import ReactDOM from 'react-dom'; import './example.css'; import './react-ds.css'; import Examples from './Examples'; import registerServiceWorker from './registerServiceWorker'; ReactDOM.render(, document.getElementById('root')); registerServiceWorker(); ================================================ FILE: example/app/src/react-ds.css ================================================ .item-container { border: solid 1px #ccc; position: relative; box-shadow: 0 1px 2px 0 rgba(34,36,38,.15); margin: 1rem 0; padding: 1em 1em; border-radius: .28571429rem; } .item { display: inline-block; min-width: 40px; min-height: 40px; border: solid 1px #ccc; text-align: center; padding: 1em; margin: 1em; border-radius: .28571429rem; } ================================================ FILE: example/app/src/registerServiceWorker.js ================================================ // In production, we register a service worker to serve assets from local cache. // This lets the app load faster on subsequent visits in production, and gives // it offline capabilities. However, it also means that developers (and users) // will only see deployed updates on the "N+1" visit to a page, since previously // cached resources are updated in the background. // To learn more about the benefits of this model, read https://goo.gl/KwvDNy. // This link also includes instructions on opting out of this behavior. const isLocalhost = Boolean( window.location.hostname === 'localhost' || // [::1] is the IPv6 localhost address. window.location.hostname === '[::1]' || // 127.0.0.1/8 is considered localhost for IPv4. window.location.hostname.match( /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ ) ); export default function register() { if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { // The URL constructor is available in all browsers that support SW. const publicUrl = new URL(process.env.PUBLIC_URL, window.location); if (publicUrl.origin !== window.location.origin) { // Our service worker won't work if PUBLIC_URL is on a different origin // from what our page is served on. This might happen if a CDN is used to // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374 return; } window.addEventListener('load', () => { const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; if (!isLocalhost) { // Is not local host. Just register service worker registerValidSW(swUrl); } else { // This is running on localhost. Lets check if a service worker still exists or not. checkValidServiceWorker(swUrl); } }); } } function registerValidSW(swUrl) { navigator.serviceWorker .register(swUrl) .then(registration => { registration.onupdatefound = () => { const installingWorker = registration.installing; installingWorker.onstatechange = () => { if (installingWorker.state === 'installed') { if (navigator.serviceWorker.controller) { // At this point, the old content will have been purged and // the fresh content will have been added to the cache. // It's the perfect time to display a "New content is // available; please refresh." message in your web app. console.log('New content is available; please refresh.'); } else { // At this point, everything has been precached. // It's the perfect time to display a // "Content is cached for offline use." message. console.log('Content is cached for offline use.'); } } }; }; }) .catch(error => { console.error('Error during service worker registration:', error); }); } function checkValidServiceWorker(swUrl) { // Check if the service worker can be found. If it can't reload the page. fetch(swUrl) .then(response => { // Ensure service worker exists, and that we really are getting a JS file. if ( response.status === 404 || response.headers.get('content-type').indexOf('javascript') === -1 ) { // No service worker found. Probably a different app. Reload the page. navigator.serviceWorker.ready.then(registration => { registration.unregister().then(() => { window.location.reload(); }); }); } else { // Service worker found. Proceed as normal. registerValidSW(swUrl); } }) .catch(() => { console.log( 'No internet connection found. App is running in offline mode.' ); }); } export function unregister() { if ('serviceWorker' in navigator) { navigator.serviceWorker.ready.then(registration => { registration.unregister(); }); } } ================================================ FILE: package.json ================================================ { "name": "react-ds", "version": "1.13.0", "description": "Simple React Drag-to-Select component", "main": "dist/index.js", "files": [ "dist" ], "scripts": { "install:example": "cd example/app && npm install", "build": "npm run build:clean && cross-env NODE_ENV=production npm run build:dist && npm run build:flow && npm run build:minify", "build:dev": "npm run build:clean && cross-env NODE_ENV=production npm run build:dist && npm run build:flow", "build:clean": "rimraf dist", "build:dist": "babel -d dist src --ignore '**/__tests__/**'", "build:flow": "flow-copy-source -v -i '**/__tests__/**' src dist", "build:minify": "./node_modules/.bin/uglifyjs dist/index.js -o dist/index.js", "lint": "npm run lint:js", "lint:eslint": "eslint", "lint:js": "npm run lint:eslint -- . ", "lint:fix": "npm run lint:eslint -- --fix . ", "lint:staged": "lint-staged && npm run flow", "pretest": "npm run test:clean && npm run lint && npm run flow", "test:clean": "rimraf ./coverage", "test": "cross-env NODE_ENV=test jest --coverage", "test:watch": "cross-env NODE_ENV=test jest --watchAll", "flow": "flow status", "changelog": "github_changelog_generator -u aurbano -p react-ds", "start": "cd example/app && npm start", "deploy": "cd example/app && npm run build && gh-pages -d build" }, "repository": { "type": "git", "url": "git+https://github.com/aurbano/react-ds.git" }, "keywords": [ "react", "select", "drag", "component", "library", "items", "selection" ], "author": "Alejandro U. Alvarez", "license": "MIT", "bugs": { "url": "https://github.com/aurbano/react-ds/issues" }, "homepage": "https://github.com/aurbano/react-ds#readme", "peerDependencies": { "react": "^16.3" }, "devDependencies": { "babel-cli": "6.26.0", "babel-core": "6.26.3", "babel-eslint": "8.2.2", "babel-jest": "^23.0.0-alpha.0", "babel-plugin-dynamic-import-node": "^1.2.0", "babel-plugin-flow-react-proptypes": "^12.1.0", "babel-plugin-flow-runtime": "^0.17.0", "babel-plugin-react-intl": "^2.4.0", "babel-plugin-react-transform": "3.0.0", "babel-plugin-transform-es2015-destructuring": "^6.23.0", "babel-plugin-transform-es2015-modules-commonjs": "^6.26.2", "babel-plugin-transform-object-rest-spread": "^6.26.0", "babel-plugin-transform-react-constant-elements": "^6.23.0", "babel-plugin-transform-react-inline-elements": "^6.22.0", "babel-plugin-transform-react-jsx-source": "^6.22.0", "babel-plugin-transform-react-remove-prop-types": "^0.4.24", "babel-preset-env": "^1.7.0", "babel-preset-flow": "^6.23.0", "babel-preset-react": "^6.24.1", "babel-preset-stage-0": "^6.24.1", "codacy-coverage": "^2.1.0", "coveralls": "^3.0.8", "cross-env": "^5.2.1", "enzyme": "^3.10.0", "enzyme-adapter-react-16": "^1.15.1", "eslint": "^4.19.0", "eslint-config-airbnb": "^16.1.0", "eslint-plugin-flowtype": "^2.46.1", "eslint-plugin-import": "^2.18.2", "eslint-plugin-jsx-a11y": "^6.2.3", "eslint-plugin-react": "^7.16.0", "flow-bin": "^0.112.0", "flow-copy-source": "^1.3.0", "flow-runtime": "^0.17.0", "gh-pages": "^1.1.0", "gulp": "^3.9.1", "jasmine": "^3.5.0", "jasmine-reporters": "^2.3.2", "jest": "^22.4.2", "jest-cli": "^23.0.0-alpha.0", "lint-staged": "^9.4.3", "lodash": "^4.17.15", "pre-commit": "^1.2.2", "prop-types": "^15.7.2", "react": "^16.12.0", "react-dom": "^16.12.0", "react-test-renderer": "^16.12.0", "rimraf": "^2.6.2", "sinon": "^4.4.6", "uglify-es": "^3.3.9" }, "lint-staged": { "*.js": "npm run lint:eslint" }, "pre-commit": "lint:staged", "jest": { "collectCoverageFrom": [ "src/**/*.{js,jsx}" ], "moduleDirectories": [ "node_modules", "src" ], "unmockedModulePathPatterns": [ "./node_modules/react", "./node_modules/react-addons-test-utils", "./node_modules/jasmine-reporters" ], "setupTestFrameworkScriptFile": "/test/util/test-bundler.js", "testRegex": ".*\\.test\\.js$" } } ================================================ FILE: src/index.js ================================================ // @flow import React from 'react'; import PropTypes from 'prop-types'; export type Point = { x: number, y: number, } export type Box = { left: number, top: number, width: number, height: number, } type Props = { disabled?: boolean, confineSelectionBox?: boolean, target: HTMLElement, onSelectionChange?: (elements: Array) => void, onHighlightChange?: (elements: Array) => void, elements: Array, // eslint-disable-next-line react/no-unused-prop-types offset?: { // eslint-disable-next-line react/no-unused-prop-types top: number, // eslint-disable-next-line react/no-unused-prop-types left: number, }, style?: any, zoom?: number, ignoreTargets?: Array, }; type State = { mouseDown: boolean, startPoint: ?Point, endPoint: ?Point, selectionBox: ?Box, offset: { top: number, left: number, }, zoom: number, }; function getOffset(props: Props) { let offset = { top: 0, left: 0, }; if (props.offset) { offset = { ...props.offset, }; } else if (props.target) { const boundingBox = props.target.getBoundingClientRect(); offset.top = boundingBox.top + window.scrollY; offset.left = boundingBox.left + window.scrollX; } return offset; } export default class Selection extends React.PureComponent { // eslint-disable-line react/prefer-stateless-function props: Props; state: State; selectedChildren: Array; highlightedChildren: Array; constructor(props: Props) { super(props); this.state = { mouseDown: false, startPoint: null, endPoint: null, selectionBox: null, offset: getOffset(props), zoom: props.zoom || 1, }; this.selectedChildren = []; this.highlightedChildren = []; } static getDerivedStateFromProps(nextProps: Props) { return { offset: getOffset(nextProps), }; } componentDidMount() { this.reset(); this.bind(); } componentDidUpdate() { this.reset(); this.bind(); if (this.state.mouseDown && this.state.selectionBox) { this.updateCollidingChildren(this.state.selectionBox); } } componentWillUnmount() { this.reset(); window.document.removeEventListener('mousemove', this.onMouseMove); window.document.removeEventListener('mouseup', this.onMouseUp); } bind = () => { this.props.target.addEventListener('mousedown', this.onMouseDown); this.props.target.addEventListener('touchstart', this.onTouchStart); }; reset = () => { if (this.props.target) { this.props.target.removeEventListener('mousedown', this.onMouseDown); } }; init = (e: Event, x: number, y: number): boolean => { if (this.props.ignoreTargets) { const Target = (e.target: any); if (!Target.matches) { // polyfill matches const defaultMatches = (s: string) => ( [].indexOf.call(window.document.querySelectorAll(s), this) !== -1 ); Target.matches = Target.matchesSelector || Target.mozMatchesSelector || Target.msMatchesSelector || Target.oMatchesSelector || Target.webkitMatchesSelector || defaultMatches; } if (Target.matches && Target.matches(this.props.ignoreTargets.join(','))) { return false; } } const nextState = {}; nextState.mouseDown = true; nextState.startPoint = { x: (x - this.state.offset.left) / this.state.zoom, y: (y - this.state.offset.top) / this.state.zoom, }; this.setState(nextState); return true; }; /** * On root element mouse down * The event should be a MouseEvent | TouchEvent, but flow won't get it... * @private */ onMouseDown = (e: MouseEvent | any) => { if (this.props.disabled || e.button === 2 || (e.nativeEvent && e.nativeEvent.which === 2)) { return; } if (this.init(e, e.pageX, e.pageY)) { window.document.addEventListener('mousemove', this.onMouseMove); window.document.addEventListener('mouseup', this.onMouseUp); } }; onTouchStart = (e: TouchEvent) => { if (this.props.disabled || !e.touches || !e.touches[0] || e.touches.length > 1) { return; } if (this.init(e, e.touches[0].pageX, e.touches[0].pageY)) { window.document.addEventListener('touchmove', this.onTouchMove); window.document.addEventListener('touchend', this.onMouseUp); } }; /** * On document element mouse up * @private */ onMouseUp = () => { window.document.removeEventListener('touchmove', this.onTouchMove); window.document.removeEventListener('mousemove', this.onMouseMove); window.document.removeEventListener('mouseup', this.onMouseUp); window.document.removeEventListener('touchend', this.onMouseUp); this.setState({ mouseDown: false, startPoint: null, endPoint: null, selectionBox: null, }); if (this.props.onSelectionChange) { this.props.onSelectionChange(this.selectedChildren); } if (this.props.onHighlightChange) { this.highlightedChildren = []; this.props.onHighlightChange(this.highlightedChildren); } this.selectedChildren = []; }; /** * On document element mouse move * @private */ onMouseMove = (e: MouseEvent) => { e.preventDefault(); if (this.state.mouseDown) { const endPoint: Point = { x: (e.pageX - this.state.offset.left) / this.state.zoom, y: (e.pageY - this.state.offset.top) / this.state.zoom, }; this.setState({ endPoint, selectionBox: this.calculateSelectionBox( this.state.startPoint, endPoint ), }); } }; onTouchMove = (e: TouchEvent) => { e.preventDefault(); if (this.state.mouseDown) { const endPoint: Point = { x: (e.touches[0].pageX - this.state.offset.left) / this.state.zoom, y: (e.touches[0].pageY - this.state.offset.top) / this.state.zoom, }; this.setState({ endPoint, selectionBox: this.calculateSelectionBox( this.state.startPoint, endPoint ), }); } }; /** * Calculate if two segments overlap in 1D * @param lineA [min, max] * @param lineB [min, max] */ lineIntersects = (lineA: [number, number], lineB: [number, number]): boolean => ( lineA[1] >= lineB[0] && lineB[1] >= lineA[0] ); /** * Detect 2D box intersection - the two boxes will intersect * if their projections to both axis overlap * @private */ boxIntersects = (boxA: Box, boxB: Box): boolean => { // calculate coordinates of all points const boxAProjection = { x: [boxA.left, boxA.left + boxA.width], y: [boxA.top, boxA.top + boxA.height], }; const boxBProjection = { x: [boxB.left, boxB.left + boxB.width], y: [boxB.top, boxB.top + boxB.height], }; return this.lineIntersects(boxAProjection.x, boxBProjection.x) && this.lineIntersects(boxAProjection.y, boxBProjection.y); }; /** * Updates the selected items based on the * collisions with selectionBox, * also updates the highlighted items if they have changed * @private */ updateCollidingChildren = (selectionBox: Box) => { this.selectedChildren = []; if (this.props.elements) { this.props.elements.forEach((ref, $index) => { if (ref) { const refBox = ref.getBoundingClientRect(); const tmpBox = { top: ((refBox.top - this.state.offset.top) + window.scrollY) / this.state.zoom, left: ((refBox.left - this.state.offset.left) + window.scrollX) / this.state.zoom, width: ref.clientWidth, height: ref.clientHeight, }; if (this.boxIntersects(selectionBox, tmpBox)) { this.selectedChildren.push($index); } } }); } if (this.props.onHighlightChange && JSON.stringify(this.highlightedChildren) !== JSON.stringify(this.selectedChildren)) { const { onHighlightChange } = this.props; this.highlightedChildren = [...this.selectedChildren]; if (window.requestAnimationFrame) { window.requestAnimationFrame(() => { onHighlightChange(this.highlightedChildren); }); } else { onHighlightChange(this.highlightedChildren); } } }; /** * Calculate selection box dimensions * @private */ calculateSelectionBox = (startPoint: ?Point, endPoint: ?Point) => { if (!this.state.mouseDown || !startPoint || !endPoint) { return null; } let left, top, width, height = 0; if (this.props.confineSelectionBox) { var refBox = this.props.target.getBoundingClientRect(); // The extra 1 pixel is to ensure that the mouse is on top // of the selection box and avoids triggering clicks on the target. left = Math.max(0, Math.min(startPoint.x, endPoint.x)) - 1; top = Math.max(0, Math.min(startPoint.y, endPoint.y)) - 1; width = (startPoint.x < endPoint.x ? Math.min(refBox.width - startPoint.x, Math.abs(startPoint.x - endPoint.x)) : Math.min(startPoint.x, Math.abs(startPoint.x - endPoint.x))) + 1; height = (startPoint.y < endPoint.y ? Math.min(refBox.height - startPoint.y, Math.abs(startPoint.y - endPoint.y)) : Math.min(startPoint.y, Math.abs(startPoint.y - endPoint.y))) + 1; } else { // The extra 1 pixel is to ensure that the mouse is on top // of the selection box and avoids triggering clicks on the target. left = Math.min(startPoint.x, endPoint.x) - 1; top = Math.min(startPoint.y, endPoint.y) - 1; width = Math.abs(startPoint.x - endPoint.x) + 1; height = Math.abs(startPoint.y - endPoint.y) + 1; } return { left, top, width, height, }; }; /** * Render */ render() { let style: any = { position: 'absolute', background: 'rgba(159, 217, 255, 0.3)', border: 'solid 1px rgba(123, 123, 123, 0.61)', zIndex: 9, cursor: 'crosshair', ...this.props.style, }; if (this.state.selectionBox) { style = { ...style, ...this.state.selectionBox, }; } if (!this.state.mouseDown || !this.state.endPoint || !this.state.startPoint) { return null; } return (
); } } Selection.propTypes = { target: PropTypes.object, confineSelectionBox: PropTypes.bool, disabled: PropTypes.bool, onSelectionChange: PropTypes.func.isRequired, onHighlightChange: PropTypes.func, elements: PropTypes.array.isRequired, // eslint-disable-next-line react/no-unused-prop-types offset: PropTypes.object, zoom: PropTypes.number, style: PropTypes.object, ignoreTargets: PropTypes.array, }; ================================================ FILE: test/index.test.js ================================================ import React from 'react'; import { shallow, mount } from 'enzyme'; import clone from 'lodash/clone'; import Selection from '../src/index'; const mockTarget = { addEventListener: jest.fn(), removeEventListener: jest.fn(), }; const defaultProps = { target: mockTarget, disabled: false, onSelectionChange: () => {}, elements: [], offset: { top: 0, left: 0, }, zoom: 1, }; const initialState = { mouseDown: false, startPoint: null, endPoint: null, selectionBox: null, offset: defaultProps.offset, zoom: defaultProps.zoom, }; describe('', () => { it('Should not render when not selecting', () => { const props = defaultProps; const renderedComponent = shallow( ); expect(renderedComponent.find('div.selection')).toHaveLength(0); }); it('onMouseDown: Should start tracking', () => { const mouseEvent = { pageX: 100, pageY: 400, button: 1, nativeEvent: { which: 1, }, }; mockTarget.addEventListener.mockClear(); mockTarget.removeEventListener.mockClear(); const renderedComponent = shallow( ); expect(renderedComponent.state()).toEqual(initialState); renderedComponent.instance().onMouseDown(mouseEvent); const mouseDownState = clone(initialState); mouseDownState.startPoint = { x: mouseEvent.pageX, y: mouseEvent.pageY, }; mouseDownState.mouseDown = true; expect(renderedComponent.state()).toEqual(mouseDownState); }); it('onMouseUp: Should stop tracking', () => { const mouseEvent = { pageX: 100, pageY: 400, button: 1, nativeEvent: { which: 1, }, }; mockTarget.addEventListener.mockClear(); mockTarget.removeEventListener.mockClear(); const renderedComponent = shallow( ); renderedComponent.instance().onMouseDown(mouseEvent); renderedComponent.instance().onMouseUp(); expect(renderedComponent.state()).toEqual(initialState); }); it('onMouseMove: Should update selection box', () => { const mouseEvent = { pageX: 100, pageY: 400, button: 1, nativeEvent: { which: 1, }, }; mockTarget.addEventListener.mockClear(); mockTarget.removeEventListener.mockClear(); const moveEvent = clone(mouseEvent); moveEvent.pageX = -400; moveEvent.pageY = -100; moveEvent.preventDefault = jest.fn(); // Using mount because we need the selectionBox ref to exist const renderedComponent = mount( ); renderedComponent.instance().onMouseDown(mouseEvent); renderedComponent.instance().onMouseMove(moveEvent); const moveState = clone(initialState); moveState.endPoint = { x: moveEvent.pageX, y: moveEvent.pageY, }; moveState.mouseDown = true; moveState.selectionBox = { height: Math.abs(moveEvent.pageX) + mouseEvent.pageX + 1, width: Math.abs(moveEvent.pageY) + mouseEvent.pageY + 1, left: moveEvent.pageX - 1, top: moveEvent.pageY - 1, }; moveState.startPoint = { x: mouseEvent.pageX, y: mouseEvent.pageY, }; expect(renderedComponent.state()).toEqual(moveState); }); it('boxIntersects: Calculates box intersections', () => { const renderedComponent = shallow( ); const boxOne = { left: 0, top: 0, width: 10, height: 10, }; const boxTwo = { left: -5, top: 0, width: 10, height: 10, }; const boxThree = { left: 40, top: 10, width: 10, height: 10, }; expect(renderedComponent.instance().boxIntersects(boxOne, boxTwo)).toBeTruthy(); expect(renderedComponent.instance().boxIntersects(boxOne, boxThree)).toBeFalsy(); }); it('updateCollidingChildren: Updates colliding children', () => { const props = clone(defaultProps); const refs = []; const elementBoxes = [ { top: 0, left: 0, width: 10, height: 10, }, { top: 100, left: 300, width: 10, height: 10, }, ]; // render the elements to get boxes for them mount(
{ elementBoxes.map((box, $index) => (
{ refs.push(element); } } key={ $index } style={ { position: 'absolute', ...box } } /> )) }
); props.elements = refs; const renderedComponent = shallow( ); const box = { left: -50, top: -50, width: 100, height: 100, }; renderedComponent.instance().updateCollidingChildren(box); expect(renderedComponent.instance().selectedChildren).toEqual([0, 1]); }); }); ================================================ FILE: test/util/test-bundler.js ================================================ /* globals jasmine */ // needed for regenerator-runtime // (ES7 generator support is required by redux-saga) import 'babel-polyfill'; // Enzyme setup for React 16 import { configure } from 'enzyme'; import Adapter from 'enzyme-adapter-react-16'; const reporters = require('jasmine-reporters'); jasmine.VERBOSE = true; jasmine.getEnv().addReporter( new reporters.JUnitXmlReporter({ savePath: 'test-report', }) ); configure({ adapter: new Adapter() });