├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .storybook └── main.js ├── LICENSE ├── README.md ├── __tests__ └── index.test.tsx ├── jest.config.js ├── package.json ├── src └── index.tsx ├── stories ├── Demo.tsx ├── Drag.stories.tsx ├── Resize.stories.tsx ├── Restrict.stories.tsx └── Snap.stories.tsx ├── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | build/ 2 | dist/ 3 | docs/ -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | extends: [ 4 | "plugin:react/recommended", 5 | "plugin:@typescript-eslint/recommended", 6 | "plugin:storybook/recommended"], 7 | parserOptions: { 8 | ecmaVersion: 13, 9 | sourceType: 'module', 10 | ecmaFeatures: { 11 | jsx: true, 12 | }, 13 | }, 14 | env: { 15 | browser: true, 16 | jest: true, 17 | es2021: true, 18 | node: true, 19 | }, 20 | plugins: [ 21 | 'react', 22 | '@typescript-eslint', 23 | 'react-hooks', 24 | ], 25 | rules: { 26 | "semi": ["error", "never"], 27 | "react/sort-comp": "off", 28 | "react/require-default-props": "warn", 29 | "@typescript-eslint/indent": ["error", 2], 30 | "react/prop-types": "off", 31 | "react/self-closing-comp": "error", 32 | "@typescript-eslint/explicit-member-accessibility": "off", 33 | "react-hooks/rules-of-hooks": "error", // Checks rules of Hooks 34 | "react-hooks/exhaustive-deps": "warn", // Checks effect dependencies 35 | "react/display-name": "off" 36 | }, 37 | settings: { 38 | react: { 39 | version: 'detect', // Tells eslint-plugin-react to automatically detect the version of React to use 40 | }, 41 | }, 42 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | 4 | # testing 5 | /coverage 6 | 7 | # production 8 | /build 9 | .cache 10 | /dist 11 | /lib 12 | 13 | #development 14 | /storybook-static 15 | 16 | # misc 17 | .DS_Store 18 | .env.local 19 | .env.development.local 20 | .env.test.local 21 | .env.production.local 22 | .vscode 23 | 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | .rts* 28 | .rpt* -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "stories": [ 3 | "../stories/*.stories.mdx", 4 | "../stories/*.stories.@(js|jsx|ts|tsx)" 5 | ], 6 | "addons": [ 7 | "@storybook/addon-links", 8 | "@storybook/addon-essentials", 9 | "@storybook/addon-storysource/register", 10 | 11 | ], 12 | "framework": "@storybook/react" 13 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Victor Wang 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 | # Reactablejs = Reactjs + Interactjs 2 | 3 | A react high-order component for [interactjs](https://github.com/taye/interact.js). 4 | 5 | Current supported features: 6 | - drag 7 | - resize 8 | - drop 9 | - multi-touch 10 | - restrict 11 | - snap 12 | - modifiers 13 | 14 | Current supported props: 15 | - options: `draggable` `resizable` `gesturable` `dropzone`. 16 | - event handlers: `onDragStart` `onDragMove` `onDragInertiaStart` `onDragEnd` `onResizeStart` `onResizeMove` `onResizeInertiaStart` `onResizeEnd` `onGestureStart` `onGestureMove` `onGestureEnd` `onDropActivate` `onDropdEactivate` `onDragEnter` `onDragLeave` `onDropMove` `onDrop` `onDown` `onMove` `onUp` `onCancel` `onTap` `onDoubleTap` `onHold`. 17 | 18 | 19 | **api details, visit [interactjs' docs](http://interactjs.io/docs/)** 20 | 21 | ## Installation 22 | > `reactablejs` use `interactjs` as `peerDependencies`, you should also install interactjs. 23 | ``` 24 | npm install reactablejs interactjs --save 25 | ``` 26 | 27 | ## Usage 28 | ```js 29 | import React from 'react' 30 | import reactable from 'reactablejs' 31 | 32 | const MyComponent = (props) => { 33 | return
34 | hello, world! 35 |
36 | } 37 | 38 | // MyComponent will receive getRef in props, put getRef to the element you want interact, then you can use all options and event handlers on Reactable 39 | 40 | const Reactable = reactable(MyComponent) 41 | 42 | ``` 43 | ## Example 44 | - visit [storybooks](https://beizhedenglong.github.io/reactablejs/) -------------------------------------------------------------------------------- /__tests__/index.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import reactable from "../src/index" 3 | import { render, fireEvent } from "@testing-library/react" 4 | 5 | interface Props { 6 | getRef: React.RefObject; 7 | } 8 | 9 | 10 | const MyComponent = (props: Props) =>
test
11 | const ReactableComponent = reactable(MyComponent) 12 | 13 | test("reactable", () => { 14 | const downMock = jest.fn() 15 | const { getByText } = render( 16 | 20 | ) 21 | 22 | const div = getByText(/test/) 23 | fireEvent.mouseDown(div) 24 | expect(downMock).toBeCalled() 25 | }) -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'jsdom', 4 | modulePathIgnorePatterns: ["/dist/"] 5 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reactablejs", 3 | "version": "0.2.0", 4 | "source": "src/index.tsx", 5 | "main": "dist/index.js", 6 | "umd:main": "dist/index.umd.js", 7 | "module": "dist/index.m.js", 8 | "types": "dist/src/index.d.ts", 9 | "repository": "https://github.com/beizhedenglong/reactablejs.git", 10 | "author": "Victor Wang(wang shaojie) ", 11 | "license": "MIT", 12 | "private": false, 13 | "scripts": { 14 | "start": "yarn storybook", 15 | "demo": "parcel build ./examples/index.html -d docs --public-url ./", 16 | "build": "microbundle --no-compress --jsx React.createElement", 17 | "test": "jest", 18 | "storybook": "start-storybook -p 6006", 19 | "build-storybook": "build-storybook", 20 | "deploy-storybook": "storybook-to-ghpages" 21 | }, 22 | "keywords": [ 23 | "react", 24 | "reactable", 25 | "interact", 26 | "drag", 27 | "resize" 28 | ], 29 | "devDependencies": { 30 | "@storybook/addon-actions": "^6.4.9", 31 | "@storybook/addon-essentials": "^6.4.9", 32 | "@storybook/addon-links": "^6.4.9", 33 | "@storybook/addon-storysource": "^6.4.9", 34 | "@storybook/addons": "^6.4.9", 35 | "@storybook/react": "^6.4.9", 36 | "@storybook/storybook-deployer": "^2.8.10", 37 | "@testing-library/react": "^12.1.2", 38 | "@types/jest": "^27.0.3", 39 | "@types/react": "^17.0.37", 40 | "@types/react-dom": "^17.0.11", 41 | "@types/storybook__react": "^4.0.2", 42 | "@typescript-eslint/eslint-plugin": "^5.7.0", 43 | "@typescript-eslint/parser": "^5.7.0", 44 | "babel-core": "^6.26.3", 45 | "babel-loader": "^8.2.3", 46 | "babel-plugin-transform-class-properties": "^6.24.1", 47 | "babel-plugin-transform-object-rest-spread": "^6.26.0", 48 | "babel-preset-env": "^1.7.0", 49 | "babel-preset-react": "^6.24.1", 50 | "eslint": "^8.4.1", 51 | "eslint-plugin-react": "^7.27.1", 52 | "eslint-plugin-react-hooks": "^4.3.0", 53 | "eslint-plugin-storybook": "^0.5.5", 54 | "interactjs": "^1.10.11", 55 | "jest": "^27.4.5", 56 | "microbundle": "^0.14.2", 57 | "parcel-bundler": "^1.6.2", 58 | "react": "^17.0.2", 59 | "react-docgen-typescript-loader": "^3.7.2", 60 | "react-dom": "^17.0.2", 61 | "react-scripts": "^4.0.3", 62 | "rimraf": "^3.0.2", 63 | "ts-jest": "^27.1.2", 64 | "ts-loader": "^9.2.6", 65 | "typescript": "^4.5.4" 66 | }, 67 | "peerDependencies": { 68 | "interactjs": "^1.10.11", 69 | "react": "^17.0.2" 70 | }, 71 | "dependencies": {} 72 | } 73 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import interact from 'interactjs' 2 | import * as React from 'react' 3 | import { useEffect, useRef } from 'react' 4 | 5 | const options = [ 6 | 'draggable', 7 | 'resizable', 8 | 'gesturable', 9 | 'dropzone', 10 | ] 11 | const events = [ 12 | // Interact Events 13 | 'DragStart', 14 | 'DragMove', 15 | 'DragInertiaStart', 16 | 'DragEnd', 17 | 'ResizeStart', 18 | 'ResizeMove', 19 | 'ResizeInertiaStart', 20 | 'ResizeEnd', 21 | 'GestureStart', 22 | 'GestureMove', 23 | 'GestureEnd', 24 | // Drop Events 25 | 'DropActivate', 26 | 'DropdEactivate', 27 | 'DragEnter', 28 | 'DragLeave', 29 | 'DropMove', 30 | 'Drop', 31 | // Pointer Events 32 | 'Down', 33 | 'Move', 34 | 'Up', 35 | 'Cancel', 36 | 'Tap', 37 | 'DoubleTap', 38 | 'Hold', 39 | ] 40 | 41 | export interface InjectedProps { 42 | getRef: React.Ref | React.LegacyRef; 43 | } 44 | export interface InteractProps { 45 | draggable?: Interact.DraggableOptions | boolean; 46 | resizable?: Interact.ResizableOptions | boolean; 47 | gesturable?: Interact.ResizableOptions | boolean; 48 | dropzone?: Interact.DropzoneOptions | boolean; 49 | onDragStart?: Interact.ListenersArg; 50 | onDragMove?: Interact.ListenersArg; 51 | onDragEnd?: Interact.ListenersArg; 52 | onResizeStart?: Interact.ListenersArg; 53 | onResizeMove?: Interact.ListenersArg; 54 | onResizeInertiaStart?: Interact.ListenersArg; 55 | onResizeEnd?: Interact.ListenersArg; 56 | onGestureStart?: Interact.ListenersArg; 57 | onGestureMove?: Interact.ListenersArg; 58 | onGestureEnd?: Interact.ListenersArg; 59 | onDropActivate?: Interact.ListenersArg; 60 | onDropdEactivate?: Interact.ListenersArg; 61 | onDragEnter?: Interact.ListenersArg; 62 | onDragLeave?: Interact.ListenersArg; 63 | onDropMove?: Interact.ListenersArg; 64 | onDrop?: Interact.ListenersArg; 65 | onDown?: Interact.ListenersArg; 66 | onMove?: Interact.ListenersArg; 67 | onUp?: Interact.ListenersArg; 68 | onCancel?: Interact.ListenersArg; 69 | onTap?: Interact.ListenersArg; 70 | onDoubleTap?: Interact.ListenersArg; 71 | onHold?: Interact.ListenersArg; 72 | } 73 | 74 | const reactable = ( 75 | BaseComponent: React.ComponentType 76 | ): React.FC & InteractProps> => (props) => { 77 | const interactable = useRef(null) 78 | const node = useRef() 79 | 80 | //Create interactable 81 | useEffect(() => { 82 | if (!node.current) { 83 | console.error(' you should apply getRef props in the dom element') // eslint-disable-line 84 | return 85 | } 86 | interactable.current = interact(node.current) 87 | return () => interactable.current.unset() 88 | }, []) 89 | 90 | //Set options 91 | useEffect(() => { 92 | if (interactable.current) { 93 | options.forEach((option) => { 94 | if (option in props) { 95 | interactable.current[option](props[option]) 96 | } 97 | }) 98 | } 99 | // eslint-disable-next-line react-hooks/exhaustive-deps 100 | }, []) // "props" should be added to the update array. However, this causes infinite rerenders. 101 | 102 | //Set handlers 103 | useEffect(() => { 104 | if (interactable.current) { 105 | events.forEach((event) => { 106 | const handler = props[`on${event}`] 107 | if (typeof handler === 'function') { 108 | interactable.current 109 | .on(event.toLowerCase(), handler) 110 | } 111 | }) 112 | return () => { 113 | events.forEach((event) => { 114 | const handler = props[`on${event}`] 115 | if (typeof handler === 'function') { 116 | interactable.current 117 | .off(event.toLowerCase(), handler) 118 | } 119 | }) 120 | } 121 | } 122 | }, [props]) 123 | 124 | const baseProps = (props) => { 125 | const baseProps = { ...props } 126 | options.forEach(option => delete baseProps[option]) 127 | events.forEach(event => delete baseProps[`on${event}`]) 128 | return baseProps 129 | } 130 | 131 | return 135 | } 136 | export default reactable 137 | -------------------------------------------------------------------------------- /stories/Demo.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | export interface DemoProps { 4 | x?: number; 5 | y?: number; 6 | width?: number; 7 | height?: number; 8 | angle?: number; 9 | getRef: React.Ref; 10 | } 11 | 12 | const Demo = (props: DemoProps) => { 13 | const { getRef, x, y, angle, width, height } = props 14 | return ( 15 |
28 | Reactable is a react hight-order component for interactjs. 29 |
    30 |
  • left: {x}
  • 31 |
  • top: {y}
  • 32 |
  • width: {width}
  • 33 |
  • height: {height}
  • 34 |
