├── .prettierignore ├── .eslintignore ├── .prettierrc ├── .babelrc ├── .editorconfig ├── .gitignore ├── demo ├── index.html ├── styles.css └── index.js ├── src ├── portal.js ├── index.js └── utils.js ├── rollup.config.js ├── .eslintrc ├── README.md └── package.json /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | dist 3 | node_modules -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "es5" 4 | } -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [["env", { "modules": false }], "react"], 3 | "plugins": ["transform-class-properties"] 4 | } -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | indent_style = space 7 | indent_size = 2 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Mac OSX Files 2 | .AppleDouble 3 | .DS_Store 4 | .LSOverride 5 | .Spotlight-V100 6 | .Trashes 7 | 8 | # NPM / Yarn 9 | coverage 10 | dist 11 | lib 12 | node_modules 13 | npm-debug.log 14 | package-lock.json 15 | yarn-error.log 16 | yarn.lock 17 | 18 | # General Files 19 | .cache 20 | .hg 21 | .idea 22 | .project 23 | .sass-cache 24 | .svn 25 | .tmp 26 | .vscode 27 | *.log -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | react-connect-elements 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/portal.js: -------------------------------------------------------------------------------- 1 | /* global document */ 2 | 3 | import ReactDOM from 'react-dom'; 4 | import PropTypes from 'prop-types'; 5 | 6 | const Portal = ({ children, query }) => 7 | ReactDOM.createPortal(children, document.querySelector(query)); 8 | 9 | Portal.propTypes = { 10 | children: PropTypes.node.isRequired, 11 | query: PropTypes.string, 12 | }; 13 | 14 | Portal.defaultProps = { 15 | query: 'body', 16 | }; 17 | 18 | export default Portal; 19 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from 'rollup-plugin-node-resolve'; 2 | import babel from 'rollup-plugin-babel'; 3 | import filesize from 'rollup-plugin-filesize'; 4 | import uglify from 'rollup-plugin-uglify'; 5 | 6 | export default { 7 | input: 'src/index.js', 8 | output: { 9 | file: 'dist/react-connect-elements.js', 10 | format: 'cjs', 11 | }, 12 | external: ['react', 'react-dom', 'prop-types'], 13 | plugins: [ 14 | resolve(), 15 | babel({ exclude: 'node_modules/**' }), 16 | uglify(), 17 | filesize(), 18 | ], 19 | }; 20 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": ["airbnb", "prettier"], 4 | "plugins": ["react", "jsx-a11y", "prettier"], 5 | "env": { 6 | "browser": true, 7 | "node": true 8 | }, 9 | "rules": { 10 | "prettier/prettier": [ 11 | "error", 12 | { 13 | "singleQuote": true, 14 | "trailingComma": "es5" 15 | } 16 | ], 17 | "global-require": 0, 18 | "no-confusing-arrow": 0, 19 | "no-param-reassign": 0, 20 | "no-plusplus": 0, 21 | "no-underscore-dangle": 0, 22 | "one-var-declaration-per-line": 0, 23 | "one-var": 0, 24 | "import/no-extraneous-dependencies": 0, 25 | "import/prefer-default-export": 0, 26 | "jsx-a11y/no-static-element-interactions": 0, 27 | "react/forbid-prop-types": 0, 28 | "react/no-danger": 0, 29 | "react/prefer-es6-class": 0, 30 | "react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx"] }] 31 | } 32 | } -------------------------------------------------------------------------------- /demo/styles.css: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; 4 | padding: 0; 5 | } 6 | a { 7 | text-decoration: none; 8 | color: #222; 9 | } 10 | a:hover { 11 | color: #666; 12 | } 13 | header { 14 | text-align: center; 15 | margin: 30px 0; 16 | width: 100%; 17 | } 18 | footer { 19 | height: 50px; 20 | display: flex; 21 | align-items: center; 22 | justify-content: center; 23 | } 24 | .container { 25 | padding: 0 20px; 26 | display: flex; 27 | flex: 1; 28 | position: absolute; 29 | flex-direction: column; 30 | top: 50%; 31 | left: 50%; 32 | margin-top: -293px; 33 | margin-left: -240px; 34 | } 35 | .elements { 36 | display: grid; 37 | grid-template-rows: repeat(3, 100px); 38 | grid-row-gap: 50px; 39 | max-width: 500px; 40 | box-sizing: border-box; 41 | padding: 20px; 42 | margin: 0 auto; 43 | border-radius: 10px; 44 | background: #222; 45 | } 46 | .elements-row { 47 | display: grid; 48 | grid-template-columns: repeat(3, 100px); 49 | grid-gap: 50px; 50 | } 51 | .element { 52 | display: grid; 53 | z-index: 11; 54 | border-radius: 5px; 55 | } 56 | .element1 { 57 | background: yellow; 58 | } 59 | .element2 { 60 | background: orange; 61 | } 62 | .element3 { 63 | background: red; 64 | } 65 | .element4 { 66 | grid-column-start: 2; 67 | grid-column-end: 2; 68 | background: purple; 69 | } 70 | .element5 { 71 | background: blue; 72 | } 73 | .element6 { 74 | background: green; 75 | } 76 | .element7 { 77 | background: teal; 78 | } -------------------------------------------------------------------------------- /demo/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'react-dom'; 3 | import ConnectElements from '../src'; 4 | import './styles.css'; 5 | 6 | const Demo = () => ( 7 |
8 |
9 | 10 |

