├── .eslintignore ├── .eslintrc.cjs ├── .github └── workflows │ ├── e2e-tests.yml │ ├── publish.yml │ └── unit-tests.yml ├── .gitignore ├── .nvmrc ├── .prettierrc ├── LICENSE ├── README.md ├── cypress.config.ts ├── cypress ├── e2e │ └── selecting.cy.js └── support │ ├── commands.ts │ └── e2e.ts ├── example ├── .gitignore ├── README.md ├── assets │ ├── air.png │ ├── disable-select-example.gif │ └── example.gif ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ ├── manifest.json │ └── robots.txt ├── src │ ├── App.css │ ├── App.tsx │ ├── index.css │ ├── index.tsx │ ├── react-app-env.d.ts │ ├── reportWebVitals.ts │ └── setupTests.ts └── tsconfig.json ├── jest.config.cjs ├── package-lock.json ├── package.json ├── src ├── components │ └── SelectionContainer.tsx ├── hooks │ ├── useSelectionContainer.tsx │ └── useSelectionLogic.ts ├── index.ts ├── typings.d.ts └── utils │ ├── __tests__ │ └── boxes.test.ts │ ├── boxes.ts │ └── types.ts ├── tsconfig.eslint.json └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | build/ 2 | dist/ 3 | node_modules/ 4 | .snapshots/ 5 | *.min.js 6 | cypress 7 | cypress.config.ts 8 | example/ 9 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended'], 3 | parser: '@typescript-eslint/parser', 4 | plugins: ['@typescript-eslint', 'prettier'], 5 | root: true, 6 | rules: { 7 | '@typescript-eslint/no-empty-interface': 'off', 8 | '@typescript-eslint/ban-ts-comment': 'off', 9 | 'prettier/prettier': 'error', 10 | '@typescript-eslint/strict-boolean-expressions': 'error', 11 | }, 12 | overrides: [ 13 | { 14 | files: ['*.ts', '*.tsx'], 15 | parserOptions: { 16 | project: 'tsconfig.eslint.json', 17 | sourceType: 'module', 18 | }, 19 | }, 20 | ], 21 | }; 22 | -------------------------------------------------------------------------------- /.github/workflows/e2e-tests.yml: -------------------------------------------------------------------------------- 1 | name: End-to-end tests 2 | on: [push] 3 | jobs: 4 | tests: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - name: Checkout 8 | uses: actions/checkout@v2 9 | 10 | - uses: actions/setup-node@v3 11 | with: 12 | node-version: 16 13 | 14 | - name: Cache root dependencies 15 | id: root-cache 16 | uses: actions/cache@v3 17 | with: 18 | path: ./node_modules 19 | key: modules-${{ hashFiles('package-lock.json') }} 20 | 21 | - name: Install root dependencies 22 | if: steps.root-cache.outputs.cache-hit != 'true' 23 | run: npm ci --ignore-scripts 24 | 25 | - name: Cache example dependencies 26 | id: example-cache 27 | uses: actions/cache@v3 28 | with: 29 | path: example/node_modules 30 | key: modules-${{ hashFiles('example/package-lock.json') }} 31 | 32 | - name: Install example dependencies 33 | if: steps.example-cache.outputs.cache-hit != 'true' 34 | run: cd example; npm ci --ignore-scripts 35 | 36 | - name: Build project 37 | run: npm run build 38 | 39 | - name: Cypress run 40 | uses: cypress-io/github-action@v4 41 | with: 42 | browser: chrome 43 | start: npm run ci:start-example 44 | wait-on: 'http://localhost:3000' 45 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to npmjs 2 | on: 3 | release: 4 | types: [created] 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | with: 11 | ref: main 12 | - uses: actions/setup-node@v3 13 | with: 14 | node-version: '16.x' 15 | registry-url: 'https://registry.npmjs.org' 16 | scope: '@air' 17 | - name: Configure Git 18 | run: | 19 | git config user.email "dev@air.inc" 20 | git config user.name "air-dev-bo" 21 | - run: npm version ${{ github.event.release.tag_name }} -m "Release ${{ github.event.release.tag_name }} 📣" 22 | - name: Push version to main 23 | uses: CasperWA/push-protected@v2 24 | with: 25 | token: ${{ secrets.AIR_DEV_BOT_PAT }} 26 | branch: main 27 | unprotect_reviews: true 28 | 29 | - name: Cache root dependencies 30 | id: root-cache 31 | uses: actions/cache@v3 32 | with: 33 | path: ./node_modules 34 | key: modules-${{ hashFiles('package-lock.json') }} 35 | 36 | - name: Install root dependencies 37 | if: steps.root-cache.outputs.cache-hit != 'true' 38 | 39 | run: npm ci --ignore-scripts 40 | - run: npm run build 41 | - run: npm ci 42 | - run: npm publish --access public 43 | env: 44 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 45 | -------------------------------------------------------------------------------- /.github/workflows/unit-tests.yml: -------------------------------------------------------------------------------- 1 | name: Unit tests 2 | on: [push] 3 | jobs: 4 | tests: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - name: Checkout 8 | uses: actions/checkout@v2 9 | 10 | - uses: actions/setup-node@v3 11 | with: 12 | node-version: 16 13 | 14 | - name: Cache root dependencies 15 | id: root-cache 16 | uses: actions/cache@v3 17 | with: 18 | path: ./node_modules 19 | key: modules-${{ hashFiles('package-lock.json') }} 20 | 21 | - name: Install root dependencies 22 | if: steps.root-cache.outputs.cache-hit != 'true' 23 | run: npm ci --ignore-scripts 24 | 25 | - name: Run unit tests 26 | run: npm run test 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # See https://help.github.com/ignore-files/ for more about ignoring files. 3 | 4 | # dependencies 5 | node_modules 6 | 7 | # builds 8 | build 9 | dist 10 | .rpt2_cache 11 | 12 | # misc 13 | .DS_Store 14 | .env 15 | .env.local 16 | .env.development.local 17 | .env.test.local 18 | .env.production.local 19 | 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | 24 | .idea 25 | 26 | # package testing 27 | .yalc 28 | 29 | # tests 30 | coverage -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 16.14.0 -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "printWidth": 120, 4 | "semi": true, 5 | "singleQuote": true, 6 | "trailingComma": "all" 7 | } 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Air 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 |

