├── .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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------