35 |
36 | ) 37 | } 38 | 39 | Demo.defaultProps = { 40 | x: 0, 41 | y: 0, 42 | width: 200, 43 | height: 200, 44 | angle: 0 45 | } 46 | 47 | export default Demo 48 | -------------------------------------------------------------------------------- /stories/Drag.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { storiesOf } from '@storybook/react' 3 | import { action } from '@storybook/addon-actions' 4 | import reactable from "../src" 5 | import Demo from "./Demo" 6 | 7 | const Reactable = reactable(Demo) 8 | 9 | const BasicDemo = () => { 10 | const [coordinate, setCoordinate] = React.useState({ x: 0, y: 0 }) 11 | return ( 12 | { 15 | const { dx, dy } = event 16 | setCoordinate(prev => ({ 17 | x: prev.x + dx, 18 | y: prev.y + dy 19 | })) 20 | action("DragMove")(event) 21 | }} 22 | x={coordinate.x} 23 | y={coordinate.y} 24 | /> 25 | ) 26 | } 27 | const DraggableOptionsDemo = () => { 28 | const [coordinate, setCoordinate] = React.useState({ x: 0, y: 0 }) 29 | return ( 30 | setCoordinate(prev => ({ 34 | x: prev.x + event.dx, 35 | y: prev.y + event.dy 36 | })), 37 | onend: action("DragEnd"), 38 | }} 39 | {...coordinate} 40 | /> 41 | ) 42 | } 43 | 44 | storiesOf('Drag', module) 45 | .add('basic', () => ) 46 | .add("with options", () => ) 47 | -------------------------------------------------------------------------------- /stories/Resize.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { storiesOf } from '@storybook/react' 3 | import { action } from '@storybook/addon-actions' 4 | import reactable from "../src" 5 | import Demo from "./Demo" 6 | 7 | const Reactable = reactable(Demo) 8 | 9 | 10 | const ResizeDemo = () => { 11 | const [coordinate, setCoordinate] = React.useState({ x: 0, y: 0,width: 300, height: 200 }) 12 | return ( 13 | { 18 | const { width, height } = e.rect 19 | const { left, top } = e.deltaRect 20 | setCoordinate(prev => { 21 | return { 22 | x: prev.x + left, 23 | y: prev.y + top, 24 | width, 25 | height 26 | } 27 | }) 28 | }} 29 | {...coordinate} 30 | /> 31 | ) 32 | } 33 | 34 | storiesOf('Resize', module) 35 | .add('basic', () => ) 36 | -------------------------------------------------------------------------------- /stories/Restrict.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { storiesOf } from '@storybook/react' 3 | import interact from 'interactjs' 4 | import reactable from "../src" 5 | import Demo from "./Demo" 6 | 7 | const Reactable = reactable(Demo) 8 | 9 | const RestrictDemo = () => { 10 | const [coordinate, setCoordinate] = React.useState({ x: 0, y: 0, width: 300, height: 200 }) 11 | return ( 12 |
20 | { 23 | setCoordinate(prev => ({ 24 | ...prev, 25 | x: prev.x + event.dx, 26 | y: prev.y + event.dy, 27 | })) 28 | }, 29 | modifiers: [ 30 | interact.modifiers.restrictRect({ 31 | restriction: 'parent', 32 | endOnly: true 33 | }) 34 | ], 35 | }} 36 | resizable={{ 37 | edges: { left: true, right: true, bottom: true, top: true }, 38 | onmove: (e) => { 39 | const { width, height } = e.rect 40 | const { left, top } = e.deltaRect 41 | setCoordinate(prev => { 42 | return { 43 | x: prev.x + left, 44 | y: prev.y + top, 45 | width, 46 | height 47 | } 48 | }) 49 | }, 50 | modifiers: [ 51 | interact.modifiers.restrictEdges({ 52 | outer: 'parent', 53 | endOnly: true 54 | }), 55 | ] 56 | }} 57 | {...coordinate} 58 | /> 59 |
60 | ) 61 | } 62 | 63 | storiesOf('Restrict', module) 64 | .add('basic', () => ) 65 | -------------------------------------------------------------------------------- /stories/Snap.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { storiesOf } from '@storybook/react' 3 | import interact from "interactjs" 4 | import reactable from "../src" 5 | import Demo from "./Demo" 6 | 7 | const Reactable = reactable(Demo) 8 | 9 | const SnapDemo = () => { 10 | const [coordinate, setCoordinate] = React.useState({ x: 0, y: 0 }) 11 | return ( 12 | setCoordinate(prev => ({ 15 | x: prev.x + event.dx, 16 | y: prev.y + event.dy 17 | })), 18 | modifiers: [ 19 | interact.modifiers.snap({ 20 | targets: [ 21 | interact.createSnapGrid({ x: 40, y: 40 } as any) as any 22 | ], 23 | range: Infinity, 24 | relativePoints: [{ x: 0, y: 0 }] 25 | }), 26 | ] 27 | }} 28 | {...coordinate} 29 | /> 30 | ) 31 | } 32 | 33 | storiesOf('Snap', module) 34 | .add('basic', () => ) -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react" 4 | } 5 | } --------------------------------------------------------------------------------