React drag-to-select

6 |

A highly-performant React library which adds drag-to-select to your app.

7 | 8 |

9 | 10 | size 11 | 12 | e2e 13 | unit 14 | size 15 |

16 | 17 | ## ✨ Features 18 | 19 | - Near 60 fps in 6x CPU slowdown on 2.3 GHz Quad-Core Intel Core i7 20 | - Simple API. It doesn't actually select items; just draws the selection box and passes you coordinates so you can determine that (we provided a utility to help though) 21 | - Fully built in TypeScript 22 | - Unit and e2e tested 23 | - Actively battle-tested in a [production-scale application](https://air.inc) 24 | 25 | ## Install 26 | 27 | ```bash 28 | npm install --save @air/react-drag-to-select 29 | ``` 30 | ```bash 31 | yarn add @air/react-drag-to-select 32 | ``` 33 | 34 | ## Usage 35 | 36 | ```tsx 37 | import { useSelectionContainer } from '@air/react-drag-to-select' 38 | 39 | const App = () => { 40 | const { DragSelection } = useSelectionContainer(); 41 | 42 | return ( 43 |
44 | 45 |
Selectable element
46 |
47 | ) 48 | } 49 | 50 | ``` 51 | 52 | Check out this codesandbox for a complete working example: https://codesandbox.io/s/billowing-lake-rzhid4 53 | 54 | ## useSelectionContainer arguments 55 | 56 | |Name|Required|Type|Default|Description| 57 | |----|--------|----|-------|-----------| 58 | |`onSelectionStart`|No|`() => void`||Method called when selection starts (mouse is down and moved)| 59 | |`onSelectionEnd`|No|`() => void`||Method called when selection ends (mouse is up) 60 | |`onSelectionChange`|Yes|`(box: Box) => void`||Method called when selection moves| 61 | |`isEnabled`|No|`boolean`|`true`|If false, selection does not fire| 62 | |`eventsElement`|No|`Window`, `HTMLElement` or `null`|`window`|Element to listen mouse events| 63 | |`selectionProps`|No|`React.HTMLAttributes`||Props of selection - you can pass style here as shown below| 64 | |`shouldStartSelecting`|No|`() => boolean`|`undefined`|If supplied, this callback is fired on mousedown and can be used to prevent selection from starting. This is useful when you want to prevent certain areas of your application from being able to be selected. Returning true will enable selection and returning false will prevent selection from starting.| 65 | 66 | ## Selection styling 67 | 68 | To style the selection box, pass `selectionProps: { style }` prop: 69 | 70 | ```tsx 71 | const { DragSelection } = useSelectionContainer({ 72 | ..., 73 | selectionProps: { 74 | style: { 75 | border: '2px dashed purple', 76 | borderRadius: 4, 77 | backgroundColor: 'brown', 78 | opacity: 0.5, 79 | }, 80 | }, 81 | }); 82 | ``` 83 | 84 | The default style for the selection box is 85 | ```ts 86 | { 87 | border: '1px solid #4C85D8', 88 | background: 'rgba(155, 193, 239, 0.4)', 89 | position: `absolute`, 90 | zIndex: 99, 91 | } 92 | ``` 93 | 94 | ## Disabling selecting in certain areas 95 | 96 |

97 | 98 |

99 | 100 | Sometimes you want to disable a user being able to start selecting in a certain area. You can use the `shouldStartSelecting` prop for this. 101 | 102 | ```tsx 103 | const { DragSelection } = useSelectionContainer({ 104 | shouldStartSelecting: (target) => { 105 | /** 106 | * In this example, we're preventing users from selecting in elements 107 | * that have a data-disableselect attribute on them or one of their parents 108 | */ 109 | if (target instanceof HTMLElement) { 110 | let el = target; 111 | while (el.parentElement && !el.dataset.disableselect) { 112 | el = el.parentElement; 113 | } 114 | return el.dataset.disableselect !== "true"; 115 | } 116 | 117 | /** 118 | * If the target doesn't exist, return false 119 | * This would most likely not happen. It's really a TS safety check 120 | */ 121 | return false; 122 | } 123 | }); 124 | ``` 125 | 126 | See full example here: https://codesandbox.io/s/exciting-rubin-xxf6r0 127 | 128 | ## Scrolling 129 | 130 | Because we use the mouse position to calculate the selection box's coordinates, if your `` is inside of an area that scrolls, you'll need to make some adjustments on your end. Our library can't inherently know which parent is being scrolled nor of it's position inside of the scrolling parent (if there are other sibling elements above it). 131 | 132 | How this is solved on your end is modifiying the `left` (for horizontal scrolling) and `top` (for vertical scrolling) of the `selectionBox` that is passed to `handleSelectionChange`. See the [`onSelectionChange` in the example](https://github.com/AirLabsTeam/react-drag-to-select/blob/main/example/src/App.tsx#L20) for an idea of how to do this. 133 | 134 | MIT License 135 | -------------------------------------------------------------------------------- /cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'cypress'; 2 | 3 | export default defineConfig({ 4 | e2e: { 5 | baseUrl: 'http://localhost:3000', 6 | setupNodeEvents(on, config) { 7 | // implement node event listeners here 8 | } 9 | } 10 | }); 11 | -------------------------------------------------------------------------------- /cypress/e2e/selecting.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | describe('react-drag-to-select example', () => { 4 | beforeEach(() => { 5 | cy.visit('/'); 6 | }); 7 | 8 | it('can select some items', () => { 9 | cy.get('.container') 10 | .trigger('mousedown', 10, 10, { 11 | eventConstructor: 'MouseEvent', 12 | }) 13 | .trigger('mousemove', 400, 150, { 14 | eventConstructor: 'MouseEvent', 15 | }) 16 | .trigger('mouseup'); 17 | 18 | for (let index = 0; index < 16; index++) { 19 | if (index < 3) { 20 | cy.get(`.element[data-testid="grid-cell-${index}"]`).should('have.class', 'selected'); 21 | } else { 22 | cy.get(`.element[data-testid="grid-cell-${index}"]`).should('not.have.class', 'selected'); 23 | } 24 | } 25 | }); 26 | 27 | it('can select some items after scrolling', { scrollBehavior: false }, () => { 28 | cy.viewport(500, 200); 29 | 30 | cy.get('.element[data-testid="grid-cell-8"]').scrollIntoView() 31 | 32 | cy.get('.container', { force: true }) 33 | .trigger('mousedown', 10, 320, { 34 | eventConstructor: 'MouseEvent', 35 | force: true 36 | }) 37 | .trigger('mousemove', 320, 325, { 38 | eventConstructor: 'MouseEvent', 39 | force: true 40 | }) 41 | .trigger('mouseup') 42 | 43 | for (let index = 0; index < 16; index++) { 44 | if (index > 7 && index < 11) { 45 | cy.get(`.element[data-testid="grid-cell-${index}"]`).should('have.class', 'selected'); 46 | } else { 47 | cy.get(`.element[data-testid="grid-cell-${index}"]`).should('not.have.class', 'selected'); 48 | } 49 | } 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************** 3 | // This example commands.ts shows you how to 4 | // create various custom commands and overwrite 5 | // existing commands. 6 | // 7 | // For more comprehensive examples of custom 8 | // commands please read more here: 9 | // https://on.cypress.io/custom-commands 10 | // *********************************************** 11 | // 12 | // 13 | // -- This is a parent command -- 14 | // Cypress.Commands.add('login', (email, password) => { ... }) 15 | // 16 | // 17 | // -- This is a child command -- 18 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 19 | // 20 | // 21 | // -- This is a dual command -- 22 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 23 | // 24 | // 25 | // -- This will overwrite an existing command -- 26 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) 27 | // 28 | // declare global { 29 | // namespace Cypress { 30 | // interface Chainable { 31 | // login(email: string, password: string): Chainable 32 | // drag(subject: string, options?: Partial): Chainable 33 | // dismiss(subject: string, options?: Partial): Chainable 34 | // visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable 35 | // } 36 | // } 37 | // } -------------------------------------------------------------------------------- /cypress/support/e2e.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/e2e.ts is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | This example was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 2 | 3 | It is linked to the react-drag-to-select package in the parent directory for development purposes. 4 | 5 | You can run `yarn install` and then `yarn start` to test your package. 6 | -------------------------------------------------------------------------------- /example/assets/air.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AirLabsTeam/react-drag-to-select/f8fcc1e0156dd575881e593f41a115a2fa78169d/example/assets/air.png -------------------------------------------------------------------------------- /example/assets/disable-select-example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AirLabsTeam/react-drag-to-select/f8fcc1e0156dd575881e593f41a115a2fa78169d/example/assets/disable-select-example.gif -------------------------------------------------------------------------------- /example/assets/example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AirLabsTeam/react-drag-to-select/f8fcc1e0156dd575881e593f41a115a2fa78169d/example/assets/example.gif -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@air/react-drag-to-select": "file:..", 7 | "@testing-library/jest-dom": "^5.16.4", 8 | "@testing-library/react": "^13.3.0", 9 | "@testing-library/user-event": "^13.5.0", 10 | "@types/jest": "^27.5.2", 11 | "@types/node": "^16.11.45", 12 | "@types/react": "^18.0.15", 13 | "@types/react-dom": "^18.0.6", 14 | "react": "file:../node_modules/react", 15 | "react-dom": "file:../node_modules/react-dom", 16 | "react-style-object-to-css": "file:../node_modules/react-style-object-to-css", 17 | "typescript": "^4.7.4", 18 | "web-vitals": "^2.1.4" 19 | }, 20 | "devDependencies": { 21 | "react-scripts": "5.0.1" 22 | }, 23 | "scripts": { 24 | "start": "react-scripts start", 25 | "build": "react-scripts build", 26 | "test": "react-scripts test", 27 | "eject": "react-scripts eject" 28 | }, 29 | "eslintConfig": { 30 | "extends": [ 31 | "react-app", 32 | "react-app/jest" 33 | ] 34 | }, 35 | "browserslist": { 36 | "production": [ 37 | ">0.2%", 38 | "not dead", 39 | "not op_mini all" 40 | ], 41 | "development": [ 42 | "last 1 chrome version", 43 | "last 1 firefox version", 44 | "last 1 safari version" 45 | ] 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /example/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AirLabsTeam/react-drag-to-select/f8fcc1e0156dd575881e593f41a115a2fa78169d/example/public/favicon.ico -------------------------------------------------------------------------------- /example/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /example/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /example/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /example/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /example/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react'; 2 | import './App.css'; 3 | import { Box, boxesIntersect, useSelectionContainer } from '@air/react-drag-to-select'; 4 | 5 | function App() { 6 | const [selectionBox, setSelectionBox] = useState(); 7 | const [selectedIndexes, setSelectedIndexes] = useState([]); 8 | const selectableItems = useRef([]); 9 | const elementsContainerRef = useRef(null); 10 | 11 | const { DragSelection } = useSelectionContainer({ 12 | eventsElement: document.getElementById('root'), 13 | onSelectionChange: (box) => { 14 | /** 15 | * Here we make sure to adjust the box's left and top with the scroll position of the window 16 | * @see https://github.com/AirLabsTeam/react-drag-to-select/#scrolling 17 | */ 18 | const scrollAwareBox = { 19 | ...box, 20 | top: box.top + window.scrollY, 21 | left: box.left + window.scrollX, 22 | }; 23 | 24 | setSelectionBox(scrollAwareBox); 25 | const indexesToSelect: number[] = []; 26 | selectableItems.current.forEach((item, index) => { 27 | if (boxesIntersect(scrollAwareBox, item)) { 28 | indexesToSelect.push(index); 29 | } 30 | }); 31 | 32 | setSelectedIndexes(indexesToSelect); 33 | }, 34 | onSelectionStart: () => {}, 35 | onSelectionEnd: () => {}, 36 | selectionProps: { 37 | style: { 38 | border: '2px dashed purple', 39 | borderRadius: 4, 40 | backgroundColor: 'brown', 41 | opacity: 0.5, 42 | }, 43 | }, 44 | shouldStartSelecting: (target) => { 45 | // do something with target to determine if the user should start selecting 46 | 47 | return true; 48 | }, 49 | }); 50 | 51 | useEffect(() => { 52 | if (elementsContainerRef.current) { 53 | Array.from(elementsContainerRef.current.children).forEach((item) => { 54 | const { left, top, width, height } = item.getBoundingClientRect(); 55 | selectableItems.current.push({ 56 | left, 57 | top, 58 | width, 59 | height, 60 | }); 61 | }); 62 | } 63 | }, []); 64 | 65 | return ( 66 |
67 | 68 |
69 | {Array.from({ length: 16 }, (_, i) => ( 70 |
75 | ))} 76 |
77 | 78 |
79 | Selection Box: 80 |
top: {selectionBox?.top || ''}
81 |
left: {selectionBox?.left || ''}
82 |
width: {selectionBox?.width || ''}
83 |
height: {selectionBox?.height || ''}
84 |
85 |
86 | ); 87 | } 88 | 89 | export default App; 90 | -------------------------------------------------------------------------------- /example/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | 15 | #root { 16 | width: 100%; 17 | height: 100vh; 18 | } 19 | 20 | .container { 21 | padding: 50px; 22 | } 23 | 24 | .elements-container { 25 | display: grid; 26 | grid-template-columns: 100px 100px 100px 100px; 27 | gap: 20px 20px; 28 | } 29 | 30 | .element { 31 | width: 100px; 32 | height: 100px; 33 | border: 1px solid black; 34 | } 35 | 36 | .selected { 37 | background-color: chocolate; 38 | } 39 | 40 | .selection-box-info { 41 | margin-top: 10px; 42 | } 43 | -------------------------------------------------------------------------------- /example/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import './index.css'; 4 | import App from './App'; 5 | import reportWebVitals from './reportWebVitals'; 6 | 7 | const root = ReactDOM.createRoot( 8 | document.getElementById('root') as HTMLElement 9 | ); 10 | root.render( 11 | 12 | 13 | 14 | ); 15 | 16 | // If you want to start measuring performance in your app, pass a function 17 | // to log results (for example: reportWebVitals(console.log)) 18 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 19 | reportWebVitals(); 20 | -------------------------------------------------------------------------------- /example/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /example/src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /example/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /jest.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | testPathIgnorePatterns: ['node_modules', 'dist'], 6 | collectCoverage: true 7 | }; 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@air/react-drag-to-select", 3 | "version": "5.0.10", 4 | "description": "A performant React library which adds drag to select to your app", 5 | "type": "module", 6 | "author": "Air Labs, Inc.", 7 | "license": "MIT", 8 | "repository": "AirLabsTeam/react-drag-to-select", 9 | "source": "src/index.ts", 10 | "exports": { 11 | "require": "./dist/index.cjs", 12 | "types": "./dist/index.d.ts", 13 | "default": "./dist/index.modern.js" 14 | }, 15 | "main": "./dist/index.cjs", 16 | "module": "./dist/index.module.js", 17 | "unpkg": "./dist/index.umd.js", 18 | "typings": "dist/index", 19 | "scripts": { 20 | "lint": "npx eslint .", 21 | "build": "npm run lint && microbundle", 22 | "dev": "microbundle watch", 23 | "pretty": "prettier --config .prettierrc 'src/**/*.(ts|tsx)' --write", 24 | "test": "jest", 25 | "ci:start-example": "cd example; npm start", 26 | "cypress": "npx cypress open" 27 | }, 28 | "dependencies": { 29 | "react-style-object-to-css": "^1.1.2" 30 | }, 31 | "peerDependencies": { 32 | "react": "16 - 18", 33 | "react-dom": "16 - 18" 34 | }, 35 | "devDependencies": { 36 | "@types/jest": "^28.1.6", 37 | "@types/react": "^18.0.15", 38 | "@types/react-dom": "^18.0.6", 39 | "@typescript-eslint/eslint-plugin": "^5.31.0", 40 | "@typescript-eslint/parser": "^5.31.0", 41 | "cypress": "13.13.2", 42 | "eslint": "^8.20.0", 43 | "eslint-config-prettier": "^8.5.0", 44 | "eslint-plugin-prettier": "^4.2.1", 45 | "jest": "^28.1.3", 46 | "microbundle": "^0.15.0", 47 | "prettier": "^2.7.1", 48 | "ts-jest": "^28.0.7", 49 | "typescript": "^4.7.4" 50 | }, 51 | "files": [ 52 | "dist" 53 | ] 54 | } 55 | -------------------------------------------------------------------------------- /src/components/SelectionContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { SelectionContainerRef, SelectionBox } from '../utils/types'; 4 | // @ts-ignore 5 | import styleObjectToCSS from 'react-style-object-to-css'; 6 | 7 | export interface SelectionContainerProps extends React.HTMLAttributes {} 8 | 9 | /** 10 | * This is a component responsible for displaying mouse selection box 11 | */ 12 | export const SelectionContainer = forwardRef(({ style = {}, ...props }: SelectionContainerProps, ref) => { 13 | const containerRef = useRef(null); 14 | const selectionBoxRef = useRef(null); 15 | const [, setForceUpdate] = useState(0); 16 | 17 | useImperativeHandle( 18 | ref, 19 | (): SelectionContainerRef => ({ 20 | getBoundingClientRect: () => containerRef.current?.getBoundingClientRect(), 21 | getParentBoundingClientRect: () => containerRef?.current?.parentElement?.getBoundingClientRect(), 22 | drawSelectionBox: (box: SelectionBox) => { 23 | requestAnimationFrame(() => { 24 | if (selectionBoxRef.current) { 25 | const styles: React.CSSProperties = { 26 | border: '1px solid #4C85D8', 27 | background: 'rgba(155, 193, 239, 0.4)', 28 | position: 'absolute', 29 | pointerEvents: 'none', 30 | ...style, 31 | top: box.top, 32 | left: box.left, 33 | width: box.width, 34 | height: box.height, 35 | }; 36 | 37 | selectionBoxRef.current.style.cssText = styleObjectToCSS(styles); 38 | } 39 | }); 40 | }, 41 | clearSelectionBox: () => { 42 | requestAnimationFrame(() => { 43 | if (selectionBoxRef.current) { 44 | const styles: React.CSSProperties = { 45 | top: 0, 46 | left: 0, 47 | width: 0, 48 | height: 0, 49 | }; 50 | 51 | selectionBoxRef.current.style.cssText = styleObjectToCSS(styles); 52 | } 53 | }); 54 | }, 55 | }), 56 | ); 57 | 58 | useEffect(() => { 59 | setForceUpdate((number) => number + 1); 60 | }, []); 61 | 62 | return ( 63 |
64 | {containerRef.current 65 | ? ReactDOM.createPortal(
, containerRef.current) 66 | : null} 67 |
68 | ); 69 | }); 70 | -------------------------------------------------------------------------------- /src/hooks/useSelectionContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, useCallback, useRef } from 'react'; 2 | import { SelectionContainer, SelectionContainerProps } from '../components/SelectionContainer'; 3 | import { SelectionContainerRef } from '../utils/types'; 4 | import { useSelectionLogic, UseSelectionLogicParams } from './useSelectionLogic'; 5 | 6 | export interface UseSelectionContainerResult { 7 | /** 8 | * method to cancel current selecting 9 | */ 10 | cancelCurrentSelection: ReturnType['cancelCurrentSelection']; 11 | /** 12 | * ReactNode which displays mouse selection. It should be rendered at the top of container of elements we want to select 13 | */ 14 | DragSelection: () => ReactElement; 15 | } 16 | 17 | export interface UseSelectionContainerParams 18 | extends Pick< 19 | UseSelectionLogicParams, 20 | | 'onSelectionChange' 21 | | 'onSelectionEnd' 22 | | 'onSelectionStart' 23 | | 'isEnabled' 24 | | 'eventsElement' 25 | | 'shouldStartSelecting' 26 | | 'isValidSelectionStart' 27 | > { 28 | /** These are props that get passed to the selection box component (where styling gets passed in) */ 29 | selectionProps?: SelectionContainerProps; 30 | } 31 | 32 | /** 33 | * Use this hook to enable mouse selection on a container. 34 | * To prevent interfering with drag-n-drop feature, add data-draggable='true' to draggable item. Selection won't fire when click happens on that element 35 | */ 36 | export function useSelectionContainer( 37 | props?: UseSelectionContainerParams, 38 | ): UseSelectionContainerResult { 39 | const { 40 | onSelectionChange, 41 | onSelectionEnd, 42 | onSelectionStart, 43 | isEnabled = true, 44 | selectionProps = {}, 45 | eventsElement, 46 | shouldStartSelecting, 47 | isValidSelectionStart, 48 | } = props || {}; 49 | 50 | const containerRef = useRef(null); 51 | 52 | const { cancelCurrentSelection } = useSelectionLogic({ 53 | containerRef, 54 | onSelectionEnd, 55 | onSelectionStart, 56 | onSelectionChange, 57 | isEnabled, 58 | eventsElement, 59 | shouldStartSelecting, 60 | isValidSelectionStart, 61 | }); 62 | 63 | const DragSelection = useCallback(() => , []); 64 | 65 | return { 66 | cancelCurrentSelection, 67 | DragSelection, 68 | }; 69 | } 70 | -------------------------------------------------------------------------------- /src/hooks/useSelectionLogic.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useCallback, useEffect, useRef } from 'react'; 2 | import { SelectionContainerRef, OnSelectionChange, Point, SelectionBox, Box } from '../utils/types'; 3 | import { calculateBoxArea, calculateSelectionBox } from '../utils/boxes'; 4 | 5 | export interface UseSelectionLogicResult { 6 | cancelCurrentSelection: () => void; 7 | } 8 | 9 | export interface UseSelectionLogicParams { 10 | /** This callback will fire when the user starts selecting */ 11 | onSelectionStart?: (event: MouseEvent) => void; 12 | /** This callback will fire when the user finishes selecting */ 13 | onSelectionEnd?: (event: MouseEvent) => void; 14 | /** This callback will fire when the user's mouse changes position while selecting using requestAnimationFrame */ 15 | onSelectionChange?: OnSelectionChange; 16 | /** This boolean enables selecting */ 17 | isEnabled?: boolean; 18 | /** This is an HTML element that the mouse events (mousedown, mouseup, mousemove) should be attached to. Defaults to the document.body */ 19 | eventsElement?: T | null; 20 | /** This is the ref of the parent of the selection box */ 21 | containerRef: RefObject; 22 | /** 23 | * If supplied, this callback is fired on mousedown and can be used to prevent selection from starting. 24 | * This is useful when you want to prevent certain areas of your application from being able to be selected. 25 | * Returning true will enable selection and returning false will prevent selection from starting. 26 | * 27 | * @param {EventTarget | null} target - The element the mousedown event fired on when the user started selected 28 | */ 29 | shouldStartSelecting?: (target: EventTarget | null) => boolean; 30 | 31 | /** 32 | * Determines whether a selection's dimensions meet the criteria for initiating a selection. 33 | * The purpose is to distinguish between clicks and the start of a selection gesture. 34 | * 35 | * The default implementation checks if the area of the box (width * height) is greater than 10. 36 | * 37 | * @returns `true` if the box dimensions meet the threshold for starting a selection, otherwise `false`. 38 | */ 39 | isValidSelectionStart?: (box: Box) => boolean; 40 | } 41 | 42 | /** 43 | * This hook contains logic for selecting. It starts 'selection' on mousedown event and finishes it on mouseup event. 44 | * When mousemove event is detected and user is selecting, it calls onSelectionChange and containerRef.drawSelectionBox 45 | */ 46 | export function useSelectionLogic({ 47 | containerRef, 48 | onSelectionChange, 49 | onSelectionStart, 50 | onSelectionEnd, 51 | isEnabled = true, 52 | eventsElement, 53 | shouldStartSelecting, 54 | isValidSelectionStart = isMinumumBoxArea, 55 | }: UseSelectionLogicParams): UseSelectionLogicResult { 56 | const startPoint = useRef(null); 57 | const endPoint = useRef(null); 58 | const isSelecting = useRef(false); 59 | 60 | // these are used in listeners attached to eventsElement. They are used as refs to ensure we always use the latest version 61 | const currentSelectionChange = useRef(onSelectionChange); 62 | const currentSelectionStart = useRef(onSelectionStart); 63 | const currentSelectionEnd = useRef(onSelectionEnd); 64 | const onChangeRefId = useRef(); 65 | const isEnabledRef = useRef(isEnabled); 66 | 67 | currentSelectionChange.current = useCallback( 68 | (box: Box) => { 69 | onChangeRefId.current = onSelectionChange 70 | ? requestAnimationFrame(() => { 71 | onSelectionChange(box); 72 | }) 73 | : undefined; 74 | }, 75 | [onSelectionChange], 76 | ); 77 | currentSelectionStart.current = onSelectionStart; 78 | currentSelectionEnd.current = onSelectionEnd; 79 | isEnabledRef.current = isEnabled; 80 | 81 | /** 82 | * Method to cancel selecting and reset internal data 83 | */ 84 | const cancelCurrentSelection = useCallback(() => { 85 | startPoint.current = null; 86 | endPoint.current = null; 87 | isSelecting.current = false; 88 | containerRef.current?.clearSelectionBox(); 89 | if (typeof onChangeRefId.current === 'number') { 90 | cancelAnimationFrame(onChangeRefId.current); 91 | } 92 | }, [containerRef]); 93 | 94 | /** 95 | * method to calculate point from event in context of the whole screen 96 | */ 97 | const getPointFromEvent = useCallback( 98 | (event: MouseEvent): Point => { 99 | const rect = containerRef.current?.getParentBoundingClientRect(); 100 | 101 | return { 102 | x: event.clientX - (typeof rect?.left === 'number' ? rect.left : 0), 103 | y: event.clientY - (typeof rect?.top === 'number' ? rect.top : 0), 104 | }; 105 | }, 106 | [containerRef], 107 | ); 108 | 109 | /** 110 | * Method called on mousemove event 111 | */ 112 | const handleMouseMove = useCallback( 113 | (event: MouseEvent, rect?: DOMRect) => { 114 | if (startPoint.current && endPoint.current) { 115 | if (!rect) { 116 | return; 117 | } 118 | 119 | const newSelectionBox = calculateSelectionBox({ 120 | startPoint: startPoint.current, 121 | endPoint: endPoint.current, 122 | }); 123 | 124 | // calculate box in context of container to compare with items' coordinates 125 | const boxInContainer: SelectionBox = { 126 | ...newSelectionBox, 127 | top: newSelectionBox.top + (rect?.top || 0), 128 | left: newSelectionBox.left + (rect?.left || 0), 129 | }; 130 | 131 | // we detect move only after some small movement 132 | if (isValidSelectionStart(newSelectionBox)) { 133 | if (!isSelecting.current) { 134 | if (currentSelectionStart?.current) { 135 | currentSelectionStart.current(event); 136 | } 137 | isSelecting.current = true; 138 | } 139 | containerRef.current?.drawSelectionBox(newSelectionBox); 140 | currentSelectionChange.current?.(boxInContainer); 141 | } else if (isSelecting.current) { 142 | currentSelectionChange.current?.(boxInContainer); 143 | } 144 | } else { 145 | cancelCurrentSelection(); 146 | } 147 | }, 148 | [cancelCurrentSelection, containerRef], 149 | ); 150 | 151 | const onMouseMove = useCallback( 152 | (event: MouseEvent) => { 153 | if (!startPoint.current) { 154 | return; 155 | } 156 | 157 | const rect = containerRef.current?.getParentBoundingClientRect(); 158 | endPoint.current = getPointFromEvent(event); 159 | handleMouseMove(event, rect); 160 | }, 161 | [handleMouseMove, getPointFromEvent, containerRef], 162 | ); 163 | 164 | const onMouseUp = useCallback( 165 | (event: MouseEvent) => { 166 | /** 167 | * handle only left button up event 168 | */ 169 | if (event.button === 0) { 170 | /** 171 | * If the user just clicked down and up in the same place without dragging, 172 | * we don't want to fire the onSelectionEnd event. We can do this 173 | * by checking if endPoint.current exists. 174 | */ 175 | if (endPoint.current) { 176 | currentSelectionEnd.current?.(event); 177 | } 178 | 179 | cancelCurrentSelection(); 180 | 181 | document.body.style.removeProperty('user-select'); 182 | document.body.style.removeProperty('-webkit-user-select'); 183 | 184 | (eventsElement || document.body).removeEventListener('mousemove', onMouseMove); 185 | window?.removeEventListener('mouseup', onMouseUp); 186 | } 187 | }, 188 | [eventsElement, cancelCurrentSelection, onMouseMove], 189 | ); 190 | 191 | const onMouseDown = useCallback( 192 | (e: MouseEvent) => { 193 | // handle only left button click 194 | if (e.button === 0 && isEnabledRef.current) { 195 | if (typeof shouldStartSelecting === 'function' && !shouldStartSelecting(e.target)) { 196 | return; 197 | } 198 | 199 | // disable text selection for all document 200 | document.body.style.userSelect = 'none'; 201 | document.body.style.webkitUserSelect = 'none'; 202 | startPoint.current = getPointFromEvent(e); 203 | 204 | (eventsElement || document.body).addEventListener('mousemove', onMouseMove); 205 | window?.addEventListener('mouseup', onMouseUp); 206 | } 207 | }, 208 | [eventsElement, getPointFromEvent, onMouseMove, onMouseUp], 209 | ); 210 | 211 | useEffect(() => { 212 | /** 213 | * On mount, add the mouse down listener to begin listening for dragging 214 | */ 215 | (eventsElement || document.body).addEventListener('mousedown', onMouseDown); 216 | 217 | /** 218 | * On unmount, remove any listeners that we're applied. 219 | */ 220 | return () => { 221 | (eventsElement || document.body).removeEventListener('mousedown', onMouseDown); 222 | (eventsElement || document.body).removeEventListener('mousemove', onMouseMove); 223 | window.removeEventListener('mouseup', onMouseUp); 224 | }; 225 | }, [eventsElement, onMouseDown, onMouseMove, onMouseUp]); 226 | 227 | return { 228 | cancelCurrentSelection, 229 | }; 230 | } 231 | 232 | function isMinumumBoxArea(box: Box): boolean { 233 | return calculateBoxArea(box) > 10; 234 | } 235 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { SelectionContainerProps } from './components/SelectionContainer'; 2 | import { useSelectionContainer } from './hooks/useSelectionContainer'; 3 | import { boxesIntersect } from './utils/boxes'; 4 | import { Point, Box, SelectionBox, OnSelectionChange, SelectionContainerRef } from './utils/types'; 5 | 6 | export type { Point, Box, SelectionBox, OnSelectionChange, SelectionContainerRef, SelectionContainerProps }; 7 | 8 | export { useSelectionContainer, boxesIntersect }; 9 | -------------------------------------------------------------------------------- /src/typings.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Default CSS definition for typescript, 3 | * will be overridden with file-specific definitions by rollup 4 | */ 5 | declare module '*.css' { 6 | const content: { [className: string]: string }; 7 | export default content; 8 | } 9 | 10 | interface SvgrComponent extends React.FC> {} 11 | 12 | declare module '*.svg' { 13 | const svgUrl: string; 14 | const svgComponent: SvgrComponent; 15 | export default svgUrl; 16 | export { svgComponent as ReactComponent }; 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/__tests__/boxes.test.ts: -------------------------------------------------------------------------------- 1 | import { boxesIntersect, calculateBoxArea } from '../boxes'; 2 | 3 | describe('boxes utils', () => { 4 | describe('boxesIntersect', () => { 5 | it('should return true if boxes overlap', () => { 6 | expect( 7 | boxesIntersect( 8 | { 9 | left: 0, 10 | top: 0, 11 | width: 3, 12 | height: 3, 13 | }, 14 | { 15 | left: 2, 16 | top: 2, 17 | width: 3, 18 | height: 3, 19 | }, 20 | ), 21 | ).toBe(true); 22 | 23 | expect( 24 | boxesIntersect( 25 | { 26 | left: 0, 27 | top: 2, 28 | width: 3, 29 | height: 3, 30 | }, 31 | { 32 | left: 2, 33 | top: 0, 34 | width: 3, 35 | height: 3, 36 | }, 37 | ), 38 | ).toBe(true); 39 | 40 | expect( 41 | boxesIntersect( 42 | { 43 | left: 2, 44 | top: 2, 45 | width: 3, 46 | height: 3, 47 | }, 48 | { 49 | left: 0, 50 | top: 0, 51 | width: 3, 52 | height: 3, 53 | }, 54 | ), 55 | ).toBe(true); 56 | 57 | expect( 58 | boxesIntersect( 59 | { 60 | left: 2, 61 | top: 0, 62 | width: 3, 63 | height: 3, 64 | }, 65 | { 66 | left: 0, 67 | top: 2, 68 | width: 3, 69 | height: 3, 70 | }, 71 | ), 72 | ).toBe(true); 73 | 74 | expect( 75 | boxesIntersect( 76 | { 77 | left: 2, 78 | top: 2, 79 | width: 3, 80 | height: 3, 81 | }, 82 | { 83 | left: 1, 84 | top: 1, 85 | width: 6, 86 | height: 3, 87 | }, 88 | ), 89 | ).toBe(true); 90 | 91 | expect( 92 | boxesIntersect( 93 | { 94 | left: 1, 95 | top: 0, 96 | width: 4, 97 | height: 4, 98 | }, 99 | { 100 | left: 3, 101 | top: 1, 102 | width: 1, 103 | height: 1, 104 | }, 105 | ), 106 | ).toBe(true); 107 | 108 | expect( 109 | boxesIntersect( 110 | { 111 | left: 3, 112 | top: 1, 113 | width: 1, 114 | height: 1, 115 | }, 116 | { 117 | left: 1, 118 | top: 0, 119 | width: 4, 120 | height: 4, 121 | }, 122 | ), 123 | ).toBe(true); 124 | }); 125 | 126 | it('should return false if boxes do not overlap', () => { 127 | expect( 128 | boxesIntersect( 129 | { 130 | left: 0, 131 | top: 0, 132 | width: 20, 133 | height: 20, 134 | }, 135 | { 136 | left: 30, 137 | top: 0, 138 | width: 20, 139 | height: 20, 140 | }, 141 | ), 142 | ).toBe(false); 143 | }); 144 | }); 145 | 146 | describe('calculateBoxArea', () => { 147 | it('should calculate correct box area', () => { 148 | const area = calculateBoxArea({ 149 | left: 2, 150 | top: 3, 151 | height: 4, 152 | width: 5, 153 | }); 154 | 155 | expect(area).toBe(20); 156 | }); 157 | }); 158 | }); 159 | -------------------------------------------------------------------------------- /src/utils/boxes.ts: -------------------------------------------------------------------------------- 1 | import { Box, Point } from './types'; 2 | 3 | /** This method returns true if two boxes intersects 4 | * @param boxA 5 | * @param boxB 6 | */ 7 | export const boxesIntersect = (boxA: Box, boxB: Box) => 8 | boxA.left <= boxB.left + boxB.width && 9 | boxA.left + boxA.width >= boxB.left && 10 | boxA.top <= boxB.top + boxB.height && 11 | boxA.top + boxA.height >= boxB.top; 12 | 13 | export const calculateSelectionBox = ({ startPoint, endPoint }: { startPoint: Point; endPoint: Point }): Box => ({ 14 | left: Math.min(startPoint.x, endPoint.x), 15 | top: Math.min(startPoint.y, endPoint.y), 16 | width: Math.abs(startPoint.x - endPoint.x), 17 | height: Math.abs(startPoint.y - endPoint.y), 18 | }); 19 | 20 | export const calculateBoxArea = (box: Box) => box.width * box.height; 21 | -------------------------------------------------------------------------------- /src/utils/types.ts: -------------------------------------------------------------------------------- 1 | export interface Point { 2 | x: number; 3 | y: number; 4 | } 5 | 6 | export interface Box { 7 | left: number; 8 | top: number; 9 | width: number; 10 | height: number; 11 | } 12 | 13 | export interface SelectionBox extends Box {} 14 | 15 | export type OnSelectionChange = (box: SelectionBox) => void; 16 | 17 | export interface SelectionContainerRef { 18 | drawSelectionBox: OnSelectionChange; 19 | clearSelectionBox: () => void; 20 | getBoundingClientRect: () => DOMRect | undefined; 21 | getParentBoundingClientRect: () => DOMRect | undefined; 22 | } 23 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src", ".eslintrc.js"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "target": "es5", 5 | "lib": [ 6 | "dom", 7 | "dom.iterable", 8 | "esnext" 9 | ], 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react", 22 | "jsxFactory": "", 23 | "jsxFragmentFactory": "" 24 | }, 25 | "include": [ 26 | "src" 27 | ] 28 | } 29 | --------------------------------------------------------------------------------