React Connect Elements

11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | 33 | 45 |
46 | ); 47 | 48 | render(, document.querySelector('#root')); 49 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Connect Elements 2 | 3 | Connect elements with SVG 4 | 5 | [DEMO](https://emersonlaurentino.github.io/react-connect-elements/) 6 | 7 | ## Setup 8 | 9 | ```bash 10 | yarn add react-connect-elements 11 | ``` 12 | 13 | ## Getting Started 14 | Include ConnectElements in the parent component. 15 | 16 | ```jsx 17 | import ConnectElements from 'react-connect-elements'; 18 | 19 | const Component = () => ( 20 |
21 |
22 |
23 |
24 |
25 | 29 |
30 | ); 31 | ``` 32 | 33 | ## Props 34 | 35 | |Prop|Description|Type|Default| 36 | |---|---|---|---| 37 | |elements|The connections of the elements|array|required| 38 | |selector|The DOM target selector of the parent element|string|required| 39 | |overlay|z-index value of the line connecting the elements|number|0| 40 | |strokeWidth|width of the line in pixels|number|5| 41 | |color|Color of the line connecting the elements|string|#666| 42 | 43 | ### Elements Syntax 44 | 45 | |Attribute|Description|Required| 46 | |---|---|---| 47 | |from|The DOM target selector of the start element|true| 48 | |to|The DOM target selector of the end element|true| 49 | |color|Color of the line connecting the elements (overrides the prop `color`) |false| 50 | 51 | ### Credits 52 | 53 | [This gist](https://gist.github.com/pmkary/3694ac1a2e89cc74a3777529a69cfcb3) where I got how to connect two elements / draw a path between two elements with SVG path (using jQuery). 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-connect-elements", 3 | "version": "0.1.1", 4 | "description": "Connect elements with SVG", 5 | "license": "MIT", 6 | "author": "Emerson Laurentino ", 7 | "homepage": "https://github.com/emersonlaurentino/react-connect-elements", 8 | "main": "dist/react-connect-elements.js", 9 | "source": "src/index.js", 10 | "files": [ 11 | "/dist" 12 | ], 13 | "scripts": { 14 | "build": "rollup -c", 15 | "demo:build": "parcel build --out-dir demo/dist demo/index.html --public-url=/react-connect-elements", 16 | "demo:deploy": "yarn demo:build && gh-pages -d demo/dist", 17 | "demo:dev": "parcel --out-dir demo/dist demo/index.html", 18 | "lint": "esw -c .eslintrc src --ext .js --ignore-path .eslintignore --color", 19 | "lint:fix": "npm run lint -- --fix", 20 | "lint:watch": "npm run lint -- --watch", 21 | "precommit": "pretty-quick --staged", 22 | "prepare": "npm run build" 23 | }, 24 | "repository": { 25 | "type": "git", 26 | "url": "https://github.com/emersonlaurentino/react-connect-elements" 27 | }, 28 | "bugs": { 29 | "url": "https://github.com/emersonlaurentino/react-connect-elements/issues" 30 | }, 31 | "keywords": [ 32 | "react", 33 | "react-connect-elements", 34 | "component", 35 | "svg", 36 | "connect" 37 | ], 38 | "peerDependencies": { 39 | "react": "^16.0.0", 40 | "react-dom": "^16.0.0" 41 | }, 42 | "devDependencies": { 43 | "babel-core": "^6.26.2", 44 | "babel-eslint": "^8.2.3", 45 | "babel-plugin-transform-class-properties": "^6.24.1", 46 | "babel-preset-env": "^1.6.1", 47 | "babel-preset-react": "^6.24.1", 48 | "eslint-config-airbnb": "^16.1.0", 49 | "eslint-config-prettier": "^2.9.0", 50 | "eslint-plugin-import": "^2.11.0", 51 | "eslint-plugin-jsx-a11y": "^6.0.3", 52 | "eslint-plugin-prettier": "^2.6.0", 53 | "eslint-plugin-react": "^7.7.0", 54 | "eslint-watch": "^3.1.4", 55 | "eslint": "^4.19.1", 56 | "gh-pages": "^1.1.0", 57 | "parcel-bundler": "^1.7.1", 58 | "prettier": "^1.12.1", 59 | "pretty-quick": "^1.4.1", 60 | "prop-types": "^15.6.1", 61 | "react-dom": "^16.3.2", 62 | "react": "^16.3.2", 63 | "rollup-plugin-babel": "^3.0.4", 64 | "rollup-plugin-commonjs": "^9.1.0", 65 | "rollup-plugin-filesize": "^1.5.0", 66 | "rollup-plugin-node-resolve": "^3.3.0", 67 | "rollup-plugin-uglify": "^3.0.0", 68 | "rollup": "^0.58.2" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Portal from './portal'; 4 | import { connectElements } from './utils'; 5 | 6 | export default class ReactConnectElements extends PureComponent { 7 | static propTypes = { 8 | elements: PropTypes.array.isRequired, 9 | overlay: PropTypes.number, 10 | selector: PropTypes.string.isRequired, 11 | strokeWidth: PropTypes.number, 12 | color: PropTypes.string, 13 | }; 14 | 15 | static defaultProps = { 16 | overlay: 0, 17 | strokeWidth: 5, 18 | color: '#666', 19 | }; 20 | 21 | state = { 22 | querySelector: 'body', 23 | }; 24 | 25 | componentDidMount() { 26 | this.checkSelector(); 27 | } 28 | 29 | checkSelector = () => { 30 | if (document.querySelector(this.props.selector)) { 31 | this.setState({ querySelector: this.props.selector }, () => 32 | this.connectAll() 33 | ); 34 | } 35 | }; 36 | 37 | connectAll = () => { 38 | const { elements } = this.props; 39 | 40 | elements.map((element, index) => { 41 | const start = document.querySelector(element.from); 42 | const end = document.querySelector(element.to); 43 | const path = document.querySelector(`#path${index + 1}`); 44 | 45 | return connectElements(this.svgContainer, this.svg, path, start, end); 46 | }); 47 | }; 48 | 49 | render() { 50 | const { elements, overlay, strokeWidth, color } = this.props; 51 | 52 | return ( 53 | this.state.querySelector && ( 54 | 55 |
{ 59 | this.svgContainer = svg; 60 | }} 61 | > 62 | { 66 | this.svg = svg; 67 | }} 68 | > 69 | {elements.map((element, index) => ( 70 | 78 | ))} 79 | 80 |
81 |
82 | ) 83 | ); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | // helper functions, it turned out chrome doesn't support Math.sgn() 2 | export const signum = x => (x < 0 ? -1 : 1); 3 | export const absolute = x => (x < 0 ? -x : x); 4 | 5 | export const drawPath = (svg, path, startX, startY, endX, endY) => { 6 | // get the path's stroke width (if one wanted to be really precize, one could use half the stroke size) 7 | 8 | const stroke = parseFloat(path.getAttribute('stroke-width')); 9 | // check if the svg is big enough to draw the path, if not, set heigh/width 10 | if (svg.getAttribute('height') < endY) svg.setAttribute('height', endY); 11 | if (svg.getAttribute('width') < startX + stroke) 12 | svg.setAttribute('width', startX + stroke); 13 | if (svg.getAttribute('width') < endX + stroke) 14 | svg.setAttribute('width', endX + stroke); 15 | 16 | const deltaX = (endX - startX) * 0.15; 17 | const deltaY = (endY - startY) * 0.15; 18 | 19 | // for further calculations which ever is the shortest distance 20 | const delta = deltaY < absolute(deltaX) ? deltaY : absolute(deltaX); 21 | 22 | // set sweep-flag (counter/clock-wise) 23 | // if start element is closer to the left edge, 24 | // draw the first arc counter-clockwise, and the second one clock-wise 25 | let arc1 = 0; 26 | let arc2 = 1; 27 | if (startX > endX) { 28 | arc1 = 1; 29 | arc2 = 0; 30 | } 31 | 32 | // draw tha pipe-like path 33 | // 1. move a bit down, 2. arch, 3. move a bit to the right, 4.arch, 5. move down to the end 34 | path.setAttribute( 35 | 'd', 36 | `M${startX} ${startY} V${startY + 37 | delta} A${delta} ${delta} 0 0 ${arc1} ${startX + 38 | delta * signum(deltaX)} ${startY + 2 * delta} H${endX - 39 | delta * signum(deltaX)} A${delta} ${delta} 0 0 ${arc2} ${endX} ${startY + 40 | 3 * delta} V${endY}` 41 | ); 42 | }; 43 | 44 | export const connectElements = (container, svg, path, startElem, endElem) => { 45 | // if first element is lower than the second, swap! 46 | if ( 47 | startElem.getBoundingClientRect().top > endElem.getBoundingClientRect().top 48 | ) { 49 | const temp = startElem; 50 | startElem = endElem; 51 | endElem = temp; 52 | } 53 | 54 | // get (top, left) corner coordinates of the svg container 55 | const svgTop = container.getBoundingClientRect().top; 56 | const svgLeft = container.getBoundingClientRect().left; 57 | 58 | // get (top, left) coordinates for the two elements 59 | const startCoord = startElem.getBoundingClientRect(); 60 | const endCoord = endElem.getBoundingClientRect(); 61 | 62 | // calculate path's start (x,y) coords 63 | // we want the x coordinate to visually result in the element's mid point 64 | const startX = startCoord.left + 0.5 * startCoord.width - svgLeft; // x = left offset + 0.5*width - svg's left offset 65 | const startY = startCoord.top + startCoord.height - svgTop; // y = top offset + height - svg's top offset 66 | 67 | // calculate path's end (x,y) coords 68 | const endX = endCoord.left + 0.5 * endCoord.width - svgLeft; 69 | 70 | const endY = endCoord.top - svgTop; 71 | 72 | // call function for drawing the path 73 | drawPath(svg, path, startX, startY, endX, endY); 74 | }; 75 | --------------------------------------------------------------------------------